Compare commits
3
Commits
v0.4.0
..
79c9e4d9b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c9e4d9b9 | ||
|
|
2fea315430 | ||
|
|
b4ef98a25e |
@@ -2,6 +2,23 @@
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||
|
||||
## [0.5.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复外部身份(OAuth2)登录 / 绑定无系统日志的问题:回调成功记 200(用户名为面板账号)、失败记 401(附失败原因);此前回调为 GET 请求不经写操作日志中间件,完全无记录
|
||||
|
||||
## [0.5.0]
|
||||
|
||||
### Added
|
||||
|
||||
- xAI 官方格式文本转语音端点 `POST /ai/v1/tts`:接受 xAI 官方 TTS 请求(`text` / `language` 必填,`voice_id`、`output_format`{codec, sample_rate, bit_rate}、`speed` 等),网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与渠道调度;`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format` 对象与 `speed` 透传生效,xAI SDK / 客户端可直接指向网关
|
||||
- 「过滤弃用模型」开关(`GET` / `PUT /api/v1/ai-settings`,持久化):开启后 OCI 已宣布弃用(即使未到退役日)的模型从模型列表与路由中同时排除,关闭恢复;渠道同步入库与 30 天退役提醒不受影响
|
||||
|
||||
### Changed
|
||||
|
||||
- AI 网关文档更名为 `docs/AI网关.md`(原 `docs/ai-gateway.md`),README 引用同步
|
||||
|
||||
## [0.4.0]
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.4.0
|
||||
v0.5.1
|
||||
|
||||
@@ -206,11 +206,12 @@ server {
|
||||
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
||||
| `POST /ai/v1/audio/speech` | 文本转语音(xAI Voice) | 否 |
|
||||
| `POST /ai/v1/tts` | 文本转语音(xAI 官方格式) | 否 |
|
||||
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank) | 否 |
|
||||
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails) | 否 |
|
||||
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||
|
||||
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](docs/ai-gateway.md)**。
|
||||
协议兼容边界、已知上游限制与逐字段兼容矩阵见 **[AI 网关文档](./docs/AI网关.md)**。
|
||||
|
||||
## API 与配置
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer
|
||||
| `POST /ai/v1/messages` | Anthropic Messages 转换层 | SSE |
|
||||
| `POST /ai/v1/embeddings` | OpenAI Embeddings | 否 |
|
||||
| `POST /ai/v1/audio/speech` | OpenAI Audio Speech,文本转语音(xAI Voice) | 否 |
|
||||
| `POST /ai/v1/tts` | xAI 官方格式文本转语音(同一上游,网关转换) | 否 |
|
||||
| `POST /ai/v1/rerank` | 文档重排(Cohere Rerank,Jina 风格协议) | 否 |
|
||||
| `POST /ai/v1/moderations` | 内容审核(OCI Guardrails:内容审核 / PII / 提示注入) | 否 |
|
||||
| `GET /ai/v1/models` | 当前密钥可见的模型列表 | 否 |
|
||||
@@ -25,6 +26,7 @@ AI 网关使用面板创建的独立密钥鉴权,支持 `Authorization: Bearer
|
||||
- Chat Completions 只承担协议转换与兼容修复;新能力优先在 Responses 和 Messages 提供
|
||||
- 单次请求最多尝试三个渠道;可重试错误会切换渠道,流式响应建立后不会换渠道重试
|
||||
- Audio Speech 直通 OCI 兼容面(模型 `xai.grok-tts`,voice 取 xAI Grok Voice 列表:`ara`/`eve`/`leo`/`rex`/`sal`);上游把 `language` 当必填,缺省时网关自动注入 `"auto"`,xAI 专属参数(`output_format` 等)可平铺在请求体透传;仅单请求返回音频,不提供 WebSocket 流式
|
||||
- `/ai/v1/tts` 为 xAI 官方 TTS 格式(`text`/`language` 必填、`voice_id`、`output_format` 对象)的转换端点:网关转换为 OpenAI 兼容形态后走同一上游与渠道调度,`model` 为网关扩展字段(缺省 `xai.grok-tts`);实测 `output_format`(codec/sample_rate/bit_rate)与 `speed` 透传生效
|
||||
- Rerank 走 OCI typed 面(`cohere.rerank-v4.0-pro` / `-fast`),请求 `{model, query, documents[], top_n?, return_documents?}`,响应 `results[].index` 指向入参下标;无 token 用量口径,调用日志只记时延
|
||||
- Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails:`input` 为字符串或字符串数组(至多 8 条),categories 用 OCI 原生维度 `overall` / `blocklist` / `prompt_injection`(阈值 0.5 判定 `flagged`),PII 命中放扩展字段 `results[].pii`(不参与 flagged);`model` 字段接受但忽略,无模型白名单维度;实测中文人名/手机号识别较弱,英文 PII 识别正常
|
||||
- Responses 的 `input_file` 内容块(file_url / file_data)实测被上游拒绝:`File content is currently unsupported for ZDR customers`——网关强制 `store:false` 属 ZDR 形态,该能力在上游侧不可用
|
||||
@@ -217,6 +219,32 @@ Audio Speech 与 Responses 同为“原始 JSON 直通 + 本地门禁”:除
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/tts</code> 对比 xAI TTS</summary>
|
||||
|
||||
`/ai/v1/tts` 接受 [xAI 官方 TTS 格式](https://docs.x.ai/developers/model-capabilities/audio/text-to-speech)(OCI 无 HTTP 版 xAI 面,网关转换为 OpenAI 兼容形态后与 `/ai/v1/audio/speech` 走同一上游与调度)。
|
||||
|
||||
| 标准字段 | 状态 | 网关行为 |
|
||||
| --- | :---: | --- |
|
||||
| `text` | 🔄 | 必填非空,转换为上游 `input` |
|
||||
| `language` | ◐ | 必填(对齐 xAI 官方;接受 BCP-47 或 `auto`),原样透传 |
|
||||
| `voice_id` | 🔄 | 转换为上游 `voice`;缺省交上游默认(`eve`) |
|
||||
| `output_format` | ➡️ | `{codec, sample_rate, bit_rate}` 对象原样透传,实测生效(44.1kHz/192kbps 验证) |
|
||||
| `speed` | ➡️ | 原样透传,实测接受 |
|
||||
| `model`(网关扩展) | ◐ | xAI 官方无此字段;缺省注入 `xai.grok-tts`,可显式覆盖,受密钥白名单限制 |
|
||||
| `optimize_streaming_latency`、`text_normalization`、`with_timestamps` 及其他未知字段 | ➡️ | 原样透传,是否生效由上游决定 |
|
||||
|
||||
不支持:流式音频输出(xAI 官方 `optimize_streaming_latency` 面向流式场景,本端点响应恒为一次性完整音频);WebSocket 流式(OCI 另有 `wss://…/xai/v1/tts` 私有协议面,网关未代理)。
|
||||
|
||||
响应边界:
|
||||
|
||||
- 成功响应为音频字节,`Content-Type` 透传上游(缺省 `audio/mpeg`)
|
||||
- 无 token 用量口径,调用日志只记时延与渠道(端点名 `tts`)
|
||||
|
||||
实现依据:[`service/aigateway_extras.go`](../internal/service/aigateway_extras.go) · [`api/aigateway_extras.go`](../internal/api/aigateway_extras.go)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>POST /ai/v1/rerank</code> 对比 Cohere Rerank</summary>
|
||||
|
||||
@@ -263,4 +291,4 @@ Moderations 是 OpenAI moderations 外壳映射 OCI Guardrails(内容审核 /
|
||||
|
||||
</details>
|
||||
|
||||
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。
|
||||
`GET /ai/v1/models` 使用 OpenAI Models 列表外壳(`object`、`data[].id/object/created/owned_by`),但只返回当前渠道目录中通过分组、全局黑名单和密钥白名单筛选后的模型;网关不提供标准的单模型检索端点。面板「过滤弃用模型」开关开启时,OCI 已宣布弃用(即使未到退役日)的模型同时从模型列表与路由中排除,关闭后恢复。
|
||||
+123
@@ -220,6 +220,33 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ai/v1/tts": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AI 网关"
|
||||
],
|
||||
"summary": "xAI 官方格式文本转语音端点",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/oci-portal_internal_aiwire.TtsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "音频字节(Content-Type 透传上游,默认 audio/mpeg)",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/about": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -705,6 +732,57 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-settings": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "AI 网关全局设置",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "更新 AI 网关全局设置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/credentials": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -5893,6 +5971,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.aiSettingsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filterDeprecated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.attachBootVolumeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7689,6 +7775,43 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_aiwire.TtsOutputFormat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bit_rate": {
|
||||
"type": "integer"
|
||||
},
|
||||
"codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_rate": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_aiwire.TtsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"output_format": {
|
||||
"$ref": "#/definitions/oci-portal_internal_aiwire.TtsOutputFormat"
|
||||
},
|
||||
"speed": {
|
||||
"type": "number"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_aiwire.Usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -213,6 +213,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ai/v1/tts": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AI 网关"
|
||||
],
|
||||
"summary": "xAI 官方格式文本转语音端点",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/oci-portal_internal_aiwire.TtsRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "音频字节(Content-Type 透传上游,默认 audio/mpeg)",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/about": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -698,6 +725,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/ai-settings": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "AI 网关全局设置",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI 管理"
|
||||
],
|
||||
"summary": "更新 AI 网关全局设置",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api.aiSettingsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/credentials": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -5886,6 +5964,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.aiSettingsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filterDeprecated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_api.attachBootVolumeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7682,6 +7768,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_aiwire.TtsOutputFormat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bit_rate": {
|
||||
"type": "integer"
|
||||
},
|
||||
"codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_rate": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_aiwire.TtsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"output_format": {
|
||||
"$ref": "#/definitions/oci-portal_internal_aiwire.TtsOutputFormat"
|
||||
},
|
||||
"speed": {
|
||||
"type": "number"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oci-portal_internal_aiwire.Usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -46,6 +46,11 @@ definitions:
|
||||
key:
|
||||
type: string
|
||||
type: object
|
||||
internal_api.aiSettingsResponse:
|
||||
properties:
|
||||
filterDeprecated:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_api.attachBootVolumeRequest:
|
||||
properties:
|
||||
bootVolumeId:
|
||||
@@ -1219,6 +1224,30 @@ definitions:
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
oci-portal_internal_aiwire.TtsOutputFormat:
|
||||
properties:
|
||||
bit_rate:
|
||||
type: integer
|
||||
codec:
|
||||
type: string
|
||||
sample_rate:
|
||||
type: integer
|
||||
type: object
|
||||
oci-portal_internal_aiwire.TtsRequest:
|
||||
properties:
|
||||
language:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
output_format:
|
||||
$ref: '#/definitions/oci-portal_internal_aiwire.TtsOutputFormat'
|
||||
speed:
|
||||
type: number
|
||||
text:
|
||||
type: string
|
||||
voice_id:
|
||||
type: string
|
||||
type: object
|
||||
oci-portal_internal_aiwire.Usage:
|
||||
properties:
|
||||
completion_tokens:
|
||||
@@ -2752,6 +2781,23 @@ paths:
|
||||
summary: OpenAI Responses 兼容端点
|
||||
tags:
|
||||
- AI 网关
|
||||
/ai/v1/tts:
|
||||
post:
|
||||
parameters:
|
||||
- description: xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/oci-portal_internal_aiwire.TtsRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: 音频字节(Content-Type 透传上游,默认 audio/mpeg)
|
||||
schema:
|
||||
type: file
|
||||
summary: xAI 官方格式文本转语音端点
|
||||
tags:
|
||||
- AI 网关
|
||||
/api/v1/about:
|
||||
get:
|
||||
responses:
|
||||
@@ -3040,6 +3086,36 @@ paths:
|
||||
summary: '---- 聚合模型与调用日志 ----'
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/ai-settings:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: AI 网关全局设置
|
||||
tags:
|
||||
- AI 管理
|
||||
put:
|
||||
parameters:
|
||||
- description: 开启后已宣布弃用(即使未退役)的模型从列表与路由中排除
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api.aiSettingsResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 更新 AI 网关全局设置
|
||||
tags:
|
||||
- AI 管理
|
||||
/api/v1/auth/credentials:
|
||||
get:
|
||||
responses:
|
||||
|
||||
@@ -43,3 +43,21 @@ type SpeechRequest struct {
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// TtsRequest 描述 /ai/v1/tts 请求体的已知字段(xAI 官方 TTS 格式,文档用途)。
|
||||
// model 为网关扩展字段(缺省 xai.grok-tts);未列字段原样透传上游。
|
||||
type TtsRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language"`
|
||||
VoiceID string `json:"voice_id,omitempty"`
|
||||
OutputFormat *TtsOutputFormat `json:"output_format,omitempty"`
|
||||
Speed float64 `json:"speed,omitempty"`
|
||||
}
|
||||
|
||||
// TtsOutputFormat 是 xAI TTS 输出格式配置(缺省 MP3 24kHz/128kbps)。
|
||||
type TtsOutputFormat struct {
|
||||
Codec string `json:"codec,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
BitRate int `json:"bit_rate,omitempty"`
|
||||
}
|
||||
|
||||
@@ -372,3 +372,40 @@ func aiPathID(c *gin.Context) (uint, bool) {
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// aiSettingsResponse 是 AI 网关全局设置(文档与响应共用)。
|
||||
type aiSettingsResponse struct {
|
||||
FilterDeprecated bool `json:"filterDeprecated"`
|
||||
}
|
||||
|
||||
// aiSettings 返回 AI 网关全局设置。
|
||||
//
|
||||
// @Summary AI 网关全局设置
|
||||
// @Tags AI 管理
|
||||
// @Success 200 {object} aiSettingsResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-settings [get]
|
||||
func (h *aiAdminHandler) aiSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||
}
|
||||
|
||||
// updateAiSettings 更新 AI 网关全局设置(当前仅「过滤弃用模型」开关)。
|
||||
//
|
||||
// @Summary 更新 AI 网关全局设置
|
||||
// @Tags AI 管理
|
||||
// @Param body body aiSettingsResponse true "开启后已宣布弃用(即使未退役)的模型从列表与路由中排除"
|
||||
// @Success 200 {object} aiSettingsResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-settings [put]
|
||||
func (h *aiAdminHandler) updateAiSettings(c *gin.Context) {
|
||||
var req aiSettingsResponse
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.gw.SetFilterDeprecated(c.Request.Context(), req.FilterDeprecated); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, aiSettingsResponse{FilterDeprecated: h.gw.FilterDeprecated()})
|
||||
}
|
||||
|
||||
@@ -29,12 +29,38 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
h.speechRespond(c, "speech", modelName, raw, body)
|
||||
}
|
||||
|
||||
// tts 是 xAI 官方格式 TTS 端点(/ai/v1/tts),转换为上游 OpenAI 兼容形态后复用 Speech 编排。
|
||||
//
|
||||
// @Summary xAI 官方格式文本转语音端点
|
||||
// @Tags AI 网关
|
||||
// @Param body body aiwire.TtsRequest true "xAI TTS 请求体(text/language 必填,voice_id 缺省 eve;model 为网关扩展,缺省 xai.grok-tts;未列字段原样透传)"
|
||||
// @Success 200 {file} binary "音频字节(Content-Type 透传上游,默认 audio/mpeg)"
|
||||
// @Router /ai/v1/tts [post]
|
||||
func (h *aiGatewayHandler) tts(c *gin.Context) {
|
||||
raw, err := c.GetRawData()
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
modelName, body, err := service.TtsBodyConvert(raw)
|
||||
if err != nil {
|
||||
aiError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
h.speechRespond(c, "tts", modelName, raw, body)
|
||||
}
|
||||
|
||||
// speechRespond 是 audioSpeech / tts 的公共主体:白名单校验、上游调用、日志与音频响应。
|
||||
func (h *aiGatewayHandler) speechRespond(c *gin.Context, endpoint, modelName string, raw, body []byte) {
|
||||
if !checkKeyModel(c, modelName) {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
audio, contentType, meta, err := h.gw.Speech(c.Request.Context(), modelName, body, keyGroup(c))
|
||||
entry := h.logEntry(c, "speech", modelName, false, meta, start)
|
||||
entry := h.logEntry(c, endpoint, modelName, false, meta, start)
|
||||
if err != nil {
|
||||
upstreamError(c, err)
|
||||
entry.ErrMsg = err.Error()
|
||||
@@ -43,7 +69,7 @@ func (h *aiGatewayHandler) audioSpeech(c *gin.Context) {
|
||||
}
|
||||
entry.Status = http.StatusOK
|
||||
callID := h.gw.LogCall(entry)
|
||||
h.maybeLogContent(c, callID, "speech", modelName, false, string(raw), nil)
|
||||
h.maybeLogContent(c, callID, endpoint, modelName, false, string(raw), nil)
|
||||
if contentType == "" {
|
||||
contentType = "audio/mpeg"
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||
`{"model":"ghost-model","query":"q"}`, 400, []string{"invalid_request_error"}},
|
||||
{"moderations 空 input 拒绝", "open-key-12345", "/ai/v1/moderations",
|
||||
`{"input":[]}`, 400, []string{"invalid_request_error"}},
|
||||
{"tts 缺 language 拒绝", "open-key-12345", "/ai/v1/tts",
|
||||
`{"text":"你好"}`, 400, []string{"invalid_request_error"}},
|
||||
{"tts 白名单外拦截", "limited-key-1234", "/ai/v1/tts",
|
||||
`{"model":"meta.llama-3.3-70b-instruct","text":"你好","language":"zh"}`, 404, deny},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
+25
-2
@@ -5,10 +5,11 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
_ "oci-portal/internal/model" // swagger 注解引用
|
||||
"oci-portal/internal/model"
|
||||
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
type authxHandler struct {
|
||||
auth *service.AuthService
|
||||
oauth *service.OAuthService
|
||||
logs *service.SystemLogService
|
||||
}
|
||||
|
||||
// ---- TOTP(JWT 组内) ----
|
||||
@@ -301,10 +303,12 @@ func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) {
|
||||
// @Success 302 "登录 token 经 fragment 回前端,绑定回设置页"
|
||||
// @Router /api/v1/auth/oauth/{provider}/callback [get]
|
||||
func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
start := time.Now()
|
||||
provider := c.Param("provider")
|
||||
token, _, mode, err := h.oauth.HandleCallback(
|
||||
token, username, mode, err := h.oauth.HandleCallback(
|
||||
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
||||
if err != nil {
|
||||
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
||||
target := "/login"
|
||||
if mode == "bind" {
|
||||
target = "/settings"
|
||||
@@ -312,6 +316,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err)))
|
||||
return
|
||||
}
|
||||
h.recordOauth(c, username, http.StatusOK, "", start)
|
||||
if mode == "bind" {
|
||||
// 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发
|
||||
c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token))
|
||||
@@ -321,6 +326,24 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// recordOauth 把外部登录 / 绑定结果记入系统日志——回调是 GET,
|
||||
// 不经写方法中间件;实际响应恒为 302,日志按语义记 200 / 401。
|
||||
func (h *authxHandler) recordOauth(c *gin.Context, username string, status int, errMsg string, start time.Time) {
|
||||
if h.logs == nil {
|
||||
return
|
||||
}
|
||||
h.logs.Record(model.SystemLog{
|
||||
Username: username,
|
||||
Method: c.Request.Method,
|
||||
Path: requestPath(c),
|
||||
Status: status,
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||
ErrMsg: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||
func oauthErrText(err error) string {
|
||||
for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} {
|
||||
|
||||
@@ -16,6 +16,7 @@ func registerAiGateway(r *gin.Engine, aiGateway *service.AiGatewayService) {
|
||||
ai.POST("/messages", aih.messages)
|
||||
ai.POST("/embeddings", aih.embeddings)
|
||||
ai.POST("/audio/speech", aih.audioSpeech)
|
||||
ai.POST("/tts", aih.tts)
|
||||
ai.POST("/rerank", aih.rerank)
|
||||
ai.POST("/moderations", aih.moderations)
|
||||
ai.GET("/models", aih.listModels)
|
||||
@@ -36,6 +37,8 @@ func registerAiAdmin(secured *gin.RouterGroup, aiGateway *service.AiGatewayServi
|
||||
secured.POST("/ai-channels/:id/probe", aiadmin.probeChannel)
|
||||
secured.POST("/ai-channels/:id/sync-models", aiadmin.syncChannelModels)
|
||||
secured.GET("/ai-models", aiadmin.gatewayModels)
|
||||
secured.GET("/ai-settings", aiadmin.aiSettings)
|
||||
secured.PUT("/ai-settings", aiadmin.updateAiSettings)
|
||||
secured.GET("/ai-blacklist", aiadmin.listBlacklist)
|
||||
secured.POST("/ai-blacklist", aiadmin.addBlacklist)
|
||||
secured.DELETE("/ai-blacklist/:id", aiadmin.removeBlacklist)
|
||||
|
||||
@@ -12,7 +12,7 @@ func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *s
|
||||
v1.POST("/auth/login", ah.login)
|
||||
|
||||
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
|
||||
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -58,11 +59,39 @@ type AiGatewayService struct {
|
||||
lastTouch map[uint]time.Time
|
||||
// onChannelsChanged 在渠道增删后触发,由 main 装配为探测任务同步钩子
|
||||
onChannelsChanged func(context.Context)
|
||||
// filterDeprecated 是「过滤弃用模型」开关(内存镜像,持久化在 settings 表)
|
||||
filterDeprecated atomic.Bool
|
||||
}
|
||||
|
||||
// NewAiGatewayService 组装依赖;调用 StartCleanup 后开始调用日志周期清理。
|
||||
func NewAiGatewayService(db *gorm.DB, configs *OciConfigService, client oci.Client) *AiGatewayService {
|
||||
return &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||
s := &AiGatewayService{db: db, configs: configs, client: client, lastTouch: map[uint]time.Time{}}
|
||||
var row model.Setting
|
||||
if err := db.Where("key = ?", settingAiFilterDeprecated).First(&row).Error; err == nil {
|
||||
s.filterDeprecated.Store(row.Value == "1")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// settingAiFilterDeprecated 是「过滤弃用模型」开关的配置键,值 "1"/"0",缺省关闭。
|
||||
const settingAiFilterDeprecated = "ai_filter_deprecated"
|
||||
|
||||
// FilterDeprecated 返回「过滤弃用模型」开关状态。
|
||||
func (s *AiGatewayService) FilterDeprecated() bool { return s.filterDeprecated.Load() }
|
||||
|
||||
// SetFilterDeprecated 持久化并即时生效开关:开启后已宣布弃用
|
||||
// (deprecated_at 非空,即使未退役)的模型从列表与路由中排除。
|
||||
func (s *AiGatewayService) SetFilterDeprecated(ctx context.Context, on bool) error {
|
||||
value := "0"
|
||||
if on {
|
||||
value = "1"
|
||||
}
|
||||
err := s.db.WithContext(ctx).Save(&model.Setting{Key: settingAiFilterDeprecated, Value: value}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存过滤弃用模型开关: %w", err)
|
||||
}
|
||||
s.filterDeprecated.Store(on)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnChannelsChanged 注册渠道数量变化钩子(渠道创建/删除成功后调用)。
|
||||
@@ -511,6 +540,9 @@ func (s *AiGatewayService) GatewayModels(ctx context.Context, group string) (aiw
|
||||
if group != "" {
|
||||
q = q.Where("ai_channels.channel_group = ?", group)
|
||||
}
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("ai_model_caches.deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
err := q.Order("ai_model_caches.name ASC").Find(&rows).Error
|
||||
list := aiwire.ModelList{Object: "list", Data: []aiwire.Model{}}
|
||||
|
||||
@@ -164,6 +164,9 @@ func (s *AiGatewayService) modelChannels(ctx context.Context, modelName, capabil
|
||||
} else {
|
||||
q = q.Where("capability = ?", capability)
|
||||
}
|
||||
if s.FilterDeprecated() {
|
||||
q = q.Where("deprecated_at IS NULL")
|
||||
}
|
||||
var rows []model.AiModelCache
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -40,6 +40,42 @@ func SpeechBodyNormalize(raw []byte) (string, []byte, error) {
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// ttsDefaultModel 是 /ai/v1/tts 缺省注入的模型(xAI 官方格式无 model 字段)。
|
||||
const ttsDefaultModel = "xai.grok-tts"
|
||||
|
||||
// TtsBodyConvert 把 xAI 官方 TTS 格式转换为上游 OpenAI 兼容 audio/speech 形态:
|
||||
// text→input、voice_id→voice,text 与 language 必填(对齐 xAI 官方);
|
||||
// model 为网关扩展字段,缺省注入 ttsDefaultModel;其余字段原样保留。
|
||||
func TtsBodyConvert(raw []byte) (string, []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)
|
||||
}
|
||||
text, _ := body["text"].(string)
|
||||
language, _ := body["language"].(string)
|
||||
if strings.TrimSpace(text) == "" || strings.TrimSpace(language) == "" {
|
||||
return "", nil, fmt.Errorf("text 与 language 不能为空")
|
||||
}
|
||||
modelName, _ := body["model"].(string)
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = ttsDefaultModel
|
||||
body["model"] = modelName
|
||||
}
|
||||
delete(body, "text")
|
||||
body["input"] = text
|
||||
if voice, ok := body["voice_id"].(string); ok && strings.TrimSpace(voice) != "" {
|
||||
body["voice"] = voice
|
||||
}
|
||||
delete(body, "voice_id")
|
||||
out, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("重组请求体: %w", err)
|
||||
}
|
||||
return modelName, out, nil
|
||||
}
|
||||
|
||||
// Speech 编排 TTS 调用:按 TTS 能力选渠道,可重试错误换渠道(上限 3 次)。
|
||||
func (s *AiGatewayService) Speech(ctx context.Context, modelName string, body []byte, group string) ([]byte, string, ChatMeta, error) {
|
||||
meta := ChatMeta{}
|
||||
|
||||
@@ -45,6 +45,64 @@ func TestSpeechBodyNormalize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTtsBodyConvert 断言 xAI 官方 TTS 格式到 OpenAI 兼容形态的转换。
|
||||
func TestTtsBodyConvert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
wantModel string
|
||||
}{
|
||||
{"缺 text 拒绝", `{"language":"zh"}`, true, ""},
|
||||
{"缺 language 拒绝", `{"text":"你好"}`, true, ""},
|
||||
{"缺省注入默认模型", `{"text":"你好","language":"zh"}`, false, "xai.grok-tts"},
|
||||
{"model 扩展字段可覆盖", `{"model":"xai.other-tts","text":"你好","language":"auto"}`, false, "xai.other-tts"},
|
||||
{"非 JSON 拒绝", `<html>`, true, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
modelName, _, err := TtsBodyConvert([]byte(tt.raw))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if modelName != tt.wantModel {
|
||||
t.Fatalf("model = %s, want %s", modelName, tt.wantModel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTtsBodyConvertMapping 断言字段映射与未知字段保留。
|
||||
func TestTtsBodyConvertMapping(t *testing.T) {
|
||||
raw := `{"text":"你好","language":"zh","voice_id":"ara","speed":1.2,` +
|
||||
`"output_format":{"codec":"mp3","sample_rate":44100}}`
|
||||
_, body, err := TtsBodyConvert([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(body, &out)
|
||||
if out["input"] != "你好" || out["voice"] != "ara" {
|
||||
t.Fatalf("input/voice 映射错误: %v", out)
|
||||
}
|
||||
if _, ok := out["text"]; ok {
|
||||
t.Fatal("text 字段应被移除")
|
||||
}
|
||||
if _, ok := out["voice_id"]; ok {
|
||||
t.Fatal("voice_id 字段应被移除")
|
||||
}
|
||||
of, _ := out["output_format"].(map[string]any)
|
||||
if of == nil || of["codec"] != "mp3" {
|
||||
t.Fatalf("output_format 应原样保留: %v", out["output_format"])
|
||||
}
|
||||
if out["language"] != "zh" || out["speed"] == nil {
|
||||
t.Fatalf("language/speed 应保留: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModerationInputs 断言 input 的 string / []string 解析与边界校验。
|
||||
func TestModerationInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -136,7 +136,7 @@ func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credent
|
||||
func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
|
||||
t.Helper()
|
||||
svc := newTestService(t, client)
|
||||
if err := svc.db.AutoMigrate(&model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||
if err := svc.db.AutoMigrate(&model.Setting{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
|
||||
t.Fatalf("auto migrate ai tables: %v", err)
|
||||
}
|
||||
return NewAiGatewayService(svc.db, svc, client), svc
|
||||
@@ -228,6 +228,46 @@ func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string,
|
||||
return ch
|
||||
}
|
||||
|
||||
// TestFilterDeprecatedModels 断言「过滤弃用模型」开关对列表 / 路由的过滤与持久化。
|
||||
func TestFilterDeprecatedModels(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
|
||||
dep := time.Now().Add(-24 * time.Hour)
|
||||
old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
|
||||
Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
|
||||
if err := gw.db.Create(old).Error; err != nil {
|
||||
t.Fatalf("seed deprecated cache: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
list, _ := gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 2 {
|
||||
t.Fatalf("开关关:应含弃用模型,got %d", len(list.Data))
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, true); err != nil {
|
||||
t.Fatalf("SetFilterDeprecated: %v", err)
|
||||
}
|
||||
list, _ = gw.GatewayModels(ctx, "")
|
||||
if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
|
||||
t.Fatalf("开关开:弃用模型应被过滤,got %+v", list.Data)
|
||||
}
|
||||
if _, err := gw.pick(ctx, "meta.llama-old", "", "CHAT", map[uint]bool{}); !errors.Is(err, ErrAiUnknownModel) {
|
||||
t.Errorf("开关开:弃用模型路由应不可达,err = %v", err)
|
||||
}
|
||||
// 持久化:重建 service 后开关仍生效
|
||||
gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
|
||||
if !gw2.FilterDeprecated() {
|
||||
t.Error("重建后开关状态应保持开启")
|
||||
}
|
||||
if err := gw.SetFilterDeprecated(ctx, false); err != nil {
|
||||
t.Fatalf("关闭开关: %v", err)
|
||||
}
|
||||
if list, _ = gw.GatewayModels(ctx, ""); len(list.Data) != 2 {
|
||||
t.Errorf("开关关:弃用模型应恢复,got %d", len(list.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPriorityAndBreaker(t *testing.T) {
|
||||
gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
|
||||
cfg := importAliveConfig(t, svc)
|
||||
|
||||
@@ -187,7 +187,7 @@ type externalIdentity struct {
|
||||
|
||||
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
||||
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, display, mode string, err error) {
|
||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, username, mode string, err error) {
|
||||
p, err := o.takeState(provider, state)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
@@ -198,14 +198,14 @@ func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code
|
||||
}
|
||||
if p.mode == "bind" {
|
||||
if err := o.bind(ctx, p.username, provider, ident); err != nil {
|
||||
return "", ident.Display, p.mode, err
|
||||
return "", p.username, p.mode, err
|
||||
}
|
||||
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
|
||||
token, _, err := o.auth.RevokeSessions(ctx, p.username)
|
||||
return token, ident.Display, p.mode, err
|
||||
return token, p.username, p.mode, err
|
||||
}
|
||||
token, display, err = o.loginByIdentity(ctx, provider, ident)
|
||||
return token, display, p.mode, err
|
||||
token, username, err = o.loginByIdentity(ctx, provider, ident)
|
||||
return token, username, p.mode, err
|
||||
}
|
||||
|
||||
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
||||
@@ -311,7 +311,7 @@ func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ide
|
||||
return "", "", fmt.Errorf("find bound user: %w", err)
|
||||
}
|
||||
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
|
||||
return token, ident.Display, err
|
||||
return token, user.Username, err
|
||||
}
|
||||
|
||||
// Identities 列出账号已绑定的外部身份。
|
||||
|
||||
@@ -169,12 +169,12 @@ func TestOAuthBindLoginUnbind(t *testing.T) {
|
||||
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
|
||||
}
|
||||
// 已绑定身份可登录并拿到有效 JWT
|
||||
token, display, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
||||
token, loginUser, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
||||
if err != nil || token == "" {
|
||||
t.Fatalf("loginByIdentity: %v", err)
|
||||
}
|
||||
if display != "octocat" {
|
||||
t.Errorf("display = %q", display)
|
||||
if loginUser != "admin" {
|
||||
t.Errorf("loginUser = %q, want admin", loginUser)
|
||||
}
|
||||
if username, err := auth.ParseToken(context.Background(), token); err != nil || username != "admin" {
|
||||
t.Errorf("token 应属 admin, got %q (%v)", username, err)
|
||||
|
||||
Reference in New Issue
Block a user