Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d4650f3d | ||
|
|
c7cc5616ed | ||
|
|
489cb49cb3 | ||
|
|
7706f59549 | ||
|
|
dbba1f4905 |
@@ -23,19 +23,20 @@ jobs:
|
|||||||
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
|
echo "IMAGE=${REGISTRY}/${{ gitea.repository }}" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: 下载前端最新 dist.zip 解压进嵌入目录
|
- name: 下载前端 dist.zip
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
DASH_TAG=$(curl -fsSL -H "Authorization: token ${TOKEN}" \
|
DASH_TAG=$(head -1 DASH_VERSION)
|
||||||
"${{ gitea.server_url }}/api/v1/repos/${DASH_REPO}/releases/latest" | jq -r '.tag_name')
|
BASE="${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}"
|
||||||
curl -fsSL -H "Authorization: token ${TOKEN}" \
|
curl -fsSL -H "Authorization: token ${TOKEN}" -o dist.zip "${BASE}/dist.zip"
|
||||||
-o dist.zip "${{ gitea.server_url }}/${DASH_REPO}/releases/download/${DASH_TAG}/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
|
rm -rf internal/webui/dist && mkdir -p internal/webui/dist
|
||||||
unzip -q dist.zip -d internal/webui/dist
|
unzip -q dist.zip -d internal/webui/dist
|
||||||
test -f internal/webui/dist/index.html
|
test -f internal/webui/dist/index.html
|
||||||
|
|
||||||
- name: 构建双架构二进制(artifact stage 导出)
|
- name: 构建双架构二进制
|
||||||
run: |
|
run: |
|
||||||
docker buildx build --pull \
|
docker buildx build --pull \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
@@ -46,8 +47,9 @@ jobs:
|
|||||||
mkdir -p assets
|
mkdir -p assets
|
||||||
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
|
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
|
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
|
||||||
|
(cd assets && sha256sum * > SHA256SUMS)
|
||||||
|
|
||||||
- name: 构建并推送多平台镜像(与上一步共享构建缓存)
|
- name: 构建并推送多平台镜像
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||||
run: |
|
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
|
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
|
[ -s release-notes.md ] || echo "Release ${TAG}" > release-notes.md
|
||||||
|
|
||||||
- name: 创建 Release 并上传二进制附件(重跑幂等,同名附件自动替换)
|
- name: 创建 Release 并上传二进制附件
|
||||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.BUILD_TOKEN }}
|
token: ${{ secrets.BUILD_TOKEN }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
BUILDX_NO_DEFAULT_ATTESTATIONS: "1"
|
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
|
IMAGE: ghcr.io/wangdefaa/oci-portal
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -21,14 +21,18 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 下载前端最新 dist.zip 解压进嵌入目录
|
- name: 下载前端 dist.zip
|
||||||
run: |
|
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
|
rm -rf internal/webui/dist && mkdir -p internal/webui/dist
|
||||||
unzip -q dist.zip -d internal/webui/dist
|
unzip -q dist.zip -d internal/webui/dist
|
||||||
test -f internal/webui/dist/index.html
|
test -f internal/webui/dist/index.html
|
||||||
|
|
||||||
- name: 构建双架构二进制(artifact stage 导出)
|
- name: 构建双架构二进制
|
||||||
run: |
|
run: |
|
||||||
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
echo "BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV"
|
echo "BUILD_TIME=${BUILD_TIME}" >> "$GITHUB_ENV"
|
||||||
@@ -41,6 +45,7 @@ jobs:
|
|||||||
mkdir -p assets
|
mkdir -p assets
|
||||||
mv output/linux_amd64/oci-portal-server assets/oci-portal-server-linux-amd64
|
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
|
mv output/linux_arm64/oci-portal-server assets/oci-portal-server-linux-arm64
|
||||||
|
(cd assets && sha256sum * > SHA256SUMS)
|
||||||
ls -lh assets/
|
ls -lh assets/
|
||||||
|
|
||||||
- name: 提取 CHANGELOG 版本段作为 Release 描述
|
- name: 提取 CHANGELOG 版本段作为 Release 描述
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.5
|
0.6.6
|
||||||
+13
-13
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
|
|||||||
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
||||||
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||||
- 1.2 Research `[optional · repeatable]`
|
- 1.2 Research `[optional · repeatable]`
|
||||||
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
|
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
|
||||||
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||||
- 1.5 Completion criteria
|
- 1.5 Completion criteria
|
||||||
|
|
||||||
@@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
|||||||
|
|
||||||
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac
|
|||||||
|
|
||||||
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
Spawn the research sub-agent:
|
Spawn the research sub-agent:
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ Spawn the research sub-agent:
|
|||||||
- **Task description**: Research <specific question>
|
- **Task description**: Research <specific question>
|
||||||
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical
|
|||||||
|
|
||||||
#### 1.3 Configure context `[required · once]`
|
#### 1.3 Configure context `[required · once]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r
|
|||||||
|
|
||||||
Skip this step only when both files already have real curated entries.
|
Skip this step only when both files already have real curated entries.
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h
|
|||||||
| `design.md` exists (complex tasks) | ✅ |
|
| `design.md` exists (complex tasks) | ✅ |
|
||||||
| `implement.md` exists (complex tasks) | ✅ |
|
| `implement.md` exists (complex tasks) | ✅ |
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
|
|||||||
|
|
||||||
#### 2.1 Implement `[required · repeatable]`
|
#### 2.1 Implement `[required · repeatable]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
|
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
||||||
|
|
||||||
Spawn the implement sub-agent:
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ The platform hook/plugin auto-handles:
|
|||||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
|
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
||||||
|
|
||||||
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
@@ -526,7 +526,7 @@ The platform prelude auto-handles the context load requirement:
|
|||||||
|
|
||||||
#### 2.2 Quality check `[required · repeatable]`
|
#### 2.2 Quality check `[required · repeatable]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
Spawn the check sub-agent:
|
Spawn the check sub-agent:
|
||||||
|
|
||||||
@@ -540,7 +540,7 @@ The check agent's job:
|
|||||||
- Auto-fix issues it finds
|
- Auto-fix issues it finds
|
||||||
- Run lint and typecheck to verify
|
- Run lint and typecheck to verify
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"DATA_KEY": "dev-data-key",
|
"DATA_KEY": "dev-data-key",
|
||||||
"JWT_SECRET": "dev-jwt-secret",
|
"JWT_SECRET": "dev-jwt-secret",
|
||||||
"ADMIN_PASSWORD": "admin123"
|
"ADMIN_PASSWORD": "admin123",
|
||||||
|
"GIN_MODE": "debug"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,78 @@
|
|||||||
|
|
||||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 恢复 OpenAI Chat Completions 兼容端点 `/ai/v1/chat/completions`:请求经 OCI Responses 接口转换转发,支持非流式与 SSE、文本与图片、function 工具调用、结构化输出、推理力度、模型白名单、缓存 token 用量和调用日志
|
||||||
|
- 代理侧批量关联租户接口 `PUT /api/v1/proxies/{id}/tenants`:一次调用设置关联该代理的租户全集——列表内租户改挂到本代理(含从其他代理改挂),列表外已关联的解除,事务生效并返回最新关联计数;含无效租户 ID 返回 400,代理不存在返回 404
|
||||||
|
- `/api/v1/about` 扩展运行时信息:进程启动时刻 `startedAt`、运行秒数 `uptimeSeconds` 与资源占用 `resources`——CPU 自启动均值(占单核百分比)、CPU 核数、goroutine 数、Go 堆内存与向系统申请内存、数据库引擎与磁盘占用(SQLite 统计主文件与 -wal / -shm;MySQL / PostgreSQL 外部库返回 -1 表示不可度量)
|
||||||
|
- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 云端关键事件通知补充操作者、来源 IP、执行结果和说明信息;支持从 OCI Audit 与 IDCS 登录事件提取成功 / 失败状态、策略描述和登录失败原因,缺失字段统一显示占位符
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **移除自定义审计告警规则**及其阈值、窗口和冷却匹配能力;`/api/v1/log-events/alert-rules` 与 `/api/v1/log-events/alert-rules/{ruleId}` 管理接口、`audit_alert` 通知模板不再提供,已有规则升级后不再执行
|
||||||
|
|
||||||
|
## [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 兼容面不承接,上游 400;cohere embed 向量模型不受影响),模型目录按 `xai.` / `meta.` / `openai.` 厂商前缀过滤
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 渠道「同步模型成功但探测报错不可用」:法兰克福等区域的微调基座模型占满探测候选导致的误报(探测状态与真实可用性不符的根因)
|
||||||
|
- 租户删除:告警命中清理改子查询,日志事件数万条时不再超出 SQL 绑定变量上限导致删除失败;无法解析的任务 payload 记警告跳过并保留原任务,不再永久阻断租户删除
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 通知渠道扩展:Webhook(Slack / 飞书 / 钉钉 / 企微预设)、ntfy、Bark、SMTP 邮件与 Telegram 五渠道并存,逐渠道推送互不影响,密钥加密落库
|
||||||
|
- 审计告警规则:按租户 / 事件 / 资源 / 来源 IP(CIDR 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
|
## [0.0.1] - 2026-07-09
|
||||||
|
|
||||||
首个版本:自托管 OCI 多租户管理面板后端。
|
首个版本:自托管 OCI 多租户管理面板后端。
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
v0.3.0
|
||||||
@@ -4,16 +4,21 @@
|
|||||||
|
|
||||||
# OCI Portal
|
# OCI Portal
|
||||||
|
|
||||||
**Oracle Cloud Infrastructure 多租户管理面板**
|
**自托管的 OCI 多租户管理面板与 GenAI 兼容网关**
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
本仓库为后端;前端工程见 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)(构建产物嵌入本服务成单文件)
|
[快速开始](#快速开始) · [核心能力](#核心能力) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
OCI Portal 将多份 OCI API Key、云资源、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
|
||||||
|
|
||||||
|
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|
||||||
| 总览 | 登录 |
|
| 总览 | 登录 |
|
||||||
@@ -28,78 +33,415 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## 特性
|
## 核心能力
|
||||||
|
|
||||||
- **多租户管理**:多份 OCI API Key 配置集中管理,私钥/口令 AES-256-GCM 加密落库;分组、批量测活、账户类型与订阅信息识别
|
- **租户与区域**:集中管理多份 OCI API Key,支持分组、批量测活、账户画像、订阅区域缓存与区域切换;私钥和口令使用 AES-256-GCM 加密落库
|
||||||
- **资源管理**:实例(含创建/电源操作/更换公网 IP/IPv6/VNIC/引导卷)、VCN/子网/安全列表、块存储、限额与成本查询,多区域/多区间支持
|
- **计算、网络与存储**:实例创建与电源操作、公网 IP、IPv6、VNIC、串行控制台连接,VCN / 子网 / 安全列表,引导卷与块存储挂载,限额和成本查询
|
||||||
- **抢机任务**:cron 周期尝试创建实例直到成功,支持熔断与通知
|
- **自动化任务**:抢机、租户测活、成本同步、AI 渠道探测四类 cron 任务,提供执行日志、重叠执行防护、熔断与结果通知
|
||||||
- **网页控制台**:实例串行控制台(xterm)与 VNC(noVNC),两跳 SSH 隧道
|
- **网页控制台**:浏览器内使用 xterm 串行终端和 noVNC,通过 OCI 控制台连接建立两跳 SSH 隧道
|
||||||
- **AI 网关**:OpenAI / Anthropic 兼容端点转发 OCI GenAI(对话/Responses/Embeddings),号池渠道加权负载均衡、熔断探测、密钥管理与用量日志
|
- **身份与审计**:IAM 用户、MFA、API Key、密码策略、SAML 身份提供商、通知收件人和多 Identity Domain 管理;OCI Audit 事件可经 Service Connector Hub 与 Notifications 回传,关键事件按类别通过「云端事件」通知推送
|
||||||
- **日志回传**:OCI Audit 事件经 Connector Hub → Notifications HTTPS 订阅回传入库,一键创建链路
|
- **通知与安全**:Telegram、Webhook、ntfy、Bark、SMTP 五类渠道;JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和系统操作审计
|
||||||
- **租户治理**:IAM 用户/MFA/API Key 管理、密码策略、身份提供商(SAML)、通知收件人
|
- **AI 网关**:提供 OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages 兼容接口,支持渠道分组、加权路由、熔断探测、模型黑白名单、密钥管理和调用日志
|
||||||
- **安全**:JWT + bcrypt、TOTP 两步验证、OIDC/GitHub 外部登录、登录锁定、IP 限速、真实 IP 头可配、系统操作审计
|
|
||||||
- **通知**:Telegram 模板化推送(测活/抢机/成本/锁定等事件)
|
## 运行形态
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器 / API 客户端
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────── OCI Portal 单个 Go 进程 ────────────────────┐
|
||||||
|
│ Vue 3 静态资源(go:embed) │
|
||||||
|
│ /api/v1 → Gin → Service → OCI SDK │
|
||||||
|
│ /ai/v1 → 兼容转换与路由 → OCI Generative AI │
|
||||||
|
│ GORM → SQLite(默认)/ MySQL / PostgreSQL(experimental) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
默认推荐 SQLite 单实例部署。MySQL 和 PostgreSQL 适配仍属 experimental,不应据此推断服务支持多副本并发运行。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
### Docker Compose(推荐)
|
||||||
|
|
||||||
|
前置条件:Docker、Docker Compose v2、OpenSSL。
|
||||||
|
|
||||||
|
1. 克隆仓库并生成一份需要长期保存的 `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/wangdefaa/oci-portal.git
|
||||||
|
cd oci-portal
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
printf 'DATA_KEY=%s\nJWT_SECRET=%s\nADMIN_PASSWORD=%s\n' \
|
||||||
|
"$(openssl rand -hex 32)" \
|
||||||
|
"$(openssl rand -hex 32)" \
|
||||||
|
"$(openssl rand -base64 24)" > .env
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 准备数据目录并启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
|
||||||
|
# Linux bind mount 需要让镜像内的 nonroot 用户(uid 65532)可写。
|
||||||
|
sudo chown 65532:65532 data
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 查看初始管理员密码并登录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep '^ADMIN_PASSWORD=' .env
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://127.0.0.1:18888`,默认用户名为 `admin`。
|
||||||
|
|
||||||
|
> `DATA_KEY` 用于解密数据库中的 OCI 私钥、口令和渠道凭据。它只能生成一次并持续复用;丢失或更换后,已有密文无法恢复。请将 `.env` 与 `data/oci-portal.db` 一起备份。
|
||||||
|
|
||||||
### 二进制运行
|
### 二进制运行
|
||||||
|
|
||||||
从 Release 下载对应架构的二进制后:
|
Release 提供 Linux amd64 / arm64 二进制。以下以 amd64 为例;arm64 主机将文件名中的 `amd64` 替换为 `arm64`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> ./oci-portal-server
|
curl -fLO https://github.com/wangdefaa/oci-portal/releases/latest/download/oci-portal-server-linux-amd64
|
||||||
|
chmod +x oci-portal-server-linux-amd64
|
||||||
|
|
||||||
|
# 复用上文生成并妥善保存的 .env。
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
./oci-portal-server-linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://localhost:8080`,用 `admin` 与初始密码登录。
|
默认访问地址为 `http://localhost:8080`。`ADMIN_PASSWORD` 只在数据库中没有用户时创建初始管理员,后续启动不会用它重置密码。
|
||||||
|
|
||||||
### Docker Compose
|
### 源码构建
|
||||||
|
|
||||||
|
源码构建要求 Go 1.26.5、`curl`、`unzip` 和 `sha256sum`。仓库只保留前端占位页,编译完整单文件前应下载 `DASH_VERSION` 指定的前端产物并校验:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATA_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=<初始密码> docker compose up -d
|
DASH_TAG="$(tr -d '\r\n' < DASH_VERSION)"
|
||||||
|
DASH_BASE="https://github.com/wangdefaa/oci-portal-dash/releases/download/${DASH_TAG}"
|
||||||
|
|
||||||
|
curl -fL -o dist.zip "${DASH_BASE}/dist.zip"
|
||||||
|
curl -fL -o dist.zip.sha256 "${DASH_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
|
||||||
|
|
||||||
|
CGO_ENABLED=0 go build -trimpath -o bin/oci-portal-server ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
### 源码构建(单文件,含前端)
|
本地构建显示 `dev` 版本;Release 工作流会注入正式版本和构建时间。`internal/webui/dist` 中的真实前端产物不应提交到仓库。
|
||||||
|
|
||||||
```bash
|
## 生产部署
|
||||||
# 1. 获取前端产物:从前端仓库 Release 下载 dist.zip(或本地 npm run build 后拷入)
|
|
||||||
curl -fL -o dist.zip https://github.com/wangdefaa/oci-portal-dash/releases/latest/download/dist.zip
|
服务本身只提供 HTTP。除本机试用外,应保持服务仅监听回环地址或容器内部网络,并由 TLS 反向代理提供 HTTPS;否则管理员密码、JWT、AI 密钥和租户凭据会经明文连接传输。
|
||||||
rm -rf internal/webui/dist && mkdir -p internal/webui/dist && unzip -q dist.zip -d internal/webui/dist
|
|
||||||
# 2. 编译(免 CGO,可交叉编译;-X 两项注入「设置·关于」页的版本与构建时间,可省略,省略则显示 dev)
|
### Caddy
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
|
||||||
-ldflags "-s -w -X oci-portal/internal/api.buildVersion=v0.0.1 -X oci-portal/internal/api.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
```caddyfile
|
||||||
-o bin/oci-portal-server ./cmd/server
|
portal.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:18888
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 注意:上述解压会覆盖仓库占位文件 `internal/webui/dist/index.html`,提交代码前勿把真实产物带入版本库。
|
Caddy 会自动处理 WebSocket。使用 nginx、Traefik 或其他反向代理时,还需要满足:
|
||||||
|
|
||||||
## 环境变量
|
- 为串行终端和 VNC 转发 WebSocket Upgrade 头
|
||||||
|
- 为 AI 流式响应和控制台连接设置较长的读写超时,建议 `1h`
|
||||||
|
- 整站请求体上限至少为 `10MB`;后端会继续限制面板 API 为 `1MB`、AI 网关为 `10MB`
|
||||||
|
- 将面板内「设置 → 安全 → 真实 IP 请求头」配置为反向代理实际写入的头;系统审计、登录锁定和 IP 限速依赖它
|
||||||
|
- 配置 `PUBLIC_URL` 或面板地址,供 OAuth 回调和 OCI 日志回传链路使用
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>nginx 最小示例</summary>
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
若前端静态文件与后端分离部署,`/api/*` 和 `/ai/*` 必须代理到后端,其余路径由 SPA 静态服务处理并回退到 `index.html`。Traefik 通过容器网络连接时应直达容器端口 `8080`,无需暴露宿主机端口。
|
||||||
|
|
||||||
|
## AI 网关
|
||||||
|
|
||||||
|
AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer sk-...` 和 `x-api-key: sk-...`。密钥可绑定渠道分组和模型白名单;全局模型黑名单会从模型列表、路由和探测候选中同时排除目标模型。
|
||||||
|
|
||||||
|
| 端点 | 定位 | 流式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /ai/v1/responses` | OpenAI Responses,无状态主接口 | SSE;服务端工具除外 |
|
||||||
|
| `POST /ai/v1/chat/completions` | OpenAI Chat Completions,存量客户端兼容层 | SSE |
|
||||||
|
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||||
|
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
||||||
|
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||||
|
|
||||||
|
兼容边界:
|
||||||
|
|
||||||
|
- 对话请求统一转发 OCI OpenAI 兼容面,当前供给以 `xai.`、`meta.`、`openai.` 前缀模型为主;Cohere Embeddings 不受该对话模型范围影响
|
||||||
|
- 网关不保存会话历史,客户端需要携带完整上下文;Responses 拒绝非空 `previous_response_id`、非 `null` `conversation` 和 `background:true`
|
||||||
|
- Responses 支持 xAI Grok `web_search` / `x_search` 服务端工具,但仅限非流式请求
|
||||||
|
- Responses 的 `reasoning.effort`、Messages 的 `output_config.effort` 和 Chat Completions 的 `reasoning_effort` 会传给上游,实际档位和效果由模型决定
|
||||||
|
- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供
|
||||||
|
- 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试
|
||||||
|
|
||||||
|
这里提供的是兼容接口而非 OpenAI / Anthropic 协议的完整实现。OCI OpenAI 兼容面的部分行为来自实测,未见 Oracle 文档承诺,可能随上游调整。路由与鉴权定义以 [Swagger YAML](docs/swagger.yaml) 或运行时 Swagger UI 为准;无法由 OpenAPI 完整表达的兼容边界列于上方。
|
||||||
|
|
||||||
|
### 字段兼容矩阵
|
||||||
|
|
||||||
|
以下矩阵以 2026-07-13 的 [OpenAI Responses](https://developers.openai.com/api/reference/resources/responses/methods/create)、[Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)、[Embeddings](https://developers.openai.com/api/reference/resources/embeddings/methods/create) 和 [Anthropic Messages](https://platform.claude.com/docs/en/api/messages/create) 为标准基线,并与当前实现逐项核对。
|
||||||
|
|
||||||
|
这是一份兼容性快照,不替代 Swagger。标准接口和 OCI 上游都可能变化,最终行为以当前版本代码与实测为准。
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
| :---: | --- |
|
||||||
|
| ✅ | 网关直接支持 |
|
||||||
|
| ➡️ | 网关原样透传;是否生效由 OCI 上游决定 |
|
||||||
|
| 🔄 | 网关进行字段或协议转换后支持 |
|
||||||
|
| ◐ | 部分支持、存在前置条件或语义降级 |
|
||||||
|
| ⚠️ | 请求可被接受,但字段会被忽略 |
|
||||||
|
| ❌ | 网关在请求到达上游前拒绝 |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/responses</code> 对比 OpenAI Responses</summary>
|
||||||
|
|
||||||
|
Responses 是“原始 JSON 直通 + 本地门禁”。除 `store` 外,网关不会重建请求体;未知顶层字段也会保留并送往 OCI。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必须非空,还要通过密钥模型白名单并匹配可用渠道;随后原样透传 |
|
||||||
|
| `input` | ➡️ | 支持标准的字符串或 item 数组,原始内容透传;网关不逐项保证 OCI 能处理所有 item 类型 |
|
||||||
|
| `instructions`、`max_output_tokens` | ➡️ | 类型可解析后原样透传,不做范围或模型能力校验 |
|
||||||
|
| `temperature`、`top_p`、`parallel_tool_calls` | ➡️ | 原样透传,不做取值范围校验 |
|
||||||
|
| `text` / `text.format` / `text.verbosity` | ➡️ | 整个原始对象透传;结构化输出是否可用由 OCI 模型决定 |
|
||||||
|
| `reasoning` | ➡️ | 整个原始对象透传;`effort` 不校验档位,`summary` 等字段不会被网关删除 |
|
||||||
|
| `tool_choice` | ➡️ | 任意 JSON 原样透传,本地不校验枚举或结构 |
|
||||||
|
| `context_management`、`include`、`max_tool_calls`、`metadata`、`moderation`、`prompt` | ➡️ | 网关未建模但会保留在原始请求中,由 OCI 决定是否接受 |
|
||||||
|
| `prompt_cache_key`、`prompt_cache_retention`、`safety_identifier`、`service_tier` | ➡️ | 原样透传,不代表 OCI 一定实现 OpenAI 的对应语义 |
|
||||||
|
| `stream_options`、`top_logprobs`、`truncation`、`user` | ➡️ | 原样透传,不做本地语义校验 |
|
||||||
|
| `store` | 🔄 | 无论客户端传什么,上游请求都强制改写为 `false` |
|
||||||
|
| `previous_response_id` | ❌ | 非空即返回 400;网关不保存历史响应 |
|
||||||
|
| `conversation` | ◐ | 省略或 `null` 可通过;任何非 `null` 值返回 400 |
|
||||||
|
| `background` | ◐ | `true` 返回 400;`false`、`null` 或省略时继续透传 |
|
||||||
|
| `stream` | ◐ | 普通请求和 `function` 工具支持 SSE;含 `web_search` / `x_search` 时拒绝流式 |
|
||||||
|
| `tools[].type=function` | ➡️ | 非流式和流式均可,工具对象原样透传 |
|
||||||
|
| `tools[].type=web_search` / `x_search` | ◐ | 使用 xAI 服务端工具语义且仅支持非流式;`x_search` 不是 OpenAI 标准工具 |
|
||||||
|
| 其他标准工具类型 | ❌ | `web_search_preview`、`file_search`、`computer`、`code_interpreter`、`image_generation`、`mcp`、`shell`、`custom` 等返回 400 |
|
||||||
|
| 其他未知顶层字段 | ➡️ | 只要整个请求是合法 JSON,字段名、结构和数值都会保留 |
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式成功响应不做转换,OCI JSON 原样返回;usage 解析只用于面板调用日志
|
||||||
|
- SSE 事件逐行原样转发,不补 `data: [DONE]`,推理增量也不会被网关过滤
|
||||||
|
- 流建立前最多切换三个渠道;流建立后中断不重试,客户端可能只收到部分事件
|
||||||
|
- 未知模型返回 404、无渠道返回 503;上游错误会套入 OpenAI 风格错误体,不保证与标准 OpenAI 错误字段完全相同
|
||||||
|
|
||||||
|
实现依据:[`airesponses.go`](internal/service/airesponses.go) · [`responses.go`](internal/aiwire/responses.go) · [`aigateway.go`](internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/chat/completions</code> 对比 OpenAI Chat Completions</summary>
|
||||||
|
|
||||||
|
Chat Completions 会先转换为 Responses 请求,再把 OCI Responses 响应桥接回 Chat Completions 形态。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单和可用渠道限制;模型名保留到上游请求 |
|
||||||
|
| `messages` | 🔄 | 必填并转换为 Responses `input` / `instructions` |
|
||||||
|
| `system` / `developer` 消息 | ◐ | 文本按出现顺序合并为 `instructions`;块数组中的非文本内容被忽略 |
|
||||||
|
| `user` / `assistant` 文本内容 | 🔄 | 字符串及 `text` 块分别转为 `input_text` / `output_text` |
|
||||||
|
| `image_url` 内容块 | ◐ | URL 或 data URI 转为 `input_image`;`image_url.detail` 被忽略 |
|
||||||
|
| `input_audio`、`file`、`refusal` 等内容块 | ❌ | 当前消息转换器不支持,返回 400 |
|
||||||
|
| assistant `tool_calls` / `role=tool` | 🔄 | 转为 `function_call` / `function_call_output`,保留调用 ID、函数名和参数 |
|
||||||
|
| 消息 `name`、`refusal`、`audio`、旧式 `function_call` | ⚠️ | 当前消息结构未建模,静默忽略 |
|
||||||
|
| `max_completion_tokens` | 🔄 | 转为 `max_output_tokens`,优先于 `max_tokens` |
|
||||||
|
| `max_tokens` | 🔄 | 未提供 `max_completion_tokens` 时转为 `max_output_tokens` |
|
||||||
|
| `temperature`、`top_p`、`parallel_tool_calls` | ◐ | 原值写入 Responses 请求,但不校验范围或模型能力 |
|
||||||
|
| `stream` | 🔄 | OCI Responses SSE 桥接为 `chat.completion.chunk`,末尾补 `data: [DONE]` |
|
||||||
|
| `stream_options.include_usage` | 🔄 | 控制网关在终块后追加 `choices: []` 的 usage 块 |
|
||||||
|
| `stream_options.include_obfuscation` | ⚠️ | 未建模,静默忽略 |
|
||||||
|
| `tools[].type=function` | ◐ | `name`、`description`、`parameters` 支持;`function.strict` 被忽略 |
|
||||||
|
| `tools[].type=custom` 及其他工具 | ❌ | 只接受 `function`,其他类型返回 400 |
|
||||||
|
| `tool_choice` | ◐ | 支持 `auto` / `none` / `required` 和具名 function;非法或未知值被静默忽略 |
|
||||||
|
| `response_format` | ◐ | `json_object`、`json_schema` 转为 Responses `text.format`;未知类型交给 OCI 处理 |
|
||||||
|
| `reasoning_effort` | ◐ | 转小写后映射为 `reasoning.effort`,不校验模型或档位 |
|
||||||
|
| `store` | ⚠️ / 🔄 | 客户端字段被忽略,转换后的上游请求始终使用 `store:false` |
|
||||||
|
| `stop`、`seed`、`n`、`frequency_penalty`、`presence_penalty` | ⚠️ | 静默忽略;`n` 因此恒为单个 choice |
|
||||||
|
| `logprobs`、`top_logprobs`、`logit_bias`、`user` | ⚠️ | 静默忽略 |
|
||||||
|
| `audio`、`modalities`、`prediction`、`metadata`、`moderation` | ⚠️ | 静默忽略 |
|
||||||
|
| `prompt_cache_key`、`safety_identifier`、`service_tier`、`verbosity`、`web_search_options` | ⚠️ | 静默忽略 |
|
||||||
|
| 其他未知字段 | ⚠️ | JSON 解码器不会拒绝未知字段,但转换后的上游请求不包含它们 |
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式固定生成一个 `choices[0]`;文本会合并,函数调用转为 `tool_calls`
|
||||||
|
- `finish_reason` 只生成 `stop`、`tool_calls`、`length`;其他上游终止原因不保留
|
||||||
|
- reasoning 输出、logprobs、refusal、annotations、audio、service tier 和 system fingerprint 不返回
|
||||||
|
- usage 保留 `prompt_tokens`、`completion_tokens`、`total_tokens` 和 `prompt_tokens_details.cached_tokens`
|
||||||
|
- 已建立的流中断或上游 `response.failed` 不会转换成标准 SSE 错误事件
|
||||||
|
|
||||||
|
实现依据:[`chatresponses.go`](internal/service/chatresponses.go) · [`openai.go`](internal/aiwire/openai.go) · [`aigateway.go`](internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/embeddings</code> 对比 OpenAI Embeddings</summary>
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 必填,受密钥白名单限制,并且必须存在具有 `EMBEDDING` 能力的渠道 |
|
||||||
|
| `input` 为字符串 | ✅ | 包装为单个输入后调用 OCI |
|
||||||
|
| `input` 为字符串数组 | ✅ | 按原顺序调用 OCI |
|
||||||
|
| `input` 为 token ID 数组或二维 token ID 数组 | ❌ | 只能解码字符串或字符串数组,绑定阶段返回 400 |
|
||||||
|
| 空数组 / `null` | ❌ | 本地返回 400;空字符串不在本地拒绝,由 OCI 决定 |
|
||||||
|
| `dimensions` | ◐ | 映射为 OCI 输出维度,不做范围或模型能力校验 |
|
||||||
|
| `encoding_format=float` | ✅ | 返回 float 数组;省略时行为相同 |
|
||||||
|
| `encoding_format=base64` | ❌ | 本地返回 400,不提供 base64 响应 |
|
||||||
|
| `user` | ⚠️ | 能解析但不会传给 OCI |
|
||||||
|
| 其他未知字段 | ⚠️ | 静默忽略 |
|
||||||
|
|
||||||
|
响应使用标准的 `object:"list"`、`data[].object:"embedding"`、`index`、`model` 和可选 `usage` 外壳;向量为 `float32` 数组,不支持流式。
|
||||||
|
|
||||||
|
实现依据:[`embeddings.go`](internal/aiwire/embeddings.go) · [`aigateway_chat.go`](internal/service/aigateway_chat.go) · [`aigateway.go`](internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>POST /ai/v1/messages</code> 对比 Anthropic Messages</summary>
|
||||||
|
|
||||||
|
网关接受 `Authorization: Bearer` 或 `x-api-key`,但不会校验或使用标准 Anthropic `anthropic-version`、`anthropic-beta` 请求头。
|
||||||
|
|
||||||
|
| 标准字段 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| `model` | ◐ | 用于模型与渠道选择,但空字符串不会在 handler 中按参数错误拒绝,通常最终返回模型不存在 |
|
||||||
|
| `max_tokens` | 🔄 | 必填且必须大于 0,转换为 `max_output_tokens` |
|
||||||
|
| `messages` | ◐ | 必须非空;角色、交替顺序和空内容不做完整校验 |
|
||||||
|
| `system` | ◐ | 支持字符串或 text 块数组;多个文本块直接拼接,`cache_control` 等附加字段被忽略 |
|
||||||
|
| `temperature`、`top_p` | ◐ | 写入 Responses 请求,不做取值范围或模型能力校验 |
|
||||||
|
| `top_k` | ⚠️ | 能解析但不会传给上游 |
|
||||||
|
| `stop_sequences` | ⚠️ | 能解析但不会传给上游;响应 `stop_sequence` 恒为 `null` |
|
||||||
|
| `stream` | 🔄 | Responses SSE 桥接为 Anthropic 事件序列 |
|
||||||
|
| `tools` | ◐ | 每个工具都转换成 Responses `function`;自定义客户端工具可用,Anthropic 服务端工具类型不保留原语义 |
|
||||||
|
| `tool_choice` | ◐ | 支持 `auto`、`any`、`none`、具名 `tool`;`disable_parallel_tool_use` 等附加字段被忽略 |
|
||||||
|
| `metadata` | ⚠️ | 能解析但不会传给上游 |
|
||||||
|
| `thinking` | ⚠️ | 顶层 thinking 配置不会控制上游思考预算 |
|
||||||
|
| `output_config.effort` | 🔄 | 转小写后映射为 Responses `reasoning.effort` |
|
||||||
|
| `output_config` 其他子字段 | ⚠️ | 未建模,静默忽略 |
|
||||||
|
| `cache_control`、`container`、`inference_geo`、`service_tier` | ⚠️ | 标准 SDK 中存在,但当前请求结构未建模,静默忽略 |
|
||||||
|
| 其他未知顶层字段 | ⚠️ | JSON 解码器接受,但转换后的上游请求不包含它们 |
|
||||||
|
|
||||||
|
`messages[].content`:
|
||||||
|
|
||||||
|
| 标准内容块 | 状态 | 网关行为 |
|
||||||
|
| --- | :---: | --- |
|
||||||
|
| 字符串 / `text` | 🔄 | user 转 `input_text`,assistant 历史转 `output_text` |
|
||||||
|
| `image` | ◐ | 仅支持 `base64` 和 `url` source;缺字段或其他 source 类型返回 400 |
|
||||||
|
| `tool_use` | 🔄 | 转为 `function_call`,保留 ID、名称和输入 |
|
||||||
|
| `tool_result` | ◐ | 转为 `function_call_output`;块数组只拼接 text,`is_error` 和非文本结果丢失 |
|
||||||
|
| `thinking` / `redacted_thinking` | ⚠️ | 历史思考块被删除,不进入上游上下文 |
|
||||||
|
| `document`、服务端工具结果及其他未知块 | ❌ | 返回 400 |
|
||||||
|
| `null` / 空块数组 | ◐ | 该消息可能从上游 input 中消失,不返回参数错误 |
|
||||||
|
|
||||||
|
响应边界:
|
||||||
|
|
||||||
|
- 非流式只把 Responses `output_text` 转成 `text`、`function_call` 转成 `tool_use`
|
||||||
|
- `stop_reason` 只生成 `end_turn`、`tool_use`、`max_tokens`;`stop_sequence` 恒为 `null`
|
||||||
|
- reasoning 不会生成 Anthropic `thinking` / `redacted_thinking` 块,也没有 signature
|
||||||
|
- usage 只保留 `input_tokens`、`output_tokens` 和 `cache_read_input_tokens`,不提供 `cache_creation_input_tokens`
|
||||||
|
- 流式输出标准事件骨架,但不生成 `thinking_delta`、`signature_delta` 或上游失败对应的 Anthropic `error` 事件
|
||||||
|
|
||||||
|
实现依据:[`anthresponses.go`](internal/service/anthresponses.go) · [`anthropic.go`](internal/aiwire/anthropic.go) · [`aigateway.go`](internal/api/aigateway.go)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。
|
||||||
|
|
||||||
|
## API 与配置
|
||||||
|
|
||||||
|
### API 文档
|
||||||
|
|
||||||
|
| 路径 | 鉴权 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/v1/*` | 登录返回的 JWT Bearer Token | 面板管理 API |
|
||||||
|
| `/ai/v1/*` | AI 网关密钥 | OpenAI / Anthropic 兼容接口 |
|
||||||
|
| `/api/v1/webhooks/oci-logs/:secret` | URL 中的独立回传密钥 | OCI Notifications 日志回传 |
|
||||||
|
|
||||||
|
OpenAPI 文件随仓库维护:[`docs/swagger.yaml`](docs/swagger.yaml) · [`docs/swagger.json`](docs/swagger.json)。
|
||||||
|
|
||||||
|
运行进程时设置 `SWAGGER=1` 可开放 `/swagger/index.html`。Swagger 默认关闭,生产环境建议仅在受控网络内按需开启。接口注释变更后必须重新生成 OpenAPI。
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
| 变量 | 必填 | 默认值 | 说明 |
|
| 变量 | 必填 | 默认值 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | :---: | --- | --- |
|
||||||
| `DATA_KEY` | 是 | 无 | 敏感字段加密主密钥(更换后已入库密文无法解密) |
|
| `DATA_KEY` | 是 | — | 敏感字段加密主密钥;必须持久保存,不能随意轮换 |
|
||||||
| `JWT_SECRET` | 是 | 无 | 登录令牌签名密钥 |
|
| `JWT_SECRET` | 是 | — | JWT 签名密钥;更换会使已有登录令牌失效 |
|
||||||
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
| `ADMIN_USERNAME` | 否 | `admin` | 初始管理员用户名 |
|
||||||
| `ADMIN_PASSWORD` | 首次启动是 | 无 | 仅在用户不存在时创建;已存在不重置 |
|
| `ADMIN_PASSWORD` | 首次启动 | — | 仅在数据库无用户时创建管理员,不会重置已有密码 |
|
||||||
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
| `ADDR` | 否 | `:8080` | HTTP 监听地址 |
|
||||||
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`(后两者 experimental) |
|
| `DB_DRIVER` | 否 | `sqlite` | `sqlite` / `mysql` / `postgres`;后两者为 experimental |
|
||||||
| `DB_DSN` | 外部库时是 | 无 | MySQL 需 `parseTime=True`;PostgreSQL 标准 DSN |
|
| `DB_PATH` | SQLite | `oci-portal.db` | SQLite 文件路径 |
|
||||||
| `DB_PATH` | 否 | `oci-portal.db` | SQLite 文件路径 |
|
| `DB_DSN` | 外部数据库 | — | MySQL 需 `parseTime=True`;不要在日志或文档中暴露凭据 |
|
||||||
| `PUBLIC_URL` | 否 | 无 | 面板公网基址,日志回传一键创建链路用 |
|
| `PUBLIC_URL` | 否 | — | 面板公网基址,作为 OAuth 回调和日志回传引导的回退值 |
|
||||||
| `HTTPS_PROXY` | 否 | 无 | 出站代理(如 Telegram 通知走代理) |
|
| `TZ` | 否 | 系统时区 | cron 表达式的解释时区;容器示例使用 `Asia/Shanghai` |
|
||||||
| `TZ` | 否 | 系统 | cron 表达式解释时区(容器内建议显式设置,二进制已嵌 tzdata) |
|
| `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 否 | — | Go 标准出站代理变量;面板内显式代理配置优先用于对应业务 |
|
||||||
| `SWAGGER` | 否 | 关 | `1` 时开放 `/swagger/index.html` API 文档(生产建议按需临时开启) |
|
| `SWAGGER` | 否 | 关闭 | 设为 `1` 时开放 Swagger UI |
|
||||||
|
| `GIN_MODE` | 否 | `release` | `debug` / `release` |
|
||||||
|
|
||||||
|
## 升级与备份
|
||||||
|
|
||||||
|
- 升级前同时备份 `.env` 和数据库;SQLite Compose 部署的数据文件为 `data/oci-portal.db`
|
||||||
|
- 保持 `DATA_KEY` 不变;只恢复数据库而没有原密钥,敏感字段无法解密
|
||||||
|
- 服务启动时会自动执行数据库迁移;跨版本升级前先阅读 [CHANGELOG](CHANGELOG.md)
|
||||||
|
- Compose 部署使用 `docker compose pull && docker compose up -d` 更新镜像
|
||||||
|
- OCI API Key 应遵循最小权限原则;生产环境保持 Swagger 关闭并限制管理面访问来源
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... # 全量测试
|
gofmt -l .
|
||||||
go vet ./... && gofmt -l .
|
go vet ./...
|
||||||
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency # 接口注释变更后重新生成 OpenAPI
|
go test ./...
|
||||||
|
|
||||||
|
# Handler 注释变更后重新生成唯一的对外 API 文档。
|
||||||
|
go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency
|
||||||
```
|
```
|
||||||
|
|
||||||
API 文档:全部接口带 swaggo 注释,`SWAGGER=1` 启动后访问 `/swagger/index.html`;spec 文件在 `docs/swagger.json|yaml`。
|
- Go 后端规范与目录约定:[`AGENTS.md`](AGENTS.md) · [`.trellis/spec/backend/`](.trellis/spec/backend/)
|
||||||
|
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。
|
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+42
-1
@@ -9,12 +9,15 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
// 纯 Go 时区数据(+~450KB):distroless/scratch 容器无 tzdata,
|
// 纯 Go 时区数据(+~450KB):distroless/scratch 容器无 tzdata,
|
||||||
// 嵌入后 TZ 环境变量即可让 cron 按本地时区解释
|
// 嵌入后 TZ 环境变量即可让 cron 按本地时区解释
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
// swag 生成的 OpenAPI spec(init 时注册,SWAGGER=1 的 /swagger 路由消费)
|
// swag 生成的 OpenAPI spec(init 时注册,SWAGGER=1 的 /swagger 路由消费)
|
||||||
@@ -35,6 +38,12 @@ import (
|
|||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
// @description 登录接口返回的 JWT,格式: Bearer <token>
|
// @description 登录接口返回的 JWT,格式: Bearer <token>
|
||||||
|
func init() {
|
||||||
|
if os.Getenv("GIN_MODE") == "" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
healthcheck := flag.Bool("healthcheck", false, "探活本机服务后退出:健康 0,异常 1(容器 HEALTHCHECK 用)")
|
healthcheck := flag.Bool("healthcheck", false, "探活本机服务后退出:健康 0,异常 1(容器 HEALTHCHECK 用)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -126,6 +135,7 @@ func run() error {
|
|||||||
defer aiGateway.Wait()
|
defer aiGateway.Wait()
|
||||||
defer stopCleanup()
|
defer stopCleanup()
|
||||||
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
tasks := service.NewTaskService(db, ociConfigs, notifier, settings)
|
||||||
|
ociConfigs.SetTenantCleanupDeps(tasks)
|
||||||
tasks.AttachAiGateway(aiGateway)
|
tasks.AttachAiGateway(aiGateway)
|
||||||
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
|
aiGateway.SetOnChannelsChanged(tasks.SyncAiProbeTask)
|
||||||
if err := tasks.Start(); err != nil {
|
if err := tasks.Start(); err != nil {
|
||||||
@@ -137,5 +147,36 @@ func run() error {
|
|||||||
console := service.NewConsoleService(ociConfigs)
|
console := service.NewConsoleService(ociConfigs)
|
||||||
proxies := service.NewProxyService(db, cipher)
|
proxies := service.NewProxyService(db, cipher)
|
||||||
defer proxies.Wait()
|
defer proxies.Wait()
|
||||||
return api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway).Run(cfg.Addr)
|
// 「关于」页存储指标需知数据库形态
|
||||||
|
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
|
||||||
|
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
@@ -1,9 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
oci-portal:
|
oci-portal:
|
||||||
image: ghcr.io/wangdefaa/oci-portal:latest
|
image: ghcr.io/wangdefaa/oci-portal:latest
|
||||||
|
container_name: oci-portal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "18888:8080"
|
- "127.0.0.1:18888:8080"
|
||||||
environment:
|
environment:
|
||||||
DB_PATH: /data/oci-portal.db
|
DB_PATH: /data/oci-portal.db
|
||||||
DATA_KEY: ${DATA_KEY:?DATA_KEY is required}
|
DATA_KEY: ${DATA_KEY:?DATA_KEY is required}
|
||||||
@@ -11,7 +12,4 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- oci-portal-data:/data
|
- ./data:/data
|
||||||
|
|
||||||
volumes:
|
|
||||||
oci-portal-data:
|
|
||||||
|
|||||||
+465
-13
@@ -20,10 +20,10 @@ const docTemplate = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"AI 网关"
|
"AI 网关"
|
||||||
],
|
],
|
||||||
"summary": "OpenAI 兼容对话补全",
|
"summary": "OpenAI Chat Completions 兼容端点",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "OpenAI chat/completions 请求体(支持 stream)",
|
"description": "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -34,7 +34,7 @@ const docTemplate = `{
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OpenAI 兼容响应(流式为 SSE)",
|
"description": "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
@@ -79,7 +79,7 @@ const docTemplate = `{
|
|||||||
"summary": "Anthropic Messages 兼容端点",
|
"summary": "Anthropic Messages 兼容端点",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "Anthropic messages 请求体(支持 stream)",
|
"description": "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -124,7 +124,7 @@ const docTemplate = `{
|
|||||||
"summary": "OpenAI Responses 兼容端点",
|
"summary": "OpenAI Responses 兼容端点",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "OpenAI responses 请求体(支持 stream)",
|
"description": "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -154,7 +154,7 @@ const docTemplate = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"设置"
|
"设置"
|
||||||
],
|
],
|
||||||
"summary": "返回构建与运行环境信息,「设置 · 关于」页展示",
|
"summary": "返回构建、运行时长与资源占用信息,「设置 · 关于」页展示",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -166,6 +166,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": {
|
"/api/v1/ai-channels": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -871,6 +951,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": {
|
"/api/v1/auth/totp": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1205,6 +1309,7 @@ const docTemplate = `{
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"description": "删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。",
|
||||||
"tags": [
|
"tags": [
|
||||||
"租户配置"
|
"租户配置"
|
||||||
],
|
],
|
||||||
@@ -1235,7 +1340,7 @@ const docTemplate = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"租户 IAM"
|
"租户 IAM"
|
||||||
],
|
],
|
||||||
"summary": "实时查询租户 OCI 审计事件:hours 首查",
|
"summary": "批式懒加载查询租户 OCI 审计事件",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1243,6 +1348,18 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "续查游标(上次响应原样带回)",
|
||||||
|
"name": "cursor",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "单批目标条数,缺省 100,上限 200",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1656,6 +1773,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": {
|
"/api/v1/oci-configs/{id}/identity-providers": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1674,6 +1822,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1704,6 +1858,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体",
|
"description": "请求体",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -1744,6 +1904,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "idpId",
|
"description": "idpId",
|
||||||
@@ -1778,6 +1944,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "idpId",
|
"description": "idpId",
|
||||||
@@ -1824,6 +1996,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1854,6 +2032,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体(见接口说明)",
|
"description": "请求体(见接口说明)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3015,6 +3199,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3045,6 +3235,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体(见接口说明)",
|
"description": "请求体(见接口说明)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3084,6 +3280,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3116,6 +3318,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "policyId",
|
"description": "policyId",
|
||||||
@@ -3231,6 +3439,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3478,6 +3692,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体(见接口说明)",
|
"description": "请求体(见接口说明)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3518,6 +3738,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "ruleId",
|
"description": "ruleId",
|
||||||
@@ -3551,6 +3777,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3835,6 +4067,12 @@ const docTemplate = `{
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3865,6 +4103,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体",
|
"description": "请求体",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3905,6 +4149,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -3941,6 +4191,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -3986,6 +4242,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -4058,6 +4320,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -4096,6 +4364,12 @@ const docTemplate = `{
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -4727,6 +5001,46 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/proxies/{id}/tenants": {
|
||||||
|
"put": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"设置"
|
||||||
|
],
|
||||||
|
"summary": "设置代理关联的租户全集",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "代理 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "请求体 {\\",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "usedBy",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/regions": {
|
"/api/v1/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -4749,6 +5063,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": {
|
"/api/v1/settings/notify-events": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5337,7 +5740,7 @@ const docTemplate = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"任务与日志回传"
|
"任务与日志回传"
|
||||||
],
|
],
|
||||||
"summary": "立即执行任务",
|
"summary": "立即执行任务(异步触发,结果经任务日志轮询获取)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -5348,8 +5751,15 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"202": {
|
||||||
"description": "OK",
|
"description": "Accepted",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "任务正在执行中",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
@@ -5817,14 +6227,18 @@ const docTemplate = `{
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
},
|
},
|
||||||
"totpCode": {
|
"totpCode": {
|
||||||
"description": "两步验证码;账号已启用 TOTP 时必填,缺失返回 428",
|
"description": "两步验证码;账号已启用 TOTP 时必填,缺失返回 428",
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 8
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"description": "字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5891,6 +6305,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": {
|
"internal_api.updateSecurityListRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+465
-13
@@ -13,10 +13,10 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"AI 网关"
|
"AI 网关"
|
||||||
],
|
],
|
||||||
"summary": "OpenAI 兼容对话补全",
|
"summary": "OpenAI Chat Completions 兼容端点",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "OpenAI chat/completions 请求体(支持 stream)",
|
"description": "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OpenAI 兼容响应(流式为 SSE)",
|
"description": "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"summary": "Anthropic Messages 兼容端点",
|
"summary": "Anthropic Messages 兼容端点",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "Anthropic messages 请求体(支持 stream)",
|
"description": "Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"summary": "OpenAI Responses 兼容端点",
|
"summary": "OpenAI Responses 兼容端点",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "OpenAI responses 请求体(支持 stream)",
|
"description": "OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"设置"
|
"设置"
|
||||||
],
|
],
|
||||||
"summary": "返回构建与运行环境信息,「设置 · 关于」页展示",
|
"summary": "返回构建、运行时长与资源占用信息,「设置 · 关于」页展示",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -159,6 +159,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": {
|
"/api/v1/ai-channels": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -864,6 +944,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": {
|
"/api/v1/auth/totp": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1198,6 +1302,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"description": "删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。",
|
||||||
"tags": [
|
"tags": [
|
||||||
"租户配置"
|
"租户配置"
|
||||||
],
|
],
|
||||||
@@ -1228,7 +1333,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"租户 IAM"
|
"租户 IAM"
|
||||||
],
|
],
|
||||||
"summary": "实时查询租户 OCI 审计事件:hours 首查",
|
"summary": "批式懒加载查询租户 OCI 审计事件",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1236,6 +1341,18 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "续查游标(上次响应原样带回)",
|
||||||
|
"name": "cursor",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "单批目标条数,缺省 100,上限 200",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1649,6 +1766,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": {
|
"/api/v1/oci-configs/{id}/identity-providers": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1667,6 +1815,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1697,6 +1851,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体",
|
"description": "请求体",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -1737,6 +1897,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "idpId",
|
"description": "idpId",
|
||||||
@@ -1771,6 +1937,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "idpId",
|
"description": "idpId",
|
||||||
@@ -1817,6 +1989,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1847,6 +2025,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体(见接口说明)",
|
"description": "请求体(见接口说明)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3008,6 +3192,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3038,6 +3228,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体(见接口说明)",
|
"description": "请求体(见接口说明)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3077,6 +3273,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3109,6 +3311,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "policyId",
|
"description": "policyId",
|
||||||
@@ -3224,6 +3432,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3471,6 +3685,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体(见接口说明)",
|
"description": "请求体(见接口说明)",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3511,6 +3731,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "ruleId",
|
"description": "ruleId",
|
||||||
@@ -3544,6 +3770,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3828,6 +4060,12 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3858,6 +4096,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "请求体",
|
"description": "请求体",
|
||||||
"name": "body",
|
"name": "body",
|
||||||
@@ -3898,6 +4142,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -3934,6 +4184,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -3979,6 +4235,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -4051,6 +4313,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -4089,6 +4357,12 @@
|
|||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "身份域 OCID(缺省 Default 域)",
|
||||||
|
"name": "domainId",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "userId",
|
"description": "userId",
|
||||||
@@ -4720,6 +4994,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/proxies/{id}/tenants": {
|
||||||
|
"put": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"设置"
|
||||||
|
],
|
||||||
|
"summary": "设置代理关联的租户全集",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "代理 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "请求体 {\\",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "usedBy",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/regions": {
|
"/api/v1/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -4742,6 +5056,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": {
|
"/api/v1/settings/notify-events": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -5330,7 +5733,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"任务与日志回传"
|
"任务与日志回传"
|
||||||
],
|
],
|
||||||
"summary": "立即执行任务",
|
"summary": "立即执行任务(异步触发,结果经任务日志轮询获取)",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -5341,8 +5744,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"202": {
|
||||||
"description": "OK",
|
"description": "Accepted",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "任务正在执行中",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
@@ -5810,14 +6220,18 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
},
|
},
|
||||||
"totpCode": {
|
"totpCode": {
|
||||||
"description": "两步验证码;账号已启用 TOTP 时必填,缺失返回 428",
|
"description": "两步验证码;账号已启用 TOTP 时必填,缺失返回 428",
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"maxLength": 8
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
"type": "string"
|
"description": "字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)",
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5884,6 +6298,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": {
|
"internal_api.updateSecurityListRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+297
-10
@@ -277,11 +277,15 @@ definitions:
|
|||||||
internal_api.loginRequest:
|
internal_api.loginRequest:
|
||||||
properties:
|
properties:
|
||||||
password:
|
password:
|
||||||
|
maxLength: 128
|
||||||
type: string
|
type: string
|
||||||
totpCode:
|
totpCode:
|
||||||
description: 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
description: 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
||||||
|
maxLength: 8
|
||||||
type: string
|
type: string
|
||||||
username:
|
username:
|
||||||
|
description: 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)
|
||||||
|
maxLength: 64
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- password
|
- password
|
||||||
@@ -328,6 +332,31 @@ definitions:
|
|||||||
description: 更换 shape,运行中实例会自动重启
|
description: 更换 shape,运行中实例会自动重启
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
internal_api.updateSecurityListRequest:
|
||||||
properties:
|
properties:
|
||||||
displayName:
|
displayName:
|
||||||
@@ -552,7 +581,7 @@ paths:
|
|||||||
/ai/v1/chat/completions:
|
/ai/v1/chat/completions:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
- description: OpenAI chat/completions 请求体(支持 stream)
|
- description: OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
required: true
|
required: true
|
||||||
@@ -560,11 +589,11 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OpenAI 兼容响应(流式为 SSE)
|
description: 'OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])'
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
type: object
|
type: object
|
||||||
summary: OpenAI 兼容对话补全
|
summary: OpenAI Chat Completions 兼容端点
|
||||||
tags:
|
tags:
|
||||||
- AI 网关
|
- AI 网关
|
||||||
/ai/v1/embeddings:
|
/ai/v1/embeddings:
|
||||||
@@ -588,7 +617,7 @@ paths:
|
|||||||
/ai/v1/messages:
|
/ai/v1/messages:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
- description: Anthropic messages 请求体(支持 stream)
|
- description: Anthropic messages 请求体(支持 stream;经 OCI OpenAI 兼容面直通)
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
required: true
|
required: true
|
||||||
@@ -617,7 +646,7 @@ paths:
|
|||||||
/ai/v1/responses:
|
/ai/v1/responses:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
- description: OpenAI responses 请求体(支持 stream)
|
- description: OpenAI responses 请求体(支持 stream;web_search/x_search 服务端工具仅非流式)
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
required: true
|
required: true
|
||||||
@@ -642,9 +671,57 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 返回构建与运行环境信息,「设置 · 关于」页展示
|
summary: 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||||
tags:
|
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:
|
/api/v1/ai-channels:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@@ -1078,6 +1155,20 @@ paths:
|
|||||||
summary: 密码登录开关
|
summary: 密码登录开关
|
||||||
tags:
|
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:
|
/api/v1/auth/totp:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@@ -1237,6 +1328,7 @@ paths:
|
|||||||
- 租户配置
|
- 租户配置
|
||||||
/api/v1/oci-configs/{id}:
|
/api/v1/oci-configs/{id}:
|
||||||
delete:
|
delete:
|
||||||
|
description: 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。
|
||||||
parameters:
|
parameters:
|
||||||
- description: 配置 ID
|
- description: 配置 ID
|
||||||
in: path
|
in: path
|
||||||
@@ -1301,6 +1393,14 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 续查游标(上次响应原样带回)
|
||||||
|
in: query
|
||||||
|
name: cursor
|
||||||
|
type: string
|
||||||
|
- description: 单批目标条数,缺省 100,上限 200
|
||||||
|
in: query
|
||||||
|
name: limit
|
||||||
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -1309,7 +1409,7 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 实时查询租户 OCI 审计事件:hours 首查
|
summary: 批式懒加载查询租户 OCI 审计事件
|
||||||
tags:
|
tags:
|
||||||
- 租户 IAM
|
- 租户 IAM
|
||||||
/api/v1/oci-configs/{id}/audit-events/detail:
|
/api/v1/oci-configs/{id}/audit-events/detail:
|
||||||
@@ -1560,6 +1660,25 @@ paths:
|
|||||||
summary: 配置成本快照
|
summary: 配置成本快照
|
||||||
tags:
|
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:
|
/api/v1/oci-configs/{id}/identity-providers:
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1568,6 +1687,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -1586,6 +1709,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: 请求体
|
- description: 请求体
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
@@ -1611,6 +1738,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: idpId
|
- description: idpId
|
||||||
in: path
|
in: path
|
||||||
name: idpId
|
name: idpId
|
||||||
@@ -1632,6 +1763,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: idpId
|
- description: idpId
|
||||||
in: path
|
in: path
|
||||||
name: idpId
|
name: idpId
|
||||||
@@ -1662,6 +1797,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -1680,6 +1819,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: 请求体(见接口说明)
|
- description: 请求体(见接口说明)
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
@@ -2412,6 +2555,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2430,6 +2577,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: 请求体(见接口说明)
|
- description: 请求体(见接口说明)
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
@@ -2455,6 +2606,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2474,6 +2629,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: policyId
|
- description: policyId
|
||||||
in: path
|
in: path
|
||||||
name: policyId
|
name: policyId
|
||||||
@@ -2547,6 +2706,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2701,6 +2864,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: 请求体(见接口说明)
|
- description: 请求体(见接口说明)
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
@@ -2726,6 +2893,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: ruleId
|
- description: ruleId
|
||||||
in: path
|
in: path
|
||||||
name: ruleId
|
name: ruleId
|
||||||
@@ -2747,6 +2918,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2925,6 +3100,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -2943,6 +3122,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: 请求体
|
- description: 请求体
|
||||||
in: body
|
in: body
|
||||||
name: body
|
name: body
|
||||||
@@ -2968,6 +3151,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: userId
|
- description: userId
|
||||||
in: path
|
in: path
|
||||||
name: userId
|
name: userId
|
||||||
@@ -2988,6 +3175,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: userId
|
- description: userId
|
||||||
in: path
|
in: path
|
||||||
name: userId
|
name: userId
|
||||||
@@ -3011,6 +3202,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: userId
|
- description: userId
|
||||||
in: path
|
in: path
|
||||||
name: userId
|
name: userId
|
||||||
@@ -3065,6 +3260,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: userId
|
- description: userId
|
||||||
in: path
|
in: path
|
||||||
name: userId
|
name: userId
|
||||||
@@ -3089,6 +3288,10 @@ paths:
|
|||||||
name: id
|
name: id
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
- description: 身份域 OCID(缺省 Default 域)
|
||||||
|
in: query
|
||||||
|
name: domainId
|
||||||
|
type: string
|
||||||
- description: userId
|
- description: userId
|
||||||
in: path
|
in: path
|
||||||
name: userId
|
name: userId
|
||||||
@@ -3465,6 +3668,31 @@ paths:
|
|||||||
summary: 手动重测代理出口地区,同步返回最新视图
|
summary: 手动重测代理出口地区,同步返回最新视图
|
||||||
tags:
|
tags:
|
||||||
- 设置
|
- 设置
|
||||||
|
/api/v1/proxies/{id}/tenants:
|
||||||
|
put:
|
||||||
|
parameters:
|
||||||
|
- description: 代理 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 请求体 {\
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: usedBy
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 设置代理关联的租户全集
|
||||||
|
tags:
|
||||||
|
- 设置
|
||||||
/api/v1/proxies/import:
|
/api/v1/proxies/import:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -3498,6 +3726,60 @@ paths:
|
|||||||
summary: 返回本地维护的完整区域表
|
summary: 返回本地维护的完整区域表
|
||||||
tags:
|
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:
|
/api/v1/settings/notify-events:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@@ -3856,14 +4138,19 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: OK
|
description: Accepted
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
"409":
|
||||||
|
description: 任务正在执行中
|
||||||
schema:
|
schema:
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
type: object
|
type: object
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 立即执行任务
|
summary: 立即执行任务(异步触发,结果经任务日志轮询获取)
|
||||||
tags:
|
tags:
|
||||||
- 任务与日志回传
|
- 任务与日志回传
|
||||||
/api/v1/webhooks/oci-logs/{secret}:
|
/api/v1/webhooks/oci-logs/{secret}:
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ type MessagesRequest struct {
|
|||||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
Thinking json.RawMessage `json:"thinking,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 或块数组)拍平为文本。
|
// SystemText 把顶层 system(string 或块数组)拍平为文本。
|
||||||
|
|||||||
+94
-121
@@ -1,6 +1,6 @@
|
|||||||
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Chat Completions(兼作网关内部
|
// Package aiwire 定义 AI 网关的线格式类型:OpenAI Responses / Chat Completions /
|
||||||
// 中间表示 IR,因 OCI GENERIC 格式即其镜像)与 Anthropic Messages。零内部依赖,
|
// Anthropic Messages / Embeddings 与通用记账、错误、模型列表结构。上游统一为
|
||||||
// oci 层与 service 层共同引用。
|
// OCI OpenAI 兼容面。
|
||||||
package aiwire
|
package aiwire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,28 +8,6 @@ import (
|
|||||||
"strings"
|
"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 两种线格式。
|
// StringList 兼容 OpenAI stop 的 string 与 []string 两种线格式。
|
||||||
type StringList []string
|
type StringList []string
|
||||||
|
|
||||||
@@ -50,7 +28,75 @@ func (s *StringList) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamOptions 控制流式末尾是否附带 usage 事件。
|
// Usage 是 token 用量。
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
|
||||||
|
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
|
||||||
|
type PromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
|
||||||
|
func (u *Usage) CachedTokens() int {
|
||||||
|
if u == nil || u.PromptTokensDetails == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.PromptTokensDetails.CachedTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model 是 /v1/models 列表项。
|
||||||
|
type Model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelList 是 /v1/models 响应体。
|
||||||
|
type ModelList struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []Model `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorBody 是 OpenAI 格式错误响应。
|
||||||
|
type ErrorBody struct {
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail 是错误明细。
|
||||||
|
type ErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chat Completions 线格式(/ai/v1/chat/completions,Tier 2 兼容层)----
|
||||||
|
|
||||||
|
// ChatRequest 是 OpenAI /v1/chat/completions 请求体;只声明网关读取的字段,
|
||||||
|
// 无对应物的字段(stop / seed / n / penalty 等)由 JSON 解码天然忽略。
|
||||||
|
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"`
|
||||||
|
ParallelToolCalls *bool `json:"parallel_tool_calls,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamOptions 控制流式末尾是否附带 usage 块。
|
||||||
type StreamOptions struct {
|
type StreamOptions struct {
|
||||||
IncludeUsage bool `json:"include_usage"`
|
IncludeUsage bool `json:"include_usage"`
|
||||||
}
|
}
|
||||||
@@ -59,48 +105,31 @@ type StreamOptions struct {
|
|||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content Content `json:"content"`
|
Content Content `json:"content"`
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content 兼容 string 与多模态块数组两种线格式;一期仅透传文本。
|
// Content 兼容 string 与多模态块数组两种线格式,序列化时保形。
|
||||||
type Content struct {
|
type Content struct {
|
||||||
// Text 在原始值为字符串时填充
|
|
||||||
Text string
|
Text string
|
||||||
// Parts 在原始值为数组时填充
|
|
||||||
Parts []ContentPart
|
Parts []ContentPart
|
||||||
// isArray 记录原始形态,序列化时保形
|
IsArray bool
|
||||||
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 {
|
func (c *Content) UnmarshalJSON(b []byte) error {
|
||||||
trimmed := strings.TrimSpace(string(b))
|
t := strings.TrimSpace(string(b))
|
||||||
if trimmed == "null" {
|
if t == "null" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
if strings.HasPrefix(t, "[") {
|
||||||
c.isArray = true
|
c.IsArray = true
|
||||||
return json.Unmarshal(b, &c.Parts)
|
return json.Unmarshal(b, &c.Parts)
|
||||||
}
|
}
|
||||||
return json.Unmarshal(b, &c.Text)
|
return json.Unmarshal(b, &c.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Content) MarshalJSON() ([]byte, error) {
|
func (c Content) MarshalJSON() ([]byte, error) {
|
||||||
if c.isArray {
|
if c.IsArray {
|
||||||
return json.Marshal(c.Parts)
|
return json.Marshal(c.Parts)
|
||||||
}
|
}
|
||||||
return json.Marshal(c.Text)
|
return json.Marshal(c.Text)
|
||||||
@@ -111,14 +140,9 @@ func NewTextContent(text string) Content {
|
|||||||
return Content{Text: text}
|
return Content{Text: text}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPartsContent 构造块数组形态的内容(多模态消息用)。
|
// JoinText 拍平内容为纯文本(数组形态拼接 text 块)。
|
||||||
func NewPartsContent(parts []ContentPart) Content {
|
|
||||||
return Content{Parts: parts, isArray: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JoinText 返回全部文本内容(数组形态拼接 text 块)。
|
|
||||||
func (c Content) JoinText() string {
|
func (c Content) JoinText() string {
|
||||||
if !c.isArray {
|
if !c.IsArray {
|
||||||
return c.Text
|
return c.Text
|
||||||
}
|
}
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -130,23 +154,20 @@ func (c Content) JoinText() string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasUnsupported 报告是否含网关无法承接的内容块(text / image_url 之外的类型)。
|
// ContentPart 是块数组中的一段;text 与 image_url 被网关承接,其余类型拒绝。
|
||||||
func (c Content) HasUnsupported() bool {
|
type ContentPart struct {
|
||||||
for _, p := range c.Parts {
|
Type string `json:"type"`
|
||||||
switch p.Type {
|
Text string `json:"text,omitempty"`
|
||||||
case "", "text":
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
case "image_url":
|
|
||||||
if p.ImageURL == nil || p.ImageURL.URL == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool 是 function 工具定义。
|
// ImageURL 是 image_url 块负载;url 为 data URI(base64)或公网地址。
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool 是嵌套形态的 function 工具定义。
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Function FunctionDef `json:"function"`
|
Function FunctionDef `json:"function"`
|
||||||
@@ -178,7 +199,7 @@ type ResponseFormat struct {
|
|||||||
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
|
JSONSchema json.RawMessage `json:"json_schema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatResponse 是非流式响应体。
|
// ChatResponse 是非流式响应体(chat.completion)。
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
@@ -195,28 +216,6 @@ type Choice struct {
|
|||||||
FinishReason string `json:"finish_reason"`
|
FinishReason string `json:"finish_reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage 是 token 用量。
|
|
||||||
type Usage struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
// PromptTokensDetails 携带缓存命中细分;OCI 为隐式 prompt 缓存,只有读取计数
|
|
||||||
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PromptTokensDetails 是输入 token 细分(OpenAI prompt_tokens_details 同构)。
|
|
||||||
type PromptTokensDetails struct {
|
|
||||||
CachedTokens int `json:"cached_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CachedTokens 返回缓存命中读取的 token 数;无细分时为 0。
|
|
||||||
func (u *Usage) CachedTokens() int {
|
|
||||||
if u == nil || u.PromptTokensDetails == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return u.PromptTokensDetails.CachedTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
|
// ChatChunk 是流式增量事件体(chat.completion.chunk)。
|
||||||
type ChatChunk struct {
|
type ChatChunk struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -254,29 +253,3 @@ type FunctionCallDelta struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Arguments string `json:"arguments,omitempty"`
|
Arguments string `json:"arguments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model 是 /v1/models 列表项。
|
|
||||||
type Model struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
Created int64 `json:"created"`
|
|
||||||
OwnedBy string `json:"owned_by"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelList 是 /v1/models 响应体。
|
|
||||||
type ModelList struct {
|
|
||||||
Object string `json:"object"`
|
|
||||||
Data []Model `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorBody 是 OpenAI 格式错误响应。
|
|
||||||
type ErrorBody struct {
|
|
||||||
Error ErrorDetail `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrorDetail 是错误明细。
|
|
||||||
type ErrorDetail struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -156,39 +156,6 @@ type RespReasoning struct {
|
|||||||
Effort string `json:"effort,omitempty"`
|
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 命名)。
|
// RespUsage 是 Responses 口径的用量(input/output 命名)。
|
||||||
type RespUsage struct {
|
type RespUsage struct {
|
||||||
InputTokens int `json:"input_tokens"`
|
InputTokens int `json:"input_tokens"`
|
||||||
|
|||||||
+51
-1
@@ -1,8 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -12,11 +15,20 @@ import (
|
|||||||
var (
|
var (
|
||||||
buildVersion = "dev"
|
buildVersion = "dev"
|
||||||
buildTime = ""
|
buildTime = ""
|
||||||
|
// startedAt 为进程启动时刻,运行时长与 CPU 均值的计算基准
|
||||||
|
startedAt = time.Now()
|
||||||
|
// aboutDB 由 main 启动时注入,存储指标据此定位数据库文件
|
||||||
|
aboutDB struct{ driver, path string }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SetAboutRuntime 注入数据库形态,「关于」页存储指标展示用;启动时调用一次。
|
||||||
|
func SetAboutRuntime(dbDriver, dbPath string) {
|
||||||
|
aboutDB.driver, aboutDB.path = dbDriver, dbPath
|
||||||
|
}
|
||||||
|
|
||||||
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
||||||
//
|
//
|
||||||
// @Summary 返回构建与运行环境信息,「设置 · 关于」页展示
|
// @Summary 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -27,5 +39,43 @@ func about(c *gin.Context) {
|
|||||||
"buildTime": buildTime,
|
"buildTime": buildTime,
|
||||||
"goVersion": runtime.Version(),
|
"goVersion": runtime.Version(),
|
||||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||||
|
"startedAt": startedAt.UTC().Format(time.RFC3339),
|
||||||
|
"uptimeSeconds": int64(time.Since(startedAt).Seconds()),
|
||||||
|
"resources": resourcesSnapshot(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resourcesSnapshot 采集进程资源占用:CPU 自启动均值、内存、goroutine 与存储。
|
||||||
|
func resourcesSnapshot() gin.H {
|
||||||
|
var ms runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&ms)
|
||||||
|
pct := 0.0
|
||||||
|
if up := time.Since(startedAt).Seconds(); up > 0 {
|
||||||
|
// 多核并行时均值可超 100%,口径为「占单核百分比」
|
||||||
|
pct = processCPUTime().Seconds() / up * 100
|
||||||
|
}
|
||||||
|
return gin.H{
|
||||||
|
"cpuAvgPercent": math.Round(pct*100) / 100,
|
||||||
|
"numCpu": runtime.NumCPU(),
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
"memHeapBytes": ms.HeapAlloc,
|
||||||
|
"memSysBytes": ms.Sys,
|
||||||
|
"dbEngine": aboutDB.driver,
|
||||||
|
"dbBytes": dbFileBytes(aboutDB.driver, aboutDB.path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbFileBytes 统计数据库磁盘占用:sqlite 累加主文件与 -wal / -shm,
|
||||||
|
// 外部数据库(mysql / postgres)不可从本机度量,返回 -1 表示不可用。
|
||||||
|
func dbFileBytes(driver, path string) int64 {
|
||||||
|
if driver != "sqlite" || path == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, p := range []string{path, path + "-wal", path + "-shm"} {
|
||||||
|
if fi, err := os.Stat(p); err == nil {
|
||||||
|
total += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
|
||||||
|
func processCPUTime() time.Duration { return 0 }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBFileBytes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "app.db")
|
||||||
|
mustWrite := func(p string, n int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(p, make([]byte, n), 0o600); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustWrite(dbPath, 100)
|
||||||
|
mustWrite(dbPath+"-wal", 30)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
driver string
|
||||||
|
path string
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{name: "sqlite 主文件加 wal", driver: "sqlite", path: dbPath, want: 130},
|
||||||
|
{name: "sqlite 文件不存在计 0", driver: "sqlite", path: filepath.Join(dir, "none.db"), want: 0},
|
||||||
|
{name: "外部数据库不可度量", driver: "postgres", path: "", want: -1},
|
||||||
|
{name: "路径为空不可度量", driver: "sqlite", path: "", want: -1},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := dbFileBytes(tc.driver, tc.path); got != tc.want {
|
||||||
|
t.Fatalf("dbFileBytes(%q, %q) = %d, want %d", tc.driver, tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourcesSnapshot(t *testing.T) {
|
||||||
|
SetAboutRuntime("sqlite", filepath.Join(t.TempDir(), "absent.db"))
|
||||||
|
got := resourcesSnapshot()
|
||||||
|
|
||||||
|
if got["numCpu"].(int) < 1 || got["goroutines"].(int) < 1 {
|
||||||
|
t.Fatalf("numCpu / goroutines 应为正数: %+v", got)
|
||||||
|
}
|
||||||
|
if got["memHeapBytes"].(uint64) == 0 || got["memSysBytes"].(uint64) == 0 {
|
||||||
|
t.Fatalf("内存指标应为正数: %+v", got)
|
||||||
|
}
|
||||||
|
if got["cpuAvgPercent"].(float64) < 0 {
|
||||||
|
t.Fatalf("cpuAvgPercent 不应为负: %+v", got)
|
||||||
|
}
|
||||||
|
if got["dbEngine"].(string) != "sqlite" || got["dbBytes"].(int64) != 0 {
|
||||||
|
t.Fatalf("存储指标不符: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processCPUTime 返回进程自启动累计的 CPU 时间(用户态 + 内核态);
|
||||||
|
// 取不到时返回 0,「关于」页 CPU 均值显示为 0 而非报错。
|
||||||
|
func processCPUTime() time.Duration {
|
||||||
|
var ru syscall.Rusage
|
||||||
|
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
|
||||||
|
}
|
||||||
+62
-2
@@ -43,12 +43,13 @@ func (h *aiAdminHandler) createKey(c *gin.Context) {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
|
Models []string `json:"models"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -72,12 +73,13 @@ func (h *aiAdminHandler) updateKey(c *gin.Context) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Group *string `json:"group"`
|
Group *string `json:"group"`
|
||||||
|
Models *[]string `json:"models"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.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)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -285,6 +287,64 @@ func (h *aiAdminHandler) gatewayModels(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": list.Data})
|
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 调用日志列表
|
// @Summary AI 调用日志列表
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
|
|||||||
+254
-178
@@ -1,13 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -86,6 +88,25 @@ func keyGroup(c *gin.Context) string {
|
|||||||
return ""
|
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 由调用方补齐。
|
// logEntry 组装调用日志骨架;usage / status 由调用方补齐。
|
||||||
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
|
func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string, stream bool, meta service.ChatMeta, start time.Time) model.AiCallLog {
|
||||||
entry := model.AiCallLog{
|
entry := model.AiCallLog{
|
||||||
@@ -102,21 +123,6 @@ func (h *aiGatewayHandler) logEntry(c *gin.Context, endpoint, modelName string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateIR 做协议无关的入参检查(模型名、消息、不支持的内容块)。
|
// 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 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
|
// maybeLogContent 在调用密钥显式开启内容日志且未过期时写正文(红线例外);
|
||||||
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
|
// respBody 为 nil 时只记请求(流式响应与向量结果不记录);callLogID 关联同次调用日志。
|
||||||
func (h *aiGatewayHandler) maybeLogContent(c *gin.Context, callLogID uint, endpoint, modelName string, stream bool, reqBody, respBody any) {
|
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)
|
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) {
|
func fillUsage(entry *model.AiCallLog, u *aiwire.Usage) {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return
|
return
|
||||||
@@ -217,6 +182,9 @@ func (h *aiGatewayHandler) embeddings(c *gin.Context) {
|
|||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "仅支持 encoding_format=float")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
|
resp, meta, err := h.gw.Embeddings(c.Request.Context(), req, keyGroup(c))
|
||||||
entry := h.logEntry(c, "embeddings", req.Model, false, meta, start)
|
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)
|
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) {
|
func sseHeaders(c *gin.Context) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
@@ -280,22 +210,11 @@ func sseHeaders(c *gin.Context) {
|
|||||||
c.Writer.Flush()
|
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 端点。
|
// messages 是 Anthropic /ai/v1/messages 端点。
|
||||||
//
|
//
|
||||||
// @Summary Anthropic Messages 兼容端点
|
// @Summary Anthropic Messages 兼容端点
|
||||||
// @Tags AI 网关
|
// @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)"
|
// @Success 200 {object} map[string]any "Anthropic 兼容响应(流式为 SSE)"
|
||||||
// @Router /ai/v1/messages [post]
|
// @Router /ai/v1/messages [post]
|
||||||
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
func (h *aiGatewayHandler) messages(c *gin.Context) {
|
||||||
@@ -308,69 +227,93 @@ func (h *aiGatewayHandler) messages(c *gin.Context) {
|
|||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "max_tokens 必填且需大于 0")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateIR(ir); err != nil {
|
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
h.streamAnthropic(c, ir)
|
h.streamAnthropic(c, body, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "anthropic", ir.Model, false, meta, start)
|
entry := h.logEntry(c, "anthropic", req.Model, false, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, req)
|
h.logFailure(c, entry, req)
|
||||||
return
|
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
|
entry.Status = http.StatusOK
|
||||||
fillUsage(&entry, resp.Usage)
|
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||||
callID := h.gw.LogCall(entry)
|
callID := h.gw.LogCall(entry)
|
||||||
out := service.IRRespToAnthropic(resp, aiRandID("msg_"))
|
h.maybeLogContent(c, callID, "anthropic", req.Model, false, req, out)
|
||||||
h.maybeLogContent(c, callID, "anthropic", ir.Model, false, req, out)
|
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamAnthropic 经状态机把 IR chunk 流聚合为 Anthropic SSE 事件流。
|
// streamAnthropic 流式直通并桥接:上游 Responses SSE 事件经 AnthRespBridge
|
||||||
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, ir aiwire.ChatRequest) {
|
// 转为 Anthropic 事件序列逐块写出;usage 从 completed 事件记账。
|
||||||
|
func (h *aiGatewayHandler) streamAnthropic(c *gin.Context, body []byte, req aiwire.MessagesRequest) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "anthropic", ir.Model, true, meta, start)
|
entry := h.logEntry(c, "anthropic", req.Model, true, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, ir)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer stream.Close()
|
defer upstream.Close()
|
||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
st := service.NewAnthStream(aiRandID("msg_"), ir.Model)
|
bridge := service.NewAnthRespBridge(aiRandID("msg_"), req.Model)
|
||||||
for {
|
if err := forwardSSEData(upstream, func(data []byte) { writeAnthEvents(c, bridge.Feed(data)) }); err != nil {
|
||||||
chunk, err := stream.Next()
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
}
|
}
|
||||||
break
|
writeAnthEvents(c, bridge.Finish())
|
||||||
}
|
|
||||||
writeAnthEvents(c, st.Feed(chunk))
|
|
||||||
}
|
|
||||||
writeAnthEvents(c, st.Finish())
|
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
usage := st.Usage()
|
usage := bridge.Usage()
|
||||||
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
|
entry.PromptTokens, entry.CompletionTokens = usage.InputTokens, usage.OutputTokens
|
||||||
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
|
entry.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||||
entry.CachedTokens = usage.CacheReadInputTokens
|
entry.CachedTokens = usage.CacheReadInputTokens
|
||||||
callID := h.gw.LogCall(entry)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardSSEData 逐行读上游 SSE,把 data 行交给 emit 即时转换写出;
|
||||||
|
// Anthropic 与 Chat Completions 两条流式桥共用。
|
||||||
|
func forwardSSEData(upstream io.Reader, emit func([]byte)) 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 {
|
||||||
|
emit(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
||||||
@@ -386,6 +329,109 @@ func writeAnthEvents(c *gin.Context, events []service.AnthEvent) {
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chatCompletions 是 OpenAI /ai/v1/chat/completions 端点(Tier 2 兼容层,
|
||||||
|
// 承接只会说 Chat Completions 的存量客户端)。
|
||||||
|
//
|
||||||
|
// @Summary OpenAI Chat Completions 兼容端点
|
||||||
|
// @Tags AI 网关
|
||||||
|
// @Param body body object true "OpenAI chat/completions 请求体(支持 stream;经 Responses 转换直通)"
|
||||||
|
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE,末尾 data: [DONE])"
|
||||||
|
// @Router /ai/v1/chat/completions [post]
|
||||||
|
func (h *aiGatewayHandler) chatCompletions(c *gin.Context) {
|
||||||
|
var req aiwire.ChatRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Model) == "" || len(req.Messages) == 0 {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", "model 与 messages 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !checkKeyModel(c, req.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := service.ChatToResponsesBody(req)
|
||||||
|
if err != nil {
|
||||||
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
h.streamChat(c, body, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.chatOnce(c, body, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatOnce 处理非流式:直通上游 → 转回 chat.completion → 记账。
|
||||||
|
func (h *aiGatewayHandler) chatOnce(c *gin.Context, body []byte, req aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "openai", req.Model, false, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := service.ResponsesToChat(payload, aiRandID("chatcmpl-"), time.Now().Unix())
|
||||||
|
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, out.Usage)
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", req.Model, false, req, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamChat 流式直通并桥接:上游 Responses SSE 经 ChatRespBridge 转为
|
||||||
|
// chat.completion.chunk 序列逐块写出,末尾发 [DONE];usage 从 completed 事件记账。
|
||||||
|
func (h *aiGatewayHandler) streamChat(c *gin.Context, body []byte, req aiwire.ChatRequest) {
|
||||||
|
start := time.Now()
|
||||||
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
|
entry := h.logEntry(c, "openai", req.Model, true, meta, start)
|
||||||
|
if err != nil {
|
||||||
|
upstreamError(c, err)
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
h.logFailure(c, entry, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
sseHeaders(c)
|
||||||
|
includeUsage := req.StreamOptions != nil && req.StreamOptions.IncludeUsage
|
||||||
|
bridge := service.NewChatRespBridge(aiRandID("chatcmpl-"), req.Model, time.Now().Unix(), includeUsage)
|
||||||
|
if err := forwardSSEData(upstream, func(data []byte) { writeChatChunks(c, bridge.Feed(data)) }); err != nil {
|
||||||
|
entry.ErrMsg = err.Error()
|
||||||
|
}
|
||||||
|
writeChatChunks(c, bridge.Finish())
|
||||||
|
c.Writer.WriteString("data: [DONE]\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
entry.Status = http.StatusOK
|
||||||
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
|
fillUsage(&entry, bridge.Usage())
|
||||||
|
callID := h.gw.LogCall(entry)
|
||||||
|
h.maybeLogContent(c, callID, "openai", req.Model, true, req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeChatChunks 逐块写出 chunk 的 SSE data 行并 flush。
|
||||||
|
func writeChatChunks(c *gin.Context, chunks []aiwire.ChatChunk) {
|
||||||
|
for _, ch := range chunks {
|
||||||
|
b, err := json.Marshal(ch)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Writer.WriteString("data: ")
|
||||||
|
c.Writer.Write(b)
|
||||||
|
c.Writer.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if len(chunks) > 0 {
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
// listModels 是 /ai/v1/models 端点(OpenAI 格式,从启用渠道的模型缓存聚合)。
|
||||||
//
|
//
|
||||||
// @Summary 可用模型列表
|
// @Summary 可用模型列表
|
||||||
@@ -398,6 +444,15 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
|||||||
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
|
aiError(c, http.StatusInternalServerError, "api_error", "查询模型列表失败")
|
||||||
return
|
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)
|
c.JSON(http.StatusOK, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,31 +460,45 @@ func (h *aiGatewayHandler) listModels(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary OpenAI Responses 兼容端点
|
// @Summary OpenAI Responses 兼容端点
|
||||||
// @Tags AI 网关
|
// @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)"
|
// @Success 200 {object} map[string]any "OpenAI 兼容响应(流式为 SSE)"
|
||||||
// @Router /ai/v1/responses [post]
|
// @Router /ai/v1/responses [post]
|
||||||
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
func (h *aiGatewayHandler) responses(c *gin.Context) {
|
||||||
var req aiwire.RespRequest
|
raw, err := c.GetRawData()
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ir, err := service.ResponsesToIR(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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())
|
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Stream {
|
if req.Stream {
|
||||||
h.streamResponses(c, ir)
|
h.responsesPassthroughStream(c, body, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, meta, err := h.gw.Chat(c.Request.Context(), ir, keyGroup(c))
|
payload, meta, err := h.gw.RespPassthrough(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "responses", ir.Model, false, meta, start)
|
entry := h.logEntry(c, "responses", req.Model, false, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
@@ -437,54 +506,61 @@ func (h *aiGatewayHandler) responses(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
fillUsage(&entry, resp.Usage)
|
fillUsage(&entry, service.RespPassthroughUsage(payload))
|
||||||
callID := h.gw.LogCall(entry)
|
callID := h.gw.LogCall(entry)
|
||||||
out := service.IRRespToResponses(resp, aiRandID("resp_"), time.Now().Unix())
|
h.maybeLogContent(c, callID, "responses", req.Model, false, req, json.RawMessage(payload))
|
||||||
h.maybeLogContent(c, callID, "responses", ir.Model, false, req, out)
|
c.Data(http.StatusOK, "application/json; charset=utf-8", payload)
|
||||||
c.JSON(http.StatusOK, out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamResponses 经状态机把 IR chunk 流聚合为 Responses 语义事件流。
|
// responsesPassthroughStream 流式直通:SSE 事件原样转发(推理增量等直达客户端),
|
||||||
func (h *aiGatewayHandler) streamResponses(c *gin.Context, ir aiwire.ChatRequest) {
|
// 逐行扫描 completed 事件提取 usage 记账。
|
||||||
|
func (h *aiGatewayHandler) responsesPassthroughStream(c *gin.Context, body []byte, req aiwire.RespRequest) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
stream, meta, err := h.gw.OpenStream(c.Request.Context(), ir, keyGroup(c))
|
upstream, meta, err := h.gw.RespPassthroughStream(c.Request.Context(), body, req.Model, keyGroup(c))
|
||||||
entry := h.logEntry(c, "responses", ir.Model, true, meta, start)
|
entry := h.logEntry(c, "responses", req.Model, true, meta, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
upstreamError(c, err)
|
upstreamError(c, err)
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
h.logFailure(c, entry, ir)
|
h.logFailure(c, entry, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer stream.Close()
|
defer upstream.Close()
|
||||||
sseHeaders(c)
|
sseHeaders(c)
|
||||||
st := service.NewRespStream(aiRandID("resp_"), ir.Model, time.Now().Unix())
|
usage, err := forwardSSE(c, upstream)
|
||||||
for {
|
|
||||||
chunk, err := stream.Next()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
entry.ErrMsg = err.Error()
|
entry.ErrMsg = err.Error()
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
writeRespEvents(c, st.Feed(chunk))
|
|
||||||
}
|
|
||||||
writeRespEvents(c, st.Finish())
|
|
||||||
entry.Status = http.StatusOK
|
entry.Status = http.StatusOK
|
||||||
entry.LatencyMs = time.Since(start).Milliseconds()
|
entry.LatencyMs = time.Since(start).Milliseconds()
|
||||||
fillUsage(&entry, st.Usage())
|
fillUsage(&entry, usage)
|
||||||
callID := h.gw.LogCall(entry)
|
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) {
|
// forwardSSE 把上游 SSE 逐行转发给客户端,空行(事件边界)即 flush;
|
||||||
for _, ev := range events {
|
// 顺带从 data 行提取 response.completed 的 usage。
|
||||||
b, err := json.Marshal(ev.Data)
|
func forwardSSE(c *gin.Context, upstream io.Reader) (*aiwire.Usage, error) {
|
||||||
if err != nil {
|
reader := bufio.NewReader(upstream)
|
||||||
continue
|
var usage *aiwire.Usage
|
||||||
}
|
for {
|
||||||
c.Writer.WriteString("event: " + ev.Event + "\ndata: ")
|
line, err := reader.ReadBytes('\n')
|
||||||
c.Writer.Write(b)
|
if len(line) > 0 {
|
||||||
c.Writer.WriteString("\n\n")
|
c.Writer.Write(line)
|
||||||
}
|
trimmed := bytes.TrimSpace(line)
|
||||||
|
if len(trimmed) == 0 {
|
||||||
c.Writer.Flush()
|
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 {
|
||||||
|
c.Writer.Flush()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
|
return usage, err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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 白名单外拦截", "limited-key-1234", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"meta.llama-3.3-70b-instruct","messages":[{"role":"user","content":"hi"}]}`, 404, deny},
|
||||||
|
{"chat completions 白名单内穿透", "limited-key-1234", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}]}`, 404, pass},
|
||||||
|
{"chat completions 缺 messages 拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model"}`, 400, []string{"invalid_request_error"}},
|
||||||
|
{"chat completions 非 function 工具拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"web_search","function":{}}]}`,
|
||||||
|
400, []string{"仅支持 function"}},
|
||||||
|
{"chat completions 不支持内容块拒绝", "open-key-12345", "/ai/v1/chat/completions",
|
||||||
|
`{"model":"ghost-model","messages":[{"role":"user","content":[{"type":"input_audio"}]}]}`,
|
||||||
|
400, []string{"invalid_request_error"}},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,11 @@ type authHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
// 字段长度上限防超长输入撑爆登录守卫与 bcrypt(S-03)
|
||||||
Password string `json:"password" binding:"required"`
|
Username string `json:"username" binding:"required,max=64"`
|
||||||
|
Password string `json:"password" binding:"required,max=128"`
|
||||||
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
// 两步验证码;账号已启用 TOTP 时必填,缺失返回 428
|
||||||
TotpCode string `json:"totpCode"`
|
TotpCode string `json:"totpCode" binding:"max=8"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// login 校验用户名密码(与可选 TOTP)后签发 JWT。
|
// login 校验用户名密码(与可选 TOTP)后签发 JWT。
|
||||||
@@ -64,7 +65,7 @@ func (h *authHandler) login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.recordLogin(c, req.Username, http.StatusOK, start)
|
h.recordLogin(c, req.Username, http.StatusOK, start)
|
||||||
|
|||||||
+37
-9
@@ -81,7 +81,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
h.respondFreshToken(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
||||||
@@ -110,7 +110,34 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
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)
|
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 组内) ----
|
// ---- 登录凭据(JWT 组内) ----
|
||||||
@@ -149,7 +176,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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) {
|
if errors.Is(err, service.ErrCredentialConfirm) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -162,7 +189,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Set(usernameKey, finalName)
|
||||||
|
h.respondFreshToken(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
||||||
@@ -191,7 +219,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
h.respondFreshToken(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OAuth ----
|
// ---- OAuth ----
|
||||||
@@ -254,7 +282,7 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
|||||||
if len(header) < 8 || header[:7] != "Bearer " {
|
if len(header) < 8 || header[:7] != "Bearer " {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
username, err := h.auth.ParseToken(header[7:])
|
username, err := h.auth.ParseToken(c.Request.Context(), header[7:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -282,9 +310,9 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if token == "" {
|
if mode == "bind" {
|
||||||
// 绑定模式:回设置页安全 tab
|
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
||||||
c.Redirect(http.StatusFound, "/settings?oauth=bound")
|
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 登录模式:token 放 fragment,不进服务端日志与 Referer
|
// 登录模式:token 放 fragment,不进服务端日志与 Referer
|
||||||
@@ -340,7 +368,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
h.respondFreshToken(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OAuth provider 设置(JWT 组内) ----
|
// ---- OAuth provider 设置(JWT 组内) ----
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func boolOr(v *bool, def bool) bool {
|
|||||||
// @Summary 身份提供商列表
|
// @Summary 身份提供商列表
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [get]
|
||||||
@@ -48,7 +49,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -59,6 +60,7 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
|||||||
// @Summary 创建身份提供商(SAML)
|
// @Summary 创建身份提供商(SAML)
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createIdpRequest true "请求体"
|
// @Param body body createIdpRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -73,7 +75,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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,
|
Name: req.Name,
|
||||||
Metadata: req.Metadata,
|
Metadata: req.Metadata,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
@@ -97,6 +99,7 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
|||||||
// @Summary 激活身份提供商
|
// @Summary 激活身份提供商
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param idpId path string true "idpId"
|
// @Param idpId path string true "idpId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -125,6 +128,7 @@ func (h *ociConfigHandler) activateIdentityProvider(c *gin.Context) {
|
|||||||
// @Summary 删除身份提供商
|
// @Summary 删除身份提供商
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param idpId path string true "idpId"
|
// @Param idpId path string true "idpId"
|
||||||
// @Success 204 "无内容"
|
// @Success 204 "无内容"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -134,7 +138,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,6 +148,7 @@ func (h *ociConfigHandler) deleteIdentityProvider(c *gin.Context) {
|
|||||||
// @Summary 下载 SAML 元数据
|
// @Summary 下载 SAML 元数据
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
// @Router /api/v1/oci-configs/{id}/saml-metadata [get]
|
||||||
@@ -152,7 +157,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -164,6 +169,7 @@ func (h *ociConfigHandler) downloadSamlMetadata(c *gin.Context) {
|
|||||||
// @Summary 单点登录规则列表
|
// @Summary 单点登录规则列表
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
// @Router /api/v1/oci-configs/{id}/sign-on-rules [get]
|
||||||
@@ -172,7 +178,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -183,6 +189,7 @@ func (h *ociConfigHandler) listSignOnRules(c *gin.Context) {
|
|||||||
// @Summary 创建 MFA 豁免规则
|
// @Summary 创建 MFA 豁免规则
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -199,7 +206,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -210,6 +217,7 @@ func (h *ociConfigHandler) createMfaExemption(c *gin.Context) {
|
|||||||
// @Summary 删除 MFA 豁免规则
|
// @Summary 删除 MFA 豁免规则
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param ruleId path string true "ruleId"
|
// @Param ruleId path string true "ruleId"
|
||||||
// @Success 204 "无内容"
|
// @Success 204 "无内容"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -219,7 +227,7 @@ func (h *ociConfigHandler) deleteMfaExemption(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import (
|
|||||||
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
||||||
const usernameKey = "username"
|
const usernameKey = "username"
|
||||||
|
|
||||||
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。
|
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
|
||||||
|
// 通过后把用户名放进上下文。
|
||||||
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
@@ -23,7 +24,7 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
|||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username, err := auth.ParseToken(token)
|
username, err := auth.ParseToken(c.Request.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
return
|
return
|
||||||
@@ -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)异步记入系统日志,只读请求跳过。
|
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
|
||||||
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
||||||
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ func (h *ociConfigHandler) create(c *gin.Context) {
|
|||||||
func (h *ociConfigHandler) list(c *gin.Context) {
|
func (h *ociConfigHandler) list(c *gin.Context) {
|
||||||
items, err := h.svc.List(c.Request.Context())
|
items, err := h.svc.List(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, items)
|
c.JSON(http.StatusOK, items)
|
||||||
@@ -168,6 +171,7 @@ func (h *ociConfigHandler) update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 删除租户配置
|
// @Summary 删除租户配置
|
||||||
|
// @Description 删除租户配置及其本地关联任务、快照、回传、告警和 AI 渠道数据,并撤销 Webhook 密钥;不删除 OCI 云端资源。
|
||||||
// @Tags 租户配置
|
// @Tags 租户配置
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Success 204 "无内容"
|
// @Success 204 "无内容"
|
||||||
@@ -258,17 +262,29 @@ func pathID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
return uint(id), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// respondError 统一出错响应边界(S-08):OCI 服务错误经脱敏透出,记录缺失
|
||||||
|
// 映射 404;其余按内部错误处理——响应只含固定文案与关联 ID,完整原因
|
||||||
|
// (可能携带 SQL / DSN / 路径等内部细节)仅写服务端日志,经同一 ID 对应。
|
||||||
func respondError(c *gin.Context, err error) {
|
func respondError(c *gin.Context, err error) {
|
||||||
status := http.StatusInternalServerError
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
}
|
|
||||||
var svcErr common.ServiceError
|
var svcErr common.ServiceError
|
||||||
if errors.As(err, &svcErr) {
|
if errors.As(err, &svcErr) {
|
||||||
respondOCIError(c, err, svcErr)
|
respondOCIError(c, err, svcErr)
|
||||||
return
|
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 状态码,
|
// respondOCIError 提炼 OCI 服务错误:透传 HTTP 状态码,
|
||||||
@@ -278,9 +294,10 @@ func respondOCIError(c *gin.Context, err error, svcErr common.ServiceError) {
|
|||||||
if status < http.StatusBadRequest {
|
if status < http.StatusBadRequest {
|
||||||
status = http.StatusBadGateway
|
status = http.StatusBadGateway
|
||||||
}
|
}
|
||||||
// 面板的 401 专属本地 JWT 失效(前端收到即登出);
|
// 面板的 401 专属本地 JWT 失效(前端收到即登出)、429 专属本地 IP 限速
|
||||||
// 上游 OCI 的 401 是代理调用被拒,改用 502 透出
|
// (前端收到即跳 /blocked);上游 OCI 的同码是代理调用被拒/云端限流,
|
||||||
if status == http.StatusUnauthorized {
|
// 均改用 502 透出,body 保留 ociCode 供辨识
|
||||||
|
if status == http.StatusUnauthorized || status == http.StatusTooManyRequests {
|
||||||
status = http.StatusBadGateway
|
status = http.StatusBadGateway
|
||||||
}
|
}
|
||||||
body := gin.H{
|
body := gin.H{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,35 @@ func (h *proxyHandler) probe(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, view)
|
c.JSON(http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setTenants 设置代理关联的租户全集(代理侧批量关联 / 解除)。
|
||||||
|
//
|
||||||
|
// @Summary 设置代理关联的租户全集
|
||||||
|
// @Tags 设置
|
||||||
|
// @Param id path int true "代理 ID"
|
||||||
|
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
|
||||||
|
// @Success 200 {object} map[string]any "usedBy"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/proxies/{id}/tenants [put]
|
||||||
|
func (h *proxyHandler) setTenants(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
OciConfigIDs []uint `json:"ociConfigIds"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := h.svc.SetTenants(c.Request.Context(), id, req.OciConfigIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.respond(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"usedBy": used})
|
||||||
|
}
|
||||||
|
|
||||||
// respond 把代理业务错误映射到语义状态码。
|
// respond 把代理业务错误映射到语义状态码。
|
||||||
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
func listRegions(c *gin.Context) {
|
func listRegions(c *gin.Context) {
|
||||||
regions, err := oci.AllRegions()
|
regions, err := oci.AllRegions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, regions)
|
c.JSON(http.StatusOK, regions)
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
|
|||||||
accessLog := gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})
|
accessLog := gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})
|
||||||
r.Use(accessLog, RealIPMiddleware(settings), IPRateMiddleware(settings), gin.Recovery())
|
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)
|
registerAuthPublic(v1, auth, oauth, systemLogs)
|
||||||
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
||||||
registerAiGateway(r, aiGateway)
|
registerAiGateway(r, aiGateway)
|
||||||
|
|||||||
+110
-1
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"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) {
|
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()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
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)
|
logEvents := service.NewLogEventService(db)
|
||||||
oauth := service.NewOAuthService(db, settings, auth)
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// 挂在全局中间件之后,仍受 IP 限速与 Recovery 保护;高频调用自有 AiCallLog。
|
// 挂在全局中间件之后,仍受 IP 限速与 Recovery 保护;高频调用自有 AiCallLog。
|
||||||
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
||||||
aih := &aiGatewayHandler{gw: aiGateway}
|
aih := &aiGatewayHandler{gw: aiGateway}
|
||||||
ai := r.Group("/ai/v1", aih.auth)
|
ai := r.Group("/ai/v1", bodyLimit(10<<20), aih.auth)
|
||||||
ai.POST("/chat/completions", aih.chatCompletions)
|
ai.POST("/chat/completions", aih.chatCompletions)
|
||||||
ai.POST("/responses", aih.responses)
|
ai.POST("/responses", aih.responses)
|
||||||
ai.POST("/messages", aih.messages)
|
ai.POST("/messages", aih.messages)
|
||||||
@@ -33,6 +33,9 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
|||||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
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-logs", aiadmin.listLogs)
|
||||||
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
|
secured.GET("/ai-content-logs", aiadmin.listContentLogs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
|
|||||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||||
secured.GET("/auth/totp", ax.totpStatus)
|
secured.GET("/auth/totp", ax.totpStatus)
|
||||||
secured.POST("/auth/totp/setup", ax.totpSetup)
|
secured.POST("/auth/totp/setup", ax.totpSetup)
|
||||||
secured.POST("/auth/totp/activate", ax.totpActivate)
|
|
||||||
secured.POST("/auth/totp/disable", ax.totpDisable)
|
|
||||||
secured.GET("/auth/credentials", ax.getCredentials)
|
secured.GET("/auth/credentials", ax.getCredentials)
|
||||||
secured.PUT("/auth/credentials", ax.updateCredentials)
|
secured.PUT("/auth/credentials", ax.updateCredentials)
|
||||||
secured.PUT("/auth/password-login", ax.updatePasswordLogin)
|
|
||||||
secured.GET("/auth/identities", ax.identities)
|
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.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.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||||
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
|||||||
func registerOciTenantIAM(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", h.getAuditEvents)
|
||||||
g.GET("/oci-configs/:id/audit-events/detail", h.getAuditEventDetail)
|
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.GET("/oci-configs/:id/users", h.listTenantUsers)
|
||||||
g.POST("/oci-configs/:id/users", h.createTenantUser)
|
g.POST("/oci-configs/:id/users", h.createTenantUser)
|
||||||
g.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
|
g.GET("/oci-configs/:id/users/:userId", h.getTenantUserDetail)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
|||||||
secured.GET("/settings/telegram", st.getTelegram)
|
secured.GET("/settings/telegram", st.getTelegram)
|
||||||
secured.PUT("/settings/telegram", st.updateTelegram)
|
secured.PUT("/settings/telegram", st.updateTelegram)
|
||||||
secured.POST("/settings/telegram/test", st.testTelegram)
|
secured.POST("/settings/telegram/test", st.testTelegram)
|
||||||
|
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.GET("/settings/notify-events", st.getNotifyEvents)
|
||||||
secured.PUT("/settings/notify-events", st.updateNotifyEvents)
|
secured.PUT("/settings/notify-events", st.updateNotifyEvents)
|
||||||
secured.GET("/settings/notify-templates", st.listNotifyTemplates)
|
secured.GET("/settings/notify-templates", st.listNotifyTemplates)
|
||||||
@@ -32,4 +35,5 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
|||||||
secured.PUT("/proxies/:id", px.update)
|
secured.PUT("/proxies/:id", px.update)
|
||||||
secured.DELETE("/proxies/:id", px.remove)
|
secured.DELETE("/proxies/:id", px.remove)
|
||||||
secured.POST("/proxies/:id/probe", px.probe)
|
secured.POST("/proxies/:id/probe", px.probe)
|
||||||
|
secured.PUT("/proxies/:id/tenants", px.setTenants)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,82 @@ func (h *settingsHandler) testTelegram(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
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 返回全部通知事件开关;键缺省(存量部署)视为开启。
|
// getNotifyEvents 返回全部通知事件开关;键缺省(存量部署)视为开启。
|
||||||
//
|
//
|
||||||
// @Summary 返回全部通知事件开关
|
// @Summary 返回全部通知事件开关
|
||||||
|
|||||||
+10
-5
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -155,10 +156,11 @@ func (h *taskHandler) logs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, logs)
|
c.JSON(http.StatusOK, logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 立即执行任务
|
// @Summary 立即执行任务(异步触发,结果经任务日志轮询获取)
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "配置 ID"
|
// @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
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id}/run [post]
|
// @Router /api/v1/tasks/{id}/run [post]
|
||||||
func (h *taskHandler) run(c *gin.Context) {
|
func (h *taskHandler) run(c *gin.Context) {
|
||||||
@@ -166,10 +168,13 @@ func (h *taskHandler) run(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry, err := h.svc.RunTaskNow(c.Request.Context(), id)
|
if err := h.svc.TriggerTask(c.Request.Context(), id); err != nil {
|
||||||
if err != nil {
|
if errors.Is(err, service.ErrTaskRunning) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, entry)
|
c.JSON(http.StatusAccepted, gin.H{"triggered": true})
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-22
@@ -67,12 +67,14 @@ func (h *ociConfigHandler) costs(c *gin.Context) {
|
|||||||
|
|
||||||
// ---- 租户审计日志 ----
|
// ---- 租户审计日志 ----
|
||||||
|
|
||||||
// getAuditEvents 实时查询租户 OCI 审计事件:hours 首查(缺省 24);
|
// getAuditEvents 批式懒加载查询审计事件:cursor 为空自当前时刻首查,
|
||||||
// start/end/page 为截断后的同窗续查;窗口越界或非法返回 400。
|
// 非空从上次响应游标继续向更早回溯;limit 单批目标条数(缺省 100,上限 200)。
|
||||||
//
|
//
|
||||||
// @Summary 实时查询租户 OCI 审计事件:hours 首查
|
// @Summary 批式懒加载查询租户 OCI 审计事件
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param cursor query string false "续查游标(上次响应原样带回)"
|
||||||
|
// @Param limit query int false "单批目标条数,缺省 100,上限 200"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
// @Router /api/v1/oci-configs/{id}/audit-events [get]
|
||||||
@@ -81,13 +83,10 @@ func (h *ociConfigHandler) getAuditEvents(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
|
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||||
q := service.AuditQuery{
|
q := service.AuditQuery{Region: c.Query("region"), Cursor: c.Query("cursor"), Limit: limit}
|
||||||
Region: c.Query("region"), Hours: hours,
|
|
||||||
Start: c.Query("start"), End: c.Query("end"), Page: c.Query("page"),
|
|
||||||
}
|
|
||||||
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
result, err := h.svc.AuditEvents(c.Request.Context(), id, q)
|
||||||
if errors.Is(err, service.ErrInvalidAuditHours) || errors.Is(err, service.ErrInvalidAuditWindow) {
|
if errors.Is(err, service.ErrInvalidAuditCursor) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -143,9 +142,29 @@ type createTenantUserRequest struct {
|
|||||||
AddToAdminGroup bool `json:"addToAdminGroup"`
|
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 用户列表
|
// @Summary 租户 IAM 用户列表
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/users [get]
|
// @Router /api/v1/oci-configs/{id}/users [get]
|
||||||
@@ -154,7 +173,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -165,6 +184,7 @@ func (h *ociConfigHandler) listTenantUsers(c *gin.Context) {
|
|||||||
// @Summary 租户用户详情
|
// @Summary 租户用户详情
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -174,7 +194,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -185,6 +205,7 @@ func (h *ociConfigHandler) getTenantUserDetail(c *gin.Context) {
|
|||||||
// @Summary 创建租户 IAM 用户
|
// @Summary 创建租户 IAM 用户
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createTenantUserRequest true "请求体"
|
// @Param body body createTenantUserRequest true "请求体"
|
||||||
// @Success 201 {object} map[string]any
|
// @Success 201 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -199,7 +220,7 @@ func (h *ociConfigHandler) createTenantUser(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
@@ -229,6 +250,7 @@ type updateTenantUserRequest struct {
|
|||||||
// @Summary 更新租户 IAM 用户
|
// @Summary 更新租户 IAM 用户
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Param body body updateTenantUserRequest true "请求体"
|
// @Param body body updateTenantUserRequest true "请求体"
|
||||||
// @Success 200 {object} map[string]any
|
// @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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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,
|
Description: req.Description,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
GivenName: req.GivenName,
|
GivenName: req.GivenName,
|
||||||
@@ -262,6 +284,7 @@ func (h *ociConfigHandler) updateTenantUser(c *gin.Context) {
|
|||||||
// @Summary 删除租户 IAM 用户
|
// @Summary 删除租户 IAM 用户
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 204 "无内容"
|
// @Success 204 "无内容"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -271,7 +294,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -281,6 +304,7 @@ func (h *ociConfigHandler) deleteTenantUser(c *gin.Context) {
|
|||||||
// @Summary 重置租户用户密码
|
// @Summary 重置租户用户密码
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -290,7 +314,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -301,6 +325,7 @@ func (h *ociConfigHandler) resetTenantUserPassword(c *gin.Context) {
|
|||||||
// @Summary 清除用户 MFA 设备
|
// @Summary 清除用户 MFA 设备
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param userId path string true "userId"
|
// @Param userId path string true "userId"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -310,7 +335,7 @@ func (h *ociConfigHandler) deleteTenantUserMfa(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -344,6 +369,7 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
|||||||
// @Summary ---- 域通知收件人与密码策略 ----
|
// @Summary ---- 域通知收件人与密码策略 ----
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
// @Router /api/v1/oci-configs/{id}/notification-recipients [get]
|
||||||
@@ -352,7 +378,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -363,6 +389,7 @@ func (h *ociConfigHandler) getNotificationRecipients(c *gin.Context) {
|
|||||||
// @Summary 更新通知收件人
|
// @Summary 更新通知收件人
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -379,7 +406,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -390,6 +417,7 @@ func (h *ociConfigHandler) updateNotificationRecipients(c *gin.Context) {
|
|||||||
// @Summary 密码策略列表
|
// @Summary 密码策略列表
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
// @Router /api/v1/oci-configs/{id}/password-policies [get]
|
||||||
@@ -398,7 +426,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -409,6 +437,7 @@ func (h *ociConfigHandler) listPasswordPolicies(c *gin.Context) {
|
|||||||
// @Summary 更新密码策略
|
// @Summary 更新密码策略
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param policyId path string true "policyId"
|
// @Param policyId path string true "policyId"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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,
|
PasswordExpiresAfter: req.PasswordExpiresAfter,
|
||||||
MinLength: req.MinLength,
|
MinLength: req.MinLength,
|
||||||
NumPasswordsInHistory: req.NumPasswordsInHistory,
|
NumPasswordsInHistory: req.NumPasswordsInHistory,
|
||||||
@@ -443,6 +472,7 @@ func (h *ociConfigHandler) updatePasswordPolicy(c *gin.Context) {
|
|||||||
// @Summary 身份域设置
|
// @Summary 身份域设置
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
// @Router /api/v1/oci-configs/{id}/identity-settings [get]
|
||||||
@@ -451,7 +481,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -462,6 +492,7 @@ func (h *ociConfigHandler) getIdentitySetting(c *gin.Context) {
|
|||||||
// @Summary 更新身份域设置
|
// @Summary 更新身份域设置
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body object true "请求体(见接口说明)"
|
// @Param body body object true "请求体(见接口说明)"
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -478,7 +509,7 @@ func (h *ociConfigHandler) updateIdentitySetting(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ var wsUpgrader = websocket.Upgrader{
|
|||||||
// @Success 101 "升级为 WebSocket"
|
// @Success 101 "升级为 WebSocket"
|
||||||
// @Router /api/v1/console-sessions/{sessionId}/ws [get]
|
// @Router /api/v1/console-sessions/{sessionId}/ws [get]
|
||||||
func (h *consoleHandler) ws(c *gin.Context) {
|
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"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
@@ -12,6 +15,18 @@ import (
|
|||||||
"oci-portal/internal/model"
|
"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 按驱动打开数据库并自动迁移全部模型。
|
// Open 按驱动打开数据库并自动迁移全部模型。
|
||||||
// driver 取值 sqlite(默认)/mysql/postgres;sqlite 用 path,其余用 dsn。
|
// driver 取值 sqlite(默认)/mysql/postgres;sqlite 用 path,其余用 dsn。
|
||||||
func Open(driver, dsn, path string) (*gorm.DB, error) {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
db, err := gorm.Open(dialector, &gorm.Config{
|
db, err := gorm.Open(dialector, &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Warn),
|
Logger: newDBLogger(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open %s database: %w", dialector.Name(), err)
|
return nil, fmt.Errorf("open %s database: %w", dialector.Name(), err)
|
||||||
@@ -53,7 +68,7 @@ func autoMigrate(db *gorm.DB) error {
|
|||||||
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
||||||
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
||||||
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
||||||
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{},
|
&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{},
|
||||||
&model.AiContentLog{},
|
&model.AiContentLog{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ const (
|
|||||||
AccountTypeUnknown = "unknown"
|
AccountTypeUnknown = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 测活状态取值。
|
// 测活状态取值;suspended 来自账户能力接口的云端暂停标记(区别于失联)。
|
||||||
const (
|
const (
|
||||||
AliveStatusAlive = "alive"
|
AliveStatusAlive = "alive"
|
||||||
AliveStatusDead = "dead"
|
AliveStatusDead = "dead"
|
||||||
|
AliveStatusSuspended = "suspended"
|
||||||
AliveStatusUnknown = "unknown"
|
AliveStatusUnknown = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ type User struct {
|
|||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
// TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证
|
// TOTP 共享密钥 AES-GCM 密文;空串表示未启用两步验证
|
||||||
TotpSecretEnc string `json:"-"`
|
TotpSecretEnc string `json:"-"`
|
||||||
|
// TokenVersion 是 JWT 版本号:凭据/TOTP/外部身份/登录策略变更或撤销会话时
|
||||||
|
// 原子递增,旧版本令牌随即失效(存量令牌无 ver 视为 0,与零值兼容)
|
||||||
|
TokenVersion uint `json:"-"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -237,6 +241,8 @@ type AiKey struct {
|
|||||||
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
|
KeyHash string `gorm:"uniqueIndex;size:64" json:"-"`
|
||||||
Tail string `gorm:"size:4" json:"tail"`
|
Tail string `gorm:"size:4" json:"tail"`
|
||||||
Group string `gorm:"column:key_group;size:32" json:"group"`
|
Group string `gorm:"column:key_group;size:32" json:"group"`
|
||||||
|
// Models 是模型白名单(精确匹配模型名);空 = 不限,非空时网关仅放行列表内模型
|
||||||
|
Models []string `gorm:"serializer:json;type:text" json:"models"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
|
// ContentLogUntil 是内容日志开启截止时间;nil = 永关(默认,红线),
|
||||||
@@ -282,6 +288,14 @@ type AiModelCache struct {
|
|||||||
RetiredAt *time.Time `json:"retiredAt"`
|
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 / 响应正文。
|
// AiCallLog 是网关调用日志:仅元数据与 token 用量,绝不记录 prompt / 响应正文。
|
||||||
type AiCallLog struct {
|
type AiCallLog struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +26,89 @@ type AccountProfile struct {
|
|||||||
PromotionExpires *time.Time
|
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 按订阅字段判定账户类别:
|
// ClassifyAccount 按订阅字段判定账户类别:
|
||||||
// - paymentModel 非空且不是 FREE_TRIAL:付费;
|
// - paymentModel 非空且不是 FREE_TRIAL:付费;
|
||||||
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
|
// - paymentModel 为 FREE_TRIAL 时,promotion ACTIVE 或 tier FREE_AND_TRIAL
|
||||||
|
|||||||
@@ -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
@@ -93,6 +93,11 @@ func (c *RealClient) ListAuditEvents(ctx context.Context, cred Credentials, regi
|
|||||||
|
|
||||||
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
// appendAuditEvents 过滤噪声后追加一页事件;原始事件只对保留条目序列化。
|
||||||
func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
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 {
|
for _, ev := range items {
|
||||||
out := toAuditEvent(ev)
|
out := toAuditEvent(ev)
|
||||||
if !keepAuditEvent(out) {
|
if !keepAuditEvent(out) {
|
||||||
@@ -101,8 +106,115 @@ func appendAuditEvents(result *AuditEventsResult, items []audit.AuditEvent) {
|
|||||||
if raw, mErr := json.Marshal(ev); mErr == nil {
|
if raw, mErr := json.Marshal(ev); mErr == nil {
|
||||||
out.Raw = raw
|
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)。
|
// auditInternalCIDRs 是 OCI 服务内部互调的发起方网段(RFC1918 + CGNAT)。
|
||||||
|
|||||||
@@ -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
@@ -39,10 +39,18 @@ func ckey(cred Credentials, parts ...string) string {
|
|||||||
// bust 写操作成功后失效该租户全部读缓存。
|
// bust 写操作成功后失效该租户全部读缓存。
|
||||||
func (c *CachedClient) bust(err error, cred Credentials) {
|
func (c *CachedClient) bust(err error, cred Credentials) {
|
||||||
if err == nil {
|
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 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
|
// cachedList 缓存切片结果;命中返回浅拷贝,防调用方排序共享底层数组。
|
||||||
func cachedList[T any](c *CachedClient, key string, ttl time.Duration, fn func() ([]T, error)) ([]T, error) {
|
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)
|
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) {
|
func (c *CachedClient) LaunchInstance(ctx context.Context, cred Credentials, in CreateInstanceInput) (Instance, error) {
|
||||||
|
|||||||
@@ -86,3 +86,18 @@ func TestCachedClientReturnsClone(t *testing.T) {
|
|||||||
t.Errorf("缓存底层数组被调用方污染: second[0]=%s, want i-1", second[0].ID)
|
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
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -99,10 +100,12 @@ type Client interface {
|
|||||||
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
|
AddVnicIpv6(ctx context.Context, cred Credentials, region, vnicID, address string) (string, error)
|
||||||
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
|
// GenAI 网关:区域模型列表、聊天(非流式/流式)、配额探测、文本向量化。
|
||||||
ListGenAiModels(ctx context.Context, cred Credentials, region string) ([]GenAiModel, error)
|
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)
|
GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error)
|
||||||
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
|
GenAiEmbed(ctx context.Context, cred Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error)
|
||||||
|
// GenAiCompatResponses 直通 OpenAI Responses 请求体到 /actions/v1/responses(xAI 服务端工具通路)。
|
||||||
|
GenAiCompatResponses(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/串口连接串)、列出、删除。
|
// 控制台连接:创建(VNC/串口连接串)、列出、删除。
|
||||||
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
CreateConsoleConnection(ctx context.Context, cred Credentials, region, instanceID, sshPublicKey string) (ConsoleConnection, error)
|
||||||
ListConsoleConnections(ctx context.Context, cred Credentials, region, instanceID 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)
|
SummarizeCosts(ctx context.Context, cred Credentials, q CostQuery) ([]CostItem, error)
|
||||||
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
|
// ListAuditEvents 实时查询租户根 compartment 的审计事件(最多翻 maxAuditPages 页,
|
||||||
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
|
// 到限截断并回传续查游标);page 传上次结果的 NextPage 可从断点续翻。
|
||||||
|
// 批式懒加载走 ListAuditEventsBatch,本方法保留给详情小窗反查。
|
||||||
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
|
ListAuditEvents(ctx context.Context, cred Credentials, region string, start, end time.Time, page string) (AuditEventsResult, error)
|
||||||
// 租户用户管理:经典 IAM 为主,新式租户自动回退 Identity Domains。
|
// ListAuditEventsBatch 从游标位置向更早方向收集约 limit 条保留事件,
|
||||||
ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error)
|
// 分窗回溯(空窗倍增),跨批不重不漏;到 365 天保留期尽头置 Exhausted。
|
||||||
GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error)
|
ListAuditEventsBatch(ctx context.Context, cred Credentials, region string, cur AuditCursor, limit int) (AuditBatchResult, error)
|
||||||
CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error)
|
// 身份域:租户 ACTIVE 域枚举(多域租户选择器数据源)。
|
||||||
UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error)
|
ListIdentityDomains(ctx context.Context, cred Credentials, region string) ([]IdentityDomain, error)
|
||||||
DeleteTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string) error
|
// 账户能力:暂停标记/账户状态/免费层信息(对暂停租户仍可访问,测活据此判「暂停」)。
|
||||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error)
|
GetAccountCapabilities(ctx context.Context, cred Credentials, region string) (AccountCapabilities, error)
|
||||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, userID string) (int, 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)
|
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
||||||
// 域设置:通知收件人、密码策略、身份设置。
|
// 域设置:通知收件人、密码策略、身份设置。
|
||||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error)
|
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
||||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error)
|
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
|
||||||
ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error)
|
ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error)
|
||||||
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
UpdatePasswordPolicy(ctx context.Context, cred Credentials, region, domainID, policyID string, in UpdatePasswordPolicyInput) (PasswordPolicyInfo, error)
|
||||||
GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error)
|
GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error)
|
||||||
UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error)
|
||||||
// Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
|
// Federation:SAML IdP 管理、域 SP 元数据、sign-on 免 MFA 规则。
|
||||||
ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error)
|
ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error)
|
||||||
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error)
|
CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error)
|
||||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error)
|
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error
|
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
|
||||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error)
|
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
|
||||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error)
|
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
|
||||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error)
|
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, ruleID string) error
|
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
|
||||||
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
|
// 日志回传链路(方案A):Topic/CUSTOM_HTTPS 订阅/IAM Policy/Service Connector
|
||||||
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
|
// 的幂等创建、状态聚合与销毁;IAM 写操作发往 homeRegion。
|
||||||
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
|
EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error)
|
||||||
|
|||||||
@@ -47,26 +47,82 @@ type IdentitySettingInfo struct {
|
|||||||
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
PrimaryEmailRequired bool `json:"primaryEmailRequired"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// domainURL 解析租户 Default Identity Domain 的 endpoint URL。
|
// IdentityDomain 是租户下一个 ACTIVE 身份域的列表视图;
|
||||||
func (c *RealClient) domainURL(ctx context.Context, cred Credentials, region string) (string, error) {
|
// 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)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
resp, err := ic.ListDomains(ctx, identity.ListDomainsRequest{CompartmentId: &cred.TenancyOCID})
|
url := pickDomainURL(items, domainID)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("list identity domains: %w", err)
|
|
||||||
}
|
|
||||||
url := pickDomainURL(resp.Items)
|
|
||||||
if url == "" {
|
if url == "" {
|
||||||
|
if domainID != "" {
|
||||||
|
return "", fmt.Errorf("identity domain %s not found or inactive", domainID)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("no active identity domain found")
|
return "", fmt.Errorf("no active identity domain found")
|
||||||
}
|
}
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// domainsClient 解析租户的 Default Identity Domain 并构造 SCIM 客户端。
|
// domainsClient 解析身份域(domainID 为空取 Default)并构造 SCIM 客户端。
|
||||||
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region string) (identitydomains.IdentityDomainsClient, error) {
|
func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region, domainID string) (identitydomains.IdentityDomainsClient, error) {
|
||||||
url, err := c.domainURL(ctx, cred, region)
|
url, err := c.domainURL(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return identitydomains.IdentityDomainsClient{}, err
|
return identitydomains.IdentityDomainsClient{}, err
|
||||||
}
|
}
|
||||||
@@ -80,13 +136,20 @@ func (c *RealClient) domainsClient(ctx context.Context, cred Credentials, region
|
|||||||
return dc, nil
|
return dc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickDomainURL 优先返回名为 Default 的域,否则取第一个 ACTIVE 的域。
|
// pickDomainURL 在 ACTIVE 域中选取:domainID 非空按 OCID 匹配;
|
||||||
func pickDomainURL(domains []identity.DomainSummary) string {
|
// 为空优先名为 Default 的域,否则取第一个 ACTIVE 的域。
|
||||||
|
func pickDomainURL(domains []identity.DomainSummary, domainID string) string {
|
||||||
fallback := ""
|
fallback := ""
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
if d.LifecycleState != identity.DomainLifecycleStateActive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if domainID != "" {
|
||||||
|
if deref(d.Id) == domainID {
|
||||||
|
return deref(d.Url)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if deref(d.DisplayName) == "Default" {
|
if deref(d.DisplayName) == "Default" {
|
||||||
return deref(d.Url)
|
return deref(d.Url)
|
||||||
}
|
}
|
||||||
@@ -98,8 +161,8 @@ func pickDomainURL(domains []identity.DomainSummary) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
// GetNotificationRecipients 实现 Client:查询域通知收件人设置。
|
||||||
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region string) (NotificationRecipients, error) {
|
func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRecipients{}, err
|
return NotificationRecipients{}, err
|
||||||
}
|
}
|
||||||
@@ -115,8 +178,8 @@ func (c *RealClient) GetNotificationRecipients(ctx context.Context, cred Credent
|
|||||||
|
|
||||||
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
// UpdateNotificationRecipients 实现 Client:把域通知改为只发给指定收件人;
|
||||||
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
// emails 为空时关闭 test mode 恢复默认发送。其余字段原样保留。
|
||||||
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region string, emails []string) (NotificationRecipients, error) {
|
func (c *RealClient) UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRecipients{}, err
|
return NotificationRecipients{}, err
|
||||||
}
|
}
|
||||||
@@ -153,8 +216,8 @@ func getNotificationSetting(ctx context.Context, dc identitydomains.IdentityDoma
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
// ListPasswordPolicies 实现 Client:列出域内全部密码策略。
|
||||||
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region string) ([]PasswordPolicyInfo, error) {
|
func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials, region, domainID string) ([]PasswordPolicyInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -171,12 +234,12 @@ func (c *RealClient) ListPasswordPolicies(ctx context.Context, cred Credentials,
|
|||||||
|
|
||||||
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
// UpdatePasswordPolicy 实现 Client:以 SCIM Patch 修改密码策略的指定字段。
|
||||||
// 仅 Custom 强度的策略可修改,Simple/Standard 内置策略 OCI 返回 403。
|
// 仅 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)
|
ops := buildPolicyPatchOps(in)
|
||||||
if len(ops) == 0 {
|
if len(ops) == 0 {
|
||||||
return PasswordPolicyInfo{}, fmt.Errorf("update password policy: nothing to update")
|
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 {
|
if err != nil {
|
||||||
return PasswordPolicyInfo{}, err
|
return PasswordPolicyInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -233,8 +296,8 @@ func removeOp(path string) identitydomains.Operations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
// GetIdentitySetting 实现 Client:读取域身份设置(IdentitySettings 单例资源)。
|
||||||
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region string) (IdentitySettingInfo, error) {
|
func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, region, domainID string) (IdentitySettingInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentitySettingInfo{}, err
|
return IdentitySettingInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -254,12 +317,12 @@ func (c *RealClient) GetIdentitySetting(ctx context.Context, cred Credentials, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
// UpdateIdentitySetting 实现 Client:修改「用户需要提供主电子邮件地址」开关。
|
||||||
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
func (c *RealClient) UpdateIdentitySetting(ctx context.Context, cred Credentials, region, domainID string, primaryEmailRequired bool) (IdentitySettingInfo, error) {
|
||||||
current, err := c.GetIdentitySetting(ctx, cred, region)
|
current, err := c.GetIdentitySetting(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentitySettingInfo{}, err
|
return IdentitySettingInfo{}, err
|
||||||
}
|
}
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentitySettingInfo{}, err
|
return IdentitySettingInfo{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,3 +76,34 @@ func ServiceStatus(err error) (int, bool) {
|
|||||||
}
|
}
|
||||||
return 0, false
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -56,8 +56,8 @@ type CreateIdpInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
// ListIdentityProviders 实现 Client:列出域内 SAML 身份提供者。
|
||||||
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region string) ([]IdentityProviderInfo, error) {
|
func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials, region, domainID string) ([]IdentityProviderInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,8 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
||||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentityProviderInfo{}, err
|
return IdentityProviderInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -139,23 +139,28 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
|||||||
return idp
|
return idp
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminGroupRef 查询域内 Administrators 组作为 JIT 静态分配目标;不存在时返回 nil。
|
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||||
|
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||||
filter := fmt.Sprintf("displayName eq %q", administratorsGroupName)
|
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
||||||
count := 1
|
count := len(adminGroupNames)
|
||||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||||
Filter: &filter,
|
Filter: &filter,
|
||||||
Attributes: common.String("id,displayName"),
|
Attributes: common.String("id,displayName"),
|
||||||
Count: &count,
|
Count: &count,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("find Administrators group: %w", err)
|
return nil, fmt.Errorf("find administrators group: %w", err)
|
||||||
|
}
|
||||||
|
for _, name := range adminGroupNames {
|
||||||
|
for _, g := range resp.Resources {
|
||||||
|
if deref(g.DisplayName) == name && g.Id != nil {
|
||||||
|
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: g.Id}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(resp.Resources) == 0 || resp.Resources[0].Id == nil {
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return &identitydomains.IdentityProviderJitUserProvAssignedGroups{Value: resp.Resources[0].Id}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
// jitMappings 生成 JIT 属性映射:来源为 NameID(或指定断言属性),
|
||||||
// 固定映射 用户名 + 姓,按需追加主邮箱(emails[work].value)。
|
// 固定映射 用户名 + 姓,按需追加主邮箱(emails[work].value)。
|
||||||
@@ -207,8 +212,8 @@ func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.Identity
|
|||||||
|
|
||||||
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
// SetIdentityProviderEnabled 实现 Client:启用/停用 IdP 并同步登录页显示。
|
||||||
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
// 顺序有讲究:启用后才能上登录页,停用前必须先从登录页移除。
|
||||||
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
func (c *RealClient) SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentityProviderInfo{}, err
|
return IdentityProviderInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -249,8 +254,8 @@ func patchIdpEnabled(ctx context.Context, dc identitydomains.IdentityDomainsClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
// DeleteIdentityProvider 实现 Client:删除 IdP;先移出登录页并停用。
|
||||||
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, idpID string) error {
|
func (c *RealClient) DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -346,8 +351,8 @@ func toggleJSONList(list, item string, add bool) (string, bool, error) {
|
|||||||
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
// DownloadDomainSamlMetadata 实现 Client:下载域的 SP SAML 元数据 XML。
|
||||||
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
// 域设置「访问签名证书」未开启时端点不可用,自动开启后重试
|
||||||
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
// (联邦配置要求 IdP 侧能读取该元数据,开启公开访问是官方标准做法)。
|
||||||
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region string) ([]byte, error) {
|
func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error) {
|
||||||
url, err := c.domainURL(ctx, cred, region)
|
url, err := c.domainURL(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -358,7 +363,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
|||||||
if status == http.StatusOK {
|
if status == http.StatusOK {
|
||||||
return body, nil
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
body, status, err = fetchSamlMetadata(ctx, url)
|
body, status, err = fetchSamlMetadata(ctx, url)
|
||||||
@@ -390,8 +395,8 @@ func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region string) error {
|
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-128
@@ -2,13 +2,11 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
"github.com/oracle/oci-go-sdk/v65/generativeai"
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
@@ -20,7 +18,6 @@ type GenAiModel struct {
|
|||||||
Ocid string `json:"ocid"`
|
Ocid string `json:"ocid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
ChatOnly bool `json:"-"`
|
|
||||||
Caps []string `json:"capabilities"`
|
Caps []string `json:"capabilities"`
|
||||||
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
// Capability 是网关侧归一能力:CHAT / EMBEDDING(兼具时算 CHAT)
|
||||||
Capability string `json:"capability"`
|
Capability string `json:"capability"`
|
||||||
@@ -31,12 +28,6 @@ type GenAiModel struct {
|
|||||||
Retired *time.Time `json:"retired"`
|
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) {
|
func (c *RealClient) genAiClient(cred Credentials, region string) (generativeai.GenerativeAiClient, error) {
|
||||||
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
gc, err := generativeai.NewGenerativeAiClientWithConfigurationProvider(provider(cred))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,18 +62,39 @@ func (c *RealClient) ListGenAiModels(ctx context.Context, cred Credentials, regi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list genai models: %w", err)
|
return nil, fmt.Errorf("list genai models: %w", err)
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
return dedupGenAiModels(resp.Items, time.Now()), nil
|
||||||
now := time.Now()
|
}
|
||||||
|
|
||||||
|
// dedupGenAiModels 压平并按名称去重;同名多条目时优先保留不含 FINE_TUNE 能力的条目
|
||||||
|
// (微调基座条目在部分区域不支持按需调用,缓存其 OCID 会导致调用 400)。
|
||||||
|
func dedupGenAiModels(items []generativeai.ModelSummary, now time.Time) []GenAiModel {
|
||||||
|
seen := map[string]int{}
|
||||||
var out []GenAiModel
|
var out []GenAiModel
|
||||||
for _, m := range resp.Items {
|
for _, m := range items {
|
||||||
gm, ok := toGenAiModel(m, now)
|
gm, ok := toGenAiModel(m, now)
|
||||||
if !ok || seen[gm.Name] {
|
if !ok {
|
||||||
continue
|
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)
|
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 压平模型摘要;无归一能力、无名称或按需推理已退役
|
// toGenAiModel 压平模型摘要;无归一能力、无名称或按需推理已退役
|
||||||
@@ -132,121 +144,15 @@ func capStrings(caps []generativeai.ModelCapabilityEnum) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenAiChat 实现 Client:非流式聊天;modelOcid 走 on-demand serving,
|
// GenAiProbeChat 实现 Client:经 OpenAI 兼容面(直通同链路)发一次极小请求探测渠道;配额探测专用的最小聊天(maxTokens=1),返回 HTTP 状态码;
|
||||||
// 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 状态码;
|
|
||||||
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
// modelName 决定请求格式(cohere.* 走 COHERE)。
|
||||||
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, region, modelOcid, modelName string) (int, error) {
|
||||||
one := 1
|
body, err := json.Marshal(map[string]any{"model": modelName, "input": "hi",
|
||||||
ir := aiwire.ChatRequest{
|
"max_output_tokens": 1, "store": false})
|
||||||
Model: modelName,
|
if err != nil {
|
||||||
Messages: []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent("hi")}},
|
return 0, err
|
||||||
MaxTokens: &one,
|
|
||||||
}
|
}
|
||||||
_, err := c.GenAiChat(ctx, cred, region, modelOcid, ir)
|
if _, err = c.GenAiCompatResponses(ctx, cred, region, body); err == nil {
|
||||||
if err == nil {
|
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
}
|
}
|
||||||
if status, ok := ServiceStatus(err); ok {
|
if status, ok := ServiceStatus(err); ok {
|
||||||
@@ -255,6 +161,27 @@ func (c *RealClient) GenAiProbeChat(ctx context.Context, cred Credentials, regio
|
|||||||
return 0, err
|
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。
|
// 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) {
|
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)
|
ic, err := c.genAiInferenceClient(cred, region)
|
||||||
|
|||||||
@@ -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), ¶ms) != nil || params == nil {
|
|
||||||
params = map[string]any{}
|
|
||||||
}
|
|
||||||
call := generativeaiinference.CohereToolCall{Name: &name, Parameters: ¶ms}
|
|
||||||
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: ¶ms}
|
|
||||||
}
|
|
||||||
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},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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, ¶ms) == nil {
|
|
||||||
fd.Parameters = ¶ms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -38,3 +38,33 @@ func TestToGenAiModelRetiredFilter(t *testing.T) {
|
|||||||
t.Error("正常模型应入池")
|
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
@@ -1,193 +1,27 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
"github.com/oracle/oci-go-sdk/v65/generativeaiinference"
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestIrToSDK 断言 IR→GENERIC 的角色拆装、采样参数与工具映射。
|
// TestSdkUsageCachedTokens 断言 embed 用量映射:缓存命中挂 details,仅命中 >0 时出现。
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSdkUsageCachedTokens(t *testing.T) {
|
func TestSdkUsageCachedTokens(t *testing.T) {
|
||||||
p, c, tot, cached := 10, 5, 15, 8
|
p, c, tot, cached := 10, 5, 15, 8
|
||||||
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
|
u := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, CompletionTokens: &c, TotalTokens: &tot,
|
||||||
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
|
PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &cached}})
|
||||||
if u.CachedTokens() != 8 {
|
if u.PromptTokens != 10 || u.CompletionTokens != 5 || u.TotalTokens != 15 {
|
||||||
t.Errorf("CachedTokens = %d, want 8", u.CachedTokens())
|
t.Fatalf("usage 映射错误: %+v", u)
|
||||||
}
|
}
|
||||||
if sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p}).CachedTokens() != 0 {
|
if u.PromptTokensDetails == nil || u.PromptTokensDetails.CachedTokens != 8 {
|
||||||
t.Error("无细分时 CachedTokens 应为 0")
|
t.Fatalf("cachedTokens 未映射: %+v", u.PromptTokensDetails)
|
||||||
}
|
}
|
||||||
|
zero := 0
|
||||||
|
if got := sdkUsageToIR(&generativeaiinference.Usage{PromptTokens: &p, PromptTokensDetails: &generativeaiinference.PromptTokensDetails{CachedTokens: &zero}}); got.PromptTokensDetails != nil {
|
||||||
|
t.Fatalf("零命中不应带 details: %+v", got.PromptTokensDetails)
|
||||||
}
|
}
|
||||||
|
if sdkUsageToIR(nil) != nil {
|
||||||
func TestIrToCohereSDK(t *testing.T) {
|
t.Fatal("nil usage 应返回 nil")
|
||||||
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("第二问")},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
client := NewClient()
|
||||||
region := cred.Region
|
region := cred.Region
|
||||||
|
|
||||||
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region)
|
metadata, err := client.DownloadDomainSamlMetadata(ctx, cred, region, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DownloadDomainSamlMetadata: %v", err)
|
t.Fatalf("DownloadDomainSamlMetadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ func TestRealOCIFederation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("domain saml metadata: %d bytes", len(metadata))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListConsoleSignOnRules: %v", err)
|
t.Fatalf("ListConsoleSignOnRules: %v", err)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func TestRealOCIFederation(t *testing.T) {
|
|||||||
idp := createTestIdp(ctx, t, client, cred, region)
|
idp := createTestIdp(ctx, t, client, cred, region)
|
||||||
verifyIdpDefaults(ctx, t, client, cred, region, idp.ID)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("activate idp: %v", err)
|
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))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("deactivate idp: %v", err)
|
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)
|
t.Fatalf("read test-idp.xml: %v", err)
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("oci-portal-e2e-idp-%d", time.Now().Unix())
|
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,
|
Name: name,
|
||||||
Metadata: string(metadata),
|
Metadata: string(metadata),
|
||||||
Description: "oci-portal e2e temporary idp",
|
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() {
|
t.Cleanup(func() {
|
||||||
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer ccancel()
|
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)
|
t.Errorf("cleanup: delete idp: %v", err)
|
||||||
} else {
|
} else {
|
||||||
t.Log("cleanup: idp deleted")
|
t.Log("cleanup: idp deleted")
|
||||||
@@ -98,7 +98,7 @@ func createTestIdp(ctx context.Context, t *testing.T, client *RealClient, cred C
|
|||||||
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
|
// verifyIdpDefaults 回读 IdP 断言控制台默认值与 JIT 属性映射两条。
|
||||||
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
|
func verifyIdpDefaults(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dc, err := client.domainsClient(ctx, cred, region)
|
dc, err := client.domainsClient(ctx, cred, region, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("domainsClient: %v", err)
|
t.Fatalf("domainsClient: %v", err)
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ func verifyJitMappings(ctx context.Context, t *testing.T, dc identitydomains.Ide
|
|||||||
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
|
// assertLoginPage 校验 DefaultIDPRule 的 SamlIDPs 是否包含目标 IdP。
|
||||||
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
|
func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, want bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dc, err := client.domainsClient(ctx, cred, region)
|
dc, err := client.domainsClient(ctx, cred, region, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("domainsClient: %v", err)
|
t.Fatalf("domainsClient: %v", err)
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ func assertLoginPage(ctx context.Context, t *testing.T, client *RealClient, cred
|
|||||||
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
|
// exemptionRoundTrip 创建免 MFA 规则断言置顶与顺延,删除后断言完全复位。
|
||||||
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
|
func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, cred Credentials, region, idpID string, beforeCount int) {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateMfaExemptionRule: %v", err)
|
t.Fatalf("CreateMfaExemptionRule: %v", err)
|
||||||
}
|
}
|
||||||
@@ -185,12 +185,12 @@ func exemptionRoundTrip(ctx context.Context, t *testing.T, client *RealClient, c
|
|||||||
if deleted {
|
if deleted {
|
||||||
return
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListConsoleSignOnRules after create: %v", err)
|
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)
|
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)
|
t.Fatalf("DeleteMfaExemptionRule: %v", err)
|
||||||
}
|
}
|
||||||
deleted = true
|
deleted = true
|
||||||
restored, err := client.ListConsoleSignOnRules(ctx, cred, region)
|
restored, err := client.ListConsoleSignOnRules(ctx, cred, region, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
|
t.Fatalf("ListConsoleSignOnRules after delete: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
|||||||
client := NewClient()
|
client := NewClient()
|
||||||
homeRegion := cred.Region
|
homeRegion := cred.Region
|
||||||
|
|
||||||
users, err := client.ListTenantUsers(ctx, cred)
|
users, err := client.ListTenantUsers(ctx, cred, homeRegion, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListTenantUsers: %v", err)
|
t.Fatalf("ListTenantUsers: %v", err)
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
|||||||
t.Logf("tenant users: %d", len(users))
|
t.Logf("tenant users: %d", len(users))
|
||||||
|
|
||||||
name := fmt.Sprintf("oci-portal-e2e-user-%d", time.Now().Unix())
|
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",
|
Name: name, Description: "e2e temp user", Email: name + "@example.com",
|
||||||
GivenName: "E2E", FamilyName: "Temp",
|
GivenName: "E2E", FamilyName: "Temp",
|
||||||
})
|
})
|
||||||
@@ -49,7 +49,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("created user %s (%s)", user.Name, user.ID)
|
t.Logf("created user %s (%s)", user.Name, user.ID)
|
||||||
defer func() {
|
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)
|
t.Errorf("cleanup: DeleteTenantUser: %v", err)
|
||||||
} else {
|
} else {
|
||||||
t.Log("cleanup: user deleted")
|
t.Log("cleanup: user deleted")
|
||||||
@@ -57,7 +57,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
newDesc, newEmail, newGiven := "e2e updated", name+"+upd@example.com", "E2E2"
|
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,
|
Description: &newDesc, Email: &newEmail, GivenName: &newGiven,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,7 +68,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("updated user desc=%q email=%q", updated.Description, updated.Email)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ResetTenantUserPassword: %v", err)
|
t.Fatalf("ResetTenantUserPassword: %v", err)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func TestRealOCITenantUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("password reset ok (len=%d)", len(password))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
|
t.Fatalf("DeleteTenantUserMfaDevices: %v", err)
|
||||||
}
|
}
|
||||||
@@ -101,12 +101,12 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
|||||||
client := NewClient()
|
client := NewClient()
|
||||||
region := cred.Region
|
region := cred.Region
|
||||||
|
|
||||||
original, err := client.GetNotificationRecipients(ctx, cred, region)
|
original, err := client.GetNotificationRecipients(ctx, cred, region, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetNotificationRecipients: %v", err)
|
t.Fatalf("GetNotificationRecipients: %v", err)
|
||||||
}
|
}
|
||||||
t.Logf("original recipients=%v testMode=%v", original.Recipients, original.TestModeEnabled)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpdateNotificationRecipients: %v", err)
|
t.Fatalf("UpdateNotificationRecipients: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,13 +117,13 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
|||||||
if !original.TestModeEnabled {
|
if !original.TestModeEnabled {
|
||||||
restoreRecipients = nil
|
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)
|
t.Errorf("restore recipients: %v", err)
|
||||||
} else {
|
} else {
|
||||||
t.Log("recipients restored")
|
t.Log("recipients restored")
|
||||||
}
|
}
|
||||||
|
|
||||||
policies, err := client.ListPasswordPolicies(ctx, cred, region)
|
policies, err := client.ListPasswordPolicies(ctx, cred, region, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListPasswordPolicies: %v", err)
|
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)
|
t.Logf("policies: %d, patch target %s strength=%s expires=%v", len(policies), target.Name, target.PasswordStrength, target.PasswordExpiresAfter)
|
||||||
days := 350
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpdatePasswordPolicy: %v", err)
|
t.Fatalf("UpdatePasswordPolicy: %v", err)
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ func TestRealOCIDomainSettings(t *testing.T) {
|
|||||||
if target.PasswordExpiresAfter != nil {
|
if target.PasswordExpiresAfter != nil {
|
||||||
restore = *target.PasswordExpiresAfter
|
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)
|
t.Errorf("restore password policy: %v", err)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("policy restored to %d", restore)
|
t.Logf("policy restored to %d", restore)
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ type SignOnRuleInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
|
// ListConsoleSignOnRules 实现 Client:按优先级列出 OCI Console sign-on 策略的规则。
|
||||||
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region string) ([]SignOnRuleInfo, error) {
|
func (c *RealClient) ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,8 @@ func fillRuleCondition(ctx context.Context, dc identitydomains.IdentityDomainsCl
|
|||||||
|
|
||||||
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
|
// CreateMfaExemptionRule 实现 Client:为指定 IdP 创建免 MFA sign-on 规则并置顶。
|
||||||
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
|
// 规则语义与控制台一致:subject.authenticatedBy in [idpID] → authenticationFactor=IDP。
|
||||||
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, idpID, ruleName string) (SignOnRuleInfo, error) {
|
func (c *RealClient) CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SignOnRuleInfo{}, err
|
return SignOnRuleInfo{}, err
|
||||||
}
|
}
|
||||||
@@ -239,11 +239,11 @@ func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsCli
|
|||||||
|
|
||||||
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
|
// DeleteMfaExemptionRule 实现 Client:删除免 MFA 规则并连带清理其条件。
|
||||||
// 拒绝删除 Oracle 预置规则。
|
// 拒绝删除 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") {
|
if strings.HasPrefix(ruleID, "OciConsole") {
|
||||||
return fmt.Errorf("delete sign-on rule: %s is a built-in rule and cannot be deleted", ruleID)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-28
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,11 +29,18 @@ type TenantUser struct {
|
|||||||
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
|
LastLoginTime *time.Time `json:"lastLoginTime,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Oracle 预置的管理员角色与管理员组显示名。
|
// Oracle 预置的「身份域管理员」应用角色显示名。
|
||||||
const (
|
const domainAdminRoleName = "Identity Domain Administrator"
|
||||||
domainAdminRoleName = "Identity Domain Administrator"
|
|
||||||
administratorsGroupName = "Administrators"
|
// 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 扩展属性的完整路径。
|
// scimAppRolesAttr 是 SCIM 用户 appRoles 扩展属性的完整路径。
|
||||||
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
|
const scimAppRolesAttr = "urn:ietf:params:scim:schemas:oracle:idcs:extension:user:User:appRoles"
|
||||||
@@ -48,8 +56,8 @@ type TenantUserDetail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
|
// GetTenantUserDetail 实现 Client:查用户域档案与管理员状态(编辑表单预填充用)。
|
||||||
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, userID string) (TenantUserDetail, error) {
|
func (c *RealClient) GetTenantUserDetail(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (TenantUserDetail, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TenantUserDetail{}, err
|
return TenantUserDetail{}, err
|
||||||
}
|
}
|
||||||
@@ -68,7 +76,7 @@ func toTenantUserDetail(u identitydomains.User) TenantUserDetail {
|
|||||||
d.FamilyName = deref(u.Name.FamilyName)
|
d.FamilyName = deref(u.Name.FamilyName)
|
||||||
}
|
}
|
||||||
for _, g := range u.Groups {
|
for _, g := range u.Groups {
|
||||||
if deref(g.Display) == administratorsGroupName {
|
if isAdminGroupName(deref(g.Display)) {
|
||||||
d.InAdminGroup = true
|
d.InAdminGroup = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +102,13 @@ func (c *RealClient) identityClientAt(cred Credentials, region string) (identity
|
|||||||
return ic, nil
|
return ic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTenantUsers 实现 Client:列出租户全部 IAM 用户。
|
// ListTenantUsers 实现 Client:列出租户 IAM 用户。
|
||||||
func (c *RealClient) ListTenantUsers(ctx context.Context, cred Credentials) ([]TenantUser, error) {
|
// 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, "")
|
ic, err := c.identityClientAt(cred, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 走
|
// CreateTenantUserInput 是创建用户的输入;GivenName/FamilyName 走
|
||||||
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
|
// Identity Domains SCIM(经典 IAM 无对应字段)。两个管理员选项同时勾选时
|
||||||
// 先授身份域管理员角色,再加入 Administrators 组。
|
// 先授身份域管理员角色,再加入 Administrators 组。
|
||||||
@@ -133,12 +218,13 @@ type CreateTenantUserInput struct {
|
|||||||
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
|
// CreateTenantUser 实现 Client:创建用户(须发往 home region)。
|
||||||
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
|
// 优先 Identity Domains SCIM(支持姓名与管理员授权);SCIM 不可用时回退
|
||||||
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
|
// 经典 IAM API,此时管理员选项无法执行、姓名并入描述。
|
||||||
func (c *RealClient) CreateTenantUser(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
// domainID 非空表示用户显式选择了目标域,SCIM 失败不再回退(避免建错域)。
|
||||||
user, err := c.createUserViaDomain(ctx, cred, homeRegion, in)
|
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 {
|
if err == nil {
|
||||||
return user, 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 TenantUser{}, fmt.Errorf("create user via identity domain: %w", err)
|
||||||
}
|
}
|
||||||
return c.createUserClassic(ctx, cred, homeRegion, in)
|
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 创建用户并按选项授权。
|
// createUserViaDomain 通过 Identity Domains SCIM 创建用户并按选项授权。
|
||||||
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion string, in CreateTenantUserInput) (TenantUser, error) {
|
func (c *RealClient) createUserViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID string, in CreateTenantUserInput) (TenantUser, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TenantUser{}, err
|
return TenantUser{}, err
|
||||||
}
|
}
|
||||||
@@ -373,9 +459,9 @@ type UpdateTenantUserInput struct {
|
|||||||
|
|
||||||
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
|
// UpdateTenantUser 实现 Client:编辑用户资料(须发往 home region)。
|
||||||
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
|
// 优先 Identity Domains SCIM(支持姓名);用户不在域中时回退经典
|
||||||
// IAM API(仅备注与邮箱,姓名变更报错)。
|
// IAM API(仅备注与邮箱,姓名变更报错);domainID 非空时不回退。
|
||||||
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, homeRegion, domainID, userID string, in UpdateTenantUserInput) (TenantUser, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TenantUser{}, err
|
return TenantUser{}, err
|
||||||
}
|
}
|
||||||
@@ -394,6 +480,9 @@ func (c *RealClient) UpdateTenantUser(ctx context.Context, cred Credentials, hom
|
|||||||
}
|
}
|
||||||
return user, nil
|
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 {
|
if in.GivenName != nil || in.FamilyName != nil {
|
||||||
return TenantUser{}, fmt.Errorf("update user %s: 姓名仅身份域用户可修改(该用户不在身份域中)", userID)
|
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)。
|
// 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)
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -506,9 +599,31 @@ func (c *RealClient) DeleteTenantUser(ctx context.Context, cred Credentials, hom
|
|||||||
return nil
|
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:重置用户控制台密码并返回一次性新密码。
|
// ResetTenantUserPassword 实现 Client:重置用户控制台密码并返回一次性新密码。
|
||||||
// 优先经典 IAM API;新式租户(用户只存在于 Identity Domain)自动回退 SCIM。
|
// domainID 非空直走该域 SCIM;为空优先经典 IAM API,
|
||||||
func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
// 新式租户(用户只存在于 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)
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -520,12 +635,12 @@ func (c *RealClient) ResetTenantUserPassword(ctx context.Context, cred Credentia
|
|||||||
if !isClassicUnsupported(err) {
|
if !isClassicUnsupported(err) {
|
||||||
return "", fmt.Errorf("reset ui password of %s: %w", userID, 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 强设随机新密码。
|
// resetPasswordViaDomain 通过 Identity Domains SCIM API 强设随机新密码。
|
||||||
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, userID string) (string, error) {
|
func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -558,7 +673,11 @@ func (c *RealClient) resetPasswordViaDomain(ctx context.Context, cred Credential
|
|||||||
|
|
||||||
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
|
// DeleteTenantUserMfaDevices 实现 Client:清除用户全部 MFA。
|
||||||
// 先删经典 TOTP 设备,再通过 Identity Domains 移除全部认证因子(尽力)。
|
// 先删经典 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)
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -567,7 +686,7 @@ func (c *RealClient) DeleteTenantUserMfaDevices(ctx context.Context, cred Creden
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return deleted, err
|
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, err
|
||||||
}
|
}
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
@@ -594,8 +713,8 @@ func deleteClassicTotpDevices(ctx context.Context, ic identity.IdentityClient, u
|
|||||||
|
|
||||||
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
|
// removeDomainFactors 通过 SCIM AuthenticationFactorsRemover 移除用户
|
||||||
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
|
// 在 Identity Domain 注册的全部认证因子;用户不在域中时视为无事可做。
|
||||||
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, userID string) error {
|
func (c *RealClient) removeDomainFactors(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) error {
|
||||||
dc, err := c.domainsClient(ctx, cred, homeRegion)
|
dc, err := c.domainsClient(ctx, cred, homeRegion, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestToTenantUserDetail(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Groups: []identitydomains.UserGroups{
|
Groups: []identitydomains.UserGroups{
|
||||||
{Value: common.String("g1"), Display: common.String("Readers")},
|
{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,
|
UrnIetfParamsScimSchemasOracleIdcsExtensionUserUser: adminRoles,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"oci-portal/internal/aiwire"
|
"oci-portal/internal/aiwire"
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
@@ -78,7 +80,7 @@ func (s *AiGatewayService) fireChannelsChanged(ctx context.Context) {
|
|||||||
|
|
||||||
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
|
// CreateKey 生成网关密钥并返回明文(仅此一次);customValue 非空时使用给定值,
|
||||||
// group 非空时该密钥只在同分组渠道内路由。
|
// 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)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", nil, fmt.Errorf("密钥名称不能为空")
|
return "", nil, fmt.Errorf("密钥名称不能为空")
|
||||||
@@ -94,13 +96,28 @@ func (s *AiGatewayService) CreateKey(ctx context.Context, name, customValue, gro
|
|||||||
if len(raw) < 8 {
|
if len(raw) < 8 {
|
||||||
return "", nil, fmt.Errorf("自定义密钥至少 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 {
|
if err := s.db.WithContext(ctx).Create(key).Error; err != nil {
|
||||||
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
|
return "", nil, fmt.Errorf("密钥名称或取值与现有密钥重复")
|
||||||
}
|
}
|
||||||
return raw, key, nil
|
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 {
|
func hashKey(raw string) string {
|
||||||
sum := sha256.Sum256([]byte(raw))
|
sum := sha256.Sum256([]byte(raw))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
@@ -113,8 +130,8 @@ func (s *AiGatewayService) Keys(ctx context.Context) ([]model.AiKey, error) {
|
|||||||
return keys, err
|
return keys, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateKey 修改密钥名称 / 启用状态 / 分组(group 指针非空即覆盖,可置空)。
|
// UpdateKey 修改密钥名称 / 启用状态 / 分组 / 模型白名单(指针非空即覆盖,可置空)。
|
||||||
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string) error {
|
func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string, enabled *bool, group *string, models *[]string) error {
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
if name = strings.TrimSpace(name); name != "" {
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
updates["name"] = name
|
updates["name"] = name
|
||||||
@@ -125,6 +142,14 @@ func (s *AiGatewayService) UpdateKey(ctx context.Context, id uint, name string,
|
|||||||
if group != nil {
|
if group != nil {
|
||||||
updates["key_group"] = strings.TrimSpace(*group)
|
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 {
|
if len(updates) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -289,12 +314,17 @@ func (s *AiGatewayService) ProbeChannel(ctx context.Context, id uint) (*model.Ai
|
|||||||
return &fresh, nil
|
return &fresh, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步。
|
// probe 执行探测并返回 (状态, 错误摘要);同时完成模型缓存同步(黑名单模型不入库)。
|
||||||
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
|
func (s *AiGatewayService) probe(ctx context.Context, cred oci.Credentials, ch *model.AiChannel) (string, string) {
|
||||||
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
|
models, err := s.client.ListGenAiModels(ctx, cred, ch.Region)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return classifyProbeErr(err), truncateErr(oci.CompactError(err))
|
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 {
|
if len(models) == 0 {
|
||||||
_ = s.replaceModels(ctx, ch.ID, nil)
|
_ = s.replaceModels(ctx, ch.ID, nil)
|
||||||
return "no_service", "区域无可用模型(GenAI 服务不可用或未开放)"
|
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)
|
return s.probeChat(ctx, cred, ch, models)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeChat 按偏好挑选至多 3 个模型依次试调:部分模型元数据标 CHAT 但实际
|
// probeChat 按候选顺序试调(上限 8):遇「模型不可按需调用」(微调基座 400 / 实体
|
||||||
// 不可对话(如 voice agent),遇 400/5xx 换下一个;401/403/404 属租户级直接定论。
|
// 不存在 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) {
|
func (s *AiGatewayService) probeChat(ctx context.Context, cred oci.Credentials, ch *model.AiChannel, models []oci.GenAiModel) (string, string) {
|
||||||
status, detail := "error", "无可试调对话模型"
|
status, detail := "error", "无可试调对话模型"
|
||||||
|
errBudget := 3
|
||||||
for _, m := range probeCandidates(models) {
|
for _, m := range probeCandidates(models) {
|
||||||
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
code, err := s.client.GenAiProbeChat(ctx, cred, ch.Region, m.Ocid, m.Name)
|
||||||
switch {
|
switch {
|
||||||
case code == 200 || code == 429:
|
case code == 200 || code == 429:
|
||||||
return "ok", ""
|
return "ok", ""
|
||||||
|
case oci.IsModelUnavailable(err):
|
||||||
|
status, detail = "error", truncateErr(fmt.Sprintf("%s: 不可按需调用,建议加入模型黑名单", m.Name))
|
||||||
case code == 401 || code == 403 || code == 404:
|
case code == 401 || code == 403 || code == 404:
|
||||||
return "no_quota", truncateErr(oci.CompactError(err))
|
return "no_quota", truncateErr(oci.CompactError(err))
|
||||||
default:
|
default:
|
||||||
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
status, detail = "error", truncateErr(fmt.Sprintf("%s: %s", m.Name, oci.CompactError(err)))
|
||||||
|
if errBudget--; errBudget == 0 {
|
||||||
|
return status, detail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return status, detail
|
return status, detail
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeCandidates 只取对话模型并按可靠度排序取前 3:主流文本模型优先,
|
// probeCandidateCap 是单次探测的候选上限:放宽到 8 让一次探测有机会越过整批
|
||||||
// voice 等非常规形态殿后(embedding / rerank 已被能力筛选排除)。
|
// 不可按需调用的坏模型找到可用者;其他错误另有 3 次止损预算。
|
||||||
|
const probeCandidateCap = 8
|
||||||
|
|
||||||
|
// probeCandidates 只取对话模型,按可靠度排序后跨厂商取候选:主流文本模型优先;
|
||||||
|
// voice 等负分形态(元数据标 CHAT 但实际不可对话)直接排除,不浪费试调预算;
|
||||||
|
// 每厂商先取最高分再按分数补位——部分区域某厂商全为微调基座(调用必失败),
|
||||||
|
// 不能让单一厂商占满候选名额拖垮整个渠道的探测结论。
|
||||||
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
func probeCandidates(models []oci.GenAiModel) []oci.GenAiModel {
|
||||||
var sorted []oci.GenAiModel
|
var sorted []oci.GenAiModel
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if m.Capability == "" || m.Capability == "CHAT" {
|
if (m.Capability == "" || m.Capability == "CHAT") && probeScore(m.Name) >= 0 {
|
||||||
sorted = append(sorted, m)
|
sorted = append(sorted, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.SliceStable(sorted, func(i, j int) bool {
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
return probeScore(sorted[i].Name) > probeScore(sorted[j].Name)
|
||||||
})
|
})
|
||||||
if len(sorted) > 3 {
|
return diversifyByVendor(sorted, probeCandidateCap)
|
||||||
sorted = sorted[:3]
|
|
||||||
}
|
}
|
||||||
return sorted
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
func probeScore(name string) int {
|
||||||
@@ -376,7 +454,7 @@ func truncateErr(msg string) string {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存。
|
// SyncModels 重新拉取渠道区域的模型列表并覆盖缓存,黑名单中的模型不入库。
|
||||||
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiModelCache, error) {
|
||||||
var ch model.AiChannel
|
var ch model.AiChannel
|
||||||
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
if err := s.db.WithContext(ctx).First(&ch, id).Error; err != nil {
|
||||||
@@ -390,6 +468,10 @@ func (s *AiGatewayService) SyncModels(ctx context.Context, id uint) ([]model.AiM
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("同步模型失败:%s", oci.CompactError(err))
|
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 {
|
if err := s.replaceModels(ctx, id, models); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -421,7 +503,7 @@ func (s *AiGatewayService) channelModels(ctx context.Context, channelID uint) ([
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GatewayModels 聚合启用渠道的模型(按名称去重),供 /ai/v1/models;
|
// GatewayModels 聚合启用渠道的可用模型(按名称去重),供 /ai/v1/models;
|
||||||
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
// group 非空时仅聚合该分组渠道(与密钥分组路由口径一致)。
|
||||||
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiwire.ModelList, error) {
|
||||||
q := s.db.WithContext(ctx).
|
q := s.db.WithContext(ctx).
|
||||||
@@ -501,18 +583,134 @@ func (s *AiGatewayService) ProbeAll(ctx context.Context) (string, error) {
|
|||||||
return msg, nil
|
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)。
|
// LogCall 落一条调用日志(仅元数据与用量,永不含请求 / 响应正文),返回落库 ID 供内容日志关联(失败为 0)。
|
||||||
func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint {
|
func (s *AiGatewayService) LogCall(entry model.AiCallLog) uint {
|
||||||
entry.ErrMsg = truncateErr(entry.ErrMsg)
|
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)
|
log.Printf("ai call log: %v", err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if !stored {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
return entry.ID
|
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 分页查询调用日志。
|
// CallLogs 分页查询调用日志。
|
||||||
func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) {
|
func (s *AiGatewayService) CallLogs(ctx context.Context, page, size int) ([]model.AiCallLog, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@@ -555,9 +753,19 @@ func (s *AiGatewayService) UpdateKeyContentLog(ctx context.Context, id uint, hou
|
|||||||
|
|
||||||
// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。
|
// LogContent 写一条内容日志(调用方已确认密钥开启且未过期);正文截断至 64KB。
|
||||||
func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
||||||
|
if entry.CallLogID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
entry.RequestBody = truncateBody(entry.RequestBody)
|
entry.RequestBody = truncateBody(entry.RequestBody)
|
||||||
entry.ResponseBody = truncateBody(entry.ResponseBody)
|
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)
|
log.Printf("ai content log: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,27 +27,31 @@ type aiCandidate struct {
|
|||||||
modelOcid string
|
modelOcid string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat 编排非流式调用:选渠道 → 调用 → 可重试错误换渠道(整请求上限 3 次)。
|
// RespPassthrough 编排一次非流式直通调用:选渠道(priority→加权随机)→ 调用 →
|
||||||
// group 非空时只在同分组渠道内路由(取自调用密钥)。
|
// 可重试错误换渠道(整请求上限 3 次)并维护熔断;group 非空时只在同分组渠道内路由。
|
||||||
func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, group string) (*aiwire.ChatResponse, ChatMeta, error) {
|
// 上游为 OpenAI-compatible /actions/v1/responses(实测可用,无 Oracle 文档合同)。
|
||||||
|
func (s *AiGatewayService) RespPassthrough(ctx context.Context, raw []byte, modelName, group string) ([]byte, ChatMeta, error) {
|
||||||
meta := ChatMeta{}
|
meta := ChatMeta{}
|
||||||
excluded := map[uint]bool{}
|
excluded := map[uint]bool{}
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
|
cand, err := s.pick(ctx, modelName, group, "CHAT", excluded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, firstErr(lastErr, err)
|
return nil, meta, firstErr(lastErr, err)
|
||||||
}
|
}
|
||||||
meta.ChannelID, meta.ChannelName = cand.ch.ID, cand.ch.Name
|
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 {
|
if err == nil {
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
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
|
return nil, meta, err
|
||||||
}
|
}
|
||||||
|
if penalize {
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
s.markFailure(ctx, cand.ch.ID)
|
||||||
|
}
|
||||||
excluded[cand.ch.ID] = true
|
excluded[cand.ch.ID] = true
|
||||||
meta.Retries++
|
meta.Retries++
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -54,22 +59,22 @@ func (s *AiGatewayService) Chat(ctx context.Context, ir aiwire.ChatRequest, grou
|
|||||||
return nil, meta, lastErr
|
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)
|
cred, err := s.configs.credentialsByID(ctx, cand.ch.OciConfigID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 编排流式调用:流建立成功后即绑定渠道,建立失败可换渠道重试。
|
// RespPassthroughStream 编排流式直通:流建立成功即绑定渠道,建立失败按 switchable
|
||||||
// group 语义与 Chat 相同。
|
// 换渠道重试;建立后的中断不重试、不计熔断(与 OpenStream 语义一致)。
|
||||||
func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest, group string) (oci.GenAiStream, ChatMeta, error) {
|
func (s *AiGatewayService) RespPassthroughStream(ctx context.Context, raw []byte, modelName, group string) (io.ReadCloser, ChatMeta, error) {
|
||||||
meta := ChatMeta{}
|
meta := ChatMeta{}
|
||||||
excluded := map[uint]bool{}
|
excluded := map[uint]bool{}
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
cand, err := s.pick(ctx, ir.Model, group, "CHAT", excluded)
|
cand, err := s.pick(ctx, modelName, group, "CHAT", excluded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, firstErr(lastErr, err)
|
return nil, meta, firstErr(lastErr, err)
|
||||||
}
|
}
|
||||||
@@ -78,15 +83,18 @@ func (s *AiGatewayService) OpenStream(ctx context.Context, ir aiwire.ChatRequest
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, meta, err
|
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 {
|
if err == nil {
|
||||||
s.markSuccess(ctx, cand.ch.ID)
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
return stream, meta, nil
|
return stream, meta, nil
|
||||||
}
|
}
|
||||||
if !retryable(err) {
|
retry, penalize := switchable(err)
|
||||||
|
if !retry {
|
||||||
return nil, meta, err
|
return nil, meta, err
|
||||||
}
|
}
|
||||||
|
if penalize {
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
s.markFailure(ctx, cand.ch.ID)
|
||||||
|
}
|
||||||
excluded[cand.ch.ID] = true
|
excluded[cand.ch.ID] = true
|
||||||
meta.Retries++
|
meta.Retries++
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -102,12 +110,17 @@ func firstErr(lastErr, pickErr error) error {
|
|||||||
return pickErr
|
return pickErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryable 判定是否换渠道重试:429 / 5xx / 网络错误可重试,其余 4xx 直接透传。
|
// switchable 判断调用失败是否换渠道重试、是否计入熔断:模型级不可用(微调基座
|
||||||
func retryable(err error) bool {
|
// 400 / 实体不存在 404)换渠道但不计熔断——这是模型×区域供给问题而非渠道健康
|
||||||
if status, ok := oci.ServiceStatus(err); ok {
|
// 问题,持久解决靠加入模型黑名单;429 / 5xx / 网络错误换渠道并计熔断;其余 4xx 直接透传。
|
||||||
return status == 429 || status >= 500
|
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 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。
|
// pick 选出支持该模型的最优渠道:能力匹配 → 启用 → 分组匹配 → 未熔断 → 最小优先级组 → 加权随机。
|
||||||
@@ -221,10 +234,13 @@ func (s *AiGatewayService) Embeddings(ctx context.Context, req aiwire.Embeddings
|
|||||||
s.markSuccess(ctx, cand.ch.ID)
|
s.markSuccess(ctx, cand.ch.ID)
|
||||||
return resp, meta, nil
|
return resp, meta, nil
|
||||||
}
|
}
|
||||||
if !retryable(err) {
|
retry, penalize := switchable(err)
|
||||||
|
if !retry {
|
||||||
return nil, meta, err
|
return nil, meta, err
|
||||||
}
|
}
|
||||||
|
if penalize {
|
||||||
s.markFailure(ctx, cand.ch.ID)
|
s.markFailure(ctx, cand.ch.ID)
|
||||||
|
}
|
||||||
excluded[cand.ch.ID] = true
|
excluded[cand.ch.ID] = true
|
||||||
meta.Retries++
|
meta.Retries++
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,14 +16,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。
|
// 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) Error() string { return "stub service error" }
|
||||||
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
|
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
|
||||||
func (e stubServiceError) GetMessage() string { return "stub" }
|
func (e stubServiceError) GetMessage() string {
|
||||||
|
if e.msg != "" {
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
|
return "stub"
|
||||||
|
}
|
||||||
func (e stubServiceError) GetCode() string { return "Stub" }
|
func (e stubServiceError) GetCode() string { return "Stub" }
|
||||||
func (e stubServiceError) GetOpcRequestID() string { return "req-1" }
|
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 逐次弹出以模拟先失败后成功。
|
// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。
|
||||||
type gatewayStubClient struct {
|
type gatewayStubClient struct {
|
||||||
*fakeClient
|
*fakeClient
|
||||||
@@ -29,14 +46,47 @@ type gatewayStubClient struct {
|
|||||||
modelsErr error
|
modelsErr error
|
||||||
probeCode int
|
probeCode int
|
||||||
probeErr error
|
probeErr error
|
||||||
chatResp *aiwire.ChatResponse
|
// probeSeq 非空时逐次弹出,模拟按候选依次试调;弹尽后回落 probeCode/probeErr
|
||||||
chatErrs []error
|
probeSeq []probeResult
|
||||||
|
// probedNames 记录试调过的模型名,供断言候选过滤
|
||||||
|
probedNames []string
|
||||||
chatCalls int
|
chatCalls int
|
||||||
regions []string
|
regions []string
|
||||||
|
|
||||||
embedVecs [][]float32
|
embedVecs [][]float32
|
||||||
embedUsage *aiwire.Usage
|
embedUsage *aiwire.Usage
|
||||||
embedErr error
|
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) {
|
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
|
return f.models, f.modelsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
|
// probeResult 是 gatewayStubClient.probeSeq 的单次探测结果。
|
||||||
return f.probeCode, f.probeErr
|
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) {
|
func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
|
||||||
f.chatCalls++
|
f.probedNames = append(f.probedNames, modelName)
|
||||||
f.regions = append(f.regions, region)
|
if len(f.probeSeq) > 0 {
|
||||||
if len(f.chatErrs) > 0 {
|
r := f.probeSeq[0]
|
||||||
err := f.chatErrs[0]
|
f.probeSeq = f.probeSeq[1:]
|
||||||
f.chatErrs = f.chatErrs[1:]
|
return r.code, r.err
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
}
|
return f.probeCode, f.probeErr
|
||||||
return f.chatResp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
svc := newTestService(t, client)
|
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)
|
t.Fatalf("auto migrate ai tables: %v", err)
|
||||||
}
|
}
|
||||||
return NewAiGatewayService(svc.db, svc, client), svc
|
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"}}})
|
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
ctx := context.Background()
|
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" {
|
if err != nil || raw != "test-api-key" || key.Tail != "-key" {
|
||||||
t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
|
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("过短自定义密钥应被拒绝")
|
t.Error("过短自定义密钥应被拒绝")
|
||||||
}
|
}
|
||||||
auto, _, err := gw.CreateKey(ctx, "auto", "", "")
|
auto, _, err := gw.CreateKey(ctx, "auto", "", "", nil)
|
||||||
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
|
if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
|
||||||
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
|
t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
|
||||||
}
|
}
|
||||||
@@ -96,7 +145,7 @@ func TestAiKeyLifecycle(t *testing.T) {
|
|||||||
t.Errorf("错误密钥 err = %v", err)
|
t.Errorf("错误密钥 err = %v", err)
|
||||||
}
|
}
|
||||||
off := false
|
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) {
|
if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
|
||||||
t.Errorf("禁用后 VerifyKey err = %v", err)
|
t.Errorf("禁用后 VerifyKey err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -159,51 +208,6 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
|||||||
return ch
|
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) {
|
func TestPickPriorityAndBreaker(t *testing.T) {
|
||||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
@@ -255,7 +259,7 @@ func TestMarkFailureBackoff(t *testing.T) {
|
|||||||
func TestAiGroupRouting(t *testing.T) {
|
func TestAiGroupRouting(t *testing.T) {
|
||||||
client := &gatewayStubClient{
|
client := &gatewayStubClient{
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
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"}}},
|
passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
|
||||||
}
|
}
|
||||||
gw, svc := newTestGateway(t, client)
|
gw, svc := newTestGateway(t, client)
|
||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
@@ -264,17 +268,17 @@ func TestAiGroupRouting(t *testing.T) {
|
|||||||
gw.db.Model(vip).Update("channel_group", "vip")
|
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()})
|
gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
|
||||||
ctx := context.Background()
|
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++ {
|
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 {
|
if err != nil || meta.ChannelID != vip.ID {
|
||||||
t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, 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)
|
t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
|
||||||
}
|
}
|
||||||
// 模型列表按分组过滤
|
// 模型列表按分组过滤
|
||||||
@@ -287,12 +291,12 @@ func TestAiGroupRouting(t *testing.T) {
|
|||||||
t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
|
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" {
|
if err != nil || key.Group != "vip" {
|
||||||
t.Fatalf("CreateKey group = %+v, %v", key, err)
|
t.Fatalf("CreateKey group = %+v, %v", key, err)
|
||||||
}
|
}
|
||||||
empty := ""
|
empty := ""
|
||||||
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty)
|
_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty, nil)
|
||||||
var fresh model.AiKey
|
var fresh model.AiKey
|
||||||
gw.db.First(&fresh, key.ID)
|
gw.db.First(&fresh, key.ID)
|
||||||
if fresh.Group != "" {
|
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) {
|
func TestDeprecatingModels(t *testing.T) {
|
||||||
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -460,7 +676,7 @@ func TestAiEmbeddings(t *testing.T) {
|
|||||||
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
|
t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
|
||||||
}
|
}
|
||||||
// embedding 模型名打 chat:同样未知模型
|
// 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)
|
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)
|
t.Fatalf("migrate content log: %v", err)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateKey: %v", err)
|
t.Fatalf("CreateKey: %v", err)
|
||||||
}
|
}
|
||||||
@@ -488,6 +704,7 @@ func TestAiContentLogSwitch(t *testing.T) {
|
|||||||
t.Error("超过 7 天上限应被拒绝")
|
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)})
|
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)
|
rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20)
|
||||||
if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+64
-201
@@ -8,38 +8,8 @@ import (
|
|||||||
"oci-portal/internal/aiwire"
|
"oci-portal/internal/aiwire"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResponsesToIR 把 Responses API 请求转换为网关 IR(OpenAI Chat 形态)。
|
// respRejectStateful 拒绝有状态特性(网关无状态)。
|
||||||
// 有状态特性与内置工具不支持,直接报错(API 层 400)。
|
func respRejectStateful(req aiwire.RespRequest) error {
|
||||||
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 {
|
|
||||||
if req.PreviousResponseID != "" {
|
if req.PreviousResponseID != "" {
|
||||||
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
|
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
|
||||||
}
|
}
|
||||||
@@ -49,195 +19,88 @@ func respRejectUnsupported(req aiwire.RespRequest) error {
|
|||||||
if req.Background != nil && *req.Background {
|
if req.Background != nil && *req.Background {
|
||||||
return fmt.Errorf("background 模式不支持")
|
return fmt.Errorf("background 模式不支持")
|
||||||
}
|
}
|
||||||
for _, t := range req.Tools {
|
|
||||||
if t.Type != "function" {
|
|
||||||
return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// respInputToMessages 把 input(string 或 item 数组)展开为 IR 消息序列。
|
// RespServerTools 报告工具列表是否含 xAI 服务端工具(web_search / x_search)。
|
||||||
func respInputToMessages(in aiwire.RespInput) ([]aiwire.ChatMessage, error) {
|
func RespServerTools(tools []aiwire.RespTool) bool {
|
||||||
if !in.IsArray {
|
|
||||||
if strings.TrimSpace(in.Text) == "" {
|
|
||||||
return nil, fmt.Errorf("input 不能为空")
|
|
||||||
}
|
|
||||||
return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]aiwire.Tool, 0, len(tools))
|
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
out = append(out, aiwire.Tool{Type: "function",
|
if t.Type == "web_search" || t.Type == "x_search" {
|
||||||
Function: aiwire.FunctionDef{Name: t.Name, Description: t.Description, Parameters: t.Parameters}})
|
return true
|
||||||
}
|
}
|
||||||
return out
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套;
|
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
|
||||||
// allowed_tools 等对象形态降级 auto。
|
// 只放行 function 与已实测的 web_search / x_search;流式仅在含服务端工具时拒绝
|
||||||
func respToolChoice(raw json.RawMessage) json.RawMessage {
|
// (工具流式事件形态未实测,不放开)。
|
||||||
if len(raw) == 0 {
|
func RespPassthroughValidate(req aiwire.RespRequest) error {
|
||||||
return nil
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
|
return fmt.Errorf("model 不能为空")
|
||||||
}
|
}
|
||||||
t := strings.TrimSpace(string(raw))
|
if req.Stream && RespServerTools(req.Tools) {
|
||||||
if strings.HasPrefix(t, "\"") {
|
return fmt.Errorf("服务端工具暂不支持流式:请去掉 stream 或改用 function 工具")
|
||||||
return raw
|
|
||||||
}
|
}
|
||||||
var obj struct {
|
if err := respRejectStateful(req); err != nil {
|
||||||
Type string `json:"type"`
|
return err
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &obj); err != nil || obj.Type != "function" || obj.Name == "" {
|
for _, t := range req.Tools {
|
||||||
return json.RawMessage(`"auto"`)
|
switch t.Type {
|
||||||
}
|
case "function", "web_search", "x_search":
|
||||||
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:
|
default:
|
||||||
|
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search", t.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return 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)
|
||||||
|
}
|
||||||
|
body["store"] = false
|
||||||
|
return json.Marshal(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IRRespToResponses 把 IR 非流式响应装配为 Response 对象。
|
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)。
|
||||||
func IRRespToResponses(resp *aiwire.ChatResponse, id string, created int64) aiwire.Response {
|
func RespPassthroughUsage(payload []byte) *aiwire.Usage {
|
||||||
out := aiwire.Response{ID: id, Object: "response", CreatedAt: created,
|
var root struct {
|
||||||
Status: "completed", Model: resp.Model, Output: []aiwire.RespOutItem{}, Store: false}
|
Usage *aiwire.RespUsage `json:"usage"`
|
||||||
if len(resp.Choices) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
choice := resp.Choices[0]
|
if json.Unmarshal(payload, &root) != nil {
|
||||||
if text := choice.Message.Content.JoinText(); text != "" {
|
return nil
|
||||||
out.Output = append(out.Output, respMessageItem(id, 0, text))
|
|
||||||
}
|
}
|
||||||
for i, tc := range choice.Message.ToolCalls {
|
return usageFromResp(root.Usage)
|
||||||
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 {
|
// usageFromResp 换算 Responses usage 为 OpenAI 口径(缓存命中透传);nil 原样返回。
|
||||||
return aiwire.RespOutItem{Type: "message", ID: respItemID(respID, "msg", idx),
|
func usageFromResp(u *aiwire.RespUsage) *aiwire.Usage {
|
||||||
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 {
|
if u == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &aiwire.RespUsage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens,
|
usage := &aiwire.Usage{PromptTokens: u.InputTokens,
|
||||||
TotalTokens: u.TotalTokens,
|
CompletionTokens: u.OutputTokens, TotalTokens: u.TotalTokens}
|
||||||
InputTokensDetails: aiwire.RespInDetails{CachedTokens: u.CachedTokens()}}
|
if cached := u.InputTokensDetails.CachedTokens; cached > 0 {
|
||||||
|
usage.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: cached}
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return RespPassthroughUsage(ev.Response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -17,47 +17,6 @@ func respReq(t *testing.T, raw string) aiwire.RespRequest {
|
|||||||
return req
|
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 {
|
func deref(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -65,112 +24,124 @@ func deref(p *int) int {
|
|||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponsesToIRRejects(t *testing.T) {
|
func TestRespServerTools(t *testing.T) {
|
||||||
cases := []struct{ name, raw string }{
|
tests := []struct {
|
||||||
{"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`},
|
name string
|
||||||
{"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`},
|
tools []aiwire.RespTool
|
||||||
{"background", `{"model":"m","input":"hi","background":true}`},
|
want bool
|
||||||
{"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"}]}]}`},
|
{"web_search", []aiwire.RespTool{{Type: "web_search"}}, true},
|
||||||
{"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`},
|
{"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 {
|
for _, test := range tests {
|
||||||
if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
t.Errorf("%s 应被拒绝", c.name)
|
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}`)
|
func TestRespPassthroughValidate(t *testing.T) {
|
||||||
ir, err := ResponsesToIR(req)
|
prev := "resp_1"
|
||||||
if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" {
|
bg := true
|
||||||
t.Errorf("字符串 input = %+v, %v", ir.Messages, err)
|
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 形态)放行并保留图文顺序
|
for _, test := range tests {
|
||||||
req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`)
|
t.Run(test.name, func(t *testing.T) {
|
||||||
ir2, err := ResponsesToIR(req2)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("input_image 应放行: %v", err)
|
t.Fatalf("RespPassthroughBody: %v", err)
|
||||||
}
|
}
|
||||||
parts := ir2.Messages[0].Content.Parts
|
var body map[string]any
|
||||||
if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" {
|
if err := json.Unmarshal(out, &body); err != nil {
|
||||||
t.Errorf("parts = %+v", parts)
|
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) {
|
func TestRespPassthroughUsage(t *testing.T) {
|
||||||
resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{
|
tests := []struct {
|
||||||
Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"),
|
name string
|
||||||
ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function",
|
payload string
|
||||||
Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}},
|
wantNil bool
|
||||||
FinishReason: "length",
|
prompt, cached int
|
||||||
}}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}
|
}{
|
||||||
out := IRRespToResponses(resp, "resp_abc", 1751966400)
|
{"完整", `{"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12,"input_tokens_details":{"cached_tokens":5}}}`, false, 9, 5},
|
||||||
if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
|
{"无细分", `{"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}`, false, 9, 0},
|
||||||
t.Errorf("length 应映射 incomplete: %+v", out)
|
{"无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" {
|
for _, test := range tests {
|
||||||
t.Fatalf("output = %+v", out.Output)
|
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 out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" {
|
if usage == nil {
|
||||||
t.Errorf("item 装配错误: %+v", out.Output)
|
return
|
||||||
}
|
}
|
||||||
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store {
|
if usage.PromptTokens != test.prompt || usage.CachedTokens() != test.cached {
|
||||||
t.Errorf("usage/store 错误: %+v", out)
|
t.Fatalf("prompt=%d cached=%d, want %d/%d", usage.PromptTokens, usage.CachedTokens(), test.prompt, test.cached)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chunkText(text string) aiwire.ChatChunk {
|
// TestRespStreamCompletedUsage 断言流式 usage 只从 completed 事件提取。
|
||||||
return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}}
|
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{
|
||||||
func TestRespStreamTextAndTool(t *testing.T) {
|
`{"type":"response.output_text.delta","delta":"hi"}`,
|
||||||
st := NewRespStream("resp_x", "m", 1751966400)
|
`{"type":"response.completed"}`,
|
||||||
var types []string
|
`not-json`,
|
||||||
collect := func(evs []RespEvent) {
|
} {
|
||||||
for _, ev := range evs {
|
if u := RespStreamCompletedUsage([]byte(data)); u != nil {
|
||||||
types = append(types, ev.Event)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
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, respTextPart(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respTextPart 按角色选择 Responses 文本部件类型(assistant 历史为 output_text);
|
||||||
|
// Anthropic 与 Chat Completions 两条转换链共用。
|
||||||
|
func respTextPart(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 respTruncated(&resp) {
|
||||||
|
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 respTruncated(resp) {
|
||||||
|
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 }
|
||||||
@@ -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
@@ -2,17 +2,19 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 审计查询时间窗(小时)上下限。
|
// 批式懒加载的单批条数上下限与缺省值。
|
||||||
const (
|
const (
|
||||||
minAuditHours = 1
|
auditLimitDefault = 100
|
||||||
maxAuditHours = 72
|
auditLimitMax = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
// 审计原始事件缓存:约 3KB/条,上限 2000 条 ≈ 6MB;TTL 内详情秒开。
|
// 审计原始事件缓存:约 3KB/条,上限 2000 条 ≈ 6MB;TTL 内详情秒开。
|
||||||
@@ -21,39 +23,32 @@ const (
|
|||||||
auditRawTTL = 10 * time.Minute
|
auditRawTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidAuditHours 表示审计时间窗参数越界,handler 据此返回 400。
|
// ErrInvalidAuditCursor 表示续查游标不可解析(过期格式/被篡改),handler 返回 400。
|
||||||
var ErrInvalidAuditHours = errors.New("audit events: hours must be between 1 and 72")
|
var ErrInvalidAuditCursor = errors.New("audit events: invalid cursor, refresh to restart")
|
||||||
|
|
||||||
// ErrInvalidAuditWindow 表示续查的绝对时间窗非法(格式/顺序/跨度),handler 返回 400。
|
|
||||||
var ErrInvalidAuditWindow = errors.New("audit events: invalid start/end window")
|
|
||||||
|
|
||||||
// ErrAuditEventGone 表示原始事件缓存过期且小窗重查未找回;handler 映射 404。
|
// ErrAuditEventGone 表示原始事件缓存过期且小窗重查未找回;handler 映射 404。
|
||||||
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
var ErrAuditEventGone = errors.New("原始事件已不可取回,请刷新列表后重试")
|
||||||
|
|
||||||
// AuditQuery 是审计查询参数:Hours 为首查(相对当前时刻);
|
// AuditQuery 是批式懒加载查询参数:Cursor 为空表示自当前时刻首查,
|
||||||
// Start/End(RFC3339)为续查绝对窗,配合 Page 从截断游标断点续翻。
|
// 非空则从上次响应的游标位置继续向更早回溯;Limit 为单批目标条数。
|
||||||
type AuditQuery struct {
|
type AuditQuery struct {
|
||||||
Region string
|
Region string
|
||||||
Hours int
|
Cursor string
|
||||||
Start string
|
Limit int
|
||||||
End string
|
|
||||||
Page string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEventsView 是审计查询响应:列表不含 raw(详情接口取回);
|
// AuditEventsView 是批式查询响应:列表不含 raw(详情接口取回);
|
||||||
// Start/End 回传本次实际使用的绝对窗,截断续查必须原样带回(游标绑定查询参数)。
|
// Cursor 供下一批续查原样带回,空且 Exhausted 表示已到 365 天保留期尽头。
|
||||||
type AuditEventsView struct {
|
type AuditEventsView struct {
|
||||||
Items []oci.AuditEvent `json:"items"`
|
Items []oci.AuditEvent `json:"items"`
|
||||||
Truncated bool `json:"truncated"`
|
Cursor string `json:"cursor,omitempty"`
|
||||||
NextPage string `json:"nextPage,omitempty"`
|
Exhausted bool `json:"exhausted"`
|
||||||
Start time.Time `json:"start"`
|
|
||||||
End time.Time `json:"end"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
// AuditEvents 实时查询租户 OCI 审计事件,纯透传不入库;region 为空时用配置
|
||||||
// 默认区域。原始事件剥离进进程内缓存,响应体瘦身约 10 倍(调研④方案 A1+B1)。
|
// 默认区域。原始事件剥离进进程内缓存,响应体瘦身约 10 倍(调研④方案 A1+B1)。
|
||||||
func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuery) (AuditEventsView, error) {
|
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 {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
}
|
}
|
||||||
@@ -61,53 +56,73 @@ func (s *OciConfigService) AuditEvents(ctx context.Context, id uint, q AuditQuer
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
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 {
|
if err != nil {
|
||||||
return AuditEventsView{}, err
|
return AuditEventsView{}, err
|
||||||
}
|
}
|
||||||
return AuditEventsView{
|
view := AuditEventsView{Items: s.stripAuditRaw(id, res.Items), Exhausted: res.Exhausted}
|
||||||
Items: s.stripAuditRaw(res.Items),
|
if res.Cursor != nil {
|
||||||
Truncated: res.Truncated,
|
view.Cursor = encodeAuditCursor(*res.Cursor)
|
||||||
NextPage: res.NextPage,
|
}
|
||||||
// 与实际请求同粒度(分钟),续查回传时窗口逐字节一致
|
return view, nil
|
||||||
Start: start.UTC().Truncate(time.Minute),
|
|
||||||
End: end.UTC().Truncate(time.Minute),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditWindow 解析查询窗口:带 start/end/page 走绝对窗校验,否则按 hours 相对窗。
|
// auditLimit 归一单批条数:缺省 100,上限 200(响应体量与页预算的平衡)。
|
||||||
func auditWindow(q AuditQuery) (time.Time, time.Time, error) {
|
func auditLimit(limit int) int {
|
||||||
if q.Start != "" || q.End != "" || q.Page != "" {
|
if limit <= 0 {
|
||||||
start, err1 := time.Parse(time.RFC3339, q.Start)
|
return auditLimitDefault
|
||||||
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
|
return min(limit, auditLimitMax)
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripAuditRaw 把每条原始事件按 eventId 放进缓存并从列表剥离。
|
// encodeAuditCursor 把游标序列化为不透明字符串(base64url JSON)。
|
||||||
func (s *OciConfigService) stripAuditRaw(items []oci.AuditEvent) []oci.AuditEvent {
|
// 内容仅时间窗与 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 {
|
for i := range items {
|
||||||
if items[i].EventId != "" && items[i].Raw != nil {
|
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
|
items[i].Raw = nil
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateAuditCache 删除指定租户的全部审计原始事件缓存。
|
||||||
|
func (s *OciConfigService) InvalidateAuditCache(configID uint) {
|
||||||
|
s.auditRaw.DeletePrefix(strconv.FormatUint(uint64(configID), 10) + ":")
|
||||||
|
}
|
||||||
|
|
||||||
// AuditEventDetail 取回单条事件的原始 JSON:缓存命中即回;miss 按事件时间
|
// AuditEventDetail 取回单条事件的原始 JSON:缓存命中即回;miss 按事件时间
|
||||||
// 所在分钟起 2 分钟小窗重查兜底(调研④方案 A2),仍未命中报 ErrAuditEventGone。
|
// 所在分钟起 2 分钟小窗重查兜底(调研④方案 A2),仍未命中报 ErrAuditEventGone。
|
||||||
func (s *OciConfigService) AuditEventDetail(ctx context.Context, id uint, region, eventID string, eventTime time.Time) (json.RawMessage, error) {
|
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
|
return raw.(json.RawMessage), nil
|
||||||
}
|
}
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
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 {
|
if ev.EventId == "" || ev.Raw == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.auditRaw.Set(ev.EventId, ev.Raw, auditRawTTL)
|
s.auditRaw.Set(auditRawKey(id, ev.EventId), ev.Raw, auditRawTTL)
|
||||||
if ev.EventId == eventID {
|
if ev.EventId == eventID {
|
||||||
found = ev.Raw
|
found = ev.Raw
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-86
@@ -13,115 +13,124 @@ import (
|
|||||||
// auditStubClient 覆写审计查询并记录透传参数,其余行为沿用 fakeClient。
|
// auditStubClient 覆写审计查询并记录透传参数,其余行为沿用 fakeClient。
|
||||||
type auditStubClient struct {
|
type auditStubClient struct {
|
||||||
*fakeClient
|
*fakeClient
|
||||||
|
batch oci.AuditBatchResult
|
||||||
result oci.AuditEventsResult
|
result oci.AuditEventsResult
|
||||||
gotRegion string
|
gotRegion string
|
||||||
|
gotCursor oci.AuditCursor
|
||||||
|
gotLimit int
|
||||||
gotStart time.Time
|
gotStart time.Time
|
||||||
gotEnd time.Time
|
gotEnd time.Time
|
||||||
gotPage string
|
|
||||||
calls int
|
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) {
|
func (f *auditStubClient) ListAuditEvents(ctx context.Context, cred oci.Credentials, region string, start, end time.Time, page string) (oci.AuditEventsResult, error) {
|
||||||
f.calls++
|
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
|
return f.result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuditEventsHoursValidation(t *testing.T) {
|
func TestAuditCursorCodec(t *testing.T) {
|
||||||
tests := []struct {
|
cur := oci.AuditCursor{
|
||||||
name string
|
Start: time.Date(2026, 7, 9, 10, 0, 0, 0, time.UTC),
|
||||||
hours int
|
End: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC),
|
||||||
wantErr bool
|
Page: "tok-1",
|
||||||
}{
|
WindowHours: 48,
|
||||||
{name: "下界 1 小时", hours: 1},
|
}
|
||||||
{name: "默认 24 小时", hours: 24},
|
got, err := decodeAuditCursor(encodeAuditCursor(cur))
|
||||||
{name: "上界 72 小时", hours: 72},
|
if err != nil {
|
||||||
{name: "0 越界", hours: 0, wantErr: true},
|
t.Fatalf("roundtrip: %v", err)
|
||||||
{name: "负数越界", hours: -3, wantErr: true},
|
}
|
||||||
{name: "100 越界", hours: 100, wantErr: true},
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空串 → 自当前时刻首查:24h 窗、分钟粒度、无窗内游标
|
||||||
|
first, err := decodeAuditCursor("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first cursor: %v", err)
|
||||||
|
}
|
||||||
|
if first.End.Sub(first.Start) != 24*time.Hour || first.Page != "" || first.End.Second() != 0 {
|
||||||
|
t.Fatalf("first cursor = %+v, 应为 24h 分钟粒度首窗", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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{
|
client := &auditStubClient{
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
result: oci.AuditEventsResult{Items: []oci.AuditEvent{{EventName: "GetInstance"}}},
|
batch: oci.AuditBatchResult{
|
||||||
}
|
Items: []oci.AuditEvent{{EventId: "e1", EventName: "GetInstance"}},
|
||||||
svc := newTestService(t, client)
|
Cursor: &next,
|
||||||
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})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AuditEvents: %v", err)
|
|
||||||
}
|
|
||||||
if client.calls != 1 {
|
|
||||||
t.Fatalf("calls = %d, want 1", client.calls)
|
|
||||||
}
|
|
||||||
if client.gotRegion != "ap-tokyo-1" {
|
|
||||||
t.Errorf("region = %q, want %q", client.gotRegion, "ap-tokyo-1")
|
|
||||||
}
|
|
||||||
if window := client.gotEnd.Sub(client.gotStart); window != 6*time.Hour {
|
|
||||||
t.Errorf("window = %v, want %v", window, 6*time.Hour)
|
|
||||||
}
|
|
||||||
// 响应回传分钟粒度的绝对窗,续查据此原样带回
|
|
||||||
if got.Start.Second() != 0 || !got.End.After(got.Start) {
|
|
||||||
t.Errorf("响应窗口 = [%v, %v), 应为分钟粒度且有序", got.Start, got.End)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuditEventsResume(t *testing.T) {
|
|
||||||
client := &auditStubClient{
|
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
|
||||||
result: oci.AuditEventsResult{Items: []oci.AuditEvent{}, Truncated: true, NextPage: "tok-2"},
|
|
||||||
}
|
}
|
||||||
svc := newTestService(t, client)
|
svc := newTestService(t, client)
|
||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 续查:绝对窗 + 游标透传;NextPage 原样回传
|
// limit 归一:0 → 100;超上限截到 200;region 透传
|
||||||
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{
|
cases := []struct {
|
||||||
Start: "2026-07-08T10:00:00Z", End: "2026-07-09T10:00:00Z", Page: "tok-1",
|
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 {
|
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)
|
t.Fatalf("续查: %v", err)
|
||||||
}
|
}
|
||||||
if client.gotPage != "tok-1" || !got.Truncated || got.NextPage != "tok-2" {
|
if !client.gotCursor.Start.Equal(next.Start) || client.gotCursor.WindowHours != 24 {
|
||||||
t.Errorf("游标透传 page=%q next=%q truncated=%v", client.gotPage, got.NextPage, got.Truncated)
|
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{})
|
||||||
bad := []AuditQuery{
|
if err != nil || end.Cursor != "" || !end.Exhausted {
|
||||||
{Start: "not-a-time", End: "2026-07-09T10:00:00Z"},
|
t.Fatalf("尽头响应 = %+v, %v", end, err)
|
||||||
{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)
|
eventTime := time.Date(2026, 7, 9, 10, 30, 40, 0, time.UTC)
|
||||||
client := &auditStubClient{
|
client := &auditStubClient{
|
||||||
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
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},
|
{EventId: "evt-1", EventTime: &eventTime, EventName: "TerminateInstance", Raw: raw},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
@@ -138,7 +147,7 @@ func TestAuditRawStrippedAndDetail(t *testing.T) {
|
|||||||
cfg := importAliveConfig(t, svc)
|
cfg := importAliveConfig(t, svc)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{Hours: 1})
|
got, err := svc.AuditEvents(ctx, cfg.ID, AuditQuery{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AuditEvents: %v", err)
|
t.Fatalf("AuditEvents: %v", err)
|
||||||
}
|
}
|
||||||
@@ -184,3 +193,27 @@ func TestAuditDetailRequeryFallback(t *testing.T) {
|
|||||||
t.Errorf("未找回 err = %v, want ErrAuditEventGone", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ var ErrLoginLocked = errors.New("too many failed attempts, try again later")
|
|||||||
// tokenTTL 是登录令牌有效期。
|
// tokenTTL 是登录令牌有效期。
|
||||||
const tokenTTL = 24 * time.Hour
|
const tokenTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// authClaims 在标准声明外携带令牌版本;版本落后于账号当前值即失效。
|
||||||
|
// 存量令牌无 ver 字段解析为 0,与存量账号的零值版本兼容(升级不强制登出)。
|
||||||
|
type authClaims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
Ver uint `json:"ver"`
|
||||||
|
}
|
||||||
|
|
||||||
// dummyBcryptHash 是恒定失败的占位哈希("dummy-password"),
|
// dummyBcryptHash 是恒定失败的占位哈希("dummy-password"),
|
||||||
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
||||||
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
||||||
@@ -106,7 +113,7 @@ func (s *AuthService) createUser(username, password string) error {
|
|||||||
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
|
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
|
||||||
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
|
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
|
||||||
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
|
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()
|
now := time.Now()
|
||||||
sec := securityOf(s.settings)
|
sec := securityOf(s.settings)
|
||||||
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
|
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
|
||||||
@@ -138,7 +145,7 @@ func (s *AuthService) Login(ctx context.Context, username, password, clientIP, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.guard.success(key)
|
s.guard.success(key)
|
||||||
return s.signToken(user.Username)
|
return s.signToken(user.Username, user.TokenVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
|
// 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()
|
now := time.Now()
|
||||||
expires := now.Add(tokenTTL)
|
expires := now.Add(tokenTTL)
|
||||||
claims := jwt.RegisteredClaims{
|
claims := authClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: username,
|
Subject: username,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(expires),
|
ExpiresAt: jwt.NewNumericDate(expires),
|
||||||
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
|
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
|
||||||
ID: newTokenID(),
|
ID: newTokenID(),
|
||||||
|
},
|
||||||
|
Ver: ver,
|
||||||
}
|
}
|
||||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,6 +196,34 @@ func (s *AuthService) signToken(username string) (string, time.Time, error) {
|
|||||||
return token, expires, nil
|
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 起不会失败)。
|
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
|
||||||
func newTokenID() string {
|
func newTokenID() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
@@ -193,9 +231,10 @@ func newTokenID() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseToken 验证 JWT 签名与有效期,返回其中的用户名。
|
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
|
||||||
func (s *AuthService) ParseToken(tokenString string) (string, error) {
|
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
|
||||||
claims := &jwt.RegisteredClaims{}
|
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
|
||||||
|
claims := &authClaims{}
|
||||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
|
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 {
|
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
|
||||||
return "", errors.New("token revoked")
|
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
|
return claims.Subject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func TestTokenRoundTrip(t *testing.T) {
|
|||||||
if expires.IsZero() {
|
if expires.IsZero() {
|
||||||
t.Error("expires is zero, want future time")
|
t.Error("expires is zero, want future time")
|
||||||
}
|
}
|
||||||
username, err := auth.ParseToken(token)
|
username, err := auth.ParseToken(context.Background(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseToken: %v", err)
|
t.Fatalf("ParseToken: %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,35 +129,92 @@ func TestParseTokenRejectsForged(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
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")
|
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")
|
t.Error("ParseToken(garbage): got nil error, want failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogoutRevokesToken(t *testing.T) {
|
func TestLogoutRevokesToken(t *testing.T) {
|
||||||
auth := newTestAuth(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 {
|
if err != nil {
|
||||||
t.Fatalf("signToken: %v", err)
|
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)
|
t.Fatalf("ParseToken before logout: %v", err)
|
||||||
}
|
}
|
||||||
auth.Logout(token)
|
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")
|
t.Error("ParseToken after logout: got nil error, want revoked")
|
||||||
}
|
}
|
||||||
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
|
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
|
||||||
auth.Logout(token)
|
auth.Logout(token)
|
||||||
auth.Logout("not.a.token")
|
auth.Logout("not.a.token")
|
||||||
fresh, _, err := auth.signToken("admin")
|
fresh, _, err := auth.signToken("admin", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("signToken fresh: %v", err)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAI Chat Completions ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。
|
||||||
|
// 端点定位 Tier 2 兼容层(承接只会说 CC 的存量客户端),机制与 anthresponses.go
|
||||||
|
// 同构。语义损失(README 披露):stop / seed / n / penalty 等无对应字段,忽略;
|
||||||
|
// 上游 reasoning 输出项与增量事件丢弃;非 function 工具类型拒绝。
|
||||||
|
|
||||||
|
// ChatToResponsesBody 把 Chat Completions 请求转为直通 body(强制 store:false)。
|
||||||
|
func ChatToResponsesBody(req aiwire.ChatRequest) ([]byte, error) {
|
||||||
|
input, instructions, err := chatInputItems(req.Messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := map[string]any{"model": req.Model, "input": input, "store": false}
|
||||||
|
if instructions != "" {
|
||||||
|
body["instructions"] = instructions
|
||||||
|
}
|
||||||
|
chatSampling(body, req)
|
||||||
|
if err := chatBodyTools(body, req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tf := chatTextFormat(req.ResponseFormat); tf != nil {
|
||||||
|
body["text"] = map[string]any{"format": tf}
|
||||||
|
}
|
||||||
|
if req.ReasoningEffort != "" {
|
||||||
|
body["reasoning"] = map[string]string{"effort": strings.ToLower(req.ReasoningEffort)}
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
body["stream"] = true
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatSampling 装配采样与输出预算;max_completion_tokens 优先于已弃用的 max_tokens。
|
||||||
|
func chatSampling(body map[string]any, req aiwire.ChatRequest) {
|
||||||
|
if req.MaxCompletionTokens != nil {
|
||||||
|
body["max_output_tokens"] = *req.MaxCompletionTokens
|
||||||
|
} else if req.MaxTokens != nil {
|
||||||
|
body["max_output_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
body["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
body["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.ParallelToolCalls != nil {
|
||||||
|
body["parallel_tool_calls"] = *req.ParallelToolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatBodyTools 装配工具与 tool_choice;非 function 工具类型拒绝
|
||||||
|
// (web_search 等新能力仅在 Responses / Messages 端点供给)。
|
||||||
|
func chatBodyTools(body map[string]any, req aiwire.ChatRequest) error {
|
||||||
|
if len(req.Tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tools := make([]map[string]any, 0, len(req.Tools))
|
||||||
|
for _, t := range req.Tools {
|
||||||
|
if t.Type != "function" {
|
||||||
|
return fmt.Errorf("不支持的工具类型 %q:该端点仅支持 function 工具", t.Type)
|
||||||
|
}
|
||||||
|
tools = append(tools, map[string]any{"type": "function", "name": t.Function.Name,
|
||||||
|
"description": t.Function.Description, "parameters": t.Function.Parameters})
|
||||||
|
}
|
||||||
|
body["tools"] = tools
|
||||||
|
if tc := chatRespToolChoice(req.ToolChoice); tc != nil {
|
||||||
|
body["tool_choice"] = tc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatInputItems 把消息序列展开为 Responses input 项与 instructions:
|
||||||
|
// system/developer 文本聚为 instructions;tool 消息为 function_call_output 项;
|
||||||
|
// 其余消息经 chatMessageItems 展开,保持相对顺序。
|
||||||
|
func chatInputItems(messages []aiwire.ChatMessage) ([]any, string, error) {
|
||||||
|
items := make([]any, 0, len(messages))
|
||||||
|
var sys []string
|
||||||
|
for _, m := range messages {
|
||||||
|
switch m.Role {
|
||||||
|
case "system", "developer":
|
||||||
|
if txt := m.Content.JoinText(); txt != "" {
|
||||||
|
sys = append(sys, txt)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
items = append(items, map[string]any{"type": "function_call_output",
|
||||||
|
"call_id": m.ToolCallID, "output": m.Content.JoinText()})
|
||||||
|
default:
|
||||||
|
msgItems, err := chatMessageItems(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
items = append(items, msgItems...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, strings.Join(sys, "\n\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatMessageItems 把 user/assistant 消息转为 message 项;assistant 的
|
||||||
|
// tool_calls 追加为独立 function_call 项(内容在前,与原语序一致)。
|
||||||
|
func chatMessageItems(m aiwire.ChatMessage) ([]any, error) {
|
||||||
|
parts, err := chatContentParts(m.Role, m.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]any, 0, 1+len(m.ToolCalls))
|
||||||
|
if len(parts) > 0 {
|
||||||
|
items = append(items, map[string]any{"role": m.Role, "content": parts})
|
||||||
|
}
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
items = append(items, map[string]any{"type": "function_call", "call_id": tc.ID,
|
||||||
|
"name": tc.Function.Name, "arguments": tc.Function.Arguments})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatContentParts 把消息内容转为 Responses 部件(文本按角色定型,图片转 input_image)。
|
||||||
|
func chatContentParts(role string, c aiwire.Content) ([]map[string]any, error) {
|
||||||
|
if !c.IsArray {
|
||||||
|
if c.Text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []map[string]any{respTextPart(role, c.Text)}, nil
|
||||||
|
}
|
||||||
|
parts := make([]map[string]any, 0, len(c.Parts))
|
||||||
|
for _, p := range c.Parts {
|
||||||
|
switch p.Type {
|
||||||
|
case "", "text":
|
||||||
|
parts = append(parts, respTextPart(role, p.Text))
|
||||||
|
case "image_url":
|
||||||
|
if p.ImageURL == nil || p.ImageURL.URL == "" {
|
||||||
|
return nil, fmt.Errorf("image_url 块缺少 url")
|
||||||
|
}
|
||||||
|
// detail 不透传:兼容面对该字段的支持无法证实(实测期间上游视觉请求不稳,
|
||||||
|
// 无从归因),它仅是质量提示,忽略合法且消除一个风险轴(README 披露)
|
||||||
|
parts = append(parts, map[string]any{"type": "input_image", "image_url": p.ImageURL.URL})
|
||||||
|
default:
|
||||||
|
return nil, ErrAiUnsupportedBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatRespToolChoice 映射 tool_choice:string 形态(auto/none/required)原样;
|
||||||
|
// {"type":"function","function":{"name":N}} 拍平为 Responses 具名形态;其余忽略。
|
||||||
|
func chatRespToolChoice(raw json.RawMessage) any {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
switch s {
|
||||||
|
case "auto", "none", "required":
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var tc struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &tc) != nil || tc.Type != "function" || tc.Function.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]string{"type": "function", "name": tc.Function.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatTextFormat 把 response_format 拍平为 text.format(json_schema 提升嵌套字段)。
|
||||||
|
func chatTextFormat(rf *aiwire.ResponseFormat) map[string]any {
|
||||||
|
if rf == nil || rf.Type == "" || rf.Type == "text" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
format := map[string]any{"type": rf.Type}
|
||||||
|
if rf.Type != "json_schema" || len(rf.JSONSchema) == 0 {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
var js struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Schema json.RawMessage `json:"schema"`
|
||||||
|
Strict *bool `json:"strict"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(rf.JSONSchema, &js) != nil {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
if js.Name != "" {
|
||||||
|
format["name"] = js.Name
|
||||||
|
}
|
||||||
|
if len(js.Schema) > 0 {
|
||||||
|
format["schema"] = js.Schema
|
||||||
|
}
|
||||||
|
if js.Strict != nil {
|
||||||
|
format["strict"] = *js.Strict
|
||||||
|
}
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesToChat 把直通非流式响应转为 Chat Completions 响应(reasoning 项丢弃)。
|
||||||
|
func ResponsesToChat(payload []byte, id string, created int64) (*aiwire.ChatResponse, error) {
|
||||||
|
var resp respPayload
|
||||||
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析上游响应: %w", err)
|
||||||
|
}
|
||||||
|
msg := aiwire.ChatMessage{Role: "assistant"}
|
||||||
|
var text strings.Builder
|
||||||
|
for _, item := range resp.Output {
|
||||||
|
switch item.Type {
|
||||||
|
case "message":
|
||||||
|
for _, part := range item.Content {
|
||||||
|
if part.Type == "output_text" {
|
||||||
|
text.WriteString(part.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "function_call":
|
||||||
|
msg.ToolCalls = append(msg.ToolCalls, aiwire.ToolCall{ID: item.CallID, Type: "function",
|
||||||
|
Function: aiwire.FunctionCall{Name: item.Name, Arguments: item.Arguments}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.Content = aiwire.NewTextContent(text.String())
|
||||||
|
choice := aiwire.Choice{Index: 0, Message: msg,
|
||||||
|
FinishReason: chatFinishReason(&resp, len(msg.ToolCalls) > 0)}
|
||||||
|
return &aiwire.ChatResponse{ID: id, Object: "chat.completion", Created: created,
|
||||||
|
Model: resp.Model, Choices: []aiwire.Choice{choice}, Usage: usageFromResp(resp.Usage)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatFinishReason 映射终态:截断优先 → length;含工具调用 → tool_calls;默认 stop。
|
||||||
|
func chatFinishReason(resp *respPayload, hasTools bool) string {
|
||||||
|
if respTruncated(resp) {
|
||||||
|
return "length"
|
||||||
|
}
|
||||||
|
if hasTools {
|
||||||
|
return "tool_calls"
|
||||||
|
}
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
// respTruncated 报告直通响应是否因 max_output_tokens 截断。
|
||||||
|
func respTruncated(resp *respPayload) bool {
|
||||||
|
return resp != nil && resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
|
||||||
|
resp.IncompleteDetails.Reason == "max_output_tokens"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chat Completions 流式桥:直通 SSE 事件 → chat.completion.chunk 序列 ----
|
||||||
|
|
||||||
|
// ChatRespBridge 把直通 SSE 事件流桥接为 chunk 序列:首个增量补 role,文本与
|
||||||
|
// 工具增量按 OpenAI 形态输出;reasoning 系列事件丢弃;[DONE] 由传输层写出。
|
||||||
|
type ChatRespBridge struct {
|
||||||
|
id, model string
|
||||||
|
created int64
|
||||||
|
includeUsage bool
|
||||||
|
started bool
|
||||||
|
toolIndex int
|
||||||
|
inTool bool
|
||||||
|
finish string
|
||||||
|
usage *aiwire.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatRespBridge 构造桥;includeUsage 即 stream_options.include_usage。
|
||||||
|
func NewChatRespBridge(id, model string, created int64, includeUsage bool) *ChatRespBridge {
|
||||||
|
return &ChatRespBridge{id: id, model: model, created: created,
|
||||||
|
includeUsage: includeUsage, toolIndex: -1, finish: "stop"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed 消费一行 SSE data JSON,返回应立即写出的 chunk。
|
||||||
|
func (b *ChatRespBridge) Feed(data []byte) []aiwire.ChatChunk {
|
||||||
|
var ev respStreamEvent
|
||||||
|
if json.Unmarshal(data, &ev) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch ev.Type {
|
||||||
|
case "response.output_text.delta":
|
||||||
|
b.inTool = false
|
||||||
|
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{Content: ev.Delta}), nil)}
|
||||||
|
case "response.output_item.added":
|
||||||
|
return b.toolOpen(ev.Item)
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
return b.argsChunk(ev.Delta)
|
||||||
|
case "response.completed", "response.incomplete", "response.failed":
|
||||||
|
b.finishFrom(ev.Response)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunk 装配一条单 choice 增量事件。
|
||||||
|
func (b *ChatRespBridge) chunk(delta aiwire.Delta, finish *string) aiwire.ChatChunk {
|
||||||
|
return aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk", Created: b.created,
|
||||||
|
Model: b.model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deltaWithRole 给首个增量补 role(OpenAI 首块携带 role 语义)。
|
||||||
|
func (b *ChatRespBridge) deltaWithRole(d aiwire.Delta) aiwire.Delta {
|
||||||
|
if !b.started {
|
||||||
|
b.started = true
|
||||||
|
d.Role = "assistant"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolOpen 在新 function_call 输出项开始时发 id/name 增量并推进聚合 index;
|
||||||
|
// 非工具输出项只复位增量目标。
|
||||||
|
func (b *ChatRespBridge) toolOpen(item *respOutputItem) []aiwire.ChatChunk {
|
||||||
|
if item == nil || item.Type != "function_call" {
|
||||||
|
b.inTool = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.toolIndex++
|
||||||
|
b.inTool = true
|
||||||
|
b.finish = "tool_calls"
|
||||||
|
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex, ID: item.CallID,
|
||||||
|
Type: "function", Function: aiwire.FunctionCallDelta{Name: item.Name}}}}
|
||||||
|
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(d), nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// argsChunk 输出当前工具的实参增量(仅在工具输出项进行中)。
|
||||||
|
func (b *ChatRespBridge) argsChunk(delta string) []aiwire.ChatChunk {
|
||||||
|
if !b.inTool {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex,
|
||||||
|
Function: aiwire.FunctionCallDelta{Arguments: delta}}}}
|
||||||
|
return []aiwire.ChatChunk{b.chunk(d, nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishFrom 记录终态 usage 与截断语义(max_output_tokens → length)。
|
||||||
|
func (b *ChatRespBridge) finishFrom(resp *respPayload) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.usage = usageFromResp(resp.Usage)
|
||||||
|
if respTruncated(resp) {
|
||||||
|
b.finish = "length"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish 在上游流结束后收尾:终块带 finish_reason;include_usage 时追加 usage 块
|
||||||
|
// (choices 为空数组,OpenAI 规范形态;上游未给 usage 时输出零值)。
|
||||||
|
func (b *ChatRespBridge) Finish() []aiwire.ChatChunk {
|
||||||
|
chunks := []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{}), &b.finish)}
|
||||||
|
if !b.includeUsage {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
usage := b.usage
|
||||||
|
if usage == nil {
|
||||||
|
usage = &aiwire.Usage{}
|
||||||
|
}
|
||||||
|
return append(chunks, aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk",
|
||||||
|
Created: b.created, Model: b.model, Choices: []aiwire.ChunkChoice{}, Usage: usage})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。
|
||||||
|
func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage }
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/aiwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustChatReq(t *testing.T, raw string) aiwire.ChatRequest {
|
||||||
|
t.Helper()
|
||||||
|
var req aiwire.ChatRequest
|
||||||
|
if err := json.Unmarshal([]byte(raw), &req); err != nil {
|
||||||
|
t.Fatalf("解析请求: %v", err)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustChatBody(t *testing.T, raw string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := ChatToResponsesBody(mustChatReq(t, raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChatToResponsesBody: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatToResponsesBody 断言消息展开、instructions 聚合、工具与顶层字段装配。
|
||||||
|
func TestChatToResponsesBody(t *testing.T) {
|
||||||
|
body := mustChatBody(t, `{
|
||||||
|
"model": "xai.grok-4.3", "max_tokens": 64, "max_completion_tokens": 128,
|
||||||
|
"temperature": 0.5, "top_p": 0.9, "parallel_tool_calls": false, "stream": true,
|
||||||
|
"reasoning_effort": "HIGH",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "你是助手"},
|
||||||
|
{"role": "developer", "content": [{"type": "text", "text": "简洁作答"}]},
|
||||||
|
{"role": "user", "content": "东京天气?"},
|
||||||
|
{"role": "assistant", "content": "查询中", "tool_calls": [
|
||||||
|
{"id": "t1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\":\"东京\"}"}}
|
||||||
|
]},
|
||||||
|
{"role": "tool", "tool_call_id": "t1", "content": "晴 25 度"},
|
||||||
|
{"role": "user", "content": "继续"}
|
||||||
|
],
|
||||||
|
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "查天气", "parameters": {"type": "object"}}}],
|
||||||
|
"tool_choice": "required"
|
||||||
|
}`)
|
||||||
|
if body["model"] != "xai.grok-4.3" || body["store"] != false || body["stream"] != true {
|
||||||
|
t.Fatalf("顶层字段装配错误: %v", body)
|
||||||
|
}
|
||||||
|
if body["max_output_tokens"] != float64(128) {
|
||||||
|
t.Fatalf("max_output_tokens = %v, want 128(max_completion_tokens 优先)", body["max_output_tokens"])
|
||||||
|
}
|
||||||
|
if body["instructions"] != "你是助手\n\n简洁作答" {
|
||||||
|
t.Fatalf("instructions = %v", body["instructions"])
|
||||||
|
}
|
||||||
|
if body["temperature"] != 0.5 || body["top_p"] != 0.9 || body["parallel_tool_calls"] != false {
|
||||||
|
t.Fatalf("采样字段装配错误: %v", body)
|
||||||
|
}
|
||||||
|
if body["tool_choice"] != "required" {
|
||||||
|
t.Fatalf("tool_choice = %v", body["tool_choice"])
|
||||||
|
}
|
||||||
|
if reasoning, _ := body["reasoning"].(map[string]any); reasoning["effort"] != "high" {
|
||||||
|
t.Fatalf("effort = %v, want high(小写透传)", body["reasoning"])
|
||||||
|
}
|
||||||
|
kinds := chatInputKinds(t, body)
|
||||||
|
want := "message/user,message/assistant,function_call,function_call_output,message/user"
|
||||||
|
if kinds != want {
|
||||||
|
t.Fatalf("input 顺序 = %s, want %s", kinds, want)
|
||||||
|
}
|
||||||
|
tools, _ := body["tools"].([]any)
|
||||||
|
tool, _ := tools[0].(map[string]any)
|
||||||
|
if tool["type"] != "function" || tool["name"] != "get_weather" {
|
||||||
|
t.Fatalf("工具应拍平为 Responses 形态: %v", tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatInputKinds(t *testing.T, body map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
input, _ := body["input"].([]any)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kinds, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatToResponsesBodyContent 断言文本部件按角色定型与图片装配/拒绝。
|
||||||
|
func TestChatToResponsesBodyContent(t *testing.T) {
|
||||||
|
body := mustChatBody(t, `{"model":"m","messages":[
|
||||||
|
{"role":"user","content":[
|
||||||
|
{"type":"text","text":"看图"},
|
||||||
|
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}]},
|
||||||
|
{"role":"assistant","content":"这是猫"}]}`)
|
||||||
|
raw, _ := json.Marshal(body["input"])
|
||||||
|
if !strings.Contains(string(raw), `"input_image"`) ||
|
||||||
|
!strings.Contains(string(raw), "data:image/png;base64,QUJD") {
|
||||||
|
t.Fatalf("图片应转 input_image: %s", raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"output_text"`) {
|
||||||
|
t.Fatalf("assistant 历史文本应为 output_text 部件: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, msg := range map[string]string{
|
||||||
|
"audio 块": `{"role":"user","content":[{"type":"input_audio","input_audio":{}}]}`,
|
||||||
|
"图片缺 url": `{"role":"user","content":[{"type":"image_url","image_url":{}}]}`,
|
||||||
|
} {
|
||||||
|
raw := `{"model":"m","messages":[` + msg + `]}`
|
||||||
|
if _, err := ChatToResponsesBody(mustChatReq(t, raw)); err == nil {
|
||||||
|
t.Errorf("%s 应拒绝", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badTool := mustChatReq(t, `{"model":"m","messages":[{"role":"user","content":"hi"}],
|
||||||
|
"tools":[{"type":"web_search","function":{}}]}`)
|
||||||
|
if _, err := ChatToResponsesBody(badTool); err == nil {
|
||||||
|
t.Error("非 function 工具类型应拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatRespToolChoice 断言 tool_choice 各形态映射。
|
||||||
|
func TestChatRespToolChoice(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, raw string
|
||||||
|
want any
|
||||||
|
}{
|
||||||
|
{"auto", `"auto"`, "auto"},
|
||||||
|
{"none", `"none"`, "none"},
|
||||||
|
{"required", `"required"`, "required"},
|
||||||
|
{"具名 function", `{"type":"function","function":{"name":"f1"}}`,
|
||||||
|
map[string]string{"type": "function", "name": "f1"}},
|
||||||
|
{"未知 string", `"whatever"`, nil},
|
||||||
|
{"缺 name", `{"type":"function","function":{}}`, nil},
|
||||||
|
{"空", ``, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := chatRespToolChoice(json.RawMessage(tc.raw))
|
||||||
|
if gotMap, ok := got.(map[string]string); ok {
|
||||||
|
wantMap, _ := tc.want.(map[string]string)
|
||||||
|
if wantMap == nil || gotMap["name"] != wantMap["name"] {
|
||||||
|
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatTextFormat 断言 response_format 拍平映射。
|
||||||
|
func TestChatTextFormat(t *testing.T) {
|
||||||
|
if got := chatTextFormat(nil); got != nil {
|
||||||
|
t.Errorf("nil 应不下发: %v", got)
|
||||||
|
}
|
||||||
|
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "text"}); got != nil {
|
||||||
|
t.Errorf("text 应不下发: %v", got)
|
||||||
|
}
|
||||||
|
if got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_object"}); got["type"] != "json_object" {
|
||||||
|
t.Errorf("json_object: %v", got)
|
||||||
|
}
|
||||||
|
got := chatTextFormat(&aiwire.ResponseFormat{Type: "json_schema",
|
||||||
|
JSONSchema: json.RawMessage(`{"name":"out","schema":{"type":"object"},"strict":true}`)})
|
||||||
|
if got["type"] != "json_schema" || got["name"] != "out" || got["strict"] != true {
|
||||||
|
t.Errorf("json_schema 应提升嵌套字段: %v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got["schema"]; !ok {
|
||||||
|
t.Errorf("schema 缺失: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponsesToChat 断言文本聚合、tool_calls、finish_reason 与 usage 回转。
|
||||||
|
func TestResponsesToChat(t *testing.T) {
|
||||||
|
payload := []byte(`{"model":"xai.grok-4.3","status":"completed","output":[
|
||||||
|
{"type":"reasoning","summary":[]},
|
||||||
|
{"type":"message","content":[{"type":"output_text","text":"你"},{"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 := ResponsesToChat(payload, "chatcmpl-1", 1700000000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResponsesToChat: %v", err)
|
||||||
|
}
|
||||||
|
if out.Object != "chat.completion" || out.ID != "chatcmpl-1" || out.Created != 1700000000 {
|
||||||
|
t.Fatalf("响应骨架错误: %+v", out)
|
||||||
|
}
|
||||||
|
msg := out.Choices[0].Message
|
||||||
|
if msg.Role != "assistant" || msg.Content.JoinText() != "你好" {
|
||||||
|
t.Fatalf("content 装配错误: %+v", msg)
|
||||||
|
}
|
||||||
|
if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].ID != "c1" ||
|
||||||
|
msg.ToolCalls[0].Function.Arguments != `{"city":"东京"}` {
|
||||||
|
t.Fatalf("tool_calls 装配错误: %+v", msg.ToolCalls)
|
||||||
|
}
|
||||||
|
if out.Choices[0].FinishReason != "tool_calls" {
|
||||||
|
t.Fatalf("finish_reason = %s, want tool_calls", out.Choices[0].FinishReason)
|
||||||
|
}
|
||||||
|
if out.Usage.PromptTokens != 10 || out.Usage.CompletionTokens != 5 || out.Usage.CachedTokens() != 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 := ResponsesToChat(trunc, "chatcmpl-2", 1)
|
||||||
|
if err != nil || out2.Choices[0].FinishReason != "length" {
|
||||||
|
t.Fatalf("截断 finish_reason = %+v, %v", out2.Choices, err)
|
||||||
|
}
|
||||||
|
if out2.Usage != nil {
|
||||||
|
t.Fatalf("无 usage 时应为 nil: %+v", out2.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatChunkShapes(chunks []aiwire.ChatChunk) string {
|
||||||
|
kinds := make([]string, 0, len(chunks))
|
||||||
|
for _, ch := range chunks {
|
||||||
|
switch {
|
||||||
|
case len(ch.Choices) == 0:
|
||||||
|
kinds = append(kinds, "usage")
|
||||||
|
case ch.Choices[0].FinishReason != nil:
|
||||||
|
kinds = append(kinds, "finish:"+*ch.Choices[0].FinishReason)
|
||||||
|
case len(ch.Choices[0].Delta.ToolCalls) > 0:
|
||||||
|
kinds = append(kinds, "tool")
|
||||||
|
case ch.Choices[0].Delta.Role != "":
|
||||||
|
kinds = append(kinds, "role+text")
|
||||||
|
default:
|
||||||
|
kinds = append(kinds, "text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kinds, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatRespBridge 断言流桥:首块 role、文本与工具增量、reasoning 丢弃、usage 块。
|
||||||
|
func TestChatRespBridge(t *testing.T) {
|
||||||
|
b := NewChatRespBridge("chatcmpl-1", "m1", 1700000000, true)
|
||||||
|
var chunks []aiwire.ChatChunk
|
||||||
|
feed := func(lines ...string) {
|
||||||
|
for _, l := range lines {
|
||||||
|
chunks = append(chunks, b.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\":"}`,
|
||||||
|
`{"type":"response.function_call_arguments.delta","delta":"1}"}`,
|
||||||
|
`{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":8,"output_tokens":4,"total_tokens":12}}}`)
|
||||||
|
chunks = append(chunks, b.Finish()...)
|
||||||
|
|
||||||
|
got := chatChunkShapes(chunks)
|
||||||
|
want := "role+text,text,tool,tool,tool,finish:tool_calls,usage"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("chunk 序列:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if chunks[0].Choices[0].Delta.Role != "assistant" || chunks[0].Choices[0].Delta.Content != "你" {
|
||||||
|
t.Fatalf("首块应含 role 与文本: %+v", chunks[0].Choices[0].Delta)
|
||||||
|
}
|
||||||
|
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
|
||||||
|
if tool.Index != 0 || tool.ID != "c1" || tool.Function.Name != "f" {
|
||||||
|
t.Fatalf("工具首块 = %+v", tool)
|
||||||
|
}
|
||||||
|
args := chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments +
|
||||||
|
chunks[4].Choices[0].Delta.ToolCalls[0].Function.Arguments
|
||||||
|
if args != `{"a":1}` {
|
||||||
|
t.Fatalf("实参增量聚合 = %s", args)
|
||||||
|
}
|
||||||
|
last := chunks[len(chunks)-1]
|
||||||
|
if last.Usage == nil || last.Usage.PromptTokens != 8 || len(last.Choices) != 0 {
|
||||||
|
t.Fatalf("usage 块 = %+v", last)
|
||||||
|
}
|
||||||
|
if b.Usage().TotalTokens != 12 {
|
||||||
|
t.Fatalf("Usage() = %+v", b.Usage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatRespBridgeVariants 断言 include_usage 关闭、截断与空流的收尾形态。
|
||||||
|
func TestChatRespBridgeVariants(t *testing.T) {
|
||||||
|
noUsage := NewChatRespBridge("c", "m", 1, false)
|
||||||
|
noUsage.Feed([]byte(`{"type":"response.output_text.delta","delta":"x"}`))
|
||||||
|
noUsage.Feed([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}`))
|
||||||
|
if got := chatChunkShapes(noUsage.Finish()); got != "finish:length" {
|
||||||
|
t.Errorf("截断且不带 usage 块: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := NewChatRespBridge("c", "m", 1, false)
|
||||||
|
fin := empty.Finish()
|
||||||
|
if got := chatChunkShapes(fin); got != "finish:stop" {
|
||||||
|
t.Errorf("空流收尾 = %s", got)
|
||||||
|
}
|
||||||
|
if fin[0].Choices[0].Delta.Role != "assistant" {
|
||||||
|
t.Errorf("空流终块应补 role: %+v", fin[0].Choices[0].Delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
)
|
)
|
||||||
@@ -33,42 +34,55 @@ type UpdateCredentialsInput struct {
|
|||||||
CurrentPassword string `json:"currentPassword" binding:"required"`
|
CurrentPassword string `json:"currentPassword" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCredentials 修改用户名 / 密码:当前密码必验;改名后旧 JWT 的
|
// UpdateCredentials 修改用户名 / 密码:当前密码必验;成功后令牌版本递增
|
||||||
// sub 不再命中账号,前端应强制重新登录。
|
// (全部旧 JWT 立即失效),返回最终用户名供调用方为操作者重签新令牌。
|
||||||
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) error {
|
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) (string, error) {
|
||||||
user, err := s.findUser(ctx, username)
|
user, err := s.findUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil {
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.CurrentPassword)) != nil {
|
||||||
return ErrCredentialConfirm
|
return "", ErrCredentialConfirm
|
||||||
}
|
}
|
||||||
newName := strings.TrimSpace(in.NewUsername)
|
newName := strings.TrimSpace(in.NewUsername)
|
||||||
if err := validateCredentialChange(user, newName, in.NewPassword); err != nil {
|
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{}
|
updates := map[string]any{}
|
||||||
|
finalName := user.Username
|
||||||
if newName != "" && newName != user.Username {
|
if newName != "" && newName != user.Username {
|
||||||
taken, err := s.usernameTaken(ctx, newName, user.ID)
|
taken, err := s.usernameTaken(ctx, newName, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if taken {
|
if taken {
|
||||||
return fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid)
|
return nil, "", fmt.Errorf("用户名已被占用: %w", ErrCredentialInvalid)
|
||||||
}
|
}
|
||||||
updates["username"] = newName
|
updates["username"] = newName
|
||||||
|
finalName = newName
|
||||||
}
|
}
|
||||||
if in.NewPassword != "" {
|
if newPassword != "" {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash password: %w", err)
|
return nil, "", fmt.Errorf("hash password: %w", err)
|
||||||
}
|
}
|
||||||
updates["password_hash"] = string(hash)
|
updates["password_hash"] = string(hash)
|
||||||
}
|
}
|
||||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
return updates, finalName, nil
|
||||||
return fmt.Errorf("update credentials: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。
|
// validateCredentialChange 校验改名 / 改密输入;两者均无实际变更时报非法。
|
||||||
@@ -122,7 +136,11 @@ func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username str
|
|||||||
return ErrNeedIdentity
|
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) {
|
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) {
|
||||||
|
|||||||
@@ -26,22 +26,22 @@ func TestUpdateCredentials(t *testing.T) {
|
|||||||
ctx := context.Background()
|
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) {
|
if !errors.Is(err, ErrCredentialConfirm) {
|
||||||
t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err)
|
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) {
|
if !errors.Is(err, ErrCredentialInvalid) {
|
||||||
t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err)
|
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) {
|
if !errors.Is(err, ErrCredentialInvalid) {
|
||||||
t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err)
|
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",
|
NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// IdentityProviders 列出域内 SAML 身份提供者。
|
// 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)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.client.ListIdentityProviders(ctx, cred, homeRegion)
|
return s.client.ListIdentityProviders(ctx, cred, homeRegion, domainID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIdentityProvider 创建禁用态 SAML IdP,激活另走 activate。
|
// CreateIdentityProvider 创建禁用态 SAML IdP,激活另走 activate。
|
||||||
// 映射与 JIT 字段缺省时填控制台默认值(normalizeIdpInput)。
|
// 映射与 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) == "" {
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
return oci.IdentityProviderInfo{}, fmt.Errorf("create identity provider: name is required")
|
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 {
|
if err != nil {
|
||||||
return oci.IdentityProviderInfo{}, err
|
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 格式「无」、
|
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
|
||||||
@@ -50,30 +50,30 @@ func normalizeIdpInput(in oci.CreateIdpInput) oci.CreateIdpInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetIdentityProviderEnabled 激活/停用 IdP 并同步登录页显示。
|
// 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)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oci.IdentityProviderInfo{}, err
|
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 规则,再从登录页
|
// DeleteIdentityProvider 级联删除 IdP:先删关联的免 MFA 规则,再从登录页
|
||||||
// 移除、停用、删除 IdP 本体。
|
// 移除、停用、删除 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)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, idpID)
|
return s.client.DeleteIdentityProvider(ctx, cred, homeRegion, domainID, idpID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteIdpExemptions 删除 sign-on 策略中引用目标 IdP 的免 MFA 规则。
|
// deleteIdpExemptions 删除 sign-on 策略中引用目标 IdP 的免 MFA 规则。
|
||||||
func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, idpID string) error {
|
func (s *OciConfigService) deleteIdpExemptions(ctx context.Context, cred oci.Credentials, region, domainID, idpID string) error {
|
||||||
rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region)
|
rules, err := s.client.ListConsoleSignOnRules(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list sign-on rules before delete idp: %w", err)
|
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) {
|
if r.BuiltIn || !strings.Contains(r.ConditionValue, idpID) {
|
||||||
continue
|
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)
|
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 侧配置)。
|
// 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)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion)
|
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
|
// 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)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion)
|
return s.client.ListConsoleSignOnRules(ctx, cred, homeRegion, domainID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateMfaExemption 为指定 IdP 创建免 MFA 规则并置为最高优先级。
|
// 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) == "" {
|
if strings.TrimSpace(idpID) == "" {
|
||||||
return oci.SignOnRuleInfo{}, fmt.Errorf("create mfa exemption: identityProviderId is required")
|
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 {
|
if err != nil {
|
||||||
return oci.SignOnRuleInfo{}, err
|
return oci.SignOnRuleInfo{}, err
|
||||||
}
|
}
|
||||||
name, err := s.exemptionRuleName(ctx, cred, homeRegion, idpID)
|
name, err := s.exemptionRuleName(ctx, cred, homeRegion, domainID, idpID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oci.SignOnRuleInfo{}, err
|
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 存在并用其名称生成规则名。
|
// exemptionRuleName 校验 IdP 存在并用其名称生成规则名。
|
||||||
func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, idpID string) (string, error) {
|
func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Credentials, region, domainID, idpID string) (string, error) {
|
||||||
idps, err := s.client.ListIdentityProviders(ctx, cred, region)
|
idps, err := s.client.ListIdentityProviders(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -137,10 +137,10 @@ func (s *OciConfigService) exemptionRuleName(ctx context.Context, cred oci.Crede
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteMfaExemption 删除免 MFA 规则并恢复其余规则优先级。
|
// 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)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, ruleID)
|
return s.client.DeleteMfaExemptionRule(ctx, cred, homeRegion, domainID, ruleID)
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-45
@@ -80,37 +80,62 @@ type LogWebhookInfo struct {
|
|||||||
|
|
||||||
// EnsureSecret 为配置生成(或幂等返回)回传 secret。
|
// EnsureSecret 为配置生成(或幂等返回)回传 secret。
|
||||||
func (s *LogEventService) EnsureSecret(ctx context.Context, cfgID uint) (LogWebhookInfo, error) {
|
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
|
return LogWebhookInfo{}, err
|
||||||
}
|
}
|
||||||
if info, ok, err := s.SecretInfo(ctx, cfgID); err != nil || ok {
|
var info LogWebhookInfo
|
||||||
return info, err
|
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)
|
buf := make([]byte, 32)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
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()}
|
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 LogWebhookInfo{}, fmt.Errorf("save webhook secret: %w", err)
|
||||||
}
|
}
|
||||||
return webhookInfo(secret, st.UpdatedAt), nil
|
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。
|
// SecretInfo 查询配置是否已生成 secret;未生成时 ok 为 false。
|
||||||
func (s *LogEventService) SecretInfo(ctx context.Context, cfgID uint) (LogWebhookInfo, bool, error) {
|
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
|
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 err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return LogWebhookInfo{}, false, nil
|
return LogWebhookInfo{}, false, nil
|
||||||
@@ -154,19 +179,61 @@ func (s *LogEventService) ResolveSecret(ctx context.Context, secret string) (uin
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if subtle.ConstantTimeCompare([]byte(row.Value), []byte(secret)) == 1 {
|
if subtle.ConstantTimeCompare([]byte(row.Value), []byte(secret)) != 1 {
|
||||||
id, err := strconv.ParseUint(strings.TrimPrefix(row.Key, logWebhookSecretPrefix), 10, 64)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
|
id, ok := webhookConfigID(row.Key)
|
||||||
|
if !ok || !s.configExists(ctx, id) {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return uint(id), true
|
return id, true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 0, false
|
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 幂等)。
|
// Ingest 落一条回传事件;MessageID 唯一索引冲突即静默忽略(at-least-once 幂等)。
|
||||||
func (s *LogEventService) Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error {
|
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{
|
event := model.LogEvent{
|
||||||
OciConfigID: cfgID,
|
OciConfigID: cfgID,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
@@ -174,13 +241,9 @@ func (s *LogEventService) Ingest(ctx context.Context, cfgID uint, messageID stri
|
|||||||
Truncated: truncated,
|
Truncated: truncated,
|
||||||
ReceivedAt: time.Now(),
|
ReceivedAt: time.Now(),
|
||||||
}
|
}
|
||||||
err := s.db.WithContext(ctx).
|
return tx.Clauses(clause.OnConflict{
|
||||||
Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "message_id"}}, DoNothing: true}).
|
Columns: []clause.Column{{Name: "message_id"}}, DoNothing: true,
|
||||||
Create(&event).Error
|
}).Create(&event).Error
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ingest log event: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogEventQuery 是回传事件查询参数;CfgID 为 0 表示全部租户。
|
// LogEventQuery 是回传事件查询参数;CfgID 为 0 表示全部租户。
|
||||||
@@ -304,20 +367,44 @@ func (s *LogEventService) parseOnce(ctx context.Context) {
|
|||||||
log.Printf("log event parse load: %v", err)
|
log.Printf("log event parse load: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := range events {
|
if len(events) == 0 {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
s.notifyCritical(ctx, e, parsed)
|
for i := range events {
|
||||||
|
s.processLogEvent(ctx, &events[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processLogEvent 仅在条件更新命中原行后触发通知,删除并发胜出时静默跳过。
|
||||||
|
func (s *LogEventService) processLogEvent(ctx context.Context, event *model.LogEvent) {
|
||||||
|
parsed := parseLogEvent([]byte(event.Payload))
|
||||||
|
if !s.updateParsedEvent(ctx, event, parsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notifyCritical(ctx, 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 审计事件的常见字段;
|
// onsEnvelope 覆盖 ONS 消息与 CloudEvents 审计事件的常见字段;
|
||||||
// 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。
|
// 真实格式以联调实测为准,提不出字段时只置 Processed 不回填。
|
||||||
type onsEnvelope struct {
|
type onsEnvelope struct {
|
||||||
@@ -326,21 +413,44 @@ type onsEnvelope struct {
|
|||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
EventTime string `json:"eventTime"`
|
EventTime string `json:"eventTime"`
|
||||||
ResourceName string `json:"resourceName"`
|
ResourceName string `json:"resourceName"`
|
||||||
|
Message string `json:"message"`
|
||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
Identity *onsIdentity `json:"identity"`
|
Identity *onsIdentity `json:"identity"`
|
||||||
|
AdditionalDetails *onsAddDetails `json:"additionalDetails"`
|
||||||
|
StateChange *onsStateChange `json:"stateChange"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。
|
// onsIdentity 是 Audit 事件 data.identity 中与展示相关的字段。
|
||||||
type onsIdentity struct {
|
type onsIdentity struct {
|
||||||
IPAddress string `json:"ipAddress"`
|
IPAddress string `json:"ipAddress"`
|
||||||
|
PrincipalName string `json:"principalName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsedEvent 是从消息原文提取的展示字段集;ResourceName 仅供 P2 推送文案,不落库。
|
// onsAddDetails 是 IDCS 登录类事件 data.additionalDetails 的补充字段;
|
||||||
|
// AuditEventMapValue 为嵌套的 JSON 字符串(含 eventId 成败与失败原因)。
|
||||||
|
type onsAddDetails struct {
|
||||||
|
ActorName string `json:"actorName"`
|
||||||
|
ClientIP string `json:"clientIp"`
|
||||||
|
AuditEventMapValue string `json:"auditEventMapValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// onsStateChange 承载 Audit v2 的资源变更快照;description 供策略类事件推送文案。
|
||||||
|
type onsStateChange struct {
|
||||||
|
Current struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsedEvent 是从消息原文提取的展示字段集;ResourceName/Actor/Outcome/Detail
|
||||||
|
// 仅供 P2 推送文案,不落库。
|
||||||
type parsedEvent struct {
|
type parsedEvent struct {
|
||||||
EventType string
|
EventType string
|
||||||
Source string
|
Source string
|
||||||
SourceIP string
|
SourceIP string
|
||||||
ResourceName string
|
ResourceName string
|
||||||
|
Actor string // 操作者(identity.principalName,登录事件回退 actorName)
|
||||||
|
Outcome string // 成功 / 失败 / 空(判读不出)
|
||||||
|
Detail string // 补充说明(策略描述、登录失败原因等)
|
||||||
EventTime *time.Time
|
EventTime *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +473,7 @@ func parseLogEvent(payload []byte) parsedEvent {
|
|||||||
return envelopeFields(env)
|
return envelopeFields(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 在内层)。
|
// mergeEnvelope 外层缺失字段时以 data 内层补齐(Audit 的 identity 等在内层)。
|
||||||
func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
|
func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
|
||||||
if outer.EventType == "" {
|
if outer.EventType == "" {
|
||||||
outer.EventType = inner.EventType
|
outer.EventType = inner.EventType
|
||||||
@@ -380,13 +490,22 @@ func mergeEnvelope(outer, inner onsEnvelope) onsEnvelope {
|
|||||||
if outer.ResourceName == "" {
|
if outer.ResourceName == "" {
|
||||||
outer.ResourceName = inner.ResourceName
|
outer.ResourceName = inner.ResourceName
|
||||||
}
|
}
|
||||||
|
if outer.Message == "" {
|
||||||
|
outer.Message = inner.Message
|
||||||
|
}
|
||||||
if outer.Identity == nil {
|
if outer.Identity == nil {
|
||||||
outer.Identity = inner.Identity
|
outer.Identity = inner.Identity
|
||||||
}
|
}
|
||||||
|
if outer.AdditionalDetails == nil {
|
||||||
|
outer.AdditionalDetails = inner.AdditionalDetails
|
||||||
|
}
|
||||||
|
if outer.StateChange == nil {
|
||||||
|
outer.StateChange = inner.StateChange
|
||||||
|
}
|
||||||
return outer
|
return outer
|
||||||
}
|
}
|
||||||
|
|
||||||
// envelopeFields 收敛字段别名并解析事件时间。
|
// envelopeFields 收敛字段别名并解析事件时间与推送用补充字段。
|
||||||
func envelopeFields(env onsEnvelope) parsedEvent {
|
func envelopeFields(env onsEnvelope) parsedEvent {
|
||||||
eventType := env.EventType
|
eventType := env.EventType
|
||||||
if eventType == "" {
|
if eventType == "" {
|
||||||
@@ -398,19 +517,81 @@ func envelopeFields(env onsEnvelope) parsedEvent {
|
|||||||
eventTime = &ts
|
eventTime = &ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ip := ""
|
outcome, detail := envOutcome(env)
|
||||||
if env.Identity != nil {
|
|
||||||
ip = env.Identity.IPAddress
|
|
||||||
}
|
|
||||||
return parsedEvent{
|
return parsedEvent{
|
||||||
EventType: clip(eventType, 128),
|
EventType: clip(eventType, 128),
|
||||||
Source: clip(env.Source, 64),
|
Source: clip(env.Source, 64),
|
||||||
SourceIP: clip(ip, 64),
|
SourceIP: clip(envIP(env), 64),
|
||||||
ResourceName: clip(env.ResourceName, 128),
|
ResourceName: clip(env.ResourceName, 128),
|
||||||
|
Actor: clipRunes(envActor(env), 64),
|
||||||
|
Outcome: outcome,
|
||||||
|
Detail: clipRunes(detail, 200),
|
||||||
EventTime: eventTime,
|
EventTime: eventTime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envIP 取发起方 IP:Audit 的 identity.ipAddress,登录事件回退 additionalDetails.clientIp。
|
||||||
|
func envIP(env onsEnvelope) string {
|
||||||
|
if env.Identity != nil && env.Identity.IPAddress != "" {
|
||||||
|
return env.Identity.IPAddress
|
||||||
|
}
|
||||||
|
if env.AdditionalDetails != nil {
|
||||||
|
return env.AdditionalDetails.ClientIP
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// envActor 取操作者:Audit 的 identity.principalName,登录事件回退 additionalDetails.actorName。
|
||||||
|
func envActor(env onsEnvelope) string {
|
||||||
|
if env.Identity != nil && env.Identity.PrincipalName != "" {
|
||||||
|
return env.Identity.PrincipalName
|
||||||
|
}
|
||||||
|
if env.AdditionalDetails != nil {
|
||||||
|
return env.AdditionalDetails.ActorName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// envOutcome 判读事件成败与补充说明:登录事件解析 auditEventMapValue;其余
|
||||||
|
// Audit 事件看 message 后缀,补充说明取 stateChange.current.description(策略描述)。
|
||||||
|
func envOutcome(env onsEnvelope) (outcome, detail string) {
|
||||||
|
if env.StateChange != nil {
|
||||||
|
detail = env.StateChange.Current.Description
|
||||||
|
}
|
||||||
|
if env.AdditionalDetails != nil && env.AdditionalDetails.AuditEventMapValue != "" {
|
||||||
|
return ssoOutcome(env.AdditionalDetails.AuditEventMapValue, detail)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(env.Message, " succeeded"):
|
||||||
|
outcome = "成功"
|
||||||
|
case strings.HasSuffix(env.Message, " failed"):
|
||||||
|
outcome = "失败"
|
||||||
|
}
|
||||||
|
return outcome, detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssoOutcome 解析 IDCS 审计负载(JSON 字符串):eventId 含 success/failure 定成败,
|
||||||
|
// 失败时以其 message 作为原因说明。
|
||||||
|
func ssoOutcome(raw, detail string) (string, string) {
|
||||||
|
var ev struct {
|
||||||
|
EventID string `json:"eventId"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(raw), &ev) != nil {
|
||||||
|
return "", detail
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(ev.EventID, "success"):
|
||||||
|
return "成功", detail
|
||||||
|
case strings.Contains(ev.EventID, "failure"):
|
||||||
|
if ev.Message != "" {
|
||||||
|
detail = ev.Message
|
||||||
|
}
|
||||||
|
return "失败", detail
|
||||||
|
}
|
||||||
|
return "", detail
|
||||||
|
}
|
||||||
|
|
||||||
// clip 按模型列宽截断解析出的字段。
|
// clip 按模型列宽截断解析出的字段。
|
||||||
func clip(s string, max int) string {
|
func clip(s string, max int) string {
|
||||||
if len(s) > max {
|
if len(s) > max {
|
||||||
@@ -419,6 +600,15 @@ func clip(s string, max int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clipRunes 按字符数截断(通知文案用,中文安全)。
|
||||||
|
func clipRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) > max {
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
|
// StartCleanup 启动周期清理:启动即清一次,之后每 24h 一次,随 ctx 取消退出。
|
||||||
func (s *LogEventService) StartCleanup(ctx context.Context) {
|
func (s *LogEventService) StartCleanup(ctx context.Context) {
|
||||||
s.wg.Add(1)
|
s.wg.Add(1)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -98,9 +100,103 @@ func TestEnsureAndResolveSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureSecretRejectsUnknownConfig(t *testing.T) {
|
func TestEnsureSecretRejectsUnknownConfig(t *testing.T) {
|
||||||
svc, _, _ := newLogEventEnv(t)
|
svc, db, _ := newLogEventEnv(t)
|
||||||
if _, err := svc.EnsureSecret(context.Background(), 9999); err == nil {
|
if _, err := svc.EnsureSecret(context.Background(), 9999); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
t.Fatal("ensure secret for unknown config succeeded, want error")
|
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 +223,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) {
|
func TestParseLogEvent(t *testing.T) {
|
||||||
ts := "2026-07-07T08:00:00Z"
|
ts := "2026-07-07T08:00:00Z"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -136,6 +245,9 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
wantSource string
|
wantSource string
|
||||||
wantIP string
|
wantIP string
|
||||||
wantTime bool
|
wantTime bool
|
||||||
|
wantActor string
|
||||||
|
wantOutcome string
|
||||||
|
wantDetail string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "CloudEvents 单事件",
|
name: "CloudEvents 单事件",
|
||||||
@@ -173,6 +285,29 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
payload: `{"eventType":"x","data":{"identity":{}}}`,
|
payload: `{"eventType":"x","data":{"identity":{}}}`,
|
||||||
wantType: "x",
|
wantType: "x",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Audit v2 提取操作者成败与策略描述",
|
||||||
|
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
|
||||||
|
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
|
||||||
|
"message":"ociportal-logs-sch CreatePolicy succeeded",
|
||||||
|
"stateChange":{"current":{"description":"允许 Connector 发布到 ONS"}}}}`,
|
||||||
|
wantType: "com.oraclecloud.identityControlPlane.CreatePolicy", wantIP: "129.159.43.9",
|
||||||
|
wantActor: "Alfonso Garcia", wantOutcome: "成功", wantDetail: "允许 Connector 发布到 ONS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDCS 登录失败提取用户 IP 与原因",
|
||||||
|
payload: `{"eventType":"com.oraclecloud.IdentitySignOn.InteractiveLogin","data":{
|
||||||
|
"additionalDetails":{"actorName":"oci","clientIp":"155.117.82.111",
|
||||||
|
"auditEventMapValue":"{\"eventId\":\"sso.authentication.failure\",\"message\":\"Authentication failure : incorrect password.\"}"}}}`,
|
||||||
|
wantType: "com.oraclecloud.IdentitySignOn.InteractiveLogin", wantIP: "155.117.82.111",
|
||||||
|
wantActor: "oci", wantOutcome: "失败", wantDetail: "Authentication failure : incorrect password.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message failed 后缀判失败",
|
||||||
|
payload: `{"eventType":"x","data":{"message":"vm TerminateInstance failed"}}`,
|
||||||
|
wantType: "x",
|
||||||
|
wantOutcome: "失败",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -181,6 +316,10 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
|
t.Errorf("parse = (%q,%q,%q), want (%q,%q,%q)",
|
||||||
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
|
got.EventType, got.Source, got.SourceIP, tt.wantType, tt.wantSource, tt.wantIP)
|
||||||
}
|
}
|
||||||
|
if got.Actor != tt.wantActor || got.Outcome != tt.wantOutcome || got.Detail != tt.wantDetail {
|
||||||
|
t.Errorf("推送字段 = (%q,%q,%q), want (%q,%q,%q)",
|
||||||
|
got.Actor, got.Outcome, got.Detail, tt.wantActor, tt.wantOutcome, tt.wantDetail)
|
||||||
|
}
|
||||||
if (got.EventTime != nil) != tt.wantTime {
|
if (got.EventTime != nil) != tt.wantTime {
|
||||||
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
|
t.Errorf("eventTime present = %v, want %v", got.EventTime != nil, tt.wantTime)
|
||||||
}
|
}
|
||||||
@@ -209,6 +348,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) {
|
func TestLogEventCleanup(t *testing.T) {
|
||||||
svc, db, cfgID := newLogEventEnv(t)
|
svc, db, cfgID := newLogEventEnv(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxGuardEntries 是 failures / lockedAt 各自的条目上限(S-03):
|
||||||
|
// 攻击者持续提交唯一用户名时先清过期、仍满驱逐最旧,内存有界。
|
||||||
|
const maxGuardEntries = 4096
|
||||||
|
|
||||||
|
// guardUserMax 是计入守卫 key 的用户名字节上限,超长截断防高基数撑爆。
|
||||||
|
const guardUserMax = 32
|
||||||
|
|
||||||
// loginGuard 按「IP|用户名」双维度做滑动窗口失败计数与锁定;
|
// 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 是否处于锁定期;过期锁惰性清除。
|
// locked 判定 key 是否处于锁定期;过期锁惰性清除。
|
||||||
func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bool {
|
func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bool {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
@@ -37,9 +55,13 @@ func (g *loginGuard) locked(key string, now time.Time, lockFor time.Duration) bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fail 记一次失败并裁剪窗口;达到阈值转入锁定并返回 true(仅触发那一次)。
|
// fail 记一次失败并裁剪窗口;达到阈值转入锁定并返回 true(仅触发那一次)。
|
||||||
|
// 新建条目前先保证容量(过期清理 + 最旧驱逐),防高基数 key 单向增长。
|
||||||
func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Duration) bool {
|
func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Duration) bool {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
if _, exists := g.failures[key]; !exists {
|
||||||
|
g.ensureCapacity(now, window)
|
||||||
|
}
|
||||||
kept := g.failures[key][:0]
|
kept := g.failures[key][:0]
|
||||||
for _, t := range g.failures[key] {
|
for _, t := range g.failures[key] {
|
||||||
if now.Sub(t) < window {
|
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)
|
kept = append(kept, now)
|
||||||
if len(kept) >= limit {
|
if len(kept) >= limit {
|
||||||
delete(g.failures, key)
|
delete(g.failures, key)
|
||||||
|
// 转入锁定同样要保证容量:该写入发生在已有 failures 条目的
|
||||||
|
// 第 N 次失败,不经过新建条目路径的 ensureCapacity
|
||||||
|
g.ensureLockCapacity(now, window)
|
||||||
g.lockedAt[key] = now
|
g.lockedAt[key] = now
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -56,6 +81,77 @@ func (g *loginGuard) fail(key string, now time.Time, limit int, window time.Dura
|
|||||||
return false
|
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 的失败计数(成功登录重置窗口)。
|
// success 清空 key 的失败计数(成功登录重置窗口)。
|
||||||
func (g *loginGuard) success(key string) {
|
func (g *loginGuard) success(key string) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
|
|||||||
@@ -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("最早锁定条目未被驱逐")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,12 +322,34 @@ func relayEventShortName(eventType string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
|
// criticalEventVars 判定关键事件并生成模板变量;非关键事件 ok 为 false。
|
||||||
|
// actor/ip/outcome/resource 缺失时兜底 —,detail 包装为独立行(空则不占行)。
|
||||||
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
|
func criticalEventVars(alias string, p parsedEvent) (map[string]string, bool) {
|
||||||
name := relayEventShortName(p.EventType)
|
name := relayEventShortName(p.EventType)
|
||||||
if name == "" || !relayCriticalSet[name] {
|
if name == "" || !relayCriticalSet[name] {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return map[string]string{"tenant": alias, "event": name, "resource": p.ResourceName}, true
|
return map[string]string{
|
||||||
|
"tenant": alias, "event": name,
|
||||||
|
"resource": orDash(p.ResourceName), "actor": orDash(p.Actor),
|
||||||
|
"ip": orDash(p.SourceIP), "outcome": orDash(p.Outcome),
|
||||||
|
"detail": detailLine(p.Detail),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// orDash 空值兜底为 —,避免模板出现悬空标签。
|
||||||
|
func orDash(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailLine 把补充说明包装为独立行;为空时不产生多余空行。
|
||||||
|
func detailLine(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n" + v
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。
|
// notifyCritical 对关键事件推送告警;依赖未注入或对应子类开关关闭时跳过。
|
||||||
|
|||||||
@@ -316,23 +316,33 @@ func TestCriticalEventText(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
event parsedEvent
|
event parsedEvent
|
||||||
wantOK bool
|
wantOK bool
|
||||||
wantText string
|
want map[string]string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "实例终止命中",
|
name: "实例终止含操作者与成败",
|
||||||
event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance", ResourceName: "vm-1"},
|
event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.TerminateInstance",
|
||||||
|
ResourceName: "vm-1", Actor: "demo@example.com", SourceIP: "203.0.113.8", Outcome: "成功"},
|
||||||
wantOK: true,
|
wantOK: true,
|
||||||
wantText: "☁️ 云端事件:免费01 TerminateInstance vm-1",
|
want: map[string]string{"event": "TerminateInstance", "resource": "vm-1",
|
||||||
|
"actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "无资源名省略尾段",
|
name: "字段缺失兜底破折号",
|
||||||
event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"},
|
event: parsedEvent{EventType: "com.oraclecloud.IdentityControlPlane.CreateApiKey"},
|
||||||
wantOK: true,
|
wantOK: true,
|
||||||
wantText: "☁️ 云端事件:免费01 CreateApiKey",
|
want: map[string]string{"event": "CreateApiKey", "resource": "—",
|
||||||
|
"actor": "—", "ip": "—", "outcome": "—", "detail": ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "补充说明独立成行",
|
||||||
|
event: parsedEvent{EventType: "CreatePolicy", Detail: "允许发布到 ONS Topic"},
|
||||||
|
wantOK: true,
|
||||||
|
want: map[string]string{"event": "CreatePolicy", "detail": "\n允许发布到 ONS Topic"},
|
||||||
},
|
},
|
||||||
{name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false},
|
{name: "List 噪声不推", event: parsedEvent{EventType: "com.oraclecloud.ComputeApi.ListInstances"}, wantOK: false},
|
||||||
{name: "空类型不推", event: parsedEvent{}, wantOK: false},
|
{name: "空类型不推", event: parsedEvent{}, wantOK: false},
|
||||||
{name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true, wantText: "☁️ 云端事件:免费01 LaunchInstance"},
|
{name: "短名直接命中", event: parsedEvent{EventType: "LaunchInstance"}, wantOK: true,
|
||||||
|
want: map[string]string{"event": "LaunchInstance"}},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -340,13 +350,15 @@ func TestCriticalEventText(t *testing.T) {
|
|||||||
if ok != tt.wantOK {
|
if ok != tt.wantOK {
|
||||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||||
}
|
}
|
||||||
if ok {
|
if !ok {
|
||||||
got := "☁️ 云端事件:" + vars["tenant"] + " " + vars["event"]
|
return
|
||||||
if vars["resource"] != "" {
|
|
||||||
got += " " + vars["resource"]
|
|
||||||
}
|
}
|
||||||
if got != tt.wantText {
|
if vars["tenant"] != "免费01" {
|
||||||
t.Errorf("vars 渲染 = %q, want %q", got, tt.wantText)
|
t.Errorf("tenant = %q, want 免费01", vars["tenant"])
|
||||||
|
}
|
||||||
|
for k, want := range tt.want {
|
||||||
|
if vars[k] != want {
|
||||||
|
t.Errorf("%s = %q, want %q", k, vars[k], want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+134
-18
@@ -24,12 +24,12 @@ const notifyTextLimit = 3800
|
|||||||
// telegramAPIBase 是 Telegram Bot API 官方根地址。
|
// telegramAPIBase 是 Telegram Bot API 官方根地址。
|
||||||
const telegramAPIBase = "https://api.telegram.org"
|
const telegramAPIBase = "https://api.telegram.org"
|
||||||
|
|
||||||
// Notifier 通过 Telegram Bot API 推送通知;未启用或未配置时静默跳过。
|
// Notifier 向 Telegram 与全部启用的通知渠道(webhook/ntfy/bark/smtp)推送;
|
||||||
// 云外 HTTP 调用,不属于 internal/oci 的职责范围。
|
// 未启用或未配置的渠道静默跳过。云外 HTTP 调用,不属于 internal/oci 的职责范围。
|
||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
settings *SettingService
|
settings *SettingService
|
||||||
client *http.Client
|
client *http.Client
|
||||||
base string // API 根地址,测试时注入 httptest 服务
|
base string // Telegram API 根地址,测试时注入 httptest 服务
|
||||||
wg sync.WaitGroup
|
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 {
|
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)
|
cfg, err := n.settings.TelegramConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -51,7 +71,38 @@ func (n *Notifier) Send(ctx context.Context, text string) error {
|
|||||||
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
|
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
|
||||||
return nil
|
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 异步发送并自带超时,失败只记日志,绝不影响调用链路。
|
// SendAsync 异步发送并自带超时,失败只记日志,绝不影响调用链路。
|
||||||
@@ -62,13 +113,13 @@ func (n *Notifier) SendAsync(text string) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := n.Send(ctx, text); err != nil {
|
if err := n.Send(ctx, text); err != nil {
|
||||||
log.Printf("telegram notify: %v", err)
|
log.Printf("notify: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送;
|
// SendTemplateAsync 按 kind 的生效模板(自定义或默认)渲染变量后异步发送;
|
||||||
// 模板支持轻量 Markdown,按 Telegram HTML 语义投递。
|
// 模板支持轻量 Markdown,Telegram 按 HTML 语义投递,其余渠道按纯文本投递。
|
||||||
func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
|
func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
|
||||||
n.wg.Add(1)
|
n.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -76,7 +127,7 @@ func (n *Notifier) SendTemplateAsync(kind string, vars map[string]string) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), notifySendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := n.sendTemplate(ctx, kind, "", vars); err != nil {
|
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 {
|
if !ok {
|
||||||
return fmt.Errorf("未知通知类型 %q", kind)
|
return fmt.Errorf("未知通知类型 %q", kind)
|
||||||
}
|
}
|
||||||
cfg, err := n.settings.TelegramConfig(ctx)
|
enabled, err := n.anyChannelEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
|
if !enabled {
|
||||||
return fmt.Errorf("Telegram 通知未启用或未配置,请先在「通知方式」保存配置")
|
return fmt.Errorf("没有已启用的通知渠道,请先在「通知方式」配置并启用")
|
||||||
}
|
}
|
||||||
vars := map[string]string{}
|
vars := map[string]string{}
|
||||||
for k, v := range def.Sample {
|
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)
|
return n.sendTemplate(ctx, kind, tplOverride, vars)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendTemplate 渲染并发送:取生效模板 → 变量替换 → 截断 → Markdown 转 Telegram HTML。
|
// anyChannelEnabled 报告是否存在至少一个已启用且配置齐全的渠道(含 Telegram)。
|
||||||
func (n *Notifier) sendTemplate(ctx context.Context, kind, tplOverride string, vars map[string]string) error {
|
func (n *Notifier) anyChannelEnabled(ctx context.Context) (bool, error) {
|
||||||
cfg, err := n.settings.TelegramConfig(ctx)
|
tg, err := n.settings.TelegramConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
if !cfg.Enabled || cfg.BotToken == "" || cfg.ChatID == "" {
|
if tg.Enabled && tg.BotToken != "" && tg.ChatID != "" {
|
||||||
return nil
|
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
|
tpl := tplOverride
|
||||||
if strings.TrimSpace(tpl) == "" {
|
if strings.TrimSpace(tpl) == "" {
|
||||||
custom, err := n.settings.NotifyTemplate(ctx, kind)
|
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)
|
tpl = notifyTplText(custom, kind)
|
||||||
}
|
}
|
||||||
text := renderNotifyTemplate(tpl, vars)
|
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 等待全部在途异步通知发完,供退出前收尾与测试同步。
|
// Wait 等待全部在途异步通知发完,供退出前收尾与测试同步。
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -38,9 +38,13 @@ var notifyTplDefs = map[string]notifyTplDef{
|
|||||||
},
|
},
|
||||||
"snatch_success": {
|
"snatch_success": {
|
||||||
Label: "抢机成功",
|
Label: "抢机成功",
|
||||||
Default: "🎉 抢机成功:{{task_name}}\n{{message}}",
|
Default: "🎉 抢机成功:{{task_name}}\n租户:{{tenant}}\n区域:{{region}}\n类型:{{shape}}\n配置:{{spec}}\n镜像:{{image}}\nIP:{{ip}}\n密码:{{root_password}}",
|
||||||
Vars: []string{"task_name", "message"},
|
Vars: []string{"task_name", "tenant", "region", "shape", "spec", "image", "ip", "root_password", "message"},
|
||||||
Sample: map[string]string{"task_name": "示例抢机任务", "message": "已创建 1/1 台实例"},
|
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": {
|
"tenant_dead": {
|
||||||
Label: "租户失联",
|
Label: "租户失联",
|
||||||
@@ -68,33 +72,40 @@ var notifyTplDefs = map[string]notifyTplDef{
|
|||||||
},
|
},
|
||||||
"log_event_instance": {
|
"log_event_instance": {
|
||||||
Label: "实例生命周期",
|
Label: "实例生命周期",
|
||||||
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
|
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
|
||||||
Vars: []string{"tenant", "event", "resource"},
|
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
|
||||||
Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1"},
|
Sample: map[string]string{"tenant": "免费01", "event": "TerminateInstance", "resource": "web-server-1",
|
||||||
|
"actor": "demo@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
|
||||||
},
|
},
|
||||||
"log_event_identity": {
|
"log_event_identity": {
|
||||||
Label: "用户与凭据",
|
Label: "用户与凭据",
|
||||||
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
|
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
|
||||||
Vars: []string{"tenant", "event", "resource"},
|
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
|
||||||
Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo"},
|
Sample: map[string]string{"tenant": "免费01", "event": "CreateApiKey", "resource": "user/demo",
|
||||||
|
"actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
|
||||||
},
|
},
|
||||||
"log_event_policy": {
|
"log_event_policy": {
|
||||||
Label: "策略变更",
|
Label: "策略变更",
|
||||||
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
|
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
|
||||||
Vars: []string{"tenant", "event", "resource"},
|
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
|
||||||
Sample: map[string]string{"tenant": "免费01", "event": "UpdatePolicy", "resource": "admin-policy"},
|
Sample: map[string]string{"tenant": "免费01", "event": "CreatePolicy", "resource": "admin-policy",
|
||||||
|
"actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功",
|
||||||
|
"detail": "\n允许 Service Connector 发布到 ONS Topic"},
|
||||||
},
|
},
|
||||||
"log_event_region": {
|
"log_event_region": {
|
||||||
Label: "区域订阅",
|
Label: "区域订阅",
|
||||||
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
|
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}} · {{outcome}}\n操作者 {{actor}} · 来源 {{ip}}{{detail}}",
|
||||||
Vars: []string{"tenant", "event", "resource"},
|
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
|
||||||
Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1"},
|
Sample: map[string]string{"tenant": "免费01", "event": "CreateRegionSubscription", "resource": "ap-osaka-1",
|
||||||
|
"actor": "admin@example.com", "ip": "203.0.113.8", "outcome": "成功", "detail": ""},
|
||||||
},
|
},
|
||||||
"log_event_login": {
|
"log_event_login": {
|
||||||
Label: "控制台登录",
|
Label: "控制台登录",
|
||||||
Default: "☁️ 云端事件:{{tenant}} {{event}} {{resource}}",
|
Default: "☁️ 云端事件:{{tenant}} {{event}} · {{outcome}}\n用户 {{actor}} · 登录 IP {{ip}}{{detail}}",
|
||||||
Vars: []string{"tenant", "event", "resource"},
|
Vars: []string{"tenant", "event", "resource", "actor", "ip", "outcome", "detail"},
|
||||||
Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "demo@example.com"},
|
Sample: map[string]string{"tenant": "免费01", "event": "InteractiveLogin", "resource": "—",
|
||||||
|
"actor": "demo@example.com", "ip": "155.117.82.111", "outcome": "失败",
|
||||||
|
"detail": "\nAuthentication failure : You entered an incorrect user name or password."},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,3 +172,11 @@ func notifyTplText(custom, kind string) string {
|
|||||||
}
|
}
|
||||||
return notifyTplDefs[kind].Default
|
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 通知"
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,7 +197,12 @@ func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code
|
|||||||
return "", "", p.mode, err
|
return "", "", p.mode, err
|
||||||
}
|
}
|
||||||
if p.mode == "bind" {
|
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)
|
token, display, err = o.loginByIdentity(ctx, provider, ident)
|
||||||
return token, display, p.mode, err
|
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 {
|
if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil {
|
||||||
return "", "", fmt.Errorf("find bound user: %w", err)
|
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
|
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 {
|
if res.RowsAffected == 0 {
|
||||||
return gorm.ErrRecordNotFound
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
return nil
|
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
|
||||||
|
return o.auth.bumpTokenVersion(ctx, username)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -18,7 +19,8 @@ type OciConfigService struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
cipher *crypto.Cipher
|
cipher *crypto.Cipher
|
||||||
client oci.Client
|
client oci.Client
|
||||||
// auditRaw 暂存审计原始事件(eventId → raw,TTL 10 分钟),
|
cleanupTasks *TaskService
|
||||||
|
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
||||||
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
||||||
auditRaw *cache.Cache
|
auditRaw *cache.Cache
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,11 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client)
|
|||||||
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
|
return &OciConfigService{db: db, cipher: cipher, client: client, auditRaw: cache.New(auditRawMax)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTenantCleanupDeps 注入租户删除提交后的任务内存状态同步依赖。
|
||||||
|
func (s *OciConfigService) SetTenantCleanupDeps(tasks *TaskService) {
|
||||||
|
s.cleanupTasks = tasks
|
||||||
|
}
|
||||||
|
|
||||||
// ImportInput 是导入一份 API Key 的输入:
|
// ImportInput 是导入一份 API Key 的输入:
|
||||||
// ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。
|
// ConfigINI 与显式字段二选一(ConfigINI 优先),私钥内容必填。
|
||||||
type ImportInput struct {
|
type ImportInput struct {
|
||||||
@@ -263,17 +270,18 @@ func (s *OciConfigService) Get(ctx context.Context, id uint) (*model.OciConfig,
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除配置及其区域 / 区间缓存。
|
// Delete 在单一事务中删除配置及全部租户级本地关联数据。
|
||||||
func (s *OciConfigService) Delete(ctx context.Context, id uint) error {
|
func (s *OciConfigService) Delete(ctx context.Context, id uint) error {
|
||||||
res := s.db.WithContext(ctx).Delete(&model.OciConfig{}, id)
|
unlock := func() {}
|
||||||
if res.Error != nil {
|
if s.cleanupTasks != nil {
|
||||||
return fmt.Errorf("delete oci config %d: %w", id, res.Error)
|
unlock = s.cleanupTasks.lockTenantCleanup()
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
defer unlock()
|
||||||
return fmt.Errorf("delete oci config %d: %w", id, gorm.ErrRecordNotFound)
|
result, err := s.deleteTenant(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.RegionCache{})
|
s.afterTenantDelete(ctx, result)
|
||||||
s.db.WithContext(ctx).Where("oci_config_id = ?", id).Delete(&model.CompartmentCache{})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,12 +302,43 @@ func (s *OciConfigService) refresh(ctx context.Context, cfg *model.OciConfig, cr
|
|||||||
s.applyProfile(ctx, cfg, cred)
|
s.applyProfile(ctx, cfg, cred)
|
||||||
s.syncScopeCaches(ctx, cfg, cred)
|
s.syncScopeCaches(ctx, cfg, cred)
|
||||||
}
|
}
|
||||||
if err := s.db.WithContext(ctx).Save(cfg).Error; err != nil {
|
s.applySuspension(ctx, cfg, cred)
|
||||||
return fmt.Errorf("save oci config snapshot: %w", err)
|
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
|
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,不影响测活结论。
|
// applyProfile 拉取订阅信息;失败只把类别降级为 unknown,不影响测活结论。
|
||||||
func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
|
func (s *OciConfigService) applyProfile(ctx context.Context, cfg *model.OciConfig, cred oci.Credentials) {
|
||||||
profile, err := s.client.FetchAccountProfile(ctx, cred)
|
profile, err := s.client.FetchAccountProfile(ctx, cred)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type fakeClient struct {
|
|||||||
tenancyErr error
|
tenancyErr error
|
||||||
profile oci.AccountProfile
|
profile oci.AccountProfile
|
||||||
profileErr error
|
profileErr error
|
||||||
|
caps oci.AccountCapabilities
|
||||||
|
capsErr error
|
||||||
|
|
||||||
regionSubs []oci.RegionSubscription
|
regionSubs []oci.RegionSubscription
|
||||||
regionSubsErr error
|
regionSubsErr error
|
||||||
@@ -43,6 +45,10 @@ type fakeClient struct {
|
|||||||
instancesErr error
|
instancesErr error
|
||||||
costItems []oci.CostItem
|
costItems []oci.CostItem
|
||||||
costErr error
|
costErr error
|
||||||
|
instance oci.Instance
|
||||||
|
instanceErr error
|
||||||
|
image oci.Image
|
||||||
|
imageErr error
|
||||||
|
|
||||||
subscribedHomeRegion string
|
subscribedHomeRegion string
|
||||||
subscribedKey string
|
subscribedKey string
|
||||||
@@ -55,6 +61,10 @@ func (f *fakeClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci
|
|||||||
return f.tenancy, f.tenancyErr
|
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) {
|
func (f *fakeClient) FetchAccountProfile(ctx context.Context, cred oci.Credentials) (oci.AccountProfile, error) {
|
||||||
return f.profile, f.profileErr
|
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
|
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) {
|
func (f *fakeClient) SummarizeCosts(ctx context.Context, cred oci.Credentials, q oci.CostQuery) ([]oci.CostItem, error) {
|
||||||
return f.costItems, f.costErr
|
return f.costItems, f.costErr
|
||||||
}
|
}
|
||||||
@@ -119,6 +137,13 @@ func newTestService(t *testing.T, client oci.Client) *OciConfigService {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open in-memory sqlite: %v", err)
|
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(
|
if err := db.AutoMigrate(
|
||||||
&model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{},
|
&model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -312,6 +337,7 @@ func TestVerifyMissingConfig(t *testing.T) {
|
|||||||
|
|
||||||
func TestDelete(t *testing.T) {
|
func TestDelete(t *testing.T) {
|
||||||
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
|
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
|
||||||
|
migrateTenantDeleteModels(t, svc.db)
|
||||||
cfg, err := svc.Import(context.Background(), trialImportInput())
|
cfg, err := svc.Import(context.Background(), trialImportInput())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Import: %v", err)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user