mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to v0.30.0
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# 开放平台概述
|
||||
|
||||
ProxyCast 开放平台为开发者和服务商提供扩展能力,包括插件系统和中转商生态合作方案。
|
||||
|
||||
## 平台能力
|
||||
|
||||
### 🔌 插件系统
|
||||
|
||||
通过插件扩展 ProxyCast 功能:
|
||||
|
||||
- **脚本插件** - JavaScript/TypeScript 插件,通过 Hook 机制扩展
|
||||
- **二进制插件** - 独立可执行文件,适合系统级操作
|
||||
- **工具插件** - 在工具箱中显示的独立工具
|
||||
|
||||
[了解更多 →](/open-platform/plugins)
|
||||
|
||||
### 🔗 ProxyCast Connect
|
||||
|
||||
中转商生态合作方案,实现一键配置:
|
||||
|
||||
- **一键配置** - 用户点击链接即可完成配置
|
||||
- **品牌展示** - 中转商在 ProxyCast 内有专属展示位
|
||||
- **统计回调** - 追踪推广效果
|
||||
|
||||
[了解更多 →](/open-platform/connect)
|
||||
|
||||
## 核心价值
|
||||
|
||||
| 角色 | 价值 |
|
||||
|------|------|
|
||||
| **开发者** | 扩展功能、定制工具、集成服务 |
|
||||
| **中转商** | 用户转化率提升、品牌曝光、差异化竞争 |
|
||||
| **用户** | 一键配置、开箱即用、统一管理 |
|
||||
| **ProxyCast** | 生态繁荣、用户增长 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<a href="/open-platform/plugins" class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-primary-500 transition-colors">
|
||||
<h3 class="font-semibold mb-2">🔌 开发插件</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">创建自定义插件扩展 ProxyCast 功能</p>
|
||||
</a>
|
||||
<a href="/open-platform/connect" class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-primary-500 transition-colors">
|
||||
<h3 class="font-semibold mb-2">🔗 接入 Connect</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">中转商接入一键配置功能</p>
|
||||
</a>
|
||||
<a href="/open-platform/plugin-development" class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-primary-500 transition-colors">
|
||||
<h3 class="font-semibold mb-2">📖 插件开发指南</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">详细的插件开发文档和规范</p>
|
||||
</a>
|
||||
<a href="/open-platform/connect-integration" class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-primary-500 transition-colors">
|
||||
<h3 class="font-semibold mb-2">📖 Connect 接入指南</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">中转商接入的详细步骤</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 相关仓库
|
||||
|
||||
| 仓库 | 说明 |
|
||||
|------|------|
|
||||
| [proxycast](https://github.com/aiclientproxy/proxycast) | ProxyCast 主项目 |
|
||||
| [connect](https://github.com/aiclientproxy/connect) | 中转商注册仓库 |
|
||||
| [MachineIdTool](https://github.com/aiclientproxy/MachineIdTool) | 插件示例项目 |
|
||||
@@ -0,0 +1,112 @@
|
||||
# 插件中心
|
||||
|
||||
ProxyCast 支持通过插件扩展功能。插件中心提供插件的安装、管理和配置。
|
||||
|
||||
## 访问插件中心
|
||||
|
||||
点击左侧导航栏的「插件中心」进入插件管理页面。
|
||||
|
||||
## 功能概览
|
||||
|
||||
### 推荐插件
|
||||
|
||||
插件中心会显示推荐的插件列表,点击「一键安装」即可快速安装。
|
||||
|
||||
### 已安装插件
|
||||
|
||||
显示所有已安装的插件,包括:
|
||||
- 插件名称和版本
|
||||
- 安装来源(本地/URL/GitHub)
|
||||
- 启用/禁用状态
|
||||
- 卸载按钮
|
||||
|
||||
### 已加载插件
|
||||
|
||||
显示当前运行中的插件状态:
|
||||
- 执行次数
|
||||
- 错误次数
|
||||
- 最后执行时间
|
||||
|
||||
## 安装插件
|
||||
|
||||
### 方式一:推荐插件一键安装
|
||||
|
||||
1. 在「推荐插件」区域找到想要的插件
|
||||
2. 点击「一键安装」
|
||||
3. 等待下载和安装完成
|
||||
|
||||
### 方式二:从 URL 安装
|
||||
|
||||
1. 点击「安装插件」按钮
|
||||
2. 输入插件 ZIP 包的下载 URL
|
||||
3. 点击「安装」
|
||||
|
||||
支持的 URL 格式:
|
||||
- GitHub Release: `https://github.com/org/repo/releases/latest/download/plugin.zip`
|
||||
- 直接下载链接: `https://example.com/plugin.zip`
|
||||
|
||||
### 方式三:从本地文件安装
|
||||
|
||||
1. 点击「安装插件」按钮
|
||||
2. 点击「选择文件」
|
||||
3. 选择本地的 `.zip` 文件
|
||||
4. 点击「安装」
|
||||
|
||||
## 使用插件
|
||||
|
||||
安装完成后,插件会根据类型出现在不同位置:
|
||||
|
||||
### 工具类插件
|
||||
|
||||
工具类插件会出现在「工具箱」页面:
|
||||
|
||||
1. 点击左侧导航栏的「工具」
|
||||
2. 在工具列表中找到已安装的插件
|
||||
3. 点击「打开工具」使用
|
||||
|
||||
### 其他类型插件
|
||||
|
||||
- **Hook 插件**: 自动在请求/响应时执行
|
||||
- **侧边栏插件**: 出现在主侧边栏(规划中)
|
||||
|
||||
## 管理插件
|
||||
|
||||
### 启用/禁用
|
||||
|
||||
在已加载插件列表中,点击电源图标可以启用或禁用插件。
|
||||
|
||||
### 卸载
|
||||
|
||||
1. 在「已安装插件包」列表中找到要卸载的插件
|
||||
2. 点击红色的删除按钮
|
||||
3. 确认卸载
|
||||
|
||||
卸载会删除插件文件和配置,但不会删除插件产生的数据。
|
||||
|
||||
## 二进制组件
|
||||
|
||||
部分功能需要安装额外的二进制组件:
|
||||
|
||||
- **aster-server**: AI Agent 框架,提供 Agent 对话能力
|
||||
|
||||
在「二进制组件」区域可以查看和管理这些组件。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 插件安装失败
|
||||
|
||||
1. 检查网络连接
|
||||
2. 确认 URL 正确且可访问
|
||||
3. 检查 ZIP 包格式是否正确
|
||||
|
||||
### 插件无法加载
|
||||
|
||||
1. 检查 ProxyCast 版本是否满足插件要求
|
||||
2. 查看日志了解详细错误信息
|
||||
3. 尝试重新安装插件
|
||||
|
||||
### 二进制插件权限问题
|
||||
|
||||
部分二进制插件需要管理员权限:
|
||||
- **Windows**: 以管理员身份运行 ProxyCast
|
||||
- **macOS/Linux**: 插件会提示需要的权限
|
||||
@@ -0,0 +1,219 @@
|
||||
# 插件开发指南
|
||||
|
||||
本文档描述 ProxyCast 插件系统的规范和开发指南。
|
||||
|
||||
## 插件类型
|
||||
|
||||
ProxyCast 支持两种类型的插件:
|
||||
|
||||
### 1. 脚本插件 (Script Plugin)
|
||||
|
||||
纯 JavaScript/TypeScript 插件,通过 Hook 机制扩展功能。
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_type": "script",
|
||||
"entry": "main.js",
|
||||
"hooks": ["on_request", "on_response"]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 二进制插件 (Binary Plugin)
|
||||
|
||||
独立的可执行文件,通过 CLI 接口与 ProxyCast 通信。适合需要系统级操作的工具。
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_type": "binary",
|
||||
"entry": "my-tool-cli",
|
||||
"binary": {
|
||||
"binary_name": "my-tool-cli",
|
||||
"github_owner": "your-org",
|
||||
"github_repo": "your-repo",
|
||||
"platform_binaries": {
|
||||
"macos-arm64": "my-tool-aarch64-apple-darwin",
|
||||
"macos-x64": "my-tool-x86_64-apple-darwin",
|
||||
"linux-x64": "my-tool-x86_64-unknown-linux-gnu",
|
||||
"linux-arm64": "my-tool-aarch64-unknown-linux-gnu",
|
||||
"windows-x64": "my-tool-x86_64-pc-windows-msvc.exe"
|
||||
},
|
||||
"checksum_file": "checksums.txt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 插件包结构
|
||||
|
||||
插件以 ZIP 包形式分发,包含以下文件:
|
||||
|
||||
```
|
||||
my-plugin.zip
|
||||
├── plugin.json # 插件元数据(必需)
|
||||
└── config.json # 默认配置(可选)
|
||||
```
|
||||
|
||||
### plugin.json 规范
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "插件描述",
|
||||
"author": "作者名",
|
||||
"homepage": "https://github.com/org/repo",
|
||||
"license": "MIT",
|
||||
"plugin_type": "binary",
|
||||
"entry": "my-tool-cli",
|
||||
"hooks": [],
|
||||
"min_proxycast_version": "1.0.0",
|
||||
"binary": { ... },
|
||||
"ui": {
|
||||
"surfaces": ["tools"],
|
||||
"icon": "Cpu",
|
||||
"title": "我的工具",
|
||||
"default_width": 800,
|
||||
"default_height": 600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | ✅ | 插件唯一标识符,小写字母和连字符 |
|
||||
| `version` | string | ✅ | 语义化版本号 (semver) |
|
||||
| `description` | string | ✅ | 插件描述 |
|
||||
| `author` | string | ❌ | 作者名称 |
|
||||
| `homepage` | string | ❌ | 项目主页 URL |
|
||||
| `license` | string | ❌ | 开源许可证 |
|
||||
| `plugin_type` | string | ✅ | `script` 或 `binary` |
|
||||
| `entry` | string | ✅ | 入口文件/二进制名称 |
|
||||
| `hooks` | array | ❌ | 注册的 Hook 列表 |
|
||||
| `min_proxycast_version` | string | ❌ | 最低 ProxyCast 版本要求 |
|
||||
| `binary` | object | ❌ | 二进制插件配置 |
|
||||
| `ui` | object | ❌ | UI 配置 |
|
||||
|
||||
## UI 展示位置 (Surfaces)
|
||||
|
||||
插件可以在以下位置显示 UI:
|
||||
|
||||
| Surface | 说明 | 入口位置 |
|
||||
|---------|------|----------|
|
||||
| `tools` | 工具箱 | 导航栏「工具」页面 |
|
||||
| `sidebar` | 侧边栏 | 主侧边栏(规划中) |
|
||||
| `settings` | 设置页 | 设置页扩展区域(规划中) |
|
||||
|
||||
### 示例:工具类插件
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"surfaces": ["tools"],
|
||||
"icon": "Cpu",
|
||||
"title": "机器码管理工具"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 图标规范
|
||||
|
||||
使用 [Lucide Icons](https://lucide.dev/icons/) 图标名称:
|
||||
|
||||
常用图标:
|
||||
- `Cpu` - 系统/硬件工具
|
||||
- `Globe` - 网络工具
|
||||
- `Database` - 数据工具
|
||||
- `Shield` - 安全工具
|
||||
- `Wrench` - 通用工具
|
||||
- `Terminal` - 命令行工具
|
||||
|
||||
## 二进制插件 CLI 接口规范
|
||||
|
||||
二进制插件通过 CLI 与 ProxyCast 通信,必须遵循以下规范:
|
||||
|
||||
### 输出格式
|
||||
|
||||
所有输出必须是 JSON 格式:
|
||||
|
||||
```bash
|
||||
# 成功
|
||||
$ my-tool-cli get
|
||||
{"machine_id": "550e8400-e29b-41d4-a716-446655440000"}
|
||||
|
||||
# 错误
|
||||
$ my-tool-cli invalid-command
|
||||
{"error": "未知命令: invalid-command"}
|
||||
```
|
||||
|
||||
### 退出码
|
||||
|
||||
- `0` - 成功
|
||||
- `1` - 错误
|
||||
|
||||
### 命令结构
|
||||
|
||||
```bash
|
||||
my-tool-cli <command> [arguments]
|
||||
```
|
||||
|
||||
建议实现 `help` 命令:
|
||||
|
||||
```bash
|
||||
$ my-tool-cli help
|
||||
MachineIdTool CLI v1.0.0
|
||||
用法: my-tool-cli <命令> [参数]
|
||||
|
||||
命令:
|
||||
get 获取当前值
|
||||
set <value> 设置新值
|
||||
help 显示帮助
|
||||
```
|
||||
|
||||
## 插件安装流程
|
||||
|
||||
1. **下载插件包** - 从 URL 或本地文件获取 ZIP
|
||||
2. **解压验证** - 解压并验证 plugin.json
|
||||
3. **下载二进制** - 如果是二进制插件,根据当前平台下载对应二进制
|
||||
4. **校验完整性** - 验证 checksum
|
||||
5. **注册插件** - 将插件信息写入数据库
|
||||
6. **加载插件** - 启用插件功能
|
||||
|
||||
## 插件发布
|
||||
|
||||
### GitHub Release 发布
|
||||
|
||||
推荐通过 GitHub Release 发布插件:
|
||||
|
||||
1. 创建 `plugin/` 目录,包含 `plugin.json` 和 `config.json`
|
||||
2. 在 GitHub Actions 中打包 ZIP
|
||||
3. 上传到 Release Assets
|
||||
|
||||
```yaml
|
||||
- name: Package plugin
|
||||
run: |
|
||||
mkdir -p plugin-package
|
||||
cp plugin/plugin.json plugin-package/
|
||||
cp plugin/config.json plugin-package/
|
||||
cd plugin-package
|
||||
zip -j ../release/my-plugin.zip plugin.json config.json
|
||||
```
|
||||
|
||||
## 开发调试
|
||||
|
||||
### 本地安装测试
|
||||
|
||||
1. 打包插件 ZIP
|
||||
2. 在「插件中心」点击「安装插件」
|
||||
3. 选择本地 ZIP 文件安装
|
||||
|
||||
### 日志调试
|
||||
|
||||
插件执行日志保存在:
|
||||
- macOS: `~/Library/Application Support/proxycast/logs/`
|
||||
- Windows: `%APPDATA%/proxycast/logs/`
|
||||
- Linux: `~/.local/share/proxycast/logs/`
|
||||
|
||||
## 示例插件
|
||||
|
||||
参考 [MachineIdTool](https://github.com/aiclientproxy/MachineIdTool) 作为二进制插件的完整示例。
|
||||
@@ -0,0 +1,169 @@
|
||||
# ProxyCast Connect
|
||||
|
||||
ProxyCast Connect 是一套中转商生态合作方案,通过 Deep Link 协议实现一键配置功能。
|
||||
|
||||
## 核心价值
|
||||
|
||||
| 角色 | 价值 |
|
||||
|------|------|
|
||||
| **中转商** | 用户转化率提升、品牌曝光、差异化竞争 |
|
||||
| **用户** | 一键配置、开箱即用、统一管理多个中转 |
|
||||
| **ProxyCast** | 用户增长、生态繁荣、市场占有率 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 一键配置流程
|
||||
|
||||
1. 用户在中转商后台点击「一键配置 ProxyCast」
|
||||
2. 浏览器打开 `proxycast://connect?relay=xxx&key=sk-xxx` 链接
|
||||
3. ProxyCast 自动打开,显示确认弹窗
|
||||
4. 用户确认后,API Key 自动添加到 ProxyCast
|
||||
5. 配置完成,可以立即使用
|
||||
|
||||
### Deep Link 协议
|
||||
|
||||
```
|
||||
proxycast://connect?relay={relay_id}&key={api_key}&name={key_name}&ref={ref_code}
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `relay` | ✅ | 中转商 ID(需在 ProxyCast 注册) |
|
||||
| `key` | ✅ | API Key |
|
||||
| `name` | ❌ | Key 名称(默认使用中转商名称) |
|
||||
| `ref` | ❌ | 推广码(用于统计) |
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
# 基础用法
|
||||
proxycast://connect?relay=openrouter&key=sk-or-v1-xxxx
|
||||
|
||||
# 带名称
|
||||
proxycast://connect?relay=siliconflow&key=sk-xxxx&name=硅基流动-主账号
|
||||
|
||||
# 带推广码
|
||||
proxycast://connect?relay=myrelay&key=sk-xxxx&ref=promo2024
|
||||
```
|
||||
|
||||
## 中转商注册
|
||||
|
||||
ProxyCast 是开源软件,中转商通过 **GitHub PR** 方式注册,无需网站注册。
|
||||
|
||||
### 注册仓库
|
||||
|
||||
```
|
||||
https://github.com/aiclientproxy/connect
|
||||
```
|
||||
|
||||
### 注册流程
|
||||
|
||||
1. **Fork 仓库** - Fork [aiclientproxy/connect](https://github.com/aiclientproxy/connect)
|
||||
2. **创建配置文件** - 在 `providers/` 目录下创建 `{your-id}.json`
|
||||
3. **提交 PR** - 提交 Pull Request 到主仓库
|
||||
4. **自动化检查** - GitHub Actions 自动验证配置
|
||||
5. **社区审核** - 维护者审核并合并 PR
|
||||
6. **自动发布** - 合并后自动构建 registry.json
|
||||
|
||||
### 配置文件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myrelay",
|
||||
"name": "我的中转站",
|
||||
"description": "稳定、便宜、快速的 AI API 中转服务",
|
||||
|
||||
"branding": {
|
||||
"logo": "https://myrelay.com/logo.png",
|
||||
"color": "#6366f1"
|
||||
},
|
||||
|
||||
"links": {
|
||||
"homepage": "https://myrelay.com",
|
||||
"register": "https://myrelay.com/register",
|
||||
"recharge": "https://myrelay.com/recharge",
|
||||
"docs": "https://docs.myrelay.com",
|
||||
"status": "https://status.myrelay.com"
|
||||
},
|
||||
|
||||
"api": {
|
||||
"base_url": "https://api.myrelay.com/v1",
|
||||
"protocol": "openai",
|
||||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer "
|
||||
},
|
||||
|
||||
"contact": {
|
||||
"email": "support@myrelay.com",
|
||||
"telegram": "@myrelay"
|
||||
},
|
||||
|
||||
"features": {
|
||||
"streaming": true,
|
||||
"models_endpoint": true
|
||||
},
|
||||
|
||||
"webhook": {
|
||||
"callback_url": "https://api.myrelay.com/proxycast/callback",
|
||||
"secret": "whsec_xxxxxxxxxxxxxxxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | ✅ | 唯一标识,小写字母、数字、连字符 |
|
||||
| `name` | ✅ | 显示名称 |
|
||||
| `description` | ✅ | 简短描述,≤100 字 |
|
||||
| `branding.logo` | ✅ | Logo URL,256x256 PNG |
|
||||
| `branding.color` | ❌ | 主题色,默认 `#6366f1` |
|
||||
| `links.homepage` | ✅ | 官网地址 |
|
||||
| `api.base_url` | ✅ | API 地址(必须 HTTPS) |
|
||||
| `api.protocol` | ✅ | 协议:`openai` 或 `anthropic` |
|
||||
| `contact.email` | ✅ | 联系邮箱 |
|
||||
| `webhook.callback_url` | ❌ | 统计回调地址 |
|
||||
| `webhook.secret` | ❌ | 回调签名密钥 |
|
||||
|
||||
### 审核标准
|
||||
|
||||
PR 合并前需满足:
|
||||
|
||||
- JSON Schema 验证通过
|
||||
- 文件名与 `id` 字段一致
|
||||
- Logo 图片可访问(256x256 PNG)
|
||||
- API 地址使用 HTTPS
|
||||
- 官网可访问
|
||||
- 联系方式有效
|
||||
|
||||
## 品牌展示
|
||||
|
||||
注册成功后,中转商会在 ProxyCast 内获得品牌展示:
|
||||
|
||||
- **扩展市场** - 在中转服务分类中展示
|
||||
- **已安装页面** - 显示品牌信息、快捷链接
|
||||
- **API Key 管理** - 统一管理该中转商的所有 Key
|
||||
|
||||
## 安全设计
|
||||
|
||||
### Deep Link 安全
|
||||
|
||||
| 风险 | 防护措施 |
|
||||
|------|---------|
|
||||
| 恶意链接 | relay_id 必须在注册表中存在 |
|
||||
| Key 泄露 | 确认弹窗显示脱敏 Key,用户确认后才添加 |
|
||||
| 钓鱼攻击 | 显示中转商完整信息,用户可核实 |
|
||||
|
||||
### 确认弹窗
|
||||
|
||||
所有通过 Deep Link 添加的 Key 必须经过用户确认,显示:
|
||||
- 中转商名称和 Logo
|
||||
- 脱敏后的 API Key
|
||||
- Key 名称(如果有)
|
||||
- 安全提示
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Connect 接入指南](/open-platform/connect-integration) - 详细的接入步骤
|
||||
- [统计回调](/open-platform/connect-webhook) - 配置统计回调追踪推广效果
|
||||
@@ -0,0 +1,229 @@
|
||||
# Connect 接入指南
|
||||
|
||||
本文档详细介绍中转商如何接入 ProxyCast Connect,实现一键配置功能。
|
||||
|
||||
## 接入流程
|
||||
|
||||
### Step 1: Fork 仓库
|
||||
|
||||
Fork [aiclientproxy/connect](https://github.com/aiclientproxy/connect) 仓库到你的 GitHub 账号。
|
||||
|
||||
### Step 2: 创建配置文件
|
||||
|
||||
在 `providers/` 目录下创建 `{your-id}.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myrelay",
|
||||
"name": "我的中转站",
|
||||
"description": "稳定、便宜、快速的 AI API 中转服务",
|
||||
|
||||
"branding": {
|
||||
"logo": "https://myrelay.com/logo.png",
|
||||
"color": "#6366f1"
|
||||
},
|
||||
|
||||
"links": {
|
||||
"homepage": "https://myrelay.com",
|
||||
"register": "https://myrelay.com/register",
|
||||
"recharge": "https://myrelay.com/recharge",
|
||||
"docs": "https://docs.myrelay.com",
|
||||
"status": "https://status.myrelay.com"
|
||||
},
|
||||
|
||||
"api": {
|
||||
"base_url": "https://api.myrelay.com/v1",
|
||||
"protocol": "openai",
|
||||
"auth_header": "Authorization",
|
||||
"auth_prefix": "Bearer "
|
||||
},
|
||||
|
||||
"contact": {
|
||||
"email": "support@myrelay.com",
|
||||
"telegram": "@myrelay"
|
||||
},
|
||||
|
||||
"features": {
|
||||
"streaming": true,
|
||||
"models_endpoint": true
|
||||
},
|
||||
|
||||
"webhook": {
|
||||
"callback_url": "https://api.myrelay.com/proxycast/callback",
|
||||
"secret": "whsec_xxxxxxxxxxxxxxxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 提交 PR
|
||||
|
||||
提交 Pull Request 到主仓库,填写 PR 模板说明你的中转服务。
|
||||
|
||||
### Step 4: 等待审核
|
||||
|
||||
GitHub Actions 会自动验证配置文件,维护者会在 1-3 个工作日内审核。
|
||||
|
||||
### Step 5: 合并上线
|
||||
|
||||
PR 合并后,registry.json 会自动构建,ProxyCast 客户端会自动同步。
|
||||
|
||||
## 配置字段说明
|
||||
|
||||
### 必填字段
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `id` | 唯一标识,小写字母、数字、连字符 | `myrelay` |
|
||||
| `name` | 显示名称 | `我的中转站` |
|
||||
| `description` | 简短描述,≤100 字 | `稳定、便宜、快速的 AI API 中转服务` |
|
||||
| `branding.logo` | Logo URL,256x256 PNG | `https://myrelay.com/logo.png` |
|
||||
| `links.homepage` | 官网地址 | `https://myrelay.com` |
|
||||
| `api.base_url` | API 地址(必须 HTTPS) | `https://api.myrelay.com/v1` |
|
||||
| `api.protocol` | 协议类型 | `openai` 或 `anthropic` |
|
||||
| `contact.email` | 联系邮箱 | `support@myrelay.com` |
|
||||
|
||||
### 可选字段
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `branding.color` | 主题色 | `#6366f1` |
|
||||
| `links.register` | 注册页面 | - |
|
||||
| `links.recharge` | 充值页面 | - |
|
||||
| `links.docs` | 文档地址 | - |
|
||||
| `links.status` | 状态页面 | - |
|
||||
| `api.auth_header` | 认证头 | `Authorization` |
|
||||
| `api.auth_prefix` | 认证前缀 | `Bearer ` |
|
||||
| `contact.telegram` | Telegram 联系方式 | - |
|
||||
| `contact.discord` | Discord 联系方式 | - |
|
||||
| `features.streaming` | 是否支持流式响应 | `true` |
|
||||
| `features.models_endpoint` | 是否提供 /models 端点 | `false` |
|
||||
| `webhook.callback_url` | 统计回调地址 | - |
|
||||
| `webhook.secret` | 回调签名密钥 | - |
|
||||
|
||||
## 集成方式
|
||||
|
||||
### 方式一:直接链接
|
||||
|
||||
最简单的方式,在用户后台放置链接:
|
||||
|
||||
```html
|
||||
<a href="proxycast://connect?relay=myrelay&key=USER_API_KEY">
|
||||
一键配置 ProxyCast
|
||||
</a>
|
||||
```
|
||||
|
||||
### 方式二:JavaScript SDK
|
||||
|
||||
提供更好的用户体验:
|
||||
|
||||
```html
|
||||
<script src="https://proxycast.dev/sdk/connect.js"></script>
|
||||
|
||||
<button onclick="ProxyCast.connect({ relay: 'myrelay', key: userApiKey })">
|
||||
一键配置 ProxyCast
|
||||
</button>
|
||||
```
|
||||
|
||||
SDK 功能:
|
||||
- 自动检测 ProxyCast 是否安装
|
||||
- 未安装时显示下载引导
|
||||
- 支持回调函数
|
||||
|
||||
```javascript
|
||||
ProxyCast.connect({
|
||||
relay: 'myrelay',
|
||||
key: userApiKey,
|
||||
name: '我的Key',
|
||||
onSuccess: () => {
|
||||
showToast('配置成功!');
|
||||
},
|
||||
onNotInstalled: () => {
|
||||
showDownloadModal();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 方式三:配置文件下载
|
||||
|
||||
生成 `.proxycast` 配置文件供用户下载:
|
||||
|
||||
```javascript
|
||||
function downloadConfig(apiKey) {
|
||||
const config = {
|
||||
relay: 'myrelay',
|
||||
key: apiKey,
|
||||
name: '我的Key'
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'myrelay.proxycast';
|
||||
a.click();
|
||||
}
|
||||
```
|
||||
|
||||
用户双击 `.proxycast` 文件,ProxyCast 自动打开并导入配置。
|
||||
|
||||
## Deep Link 参数
|
||||
|
||||
```
|
||||
proxycast://connect?relay={relay_id}&key={api_key}&name={key_name}&ref={ref_code}
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `relay` | ✅ | 中转商 ID(需在 ProxyCast 注册) |
|
||||
| `key` | ✅ | API Key |
|
||||
| `name` | ❌ | Key 名称(默认使用中转商名称) |
|
||||
| `ref` | ❌ | 推广码(用于统计) |
|
||||
|
||||
### 示例
|
||||
|
||||
```
|
||||
# 基础用法
|
||||
proxycast://connect?relay=openrouter&key=sk-or-v1-xxxx
|
||||
|
||||
# 带名称
|
||||
proxycast://connect?relay=siliconflow&key=sk-xxxx&name=硅基流动-主账号
|
||||
|
||||
# 带推广码
|
||||
proxycast://connect?relay=myrelay&key=sk-xxxx&ref=promo2024
|
||||
```
|
||||
|
||||
## 品牌素材要求
|
||||
|
||||
| 素材 | 规格 | 说明 |
|
||||
|------|------|------|
|
||||
| Logo | 256x256 PNG | 透明背景,正方形 |
|
||||
| 主题色 | HEX 色值 | 用于 UI 强调色 |
|
||||
| 简介 | ≤50 字 | 一句话描述 |
|
||||
| 详细描述 | ≤200 字 | 详细介绍 |
|
||||
|
||||
## API 要求
|
||||
|
||||
中转商的 API 需要满足:
|
||||
|
||||
| 要求 | 说明 |
|
||||
|------|------|
|
||||
| 协议兼容 | OpenAI 或 Anthropic 协议 |
|
||||
| HTTPS | 必须使用 HTTPS |
|
||||
| 模型列表 | 提供 `/models` 端点(可选) |
|
||||
| 稳定性 | 99% 以上可用性 |
|
||||
|
||||
## 审核标准
|
||||
|
||||
PR 合并前需满足:
|
||||
|
||||
- JSON Schema 验证通过
|
||||
- 文件名与 `id` 字段一致
|
||||
- Logo 图片可访问(256x256 PNG)
|
||||
- API 地址使用 HTTPS
|
||||
- 官网可访问
|
||||
- 联系方式有效
|
||||
|
||||
## 下一步
|
||||
|
||||
- [统计回调](/open-platform/connect-webhook) - 配置 Webhook 追踪推广效果
|
||||
@@ -0,0 +1,198 @@
|
||||
# 统计回调(Webhook)
|
||||
|
||||
ProxyCast Connect 提供统计回调机制,让中转商追踪推广效果。
|
||||
|
||||
## 回调流程
|
||||
|
||||
```
|
||||
用户点击一键配置
|
||||
│
|
||||
▼
|
||||
ProxyCast 打开,显示确认弹窗
|
||||
│
|
||||
├─── 用户取消 ───▶ 发送回调: status=cancelled
|
||||
│
|
||||
└─── 用户确认 ───▶ Key 添加成功
|
||||
│
|
||||
▼
|
||||
发送回调: status=success
|
||||
│
|
||||
▼
|
||||
中转商收到回调,更新统计
|
||||
```
|
||||
|
||||
## 配置回调
|
||||
|
||||
在 `providers/{id}.json` 中添加 webhook 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myrelay",
|
||||
"name": "我的中转站",
|
||||
|
||||
"webhook": {
|
||||
"callback_url": "https://api.myrelay.com/proxycast/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `webhook.callback_url` | ✅ | 回调地址(必须 HTTPS) |
|
||||
|
||||
## 回调请求格式
|
||||
|
||||
ProxyCast 向中转商发送 POST 请求:
|
||||
|
||||
```http
|
||||
POST https://api.myrelay.com/proxycast/callback
|
||||
Content-Type: application/json
|
||||
User-Agent: ProxyCast/1.2.0
|
||||
|
||||
{
|
||||
"event": "connect",
|
||||
"status": "success",
|
||||
"relay_id": "myrelay",
|
||||
"ref": "promo2024",
|
||||
"key_prefix": "sk-xxxx",
|
||||
"timestamp": "2026-01-05T12:00:00Z",
|
||||
"client": {
|
||||
"version": "1.2.0",
|
||||
"platform": "macos"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 回调字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event` | 事件类型:`connect` |
|
||||
| `status` | 状态:`success`(成功)、`cancelled`(用户取消)、`error`(失败) |
|
||||
| `relay_id` | 中转商 ID |
|
||||
| `ref` | 推广码(如果有) |
|
||||
| `key_prefix` | Key 前缀(脱敏,仅前 7 位) |
|
||||
| `timestamp` | 事件时间(ISO 8601 格式) |
|
||||
| `client.version` | ProxyCast 版本 |
|
||||
| `client.platform` | 平台:`macos`、`windows`、`linux` |
|
||||
| `error_code` | 错误码(仅 status=error 时) |
|
||||
| `error_message` | 错误信息(仅 status=error 时) |
|
||||
|
||||
## 请求验证
|
||||
|
||||
由于 ProxyCast 是开源软件,不使用签名验证。中转商应通过以下方式验证请求:
|
||||
|
||||
1. **检查 key_prefix** - 验证该 Key 前缀是否为自己下发的 Key
|
||||
2. **检查 relay_id** - 确认是自己的中转商 ID
|
||||
3. **检查 User-Agent** - 确认包含 `ProxyCast`
|
||||
|
||||
### Express 示例
|
||||
|
||||
```javascript
|
||||
app.post('/proxycast/callback', async (req, res) => {
|
||||
const { relay_id, key_prefix, status, ref } = req.body;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// 验证 User-Agent
|
||||
if (!userAgent.includes('ProxyCast')) {
|
||||
return res.status(403).json({ error: 'Invalid User-Agent' });
|
||||
}
|
||||
|
||||
// 验证 relay_id
|
||||
if (relay_id !== 'myrelay') {
|
||||
return res.status(403).json({ error: 'Invalid relay_id' });
|
||||
}
|
||||
|
||||
// 验证 key_prefix 是否为自己下发的 Key
|
||||
const isValidKey = await db.apiKeys.exists({
|
||||
key: { $regex: `^${key_prefix}` }
|
||||
});
|
||||
|
||||
if (!isValidKey) {
|
||||
return res.status(403).json({ error: 'Unknown key_prefix' });
|
||||
}
|
||||
|
||||
// 处理回调
|
||||
if (status === 'success') {
|
||||
await db.stats.increment({
|
||||
relay_id,
|
||||
ref: ref || 'direct',
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
});
|
||||
```
|
||||
|
||||
## 重试机制
|
||||
|
||||
| 重试次数 | 间隔 | 说明 |
|
||||
|----------|------|------|
|
||||
| 第 1 次 | 立即 | 首次发送 |
|
||||
| 第 2 次 | 1 分钟 | 首次失败后 |
|
||||
| 第 3 次 | 5 分钟 | 第二次失败后 |
|
||||
| 第 4 次 | 30 分钟 | 第三次失败后 |
|
||||
| 放弃 | - | 超过 4 次不再重试 |
|
||||
|
||||
成功响应:HTTP 2xx 状态码
|
||||
|
||||
|
||||
## 统计数据示例
|
||||
|
||||
基于回调数据,中转商可以构建统计面板:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ProxyCast Connect 统计 2026-01-05 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 今日概览 │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 128 │ │ 115 │ │ 13 │ │ 89.8% │ │
|
||||
│ │ 点击次数 │ │ 成功配置 │ │ 用户取消 │ │ 转化率 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ 推广码效果 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 推广码 点击 成功 取消 转化率 │ │
|
||||
│ ├─────────────────────────────────────────────────────┤ │
|
||||
│ │ promo2024 56 52 4 92.9% │ │
|
||||
│ │ twitter 38 33 5 86.8% │ │
|
||||
│ │ (无推广码) 34 30 4 88.2% │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 隐私保护
|
||||
|
||||
回调数据遵循最小化原则:
|
||||
|
||||
| 数据 | 是否发送 | 说明 |
|
||||
|------|----------|------|
|
||||
| 完整 API Key | ❌ | 仅发送前 7 位前缀 |
|
||||
| 用户 ID | ❌ | 不发送任何用户标识 |
|
||||
| 设备 ID | ❌ | 不发送设备标识 |
|
||||
| IP 地址 | ❌ | 不发送用户 IP |
|
||||
| 推广码 | ✅ | 用于统计推广效果 |
|
||||
| 平台信息 | ✅ | 仅操作系统类型 |
|
||||
|
||||
## 错误码
|
||||
|
||||
当 `status=error` 时,会包含错误信息:
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| `invalid_relay` | 中转商 ID 无效 |
|
||||
| `invalid_key` | API Key 格式无效 |
|
||||
| `relay_not_found` | 中转商未注册 |
|
||||
| `network_error` | 网络错误 |
|
||||
| `internal_error` | 内部错误 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **验证 key_prefix** - 检查是否为自己下发的 Key
|
||||
2. **幂等处理** - 同一事件可能重复发送
|
||||
3. **快速响应** - 在 5 秒内返回响应
|
||||
4. **异步处理** - 复杂逻辑放到后台队列
|
||||
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ProxyCast Connect 测试页面</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.container { max-width: 900px; margin: 0 auto; }
|
||||
h1 { color: white; text-align: center; margin-bottom: 10px; font-size: 2rem; }
|
||||
.subtitle { color: rgba(255,255,255,0.8); text-align: center; margin-bottom: 40px; }
|
||||
.tabs {
|
||||
display: flex; gap: 8px; margin-bottom: 24px;
|
||||
background: rgba(255,255,255,0.1); padding: 8px; border-radius: 12px;
|
||||
}
|
||||
.tab {
|
||||
flex: 1; padding: 12px 20px; border: none; border-radius: 8px;
|
||||
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||
background: transparent; color: rgba(255,255,255,0.7); transition: all 0.2s;
|
||||
}
|
||||
.tab.active { background: white; color: #667eea; }
|
||||
.tab:hover:not(.active) { background: rgba(255,255,255,0.1); color: white; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.card {
|
||||
background: white; border-radius: 16px; padding: 30px;
|
||||
margin-bottom: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1.25rem; font-weight: 600; margin-bottom: 20px; color: #1a1a2e;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.card-title::before {
|
||||
content: ''; width: 4px; height: 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 2px;
|
||||
}
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } }
|
||||
label { display: block; font-weight: 500; margin-bottom: 8px; color: #374151; }
|
||||
.label-hint { font-weight: normal; color: #9ca3af; font-size: 0.85rem; }
|
||||
input, select {
|
||||
width: 100%; padding: 12px 16px; border: 2px solid #e5e7eb;
|
||||
border-radius: 10px; font-size: 1rem; transition: border-color 0.2s;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: #667eea; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 14px 28px; border: none; border-radius: 10px;
|
||||
font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
.btn-secondary:hover { background: #e5e7eb; }
|
||||
.btn-group { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
|
||||
.result-box {
|
||||
background: #f8fafc; border: 2px dashed #e2e8f0;
|
||||
border-radius: 10px; padding: 20px; margin-top: 20px;
|
||||
}
|
||||
.result-box.success { background: #ecfdf5; border-color: #10b981; border-style: solid; }
|
||||
.result-label { font-size: 0.875rem; color: #64748b; margin-bottom: 8px; }
|
||||
.result-url {
|
||||
font-family: 'Monaco', 'Menlo', monospace; font-size: 0.875rem;
|
||||
word-break: break-all; color: #1e293b; background: white;
|
||||
padding: 12px; border-radius: 8px; border: 1px solid #e2e8f0;
|
||||
}
|
||||
.quick-test {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px; margin-top: 20px;
|
||||
}
|
||||
.quick-test-btn {
|
||||
padding: 16px; background: #f8fafc; border: 2px solid #e2e8f0;
|
||||
border-radius: 12px; cursor: pointer; transition: all 0.2s; text-align: left;
|
||||
}
|
||||
.quick-test-btn:hover { border-color: #667eea; background: #f0f4ff; }
|
||||
.quick-test-btn .name { font-weight: 600; color: #1a1a2e; margin-bottom: 4px; }
|
||||
.quick-test-btn .desc { font-size: 0.875rem; color: #64748b; }
|
||||
.tips {
|
||||
background: #fffbeb; border: 1px solid #fcd34d;
|
||||
border-radius: 10px; padding: 16px; margin-top: 20px;
|
||||
}
|
||||
.tips.info { background: #eff6ff; border-color: #3b82f6; }
|
||||
.tips-title { font-weight: 600; color: #92400e; margin-bottom: 8px; }
|
||||
.tips.info .tips-title { color: #1d4ed8; }
|
||||
.tips-content { font-size: 0.875rem; color: #a16207; line-height: 1.6; }
|
||||
.tips.info .tips-content { color: #1e40af; }
|
||||
.log-container {
|
||||
max-height: 300px; overflow-y: auto; background: #1a1a2e;
|
||||
border-radius: 10px; padding: 16px; margin-top: 16px;
|
||||
}
|
||||
.log-entry {
|
||||
font-family: 'Monaco', 'Menlo', monospace; font-size: 0.8rem;
|
||||
color: #a0aec0; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
.log-entry:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
.log-entry .time { color: #718096; }
|
||||
.log-entry .type { padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; margin: 0 8px; }
|
||||
.log-entry .type.info { background: #3182ce; color: white; }
|
||||
.log-entry .type.success { background: #38a169; color: white; }
|
||||
.log-entry .type.error { background: #e53e3e; color: white; }
|
||||
.log-entry .message { color: #e2e8f0; }
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; right: 20px; background: #1a1a2e;
|
||||
color: white; padding: 12px 20px; border-radius: 8px;
|
||||
opacity: 0; transform: translateY(20px); transition: all 0.3s; z-index: 1000;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.success { background: #10b981; }
|
||||
.toast.error { background: #ef4444; }
|
||||
.status-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 500;
|
||||
}
|
||||
.status-badge.success { background: #dcfce7; color: #166534; }
|
||||
.status-badge.cancelled { background: #fef3c7; color: #92400e; }
|
||||
.status-badge.error { background: #fee2e2; color: #991b1b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔗 ProxyCast Connect 测试</h1>
|
||||
<p class="subtitle">模拟中转商后台的一键配置功能 & Webhook 回调测试</p>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('deeplink')">🚀 Deep Link 测试</button>
|
||||
<button class="tab" onclick="switchTab('webhook')">📤 Webhook 回调</button>
|
||||
<button class="tab" onclick="switchTab('docs')">📖 使用说明</button>
|
||||
</div>
|
||||
|
||||
<!-- Deep Link 测试标签页 -->
|
||||
<div id="tab-deeplink" class="tab-content active">
|
||||
<div class="card">
|
||||
<h2 class="card-title">自定义测试</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="relay">中转商 ID (relay) <span class="label-hint">必填</span></label>
|
||||
<input type="text" id="relay" placeholder="例如: openrouter" value="openrouter">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="key">API Key <span class="label-hint">必填</span></label>
|
||||
<input type="text" id="key" placeholder="例如: sk-or-v1-xxxx" value="sk-test-1234567890">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name">Key 名称 <span class="label-hint">可选</span></label>
|
||||
<input type="text" id="name" placeholder="例如: 我的主账号" value="测试Key">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ref">推广码 <span class="label-hint">可选,用于统计</span></label>
|
||||
<input type="text" id="ref" placeholder="例如: promo2024">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="openDeepLink()">🚀 一键配置 ProxyCast</button>
|
||||
<button class="btn btn-secondary" onclick="generateLink()">📋 生成链接</button>
|
||||
</div>
|
||||
<div class="result-box" id="resultBox" style="display: none;">
|
||||
<div class="result-label">生成的 Deep Link:</div>
|
||||
<div class="result-url" id="resultUrl"></div>
|
||||
<button class="btn btn-secondary" style="margin-top: 12px; padding: 8px 16px; font-size: 0.875rem;" onclick="copyLink()">复制链接</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">快速测试场景</h2>
|
||||
<div class="quick-test">
|
||||
<button class="quick-test-btn" onclick="quickTest('openrouter', 'sk-or-v1-test123', 'OpenRouter测试', '')">
|
||||
<div class="name">✅ OpenRouter</div>
|
||||
<div class="desc">正常配置流程</div>
|
||||
</button>
|
||||
<button class="quick-test-btn" onclick="quickTest('siliconflow', 'sk-sf-test456', '硅基流动测试', 'promo2024')">
|
||||
<div class="name">✅ 硅基流动 + 推广码</div>
|
||||
<div class="desc">带推广码的配置</div>
|
||||
</button>
|
||||
<button class="quick-test-btn" onclick="quickTest('unknown-relay', 'sk-unknown', '未知中转', '')">
|
||||
<div class="name">❌ 未注册中转商</div>
|
||||
<div class="desc">测试错误处理</div>
|
||||
</button>
|
||||
<button class="quick-test-btn" onclick="quickTest('openrouter', '', '', '')">
|
||||
<div class="name">❌ 缺少 Key</div>
|
||||
<div class="desc">测试参数校验</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook 回调测试标签页 -->
|
||||
<div id="tab-webhook" class="tab-content">
|
||||
<div class="card">
|
||||
<h2 class="card-title">Webhook 回调测试</h2>
|
||||
<p style="color: #64748b; margin-bottom: 20px;">模拟 ProxyCast 发送的统计回调请求</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="callbackUrl">回调地址 (Callback URL) <span class="label-hint">必填</span></label>
|
||||
<input type="text" id="callbackUrl" placeholder="https://api.myrelay.com/proxycast/callback">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="callbackRelay">中转商 ID</label>
|
||||
<input type="text" id="callbackRelay" placeholder="openrouter" value="openrouter">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="callbackStatus">回调状态</label>
|
||||
<select id="callbackStatus">
|
||||
<option value="success">✅ success - 配置成功</option>
|
||||
<option value="cancelled">⚠️ cancelled - 用户取消</option>
|
||||
<option value="error">❌ error - 配置失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="callbackRef">推广码 (ref) <span class="label-hint">可选</span></label>
|
||||
<input type="text" id="callbackRef" placeholder="promo2024" value="promo2024">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="callbackKeyPrefix">Key 前缀 <span class="label-hint">脱敏后的 Key(前 7 位)</span></label>
|
||||
<input type="text" id="callbackKeyPrefix" placeholder="sk-or-v" value="sk-or-v" maxlength="7">
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="sendCallback()">📤 发送回调</button>
|
||||
<button class="btn btn-secondary" onclick="generateCallbackPayload()">📋 生成 Payload</button>
|
||||
<button class="btn btn-secondary" onclick="clearLogs()">🗑️ 清空日志</button>
|
||||
</div>
|
||||
|
||||
<div class="result-box" id="callbackResultBox" style="display: none;">
|
||||
<div class="result-label">回调 Payload:</div>
|
||||
<pre class="result-url" id="callbackPayload" style="white-space: pre-wrap; font-size: 0.8rem;"></pre>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="result-label">请求日志:</div>
|
||||
<div class="log-container" id="logContainer">
|
||||
<div class="log-entry">
|
||||
<span class="time">[--:--:--]</span>
|
||||
<span class="type info">INFO</span>
|
||||
<span class="message">等待发送回调请求...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips info" style="margin-top: 20px;">
|
||||
<div class="tips-title">💡 验证说明</div>
|
||||
<div class="tips-content">
|
||||
由于 ProxyCast 是开源软件,不使用签名验证。<br>
|
||||
中转商应通过检查 <code>key_prefix</code> 是否为自己下发的 Key 来验证请求。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">回调字段说明</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid #e5e7eb;">
|
||||
<th style="padding: 12px 0; text-align: left; color: #374151;">字段</th>
|
||||
<th style="padding: 12px 0; text-align: left; color: #374151;">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-family: monospace;">status</td>
|
||||
<td style="padding: 10px 0;">
|
||||
<span class="status-badge success">success</span>
|
||||
<span class="status-badge cancelled">cancelled</span>
|
||||
<span class="status-badge error">error</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-family: monospace;">relay_id</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">中转商 ID</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-family: monospace;">key_prefix</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">Key 前缀(脱敏,仅前 7 位)- 用于验证</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-family: monospace;">ref</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">推广码(可选)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-family: monospace;">client</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">客户端信息 {version, platform}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用说明标签页 -->
|
||||
<div id="tab-docs" class="tab-content">
|
||||
<div class="card">
|
||||
<h2 class="card-title">Deep Link 协议</h2>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="font-size: 1rem; margin-bottom: 12px; color: #374151;">协议格式</h3>
|
||||
<div class="result-url">proxycast://connect?relay={relay_id}&key={api_key}&name={key_name}&ref={ref_code}</div>
|
||||
</div>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-weight: 500; font-family: monospace;">relay</td>
|
||||
<td style="padding: 10px 0; color: #ef4444; font-weight: 500;">必填</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">中转商 ID</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-weight: 500; font-family: monospace;">key</td>
|
||||
<td style="padding: 10px 0; color: #ef4444; font-weight: 500;">必填</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">API Key</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 0; font-weight: 500; font-family: monospace;">name</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">可选</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">Key 显示名称</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 0; font-weight: 500; font-family: monospace;">ref</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">可选</td>
|
||||
<td style="padding: 10px 0; color: #64748b;">推广码</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="tips">
|
||||
<div class="tips-title">💡 测试前提</div>
|
||||
<div class="tips-content">
|
||||
1. 确保 ProxyCast 应用已安装并运行<br>
|
||||
2. 确保 Deep Link 协议已正确注册<br>
|
||||
3. 如果点击无反应,检查浏览器是否阻止了协议跳转
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">相关链接</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
|
||||
<a href="https://github.com/aiclientproxy/connect" target="_blank" class="quick-test-btn" style="text-decoration: none;">
|
||||
<div class="name">📦 中转商注册仓库</div>
|
||||
<div class="desc">提交 PR 注册中转商</div>
|
||||
</a>
|
||||
<a href="https://proxycast.dev/docs/open-platform/connect" target="_blank" class="quick-test-btn" style="text-decoration: none;">
|
||||
<div class="name">📖 Connect 文档</div>
|
||||
<div class="desc">详细的接入指南</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||
<script>
|
||||
// Tab 切换
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
document.querySelector(`[onclick="switchTab('${tabId}')"]`).classList.add('active');
|
||||
document.getElementById(`tab-${tabId}`).classList.add('active');
|
||||
}
|
||||
|
||||
// 生成 Deep Link
|
||||
function generateDeepLink() {
|
||||
const relay = document.getElementById('relay').value.trim();
|
||||
const key = document.getElementById('key').value.trim();
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const ref = document.getElementById('ref').value.trim();
|
||||
|
||||
if (!relay) { showToast('请输入中转商 ID', 'error'); return null; }
|
||||
if (!key) { showToast('请输入 API Key', 'error'); return null; }
|
||||
|
||||
let url = `proxycast://connect?relay=${encodeURIComponent(relay)}&key=${encodeURIComponent(key)}`;
|
||||
if (name) url += `&name=${encodeURIComponent(name)}`;
|
||||
if (ref) url += `&ref=${encodeURIComponent(ref)}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
// 打开 Deep Link
|
||||
function openDeepLink() {
|
||||
const url = generateDeepLink();
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
showToast('正在打开 ProxyCast...', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 生成并显示链接
|
||||
function generateLink() {
|
||||
const url = generateDeepLink();
|
||||
if (url) {
|
||||
document.getElementById('resultUrl').textContent = url;
|
||||
document.getElementById('resultBox').style.display = 'block';
|
||||
document.getElementById('resultBox').classList.add('success');
|
||||
}
|
||||
}
|
||||
|
||||
// 复制链接
|
||||
function copyLink() {
|
||||
const url = document.getElementById('resultUrl').textContent;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showToast('链接已复制', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// 快速测试
|
||||
function quickTest(relay, key, name, ref) {
|
||||
document.getElementById('relay').value = relay;
|
||||
document.getElementById('key').value = key;
|
||||
document.getElementById('name').value = name;
|
||||
document.getElementById('ref').value = ref;
|
||||
openDeepLink();
|
||||
}
|
||||
|
||||
// Toast 提示
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = `toast show ${type}`;
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
// 添加日志
|
||||
function addLog(type, message) {
|
||||
const container = document.getElementById('logContainer');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry';
|
||||
entry.innerHTML = `
|
||||
<span class="time">[${time}]</span>
|
||||
<span class="type ${type}">${type.toUpperCase()}</span>
|
||||
<span class="message">${message}</span>
|
||||
`;
|
||||
container.appendChild(entry);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
function clearLogs() {
|
||||
const container = document.getElementById('logContainer');
|
||||
container.innerHTML = `
|
||||
<div class="log-entry">
|
||||
<span class="time">[--:--:--]</span>
|
||||
<span class="type info">INFO</span>
|
||||
<span class="message">日志已清空</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 生成回调 Payload
|
||||
function generateCallbackPayload() {
|
||||
const payload = buildCallbackPayload();
|
||||
if (payload) {
|
||||
document.getElementById('callbackPayload').textContent = JSON.stringify(payload, null, 2);
|
||||
document.getElementById('callbackResultBox').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 构建回调 Payload
|
||||
function buildCallbackPayload() {
|
||||
const relay = document.getElementById('callbackRelay').value.trim();
|
||||
const status = document.getElementById('callbackStatus').value;
|
||||
const ref = document.getElementById('callbackRef').value.trim();
|
||||
const keyPrefix = document.getElementById('callbackKeyPrefix').value.trim();
|
||||
|
||||
if (!relay) { showToast('请输入中转商 ID', 'error'); return null; }
|
||||
if (!keyPrefix) { showToast('请输入 Key 前缀', 'error'); return null; }
|
||||
|
||||
const payload = {
|
||||
event: 'connect',
|
||||
status: status,
|
||||
relay_id: relay,
|
||||
key_prefix: keyPrefix.substring(0, 7),
|
||||
timestamp: new Date().toISOString(),
|
||||
client: {
|
||||
version: '0.29.0',
|
||||
platform: 'web-test'
|
||||
}
|
||||
};
|
||||
|
||||
if (ref) payload.ref_code = ref;
|
||||
if (status === 'error') {
|
||||
payload.error_code = 'TEST_ERROR';
|
||||
payload.error_message = '测试错误消息';
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// 发送回调
|
||||
async function sendCallback() {
|
||||
const url = document.getElementById('callbackUrl').value.trim();
|
||||
if (!url) { showToast('请输入回调地址', 'error'); return; }
|
||||
if (!url.startsWith('https://')) { showToast('回调地址必须使用 HTTPS', 'error'); return; }
|
||||
|
||||
const payload = buildCallbackPayload();
|
||||
if (!payload) return;
|
||||
|
||||
addLog('info', `发送回调到: ${url}`);
|
||||
addLog('info', `Payload: ${JSON.stringify(payload)}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'ProxyCast/0.29.0 (Test)'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
addLog('success', `回调成功! HTTP ${response.status}`);
|
||||
showToast('回调发送成功', 'success');
|
||||
} else {
|
||||
addLog('error', `回调失败: HTTP ${response.status}`);
|
||||
showToast(`回调失败: HTTP ${response.status}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
addLog('error', `网络错误: ${error.message}`);
|
||||
showToast('网络错误,请检查回调地址', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.29.0",
|
||||
"version": "0.30.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -66,9 +66,10 @@ impl From<DeepLinkError> for ConnectError {
|
||||
fn from(err: DeepLinkError) -> Self {
|
||||
let (code, message) = match &err {
|
||||
DeepLinkError::InvalidUrl(msg) => ("INVALID_URL".to_string(), msg.clone()),
|
||||
DeepLinkError::MissingRelay => {
|
||||
("MISSING_RELAY".to_string(), "缺少必填参数: relay".to_string())
|
||||
}
|
||||
DeepLinkError::MissingRelay => (
|
||||
"MISSING_RELAY".to_string(),
|
||||
"缺少必填参数: relay".to_string(),
|
||||
),
|
||||
DeepLinkError::MissingKey => {
|
||||
("MISSING_KEY".to_string(), "缺少必填参数: key".to_string())
|
||||
}
|
||||
@@ -203,10 +204,13 @@ pub async fn save_relay_api_key(
|
||||
})?;
|
||||
|
||||
// 获取中转商信息以确定协议类型和 base_url
|
||||
let relay_info = connect_state.registry.get(&relay_id).ok_or_else(|| ConnectError {
|
||||
code: "RELAY_NOT_FOUND".to_string(),
|
||||
message: format!("中转商 {} 不在注册表中", relay_id),
|
||||
})?;
|
||||
let relay_info = connect_state
|
||||
.registry
|
||||
.get(&relay_id)
|
||||
.ok_or_else(|| ConnectError {
|
||||
code: "RELAY_NOT_FOUND".to_string(),
|
||||
message: format!("中转商 {} 不在注册表中", relay_id),
|
||||
})?;
|
||||
|
||||
let protocol = relay_info.api.protocol.to_lowercase();
|
||||
let base_url = relay_info.api.base_url.clone();
|
||||
@@ -235,7 +239,9 @@ pub async fn save_relay_api_key(
|
||||
(provider_id, false)
|
||||
} else {
|
||||
// 创建新的自定义 Provider
|
||||
let provider_name = name.clone().unwrap_or_else(|| format!("[Connect] {}", relay_info.name));
|
||||
let provider_name = name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("[Connect] {}", relay_info.name));
|
||||
let new_provider = api_key_service
|
||||
.0
|
||||
.add_custom_provider(
|
||||
@@ -390,10 +396,7 @@ pub async fn send_connect_callback(
|
||||
let callback_url = match webhook.callback_url {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"[Connect] 中转商 {} webhook 配置不完整,跳过回调",
|
||||
relay_id
|
||||
);
|
||||
tracing::debug!("[Connect] 中转商 {} webhook 配置不完整,跳过回调", relay_id);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# connect
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
ProxyCast Connect 模块,实现中转商生态合作方案。
|
||||
通过 Deep Link 协议实现一键配置功能,支持中转商品牌展示。
|
||||
API Key 直接集成到凭证池系统,无需单独存储。
|
||||
|
||||
## 功能概述
|
||||
|
||||
1. **Deep Link 处理** - 解析 `proxycast://connect` 协议 URL
|
||||
2. **中转商注册表** - 从 GitHub 加载和管理中转商信息
|
||||
3. **统计回调** - 向中转商发送配置结果回调(Webhook)
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `mod.rs` - 模块入口,导出子模块
|
||||
- `deep_link.rs` - Deep Link URL 解析器 ✅
|
||||
- `ConnectPayload` - 解析结果结构体
|
||||
- `DeepLinkError` - 错误类型枚举
|
||||
- `parse_deep_link()` - URL 解析函数
|
||||
- `registry.rs` - 中转商注册表管理 ✅
|
||||
- `RelayRegistry` - 注册表管理器
|
||||
- `RelayInfo` - 中转商信息结构体
|
||||
- `RelayBranding` - 品牌信息
|
||||
- `RelayLinks` - 相关链接
|
||||
- `RelayApi` - API 配置
|
||||
- `RelayContact` - 联系方式
|
||||
- `RelayFeatures` - 功能特性
|
||||
- `RelayWebhook` - Webhook 配置
|
||||
- `RegistryError` - 错误类型
|
||||
- `webhook.rs` - 统计回调服务 ✅
|
||||
- `CallbackPayload` - 回调数据结构
|
||||
- `CallbackStatus` - 回调状态枚举(success/cancelled/error)
|
||||
- `WebhookSender` - 回调发送器(支持重试)
|
||||
- `send_success_callback()` - 发送成功回调
|
||||
- `send_cancelled_callback()` - 发送取消回调
|
||||
- `send_error_callback()` - 发送错误回调
|
||||
|
||||
## 相关需求
|
||||
|
||||
- Requirements 1.x - Deep Link 协议处理
|
||||
- Requirements 2.x - 中转商注册表管理
|
||||
- Requirements 4.x - API Key 存储(已集成到凭证池系统)
|
||||
- Requirements 5.3 - 统计回调(Webhook)
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Deep Link URL 解析模块
|
||||
//!
|
||||
//! 负责解析 `proxycast://connect` 协议的 URL,提取中转商配置参数。
|
||||
//!
|
||||
//! ## 功能
|
||||
//!
|
||||
//! - 解析 Deep Link URL 并提取参数
|
||||
//! - 验证必填参数(relay, key)
|
||||
//! - 返回结构化的 ConnectPayload 或错误
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//!
|
||||
//! ```rust
|
||||
//! use proxycast_lib::connect::deep_link::{parse_deep_link, ConnectPayload, DeepLinkError};
|
||||
//!
|
||||
//! let url = "proxycast://connect?relay=example&key=sk-xxx&name=MyKey";
|
||||
//! match parse_deep_link(url) {
|
||||
//! Ok(payload) => println!("Relay: {}, Key: {}", payload.relay, payload.key),
|
||||
//! Err(e) => eprintln!("Error: {:?}", e),
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
/// Deep Link 解析结果
|
||||
///
|
||||
/// 包含从 `proxycast://connect` URL 中提取的所有参数。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConnectPayload {
|
||||
/// 中转商 ID(必填)
|
||||
pub relay: String,
|
||||
/// API Key(必填)
|
||||
pub key: String,
|
||||
/// Key 名称(可选)
|
||||
pub name: Option<String>,
|
||||
/// 推广码(可选)
|
||||
pub ref_code: Option<String>,
|
||||
}
|
||||
|
||||
/// Deep Link 解析错误
|
||||
///
|
||||
/// 表示解析 Deep Link URL 时可能发生的各种错误。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum DeepLinkError {
|
||||
/// URL 格式无效
|
||||
InvalidUrl(String),
|
||||
/// 缺少必填的 relay 参数
|
||||
MissingRelay,
|
||||
/// 缺少必填的 key 参数
|
||||
MissingKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeepLinkError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DeepLinkError::InvalidUrl(msg) => write!(f, "无效的 URL: {}", msg),
|
||||
DeepLinkError::MissingRelay => write!(f, "缺少必填参数: relay"),
|
||||
DeepLinkError::MissingKey => write!(f, "缺少必填参数: key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DeepLinkError {}
|
||||
|
||||
/// 解析 Deep Link URL
|
||||
///
|
||||
/// 解析 `proxycast://connect` 格式的 URL,提取 relay、key、name 和 ref 参数。
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `url` - Deep Link URL 字符串
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// * `Ok(ConnectPayload)` - 解析成功,返回包含所有参数的结构体
|
||||
/// * `Err(DeepLinkError)` - 解析失败,返回具体错误类型
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```rust
|
||||
/// use proxycast_lib::connect::deep_link::parse_deep_link;
|
||||
///
|
||||
/// // 完整 URL
|
||||
/// let result = parse_deep_link("proxycast://connect?relay=example&key=sk-xxx&name=MyKey&ref=abc");
|
||||
/// assert!(result.is_ok());
|
||||
///
|
||||
/// // 缺少 relay 参数
|
||||
/// let result = parse_deep_link("proxycast://connect?key=sk-xxx");
|
||||
/// assert!(result.is_err());
|
||||
/// ```
|
||||
pub fn parse_deep_link(url: &str) -> Result<ConnectPayload, DeepLinkError> {
|
||||
// 解析 URL
|
||||
let parsed = Url::parse(url).map_err(|e| DeepLinkError::InvalidUrl(e.to_string()))?;
|
||||
|
||||
// 验证协议
|
||||
if parsed.scheme() != "proxycast" {
|
||||
return Err(DeepLinkError::InvalidUrl(format!(
|
||||
"无效的协议: {},期望 proxycast",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
|
||||
// 验证路径(host 在自定义协议中作为路径的一部分)
|
||||
if parsed.host_str() != Some("connect") {
|
||||
return Err(DeepLinkError::InvalidUrl(format!(
|
||||
"无效的路径: {:?},期望 connect",
|
||||
parsed.host_str()
|
||||
)));
|
||||
}
|
||||
|
||||
// 提取查询参数
|
||||
let params: HashMap<String, String> = parsed.query_pairs().into_owned().collect();
|
||||
|
||||
// 验证并提取必填参数
|
||||
let relay = params
|
||||
.get("relay")
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(DeepLinkError::MissingRelay)?
|
||||
.clone();
|
||||
|
||||
let key = params
|
||||
.get("key")
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(DeepLinkError::MissingKey)?
|
||||
.clone();
|
||||
|
||||
// 提取可选参数
|
||||
let name = params.get("name").filter(|s| !s.is_empty()).cloned();
|
||||
let ref_code = params.get("ref").filter(|s| !s.is_empty()).cloned();
|
||||
|
||||
Ok(ConnectPayload {
|
||||
relay,
|
||||
key,
|
||||
name,
|
||||
ref_code,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_url_with_all_params() {
|
||||
let url = "proxycast://connect?relay=example&key=sk-xxx&name=MyKey&ref=abc123";
|
||||
let result = parse_deep_link(url).unwrap();
|
||||
|
||||
assert_eq!(result.relay, "example");
|
||||
assert_eq!(result.key, "sk-xxx");
|
||||
assert_eq!(result.name, Some("MyKey".to_string()));
|
||||
assert_eq!(result.ref_code, Some("abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_url_with_required_params_only() {
|
||||
let url = "proxycast://connect?relay=test-relay&key=sk-12345";
|
||||
let result = parse_deep_link(url).unwrap();
|
||||
|
||||
assert_eq!(result.relay, "test-relay");
|
||||
assert_eq!(result.key, "sk-12345");
|
||||
assert_eq!(result.name, None);
|
||||
assert_eq!(result.ref_code, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_relay() {
|
||||
let url = "proxycast://connect?key=sk-xxx";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::MissingRelay)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_key() {
|
||||
let url = "proxycast://connect?relay=example";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::MissingKey)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_relay() {
|
||||
let url = "proxycast://connect?relay=&key=sk-xxx";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::MissingRelay)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_key() {
|
||||
let url = "proxycast://connect?relay=example&key=";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::MissingKey)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_protocol() {
|
||||
let url = "http://connect?relay=example&key=sk-xxx";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_path() {
|
||||
let url = "proxycast://other?relay=example&key=sk-xxx";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_malformed_url() {
|
||||
let url = "not a valid url";
|
||||
let result = parse_deep_link(url);
|
||||
|
||||
assert!(matches!(result, Err(DeepLinkError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_url_encoded_params() {
|
||||
let url = "proxycast://connect?relay=test%20relay&key=sk-xxx&name=My%20Key";
|
||||
let result = parse_deep_link(url).unwrap();
|
||||
|
||||
assert_eq!(result.relay, "test relay");
|
||||
assert_eq!(result.key, "sk-xxx");
|
||||
assert_eq!(result.name, Some("My Key".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod property_tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// 生成有效的 relay ID(字母数字和连字符)
|
||||
fn arb_relay_id() -> impl Strategy<Value = String> {
|
||||
"[a-z][a-z0-9-]{0,30}[a-z0-9]".prop_filter("非空", |s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 生成有效的 API Key
|
||||
fn arb_api_key() -> impl Strategy<Value = String> {
|
||||
"sk-[a-zA-Z0-9]{8,64}".prop_filter("非空", |s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 生成可选的 Key 名称
|
||||
fn arb_key_name() -> impl Strategy<Value = Option<String>> {
|
||||
prop_oneof![Just(None), "[a-zA-Z0-9 _-]{1,50}".prop_map(Some),]
|
||||
}
|
||||
|
||||
/// 生成可选的推广码
|
||||
fn arb_ref_code() -> impl Strategy<Value = Option<String>> {
|
||||
prop_oneof![Just(None), "[a-zA-Z0-9]{4,20}".prop_map(Some),]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(100))]
|
||||
|
||||
/// Feature: proxycast-connect, Property 1: Deep Link URL Parsing Completeness
|
||||
/// Validates: Requirements 1.1
|
||||
///
|
||||
/// *For any* valid Deep Link URL containing relay and key parameters,
|
||||
/// parsing the URL SHALL extract all parameters correctly and return
|
||||
/// a ConnectPayload with matching values.
|
||||
#[test]
|
||||
fn prop_deep_link_parsing_completeness(
|
||||
relay in arb_relay_id(),
|
||||
key in arb_api_key(),
|
||||
name in arb_key_name(),
|
||||
ref_code in arb_ref_code(),
|
||||
) {
|
||||
// 构建 URL
|
||||
let mut url = format!("proxycast://connect?relay={}&key={}", relay, key);
|
||||
if let Some(ref n) = name {
|
||||
url.push_str(&format!("&name={}", urlencoding::encode(n)));
|
||||
}
|
||||
if let Some(ref r) = ref_code {
|
||||
url.push_str(&format!("&ref={}", r));
|
||||
}
|
||||
|
||||
// 解析 URL
|
||||
let result = parse_deep_link(&url);
|
||||
|
||||
// 验证解析成功
|
||||
prop_assert!(result.is_ok(), "解析失败: {:?}", result);
|
||||
|
||||
let payload = result.unwrap();
|
||||
|
||||
// 验证所有参数正确提取
|
||||
prop_assert_eq!(&payload.relay, &relay, "relay 不匹配");
|
||||
prop_assert_eq!(&payload.key, &key, "key 不匹配");
|
||||
prop_assert_eq!(&payload.name, &name, "name 不匹配");
|
||||
prop_assert_eq!(&payload.ref_code, &ref_code, "ref_code 不匹配");
|
||||
}
|
||||
|
||||
/// Feature: proxycast-connect, Property 2: Deep Link Invalid Parameter Handling
|
||||
/// Validates: Requirements 1.2, 1.3, 7.1
|
||||
///
|
||||
/// *For any* Deep Link URL that is malformed, missing the relay parameter,
|
||||
/// or missing the key parameter, the parser SHALL return an appropriate error
|
||||
/// and not produce a valid ConnectPayload.
|
||||
#[test]
|
||||
fn prop_deep_link_invalid_parameter_handling(
|
||||
relay in arb_relay_id(),
|
||||
key in arb_api_key(),
|
||||
error_type in 0..4u8,
|
||||
) {
|
||||
let url = match error_type {
|
||||
// 缺少 relay 参数
|
||||
0 => format!("proxycast://connect?key={}", key),
|
||||
// 缺少 key 参数
|
||||
1 => format!("proxycast://connect?relay={}", relay),
|
||||
// 空 relay 参数
|
||||
2 => format!("proxycast://connect?relay=&key={}", key),
|
||||
// 空 key 参数
|
||||
_ => format!("proxycast://connect?relay={}&key=", relay),
|
||||
};
|
||||
|
||||
let result = parse_deep_link(&url);
|
||||
|
||||
// 验证解析失败
|
||||
prop_assert!(result.is_err(), "应该返回错误,但解析成功了: {:?}", result);
|
||||
|
||||
// 验证错误类型正确
|
||||
match error_type {
|
||||
0 | 2 => prop_assert!(
|
||||
matches!(result, Err(DeepLinkError::MissingRelay)),
|
||||
"期望 MissingRelay 错误,实际: {:?}", result
|
||||
),
|
||||
1 | 3 => prop_assert!(
|
||||
matches!(result, Err(DeepLinkError::MissingKey)),
|
||||
"期望 MissingKey 错误,实际: {:?}", result
|
||||
),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试无效协议
|
||||
#[test]
|
||||
fn prop_invalid_protocol(
|
||||
protocol in "(http|https|ftp|file)",
|
||||
relay in arb_relay_id(),
|
||||
key in arb_api_key(),
|
||||
) {
|
||||
let url = format!("{}://connect?relay={}&key={}", protocol, relay, key);
|
||||
let result = parse_deep_link(&url);
|
||||
|
||||
prop_assert!(
|
||||
matches!(result, Err(DeepLinkError::InvalidUrl(_))),
|
||||
"期望 InvalidUrl 错误,实际: {:?}", result
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试无效路径
|
||||
#[test]
|
||||
fn prop_invalid_path(
|
||||
path in "(other|invalid|test|api)",
|
||||
relay in arb_relay_id(),
|
||||
key in arb_api_key(),
|
||||
) {
|
||||
let url = format!("proxycast://{}?relay={}&key={}", path, relay, key);
|
||||
let result = parse_deep_link(&url);
|
||||
|
||||
prop_assert!(
|
||||
matches!(result, Err(DeepLinkError::InvalidUrl(_))),
|
||||
"期望 InvalidUrl 错误,实际: {:?}", result
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! ProxyCast Connect 模块
|
||||
//!
|
||||
//! 实现中转商生态合作方案,通过 Deep Link 协议实现一键配置功能。
|
||||
//!
|
||||
//! ## 子模块
|
||||
//!
|
||||
//! - `deep_link` - Deep Link URL 解析
|
||||
//! - `registry` - 中转商注册表管理
|
||||
//! - `webhook` - 统计回调服务
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use proxycast::connect::{parse_deep_link, RelayRegistry};
|
||||
//!
|
||||
//! // 解析 Deep Link
|
||||
//! let payload = parse_deep_link("proxycast://connect?relay=example&key=sk-xxx")?;
|
||||
//!
|
||||
//! // 查询中转商信息
|
||||
//! let registry = RelayRegistry::new(cache_path);
|
||||
//! let relay_info = registry.get(&payload.relay);
|
||||
//!
|
||||
//! // API Key 直接保存到凭证池系统(通过 ProviderPoolService)
|
||||
//! ```
|
||||
|
||||
// 子模块声明
|
||||
pub mod deep_link;
|
||||
pub mod registry;
|
||||
pub mod webhook;
|
||||
|
||||
// 重新导出核心类型
|
||||
pub use deep_link::{parse_deep_link, ConnectPayload, DeepLinkError};
|
||||
pub use registry::{
|
||||
RegistryData, RegistryError, RelayApi, RelayBranding, RelayContact, RelayFeatures, RelayInfo,
|
||||
RelayLinks, RelayRegistry, RelayWebhook,
|
||||
};
|
||||
pub use webhook::{
|
||||
send_cancelled_callback, send_error_callback, send_success_callback, CallbackPayload,
|
||||
CallbackStatus, WebhookError, WebhookSender,
|
||||
};
|
||||
@@ -0,0 +1,795 @@
|
||||
//! 中转商注册表管理模块
|
||||
//!
|
||||
//! 负责从 GitHub 加载和管理中转商注册表,提供中转商信息查询功能。
|
||||
//!
|
||||
//! ## 功能
|
||||
//!
|
||||
//! - 从远程 GitHub 仓库加载注册表
|
||||
//! - 本地缓存支持离线访问
|
||||
//! - 中转商信息查询和验证
|
||||
//!
|
||||
//! ## 使用示例
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use proxycast_lib::connect::registry::{RelayRegistry, RelayInfo};
|
||||
//!
|
||||
//! let registry = RelayRegistry::new(cache_path);
|
||||
//! registry.load_from_remote().await?;
|
||||
//!
|
||||
//! if let Some(info) = registry.get("example-relay") {
|
||||
//! println!("中转商: {}", info.name);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
use thiserror::Error;
|
||||
|
||||
/// 注册表远程 URL
|
||||
const REGISTRY_URL: &str =
|
||||
"https://raw.githubusercontent.com/aiclientproxy/connect/main/dist/registry.json";
|
||||
|
||||
/// 注册表错误类型
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RegistryError {
|
||||
/// 网络请求失败
|
||||
#[error("网络请求失败: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
/// JSON 解析失败
|
||||
#[error("JSON 解析失败: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
/// 文件 IO 错误
|
||||
#[error("文件操作失败: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
/// 缓存不存在
|
||||
#[error("缓存不存在")]
|
||||
NoCacheError,
|
||||
}
|
||||
|
||||
/// 注册表数据结构(JSON 根对象)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RegistryData {
|
||||
/// 注册表版本
|
||||
pub version: String,
|
||||
/// 更新时间
|
||||
pub updated_at: String,
|
||||
/// 中转商列表
|
||||
pub providers: Vec<RelayInfo>,
|
||||
}
|
||||
|
||||
/// 中转商信息
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayInfo {
|
||||
/// 中转商唯一 ID
|
||||
pub id: String,
|
||||
/// 中转商名称
|
||||
pub name: String,
|
||||
/// 中转商描述
|
||||
pub description: String,
|
||||
/// 品牌信息
|
||||
pub branding: RelayBranding,
|
||||
/// 相关链接
|
||||
pub links: RelayLinks,
|
||||
/// API 配置
|
||||
pub api: RelayApi,
|
||||
/// 联系方式
|
||||
pub contact: RelayContact,
|
||||
/// 功能特性(可选)
|
||||
#[serde(default)]
|
||||
pub features: RelayFeatures,
|
||||
/// Webhook 配置(可选)
|
||||
#[serde(default)]
|
||||
pub webhook: Option<RelayWebhook>,
|
||||
}
|
||||
|
||||
/// 品牌信息
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayBranding {
|
||||
/// Logo URL
|
||||
pub logo: String,
|
||||
/// 主题色(默认 #6366f1)
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
fn default_color() -> String {
|
||||
"#6366f1".to_string()
|
||||
}
|
||||
|
||||
/// 相关链接
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayLinks {
|
||||
/// 主页
|
||||
pub homepage: String,
|
||||
/// 注册链接(可选)
|
||||
#[serde(default)]
|
||||
pub register: Option<String>,
|
||||
/// 充值链接(可选)
|
||||
#[serde(default)]
|
||||
pub recharge: Option<String>,
|
||||
/// 文档链接(可选)
|
||||
#[serde(default)]
|
||||
pub docs: Option<String>,
|
||||
/// 状态页链接(可选)
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// API 配置
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayApi {
|
||||
/// API 基础 URL
|
||||
pub base_url: String,
|
||||
/// 协议类型(如 openai, claude)
|
||||
pub protocol: String,
|
||||
/// 认证头名称(默认 Authorization)
|
||||
#[serde(default = "default_auth_header")]
|
||||
pub auth_header: String,
|
||||
/// 认证前缀(默认 Bearer)
|
||||
#[serde(default = "default_auth_prefix")]
|
||||
pub auth_prefix: String,
|
||||
}
|
||||
|
||||
fn default_auth_header() -> String {
|
||||
"Authorization".to_string()
|
||||
}
|
||||
|
||||
fn default_auth_prefix() -> String {
|
||||
"Bearer".to_string()
|
||||
}
|
||||
|
||||
/// 联系方式
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayContact {
|
||||
/// 邮箱
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
/// Discord
|
||||
#[serde(default)]
|
||||
pub discord: Option<String>,
|
||||
/// Telegram
|
||||
#[serde(default)]
|
||||
pub telegram: Option<String>,
|
||||
/// Twitter
|
||||
#[serde(default)]
|
||||
pub twitter: Option<String>,
|
||||
}
|
||||
|
||||
/// 功能特性
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayFeatures {
|
||||
/// 支持的模型列表
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
/// 是否支持流式响应
|
||||
#[serde(default)]
|
||||
pub streaming: bool,
|
||||
/// 是否支持函数调用
|
||||
#[serde(default)]
|
||||
pub function_calling: bool,
|
||||
/// 是否支持视觉模型
|
||||
#[serde(default)]
|
||||
pub vision: bool,
|
||||
}
|
||||
|
||||
/// Webhook 配置
|
||||
///
|
||||
/// 用于统计回调,让中转商追踪推广效果。
|
||||
/// 中转商通过检查 key_prefix 是否为自己下发的 Key 来验证请求。
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RelayWebhook {
|
||||
/// 回调地址(必须 HTTPS)
|
||||
#[serde(default)]
|
||||
pub callback_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 中转商注册表管理器
|
||||
pub struct RelayRegistry {
|
||||
/// 中转商数据(ID -> RelayInfo)
|
||||
providers: RwLock<HashMap<String, RelayInfo>>,
|
||||
/// 缓存文件路径
|
||||
cache_path: PathBuf,
|
||||
}
|
||||
|
||||
impl RelayRegistry {
|
||||
/// 创建新的注册表实例
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `cache_path` - 缓存文件路径
|
||||
pub fn new(cache_path: PathBuf) -> Self {
|
||||
Self {
|
||||
providers: RwLock::new(HashMap::new()),
|
||||
cache_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从远程 GitHub 加载注册表
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// * `Ok(())` - 加载成功
|
||||
/// * `Err(RegistryError)` - 加载失败
|
||||
pub async fn load_from_remote(&self) -> Result<(), RegistryError> {
|
||||
tracing::info!("从远程加载中转商注册表: {}", REGISTRY_URL);
|
||||
|
||||
let response = reqwest::get(REGISTRY_URL)
|
||||
.await
|
||||
.map_err(|e| RegistryError::NetworkError(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(RegistryError::NetworkError(format!(
|
||||
"HTTP 状态码: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| RegistryError::NetworkError(e.to_string()))?;
|
||||
|
||||
let registry_data: RegistryData =
|
||||
serde_json::from_str(&text).map_err(|e| RegistryError::ParseError(e.to_string()))?;
|
||||
|
||||
// 更新内存中的数据
|
||||
let mut providers = self
|
||||
.providers
|
||||
.write()
|
||||
.map_err(|_| RegistryError::ParseError("获取写锁失败".to_string()))?;
|
||||
|
||||
providers.clear();
|
||||
for provider in registry_data.providers {
|
||||
providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
|
||||
tracing::info!("成功加载 {} 个中转商", providers.len());
|
||||
|
||||
// 保存到缓存
|
||||
drop(providers); // 释放写锁
|
||||
self.save_to_cache()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从本地缓存加载注册表
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// * `Ok(())` - 加载成功
|
||||
/// * `Err(RegistryError)` - 加载失败
|
||||
pub fn load_from_cache(&self) -> Result<(), RegistryError> {
|
||||
if !self.cache_path.exists() {
|
||||
return Err(RegistryError::NoCacheError);
|
||||
}
|
||||
|
||||
tracing::info!("从缓存加载中转商注册表: {:?}", self.cache_path);
|
||||
|
||||
let content = std::fs::read_to_string(&self.cache_path)?;
|
||||
|
||||
let registry_data: RegistryData =
|
||||
serde_json::from_str(&content).map_err(|e| RegistryError::ParseError(e.to_string()))?;
|
||||
|
||||
let mut providers = self
|
||||
.providers
|
||||
.write()
|
||||
.map_err(|_| RegistryError::ParseError("获取写锁失败".to_string()))?;
|
||||
|
||||
providers.clear();
|
||||
for provider in registry_data.providers {
|
||||
providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
|
||||
tracing::info!("从缓存加载 {} 个中转商", providers.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存注册表到本地缓存
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// * `Ok(())` - 保存成功
|
||||
/// * `Err(RegistryError)` - 保存失败
|
||||
pub fn save_to_cache(&self) -> Result<(), RegistryError> {
|
||||
let providers = self
|
||||
.providers
|
||||
.read()
|
||||
.map_err(|_| RegistryError::ParseError("获取读锁失败".to_string()))?;
|
||||
|
||||
let registry_data = RegistryData {
|
||||
version: "1.0.0".to_string(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
providers: providers.values().cloned().collect(),
|
||||
};
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = self.cache_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(®istry_data)
|
||||
.map_err(|e| RegistryError::ParseError(e.to_string()))?;
|
||||
|
||||
std::fs::write(&self.cache_path, content)?;
|
||||
|
||||
tracing::info!("注册表已缓存到: {:?}", self.cache_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询中转商信息
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `id` - 中转商 ID
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// * `Some(RelayInfo)` - 找到对应的中转商
|
||||
/// * `None` - 未找到
|
||||
pub fn get(&self, id: &str) -> Option<RelayInfo> {
|
||||
self.providers
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|providers| providers.get(id).cloned())
|
||||
}
|
||||
|
||||
/// 验证中转商是否存在于注册表中
|
||||
///
|
||||
/// # 参数
|
||||
///
|
||||
/// * `id` - 中转商 ID
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// * `true` - 存在
|
||||
/// * `false` - 不存在
|
||||
pub fn is_valid(&self, id: &str) -> bool {
|
||||
self.providers
|
||||
.read()
|
||||
.ok()
|
||||
.map(|providers| providers.contains_key(id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 获取所有中转商列表
|
||||
///
|
||||
/// # 返回值
|
||||
///
|
||||
/// 所有中转商信息的列表
|
||||
pub fn list(&self) -> Vec<RelayInfo> {
|
||||
self.providers
|
||||
.read()
|
||||
.ok()
|
||||
.map(|providers| providers.values().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取中转商数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.providers
|
||||
.read()
|
||||
.ok()
|
||||
.map(|providers| providers.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 检查注册表是否为空
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// 直接从 RegistryData 加载(用于测试)
|
||||
#[cfg(test)]
|
||||
pub fn load_from_data(&self, data: RegistryData) {
|
||||
if let Ok(mut providers) = self.providers.write() {
|
||||
providers.clear();
|
||||
for provider in data.providers {
|
||||
providers.insert(provider.id.clone(), provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 创建测试用的 RelayInfo
|
||||
fn create_test_relay_info(id: &str, name: &str) -> RelayInfo {
|
||||
RelayInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: format!("{} 描述", name),
|
||||
branding: RelayBranding {
|
||||
logo: format!("https://example.com/{}/logo.png", id),
|
||||
color: "#6366f1".to_string(),
|
||||
},
|
||||
links: RelayLinks {
|
||||
homepage: format!("https://{}.example.com", id),
|
||||
register: Some(format!("https://{}.example.com/register", id)),
|
||||
recharge: None,
|
||||
docs: Some(format!("https://docs.{}.example.com", id)),
|
||||
status: None,
|
||||
},
|
||||
api: RelayApi {
|
||||
base_url: format!("https://api.{}.example.com/v1", id),
|
||||
protocol: "openai".to_string(),
|
||||
auth_header: "Authorization".to_string(),
|
||||
auth_prefix: "Bearer".to_string(),
|
||||
},
|
||||
contact: RelayContact {
|
||||
email: Some(format!("support@{}.example.com", id)),
|
||||
discord: None,
|
||||
telegram: None,
|
||||
twitter: None,
|
||||
},
|
||||
features: RelayFeatures::default(),
|
||||
webhook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建测试用的 RegistryData
|
||||
fn create_test_registry_data(providers: Vec<RelayInfo>) -> RegistryData {
|
||||
RegistryData {
|
||||
version: "1.0.0".to_string(),
|
||||
updated_at: "2026-01-05T00:00:00Z".to_string(),
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_new() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path.clone());
|
||||
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_load_from_data() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let relay1 = create_test_relay_info("relay-1", "中转站 1");
|
||||
let relay2 = create_test_relay_info("relay-2", "中转站 2");
|
||||
let data = create_test_registry_data(vec![relay1.clone(), relay2.clone()]);
|
||||
|
||||
registry.load_from_data(data);
|
||||
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert!(!registry.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_get_existing() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let relay = create_test_relay_info("test-relay", "测试中转站");
|
||||
let data = create_test_registry_data(vec![relay.clone()]);
|
||||
registry.load_from_data(data);
|
||||
|
||||
let result = registry.get("test-relay");
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().name, "测试中转站");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_get_non_existing() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let relay = create_test_relay_info("test-relay", "测试中转站");
|
||||
let data = create_test_registry_data(vec![relay]);
|
||||
registry.load_from_data(data);
|
||||
|
||||
let result = registry.get("non-existing");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_is_valid() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let relay = create_test_relay_info("valid-relay", "有效中转站");
|
||||
let data = create_test_registry_data(vec![relay]);
|
||||
registry.load_from_data(data);
|
||||
|
||||
assert!(registry.is_valid("valid-relay"));
|
||||
assert!(!registry.is_valid("invalid-relay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_list() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let relay1 = create_test_relay_info("relay-1", "中转站 1");
|
||||
let relay2 = create_test_relay_info("relay-2", "中转站 2");
|
||||
let data = create_test_registry_data(vec![relay1, relay2]);
|
||||
registry.load_from_data(data);
|
||||
|
||||
let list = registry.list();
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_cache_round_trip() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
|
||||
// 创建并保存
|
||||
let registry1 = RelayRegistry::new(cache_path.clone());
|
||||
let relay = create_test_relay_info("cached-relay", "缓存中转站");
|
||||
let data = create_test_registry_data(vec![relay.clone()]);
|
||||
registry1.load_from_data(data);
|
||||
registry1.save_to_cache().unwrap();
|
||||
|
||||
// 从缓存加载
|
||||
let registry2 = RelayRegistry::new(cache_path);
|
||||
registry2.load_from_cache().unwrap();
|
||||
|
||||
assert_eq!(registry2.len(), 1);
|
||||
let loaded = registry2.get("cached-relay").unwrap();
|
||||
assert_eq!(loaded.name, relay.name);
|
||||
assert_eq!(loaded.description, relay.description);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_load_from_cache_no_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("non_existing.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let result = registry.load_from_cache();
|
||||
assert!(matches!(result, Err(RegistryError::NoCacheError)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_info_serialization() {
|
||||
let relay = create_test_relay_info("test", "测试");
|
||||
let json = serde_json::to_string(&relay).unwrap();
|
||||
let deserialized: RelayInfo = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(relay, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_branding_default_color() {
|
||||
let json = r#"{"logo": "https://example.com/logo.png"}"#;
|
||||
let branding: RelayBranding = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(branding.color, "#6366f1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_api_default_auth() {
|
||||
let json = r#"{"base_url": "https://api.example.com", "protocol": "openai"}"#;
|
||||
let api: RelayApi = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(api.auth_header, "Authorization");
|
||||
assert_eq!(api.auth_prefix, "Bearer");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod property_tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 生成有效的中转商 ID
|
||||
fn arb_relay_id() -> impl Strategy<Value = String> {
|
||||
"[a-z][a-z0-9-]{0,20}[a-z0-9]"
|
||||
.prop_filter("非空且有效", |s| !s.is_empty() && s.len() >= 2)
|
||||
}
|
||||
|
||||
/// 生成有效的中转商名称
|
||||
fn arb_relay_name() -> impl Strategy<Value = String> {
|
||||
"[a-zA-Z\u{4e00}-\u{9fff}]{1,20}".prop_filter("非空", |s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 生成测试用的 RelayInfo
|
||||
fn arb_relay_info() -> impl Strategy<Value = RelayInfo> {
|
||||
(arb_relay_id(), arb_relay_name()).prop_map(|(id, name)| RelayInfo {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
description: format!("{} 描述", name),
|
||||
branding: RelayBranding {
|
||||
logo: format!("https://example.com/{}/logo.png", id),
|
||||
color: "#6366f1".to_string(),
|
||||
},
|
||||
links: RelayLinks {
|
||||
homepage: format!("https://{}.example.com", id),
|
||||
register: None,
|
||||
recharge: None,
|
||||
docs: None,
|
||||
status: None,
|
||||
},
|
||||
api: RelayApi {
|
||||
base_url: format!("https://api.{}.example.com/v1", id),
|
||||
protocol: "openai".to_string(),
|
||||
auth_header: "Authorization".to_string(),
|
||||
auth_prefix: "Bearer".to_string(),
|
||||
},
|
||||
contact: RelayContact {
|
||||
email: None,
|
||||
discord: None,
|
||||
telegram: None,
|
||||
twitter: None,
|
||||
},
|
||||
features: RelayFeatures::default(),
|
||||
webhook: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成多个不重复 ID 的 RelayInfo 列表
|
||||
fn arb_relay_info_list(max_size: usize) -> impl Strategy<Value = Vec<RelayInfo>> {
|
||||
prop::collection::vec(arb_relay_info(), 0..=max_size).prop_map(|relays| {
|
||||
// 去重:保留每个 ID 的第一个出现
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
relays
|
||||
.into_iter()
|
||||
.filter(|r| seen.insert(r.id.clone()))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(100))]
|
||||
|
||||
/// Feature: proxycast-connect, Property 3: Registry Lookup Consistency
|
||||
/// Validates: Requirements 2.3, 2.4
|
||||
///
|
||||
/// *For any* RelayRegistry and relay ID, if the ID exists in the registry
|
||||
/// then `get(id)` SHALL return the corresponding RelayInfo, and if the ID
|
||||
/// does not exist then `get(id)` SHALL return None.
|
||||
#[test]
|
||||
fn prop_registry_lookup_consistency(
|
||||
relays in arb_relay_info_list(10),
|
||||
query_id in arb_relay_id(),
|
||||
) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
// 加载测试数据
|
||||
let data = RegistryData {
|
||||
version: "1.0.0".to_string(),
|
||||
updated_at: "2026-01-05T00:00:00Z".to_string(),
|
||||
providers: relays.clone(),
|
||||
};
|
||||
registry.load_from_data(data);
|
||||
|
||||
// 查找是否存在于原始列表中
|
||||
let expected = relays.iter().find(|r| r.id == query_id);
|
||||
|
||||
// 执行查询
|
||||
let result = registry.get(&query_id);
|
||||
|
||||
// 验证一致性
|
||||
match (expected, result) {
|
||||
(Some(expected_info), Some(result_info)) => {
|
||||
// ID 存在时,返回的信息应该匹配
|
||||
prop_assert_eq!(
|
||||
&result_info.id, &expected_info.id,
|
||||
"ID 不匹配"
|
||||
);
|
||||
prop_assert_eq!(
|
||||
&result_info.name, &expected_info.name,
|
||||
"名称不匹配"
|
||||
);
|
||||
prop_assert_eq!(
|
||||
&result_info.description, &expected_info.description,
|
||||
"描述不匹配"
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
// ID 不存在时,返回 None
|
||||
}
|
||||
(Some(_), None) => {
|
||||
prop_assert!(false, "期望找到 ID {} 但返回 None", query_id);
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
prop_assert!(false, "ID {} 不应该存在但返回了结果", query_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 is_valid 与 get 的一致性
|
||||
let is_valid = registry.is_valid(&query_id);
|
||||
let get_result = registry.get(&query_id);
|
||||
prop_assert_eq!(
|
||||
is_valid, get_result.is_some(),
|
||||
"is_valid 与 get 结果不一致"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试缓存往返一致性
|
||||
#[test]
|
||||
fn prop_cache_round_trip(relays in arb_relay_info_list(5)) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
|
||||
// 创建并保存
|
||||
let registry1 = RelayRegistry::new(cache_path.clone());
|
||||
let data = RegistryData {
|
||||
version: "1.0.0".to_string(),
|
||||
updated_at: "2026-01-05T00:00:00Z".to_string(),
|
||||
providers: relays.clone(),
|
||||
};
|
||||
registry1.load_from_data(data);
|
||||
registry1.save_to_cache().unwrap();
|
||||
|
||||
// 从缓存加载
|
||||
let registry2 = RelayRegistry::new(cache_path);
|
||||
registry2.load_from_cache().unwrap();
|
||||
|
||||
// 验证数量一致
|
||||
prop_assert_eq!(
|
||||
registry1.len(), registry2.len(),
|
||||
"缓存往返后数量不一致"
|
||||
);
|
||||
|
||||
// 验证每个 relay 都能正确加载
|
||||
for relay in &relays {
|
||||
let loaded = registry2.get(&relay.id);
|
||||
prop_assert!(
|
||||
loaded.is_some(),
|
||||
"缓存往返后找不到 ID: {}", relay.id
|
||||
);
|
||||
let loaded = loaded.unwrap();
|
||||
prop_assert_eq!(
|
||||
&loaded.name, &relay.name,
|
||||
"缓存往返后名称不匹配"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 list 返回所有已加载的中转商
|
||||
#[test]
|
||||
fn prop_list_returns_all(relays in arb_relay_info_list(10)) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache_path = temp_dir.path().join("registry.json");
|
||||
let registry = RelayRegistry::new(cache_path);
|
||||
|
||||
let data = RegistryData {
|
||||
version: "1.0.0".to_string(),
|
||||
updated_at: "2026-01-05T00:00:00Z".to_string(),
|
||||
providers: relays.clone(),
|
||||
};
|
||||
registry.load_from_data(data);
|
||||
|
||||
let list = registry.list();
|
||||
|
||||
// 验证数量
|
||||
prop_assert_eq!(
|
||||
list.len(), relays.len(),
|
||||
"list 返回数量不正确"
|
||||
);
|
||||
|
||||
// 验证每个 relay 都在列表中
|
||||
for relay in &relays {
|
||||
prop_assert!(
|
||||
list.iter().any(|r| r.id == relay.id),
|
||||
"list 中缺少 ID: {}", relay.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Webhook 回调模块
|
||||
//!
|
||||
//! 实现统计回调功能,让中转商追踪推广效果。
|
||||
//!
|
||||
//! ## 功能
|
||||
//!
|
||||
//! - 在用户确认/取消配置后发送回调
|
||||
//! - 支持重试机制
|
||||
//! - 隐私保护(仅发送脱敏数据)
|
||||
//!
|
||||
//! ## 回调事件
|
||||
//!
|
||||
//! - `success` - 配置成功
|
||||
//! - `cancelled` - 用户取消
|
||||
//! - `error` - 配置失败
|
||||
//!
|
||||
//! ## 安全说明
|
||||
//!
|
||||
//! 由于 ProxyCast 是开源软件,不使用签名验证。
|
||||
//! 中转商应通过检查 `key_prefix` 是否为自己下发的 Key 来验证请求。
|
||||
//!
|
||||
//! _Requirements: 5.3_
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Webhook 错误类型
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WebhookError {
|
||||
/// 网络请求失败
|
||||
#[error("网络请求失败: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
/// 回调地址无效
|
||||
#[error("回调地址无效: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
/// 重试次数耗尽
|
||||
#[error("重试次数耗尽")]
|
||||
RetryExhausted,
|
||||
}
|
||||
|
||||
/// 回调状态
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CallbackStatus {
|
||||
/// 配置成功
|
||||
Success,
|
||||
/// 用户取消
|
||||
Cancelled,
|
||||
/// 配置失败
|
||||
Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CallbackStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CallbackStatus::Success => write!(f, "success"),
|
||||
CallbackStatus::Cancelled => write!(f, "cancelled"),
|
||||
CallbackStatus::Error => write!(f, "error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端信息
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ClientInfo {
|
||||
/// ProxyCast 版本
|
||||
pub version: String,
|
||||
/// 平台(macos, windows, linux)
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
impl Default for ClientInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
platform: get_platform(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前平台
|
||||
fn get_platform() -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
return "macos".to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
return "windows".to_string();
|
||||
#[cfg(target_os = "linux")]
|
||||
return "linux".to_string();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
return "unknown".to_string();
|
||||
}
|
||||
|
||||
/// 回调 Payload
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CallbackPayload {
|
||||
/// 事件类型
|
||||
pub event: String,
|
||||
/// 状态
|
||||
pub status: CallbackStatus,
|
||||
/// 中转商 ID
|
||||
pub relay_id: String,
|
||||
/// 推广码(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ref_code: Option<String>,
|
||||
/// Key 前缀(脱敏,仅前 7 位)
|
||||
pub key_prefix: String,
|
||||
/// 事件时间
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// 客户端信息
|
||||
pub client: ClientInfo,
|
||||
/// 错误码(仅 status=error 时)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
/// 错误信息(仅 status=error 时)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl CallbackPayload {
|
||||
/// 创建成功回调
|
||||
pub fn success(relay_id: &str, key: &str, ref_code: Option<String>) -> Self {
|
||||
Self {
|
||||
event: "connect".to_string(),
|
||||
status: CallbackStatus::Success,
|
||||
relay_id: relay_id.to_string(),
|
||||
ref_code,
|
||||
key_prefix: mask_key(key),
|
||||
timestamp: Utc::now(),
|
||||
client: ClientInfo::default(),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建取消回调
|
||||
pub fn cancelled(relay_id: &str, key: &str, ref_code: Option<String>) -> Self {
|
||||
Self {
|
||||
event: "connect".to_string(),
|
||||
status: CallbackStatus::Cancelled,
|
||||
relay_id: relay_id.to_string(),
|
||||
ref_code,
|
||||
key_prefix: mask_key(key),
|
||||
timestamp: Utc::now(),
|
||||
client: ClientInfo::default(),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建错误回调
|
||||
pub fn error(
|
||||
relay_id: &str,
|
||||
key: &str,
|
||||
ref_code: Option<String>,
|
||||
error_code: &str,
|
||||
error_message: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
event: "connect".to_string(),
|
||||
status: CallbackStatus::Error,
|
||||
relay_id: relay_id.to_string(),
|
||||
ref_code,
|
||||
key_prefix: mask_key(key),
|
||||
timestamp: Utc::now(),
|
||||
client: ClientInfo::default(),
|
||||
error_code: Some(error_code.to_string()),
|
||||
error_message: Some(error_message.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 脱敏 API Key(仅保留前 7 位)
|
||||
fn mask_key(key: &str) -> String {
|
||||
if key.len() <= 7 {
|
||||
key.to_string()
|
||||
} else {
|
||||
key[..7].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Webhook 发送器
|
||||
pub struct WebhookSender {
|
||||
/// HTTP 客户端
|
||||
client: reqwest::Client,
|
||||
/// 最大重试次数
|
||||
max_retries: u32,
|
||||
/// 重试间隔(秒)
|
||||
retry_intervals: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Default for WebhookSender {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookSender {
|
||||
/// 创建新的 WebhookSender
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
max_retries: 4,
|
||||
retry_intervals: vec![0, 60, 300, 1800], // 立即, 1分钟, 5分钟, 30分钟
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送回调(带重试)
|
||||
pub async fn send(
|
||||
&self,
|
||||
callback_url: &str,
|
||||
payload: &CallbackPayload,
|
||||
) -> Result<(), WebhookError> {
|
||||
// 验证 URL
|
||||
if !callback_url.starts_with("https://") {
|
||||
return Err(WebhookError::InvalidUrl(
|
||||
"回调地址必须使用 HTTPS".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let payload_json = serde_json::to_string(payload)
|
||||
.map_err(|e| WebhookError::NetworkError(e.to_string()))?;
|
||||
|
||||
// 重试循环
|
||||
for attempt in 0..self.max_retries {
|
||||
// 等待重试间隔
|
||||
if attempt > 0 {
|
||||
let interval = self.retry_intervals.get(attempt as usize).unwrap_or(&1800);
|
||||
tracing::info!("[Webhook] 第 {} 次重试,等待 {} 秒", attempt, interval);
|
||||
tokio::time::sleep(Duration::from_secs(*interval)).await;
|
||||
}
|
||||
|
||||
match self.send_once(callback_url, &payload_json).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"[Webhook] 回调发送成功: relay={}, status={}",
|
||||
payload.relay_id,
|
||||
payload.status
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[Webhook] 回调发送失败 (尝试 {}/{}): {}",
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
e
|
||||
);
|
||||
if attempt == self.max_retries - 1 {
|
||||
return Err(WebhookError::RetryExhausted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(WebhookError::RetryExhausted)
|
||||
}
|
||||
|
||||
/// 发送单次请求
|
||||
async fn send_once(&self, url: &str, payload: &str) -> Result<(), WebhookError> {
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header(
|
||||
"User-Agent",
|
||||
format!("ProxyCast/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.body(payload.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WebhookError::NetworkError(e.to_string()))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WebhookError::NetworkError(format!(
|
||||
"HTTP 状态码: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步发送回调(不阻塞,在后台执行)
|
||||
pub fn send_async(&self, callback_url: String, payload: CallbackPayload) {
|
||||
let client = self.client.clone();
|
||||
let max_retries = self.max_retries;
|
||||
let retry_intervals = self.retry_intervals.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let sender = WebhookSender {
|
||||
client,
|
||||
max_retries,
|
||||
retry_intervals,
|
||||
};
|
||||
|
||||
if let Err(e) = sender.send(&callback_url, &payload).await {
|
||||
tracing::error!(
|
||||
"[Webhook] 回调最终失败: relay={}, error={}",
|
||||
payload.relay_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局 WebhookSender 实例
|
||||
static WEBHOOK_SENDER: std::sync::OnceLock<WebhookSender> = std::sync::OnceLock::new();
|
||||
|
||||
/// 获取全局 WebhookSender
|
||||
pub fn get_webhook_sender() -> &'static WebhookSender {
|
||||
WEBHOOK_SENDER.get_or_init(WebhookSender::new)
|
||||
}
|
||||
|
||||
/// 发送成功回调
|
||||
pub fn send_success_callback(
|
||||
callback_url: &str,
|
||||
relay_id: &str,
|
||||
key: &str,
|
||||
ref_code: Option<String>,
|
||||
) {
|
||||
let payload = CallbackPayload::success(relay_id, key, ref_code);
|
||||
get_webhook_sender().send_async(callback_url.to_string(), payload);
|
||||
}
|
||||
|
||||
/// 发送取消回调
|
||||
pub fn send_cancelled_callback(
|
||||
callback_url: &str,
|
||||
relay_id: &str,
|
||||
key: &str,
|
||||
ref_code: Option<String>,
|
||||
) {
|
||||
let payload = CallbackPayload::cancelled(relay_id, key, ref_code);
|
||||
get_webhook_sender().send_async(callback_url.to_string(), payload);
|
||||
}
|
||||
|
||||
/// 发送错误回调
|
||||
pub fn send_error_callback(
|
||||
callback_url: &str,
|
||||
relay_id: &str,
|
||||
key: &str,
|
||||
ref_code: Option<String>,
|
||||
error_code: &str,
|
||||
error_message: &str,
|
||||
) {
|
||||
let payload = CallbackPayload::error(relay_id, key, ref_code, error_code, error_message);
|
||||
get_webhook_sender().send_async(callback_url.to_string(), payload);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mask_key_short() {
|
||||
assert_eq!(mask_key("sk-abc"), "sk-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_key_long() {
|
||||
assert_eq!(mask_key("sk-1234567890abcdef"), "sk-1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_callback_payload_success() {
|
||||
let payload =
|
||||
CallbackPayload::success("test-relay", "sk-test123456", Some("promo".to_string()));
|
||||
|
||||
assert_eq!(payload.event, "connect");
|
||||
assert_eq!(payload.status, CallbackStatus::Success);
|
||||
assert_eq!(payload.relay_id, "test-relay");
|
||||
assert_eq!(payload.key_prefix, "sk-test");
|
||||
assert_eq!(payload.ref_code, Some("promo".to_string()));
|
||||
assert!(payload.error_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_callback_payload_cancelled() {
|
||||
let payload = CallbackPayload::cancelled("test-relay", "sk-test123456", None);
|
||||
|
||||
assert_eq!(payload.status, CallbackStatus::Cancelled);
|
||||
assert!(payload.ref_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_callback_payload_error() {
|
||||
let payload = CallbackPayload::error(
|
||||
"test-relay",
|
||||
"sk-test123456",
|
||||
None,
|
||||
"INVALID_KEY",
|
||||
"Key 验证失败",
|
||||
);
|
||||
|
||||
assert_eq!(payload.status, CallbackStatus::Error);
|
||||
assert_eq!(payload.error_code, Some("INVALID_KEY".to_string()));
|
||||
assert_eq!(payload.error_message, Some("Key 验证失败".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_callback_payload_serialization() {
|
||||
let payload = CallbackPayload::success("test", "sk-123456789", None);
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
|
||||
assert!(json.contains(r#""event":"connect""#));
|
||||
assert!(json.contains(r#""status":"success""#));
|
||||
assert!(json.contains(r#""relay_id":"test""#));
|
||||
// ref_code 为 None 时不应该出现在 JSON 中
|
||||
assert!(!json.contains("ref_code"));
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,10 @@ pub async fn credentials_select(
|
||||
// 没有找到可用凭证
|
||||
Err(CredentialApiError {
|
||||
error: "no_available_credentials".to_string(),
|
||||
message: format!("没有可用的 {} 凭证。您可以在 API Key Provider 中配置 API Key 作为降级选项。", request.provider_type),
|
||||
message: format!(
|
||||
"没有可用的 {} 凭证。您可以在 API Key Provider 中配置 API Key 作为降级选项。",
|
||||
request.provider_type
|
||||
),
|
||||
status_code: 503,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@ pub async fn select_credential(
|
||||
})?
|
||||
.ok_or_else(|| ApiError {
|
||||
error: "no_available_credentials".to_string(),
|
||||
message: "没有可用的 Kiro 凭证。Kiro 仅支持 OAuth 认证,无法降级到 API Key。".to_string(),
|
||||
message: "没有可用的 Kiro 凭证。Kiro 仅支持 OAuth 认证,无法降级到 API Key。"
|
||||
.to_string(),
|
||||
status_code: 503,
|
||||
})?
|
||||
};
|
||||
|
||||
+28
-44
@@ -1302,17 +1302,13 @@ async fn anthropic_messages_with_selector(
|
||||
Some(cred)
|
||||
}
|
||||
// 最后尝试按 provider 类型轮询(带智能降级)
|
||||
else if let Ok(Some(cred)) =
|
||||
state
|
||||
.pool_service
|
||||
.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&selector,
|
||||
Some(&request.model),
|
||||
None, // provider_id_hint
|
||||
)
|
||||
{
|
||||
else if let Ok(Some(cred)) = state.pool_service.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&selector,
|
||||
Some(&request.model),
|
||||
None, // provider_id_hint
|
||||
) {
|
||||
Some(cred)
|
||||
} else {
|
||||
None
|
||||
@@ -1382,17 +1378,13 @@ async fn chat_completions_with_selector(
|
||||
Some(cred)
|
||||
} else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) {
|
||||
Some(cred)
|
||||
} else if let Ok(Some(cred)) =
|
||||
state
|
||||
.pool_service
|
||||
.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&selector,
|
||||
Some(&request.model),
|
||||
None, // provider_id_hint
|
||||
)
|
||||
{
|
||||
} else if let Ok(Some(cred)) = state.pool_service.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&selector,
|
||||
Some(&request.model),
|
||||
None, // provider_id_hint
|
||||
) {
|
||||
Some(cred)
|
||||
} else {
|
||||
None
|
||||
@@ -1478,17 +1470,13 @@ async fn amp_chat_completions(
|
||||
let credential = match &state.db {
|
||||
Some(db) => {
|
||||
// 首先尝试按 provider 类型选择(带智能降级)
|
||||
if let Ok(Some(cred)) =
|
||||
state
|
||||
.pool_service
|
||||
.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&provider,
|
||||
Some(&request.model),
|
||||
Some(&provider), // provider_id_hint 使用路由中的 provider 名称
|
||||
)
|
||||
{
|
||||
if let Ok(Some(cred)) = state.pool_service.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&provider,
|
||||
Some(&request.model),
|
||||
Some(&provider), // provider_id_hint 使用路由中的 provider 名称
|
||||
) {
|
||||
Some(cred)
|
||||
}
|
||||
// 然后尝试按名称查找
|
||||
@@ -1580,17 +1568,13 @@ async fn amp_messages(
|
||||
let credential = match &state.db {
|
||||
Some(db) => {
|
||||
// 首先尝试按 provider 类型选择(带智能降级)
|
||||
if let Ok(Some(cred)) =
|
||||
state
|
||||
.pool_service
|
||||
.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&provider,
|
||||
Some(&request.model),
|
||||
Some(&provider), // provider_id_hint 使用路由中的 provider 名称
|
||||
)
|
||||
{
|
||||
if let Ok(Some(cred)) = state.pool_service.select_credential_with_fallback(
|
||||
db,
|
||||
&state.api_key_service,
|
||||
&provider,
|
||||
Some(&request.model),
|
||||
Some(&provider), // provider_id_hint 使用路由中的 provider 名称
|
||||
) {
|
||||
Some(cred)
|
||||
}
|
||||
// 然后尝试按名称查找
|
||||
|
||||
@@ -698,11 +698,7 @@ impl ApiKeyProviderService {
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
// 策略 1: 通过类型映射查找
|
||||
if let Some(api_type) = self.map_pool_type_to_api_type(pool_type) {
|
||||
tracing::debug!(
|
||||
"[智能降级] 尝试类型映射: {:?} -> {:?}",
|
||||
pool_type,
|
||||
api_type
|
||||
);
|
||||
tracing::debug!("[智能降级] 尝试类型映射: {:?} -> {:?}", pool_type, api_type);
|
||||
if let Some(cred) = self.find_by_api_type(db, pool_type, &api_type)? {
|
||||
return Ok(Some(cred));
|
||||
}
|
||||
@@ -710,10 +706,7 @@ impl ApiKeyProviderService {
|
||||
|
||||
// 策略 2: 通过 provider_id 直接查找 (支持 60+ Provider)
|
||||
if let Some(provider_id) = provider_id_hint {
|
||||
tracing::debug!(
|
||||
"[智能降级] 尝试 provider_id 查找: {}",
|
||||
provider_id
|
||||
);
|
||||
tracing::debug!("[智能降级] 尝试 provider_id 查找: {}", provider_id);
|
||||
if let Some(cred) = self.find_by_provider_id(db, provider_id)? {
|
||||
return Ok(Some(cred));
|
||||
}
|
||||
@@ -730,10 +723,7 @@ impl ApiKeyProviderService {
|
||||
/// PoolProviderType → ApiProviderType 映射
|
||||
///
|
||||
/// 仅映射有明确对应关系的类型
|
||||
fn map_pool_type_to_api_type(
|
||||
&self,
|
||||
pool_type: &PoolProviderType,
|
||||
) -> Option<ApiProviderType> {
|
||||
fn map_pool_type_to_api_type(&self, pool_type: &PoolProviderType) -> Option<ApiProviderType> {
|
||||
match pool_type {
|
||||
// API Key 类型 - 直接映射
|
||||
PoolProviderType::Claude => Some(ApiProviderType::Anthropic),
|
||||
@@ -743,7 +733,7 @@ impl ApiKeyProviderService {
|
||||
|
||||
// OAuth 类型 - 可降级到 API Key
|
||||
PoolProviderType::Gemini => Some(ApiProviderType::Gemini), // Gemini OAuth → Gemini API Key
|
||||
PoolProviderType::Qwen => Some(ApiProviderType::Openai), // Qwen OAuth → Dashscope (OpenAI 兼容)
|
||||
PoolProviderType::Qwen => Some(ApiProviderType::Openai), // Qwen OAuth → Dashscope (OpenAI 兼容)
|
||||
|
||||
// API Key Provider 类型 - 直接映射
|
||||
PoolProviderType::Anthropic => Some(ApiProviderType::Anthropic),
|
||||
@@ -770,8 +760,7 @@ impl ApiKeyProviderService {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// 查找该类型的启用的 Provider(按 sort_order 排序)
|
||||
let providers =
|
||||
ApiKeyProviderDao::get_all_providers(&conn).map_err(|e| e.to_string())?;
|
||||
let providers = ApiKeyProviderDao::get_all_providers(&conn).map_err(|e| e.to_string())?;
|
||||
|
||||
let matching_providers: Vec<_> = providers
|
||||
.into_iter()
|
||||
@@ -838,8 +827,8 @@ impl ApiKeyProviderService {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// 直接按 provider_id 查找
|
||||
let provider = ApiKeyProviderDao::get_provider_by_id(&conn, provider_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let provider =
|
||||
ApiKeyProviderDao::get_provider_by_id(&conn, provider_id).map_err(|e| e.to_string())?;
|
||||
|
||||
let provider = match provider {
|
||||
Some(p) if p.enabled => p,
|
||||
@@ -870,11 +859,8 @@ impl ApiKeyProviderService {
|
||||
|
||||
// 转换为 OpenAI 兼容的 ProviderCredential
|
||||
// 大多数 60+ Provider 都使用 OpenAI 兼容协议
|
||||
let credential = self.convert_to_openai_compatible_credential(
|
||||
&provider,
|
||||
&selected_key.id,
|
||||
&api_key,
|
||||
)?;
|
||||
let credential =
|
||||
self.convert_to_openai_compatible_credential(&provider, &selected_key.id, &api_key)?;
|
||||
|
||||
tracing::info!(
|
||||
"[智能降级] 成功通过 provider_id 找到凭证: {} (key: {})",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
use crate::database::dao::provider_pool::ProviderPoolDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::api_key_provider_service::ApiKeyProviderService;
|
||||
use crate::models::provider_pool_model::{
|
||||
get_default_check_model, get_oauth_creds_path, CredentialData, CredentialDisplay,
|
||||
HealthCheckResult, OAuthStatus, PoolProviderType, PoolStats, ProviderCredential,
|
||||
@@ -15,6 +14,7 @@ use crate::models::provider_pool_model::{
|
||||
use crate::models::route_model::RouteInfo;
|
||||
use crate::providers::antigravity::TokenRefreshError;
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
use crate::services::api_key_provider_service::ApiKeyProviderService;
|
||||
use chrono::Utc;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -351,9 +351,7 @@ impl ProviderPoolService {
|
||||
}
|
||||
|
||||
// Step 2: 智能降级到 API Key Provider
|
||||
let pt: PoolProviderType = provider_type
|
||||
.parse()
|
||||
.unwrap_or(PoolProviderType::OpenAI);
|
||||
let pt: PoolProviderType = provider_type.parse().unwrap_or(PoolProviderType::OpenAI);
|
||||
|
||||
// 传入 provider_id_hint 支持 60+ Provider
|
||||
if let Some(cred) = api_key_service.get_fallback_credential(db, &pt, provider_id_hint)? {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.29.0",
|
||||
"version": "0.30.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @file Connect 确认弹窗属性测试
|
||||
* @description 测试 Provider Display 的完整性
|
||||
* @module components/connect/ConnectConfirmDialog.test
|
||||
*
|
||||
* **Feature: proxycast-connect, Property 7: Provider Display Completeness**
|
||||
* **Validates: Requirements 6.1, 6.3**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import type { RelayInfo } from "@/hooks/useDeepLink";
|
||||
|
||||
/**
|
||||
* 生成有效的十六进制颜色值
|
||||
*/
|
||||
const hexColorArbitrary = fc
|
||||
.array(fc.integer({ min: 0, max: 15 }), { minLength: 6, maxLength: 6 })
|
||||
.map((arr) => "#" + arr.map((n) => n.toString(16)).join(""));
|
||||
|
||||
/**
|
||||
* 生成有效的 RelayInfo 对象的 Arbitrary
|
||||
*/
|
||||
const relayInfoArbitrary: fc.Arbitrary<RelayInfo> = fc.record({
|
||||
id: fc
|
||||
.string({ minLength: 1, maxLength: 50 })
|
||||
.filter((s) => s.trim().length > 0),
|
||||
name: fc
|
||||
.string({ minLength: 1, maxLength: 100 })
|
||||
.filter((s) => s.trim().length > 0),
|
||||
description: fc
|
||||
.string({ minLength: 1, maxLength: 500 })
|
||||
.filter((s) => s.trim().length > 0),
|
||||
branding: fc.record({
|
||||
logo: fc.webUrl(),
|
||||
color: hexColorArbitrary,
|
||||
}),
|
||||
links: fc.record({
|
||||
homepage: fc.webUrl(),
|
||||
register: fc.option(fc.webUrl(), { nil: undefined }),
|
||||
recharge: fc.option(fc.webUrl(), { nil: undefined }),
|
||||
docs: fc.option(fc.webUrl(), { nil: undefined }),
|
||||
status: fc.option(fc.webUrl(), { nil: undefined }),
|
||||
}),
|
||||
api: fc.record({
|
||||
base_url: fc.webUrl(),
|
||||
protocol: fc.constantFrom("openai", "claude", "gemini"),
|
||||
auth_header: fc.constant("Authorization"),
|
||||
auth_prefix: fc.constant("Bearer"),
|
||||
}),
|
||||
contact: fc.record({
|
||||
email: fc.option(fc.emailAddress(), { nil: undefined }),
|
||||
discord: fc.option(fc.string(), { nil: undefined }),
|
||||
telegram: fc.option(fc.string(), { nil: undefined }),
|
||||
twitter: fc.option(fc.string(), { nil: undefined }),
|
||||
}),
|
||||
features: fc.record({
|
||||
models: fc.array(fc.string({ minLength: 1 }), {
|
||||
minLength: 0,
|
||||
maxLength: 10,
|
||||
}),
|
||||
streaming: fc.boolean(),
|
||||
function_calling: fc.boolean(),
|
||||
vision: fc.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* 提取 Provider 显示所需的核心信息
|
||||
*
|
||||
* 这个函数模拟 ProviderInfo 组件的数据提取逻辑,
|
||||
* 用于验证所有必要信息都能被正确提取和显示。
|
||||
*
|
||||
* @param relay - 中转商信息
|
||||
* @returns 显示所需的核心字段
|
||||
*/
|
||||
function extractProviderDisplayInfo(relay: RelayInfo): {
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
description: string;
|
||||
homepageUrl: string | undefined;
|
||||
themeColor: string;
|
||||
} {
|
||||
return {
|
||||
name: relay.name,
|
||||
logoUrl: relay.branding.logo,
|
||||
description: relay.description,
|
||||
homepageUrl: relay.links.homepage,
|
||||
themeColor: relay.branding.color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Provider 显示信息的完整性
|
||||
*
|
||||
* @param displayInfo - 提取的显示信息
|
||||
* @param originalRelay - 原始 RelayInfo
|
||||
* @returns 是否完整
|
||||
*/
|
||||
function validateProviderDisplayCompleteness(
|
||||
displayInfo: ReturnType<typeof extractProviderDisplayInfo>,
|
||||
originalRelay: RelayInfo,
|
||||
): boolean {
|
||||
// 验证名称存在且与原始数据匹配
|
||||
if (!displayInfo.name || displayInfo.name !== originalRelay.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证 Logo URL 存在且与原始数据匹配
|
||||
if (
|
||||
!displayInfo.logoUrl ||
|
||||
displayInfo.logoUrl !== originalRelay.branding.logo
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证描述存在且与原始数据匹配
|
||||
if (
|
||||
!displayInfo.description ||
|
||||
displayInfo.description !== originalRelay.description
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("Provider Display 属性测试", () => {
|
||||
/**
|
||||
* Property 7: Provider Display Completeness
|
||||
*
|
||||
* *对于任意* RelayInfo 对象,渲染的 provider 显示应包含:
|
||||
* - provider 的名称
|
||||
* - logo URL
|
||||
* - 描述
|
||||
*
|
||||
* **Feature: proxycast-connect, Property 7: Provider Display Completeness**
|
||||
* **Validates: Requirements 6.1, 6.3**
|
||||
*/
|
||||
describe("Property 7: Provider Display Completeness", () => {
|
||||
test.prop([relayInfoArbitrary], { numRuns: 100 })(
|
||||
"对于任意 RelayInfo,提取的显示信息应包含 name、logo URL 和 description",
|
||||
(relay: RelayInfo) => {
|
||||
// 提取显示信息
|
||||
const displayInfo = extractProviderDisplayInfo(relay);
|
||||
|
||||
// 验证完整性
|
||||
const isComplete = validateProviderDisplayCompleteness(
|
||||
displayInfo,
|
||||
relay,
|
||||
);
|
||||
expect(isComplete).toBe(true);
|
||||
|
||||
// 额外验证:确保所有必要字段都存在且非空
|
||||
expect(displayInfo.name).toBeTruthy();
|
||||
expect(displayInfo.name.length).toBeGreaterThan(0);
|
||||
|
||||
expect(displayInfo.logoUrl).toBeTruthy();
|
||||
expect(displayInfo.logoUrl.length).toBeGreaterThan(0);
|
||||
|
||||
expect(displayInfo.description).toBeTruthy();
|
||||
expect(displayInfo.description.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([relayInfoArbitrary], { numRuns: 100 })(
|
||||
"提取的显示信息应与原始 RelayInfo 数据完全匹配",
|
||||
(relay: RelayInfo) => {
|
||||
const displayInfo = extractProviderDisplayInfo(relay);
|
||||
|
||||
// 验证数据一致性
|
||||
expect(displayInfo.name).toBe(relay.name);
|
||||
expect(displayInfo.logoUrl).toBe(relay.branding.logo);
|
||||
expect(displayInfo.description).toBe(relay.description);
|
||||
expect(displayInfo.homepageUrl).toBe(relay.links.homepage);
|
||||
expect(displayInfo.themeColor).toBe(relay.branding.color);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([relayInfoArbitrary], { numRuns: 100 })(
|
||||
"显示信息中的 URL 字段应为有效的 URL 格式",
|
||||
(relay: RelayInfo) => {
|
||||
const displayInfo = extractProviderDisplayInfo(relay);
|
||||
|
||||
// 验证 Logo URL 格式
|
||||
expect(() => new URL(displayInfo.logoUrl)).not.toThrow();
|
||||
|
||||
// 验证主页 URL 格式(如果存在)
|
||||
if (displayInfo.homepageUrl) {
|
||||
expect(() => new URL(displayInfo.homepageUrl!)).not.toThrow();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file Connect 确认弹窗组件
|
||||
* @description 显示中转商信息和脱敏 API Key,让用户确认添加
|
||||
* @module components/connect/ConnectConfirmDialog
|
||||
*
|
||||
* _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 6.1, 6.2, 6.4_
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { maskApiKey } from "@/lib/utils/apiKeyMask";
|
||||
import type { RelayInfo, ConnectError } from "@/hooks/useDeepLink";
|
||||
|
||||
/**
|
||||
* ConnectConfirmDialog 组件属性
|
||||
*/
|
||||
export interface ConnectConfirmDialogProps {
|
||||
/** 弹窗是否打开 */
|
||||
open: boolean;
|
||||
/** 中转商信息(如果在注册表中找到) */
|
||||
relay: RelayInfo | null;
|
||||
/** 中转商 ID(用于未验证警告显示) */
|
||||
relayId: string;
|
||||
/** API Key */
|
||||
apiKey: string;
|
||||
/** Key 名称(可选) */
|
||||
keyName?: string;
|
||||
/** 是否为已验证的中转商 */
|
||||
isVerified: boolean;
|
||||
/** 是否正在保存 */
|
||||
isSaving: boolean;
|
||||
/** 错误信息 */
|
||||
error: ConnectError | null;
|
||||
/** 确认回调 */
|
||||
onConfirm: () => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中转商信息展示组件(已验证)
|
||||
* _Requirements: 6.1, 6.2_
|
||||
*/
|
||||
function VerifiedProviderInfo({ relay }: { relay: RelayInfo }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-100">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="w-14 h-14 rounded-xl flex items-center justify-center overflow-hidden shadow-sm flex-shrink-0"
|
||||
style={{ backgroundColor: relay.branding.color || "#6366f1" }}
|
||||
>
|
||||
{relay.branding.logo ? (
|
||||
<img
|
||||
src={relay.branding.logo}
|
||||
alt={relay.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
e.currentTarget.parentElement!.innerHTML = `<span class="text-white text-xl font-bold">${relay.name.charAt(0).toUpperCase()}</span>`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white text-xl font-bold">
|
||||
{relay.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-lg">{relay.name}</h3>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
✓ 已验证
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{relay.description}
|
||||
</p>
|
||||
{relay.links.homepage && (
|
||||
<a
|
||||
href={relay.links.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 hover:underline mt-2 inline-flex items-center gap-1"
|
||||
>
|
||||
访问官网
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未验证中转商信息展示组件
|
||||
* _Requirements: 3.5, 6.4_
|
||||
*/
|
||||
function UnverifiedProviderInfo({ relayId }: { relayId: string }) {
|
||||
return (
|
||||
<div className="p-4 bg-gradient-to-r from-amber-50 to-orange-50 rounded-xl border border-amber-200">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 警告图标 */}
|
||||
<div className="w-14 h-14 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-8 h-8 text-amber-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-lg">{relayId}</h3>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||
未验证
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
此中转商不在官方注册表中,请确认您信任此来源后再添加。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 信息展示组件
|
||||
* _Requirements: 3.2_
|
||||
*/
|
||||
function KeyInfo({
|
||||
maskedKey,
|
||||
keyName,
|
||||
}: {
|
||||
maskedKey: string;
|
||||
keyName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">API Key</span>
|
||||
</div>
|
||||
<code className="font-mono text-sm bg-white px-3 py-1.5 rounded-md border border-gray-200 text-gray-800">
|
||||
{maskedKey}
|
||||
</code>
|
||||
</div>
|
||||
{keyName && (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">名称</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800">{keyName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect 确认弹窗组件
|
||||
*
|
||||
* 显示中转商信息和脱敏 API Key,让用户确认是否添加。
|
||||
*
|
||||
* @param props - 组件属性
|
||||
*/
|
||||
export function ConnectConfirmDialog({
|
||||
open,
|
||||
relay,
|
||||
relayId,
|
||||
apiKey,
|
||||
keyName,
|
||||
isVerified,
|
||||
isSaving,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectConfirmDialogProps) {
|
||||
const maskedKey = maskApiKey(apiKey);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||
<DialogContent className="sm:max-w-[480px] p-6">
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="text-xl flex items-center gap-2">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
添加 API Key
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-500">
|
||||
确认添加以下中转商的 API Key 到 ProxyCast
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 中转商信息 */}
|
||||
{isVerified && relay ? (
|
||||
<VerifiedProviderInfo relay={relay} />
|
||||
) : (
|
||||
<UnverifiedProviderInfo relayId={relay?.id ?? relayId} />
|
||||
)}
|
||||
|
||||
{/* API Key 信息 */}
|
||||
<KeyInfo maskedKey={maskedKey} keyName={keyName} />
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-red-700">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isSaving}
|
||||
className="flex-1 sm:flex-none"
|
||||
style={
|
||||
relay?.branding.color
|
||||
? { backgroundColor: relay.branding.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"确认添加"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectConfirmDialog;
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @file Connect 错误提示 Alert 组件
|
||||
* @description 提供 ProxyCast Connect 功能的错误提示 Alert 组件
|
||||
* @module components/connect/ConnectErrorToast
|
||||
*
|
||||
* _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type ConnectErrorType,
|
||||
getErrorConfig,
|
||||
} from "@/lib/utils/connectError";
|
||||
|
||||
/**
|
||||
* ConnectErrorAlert 组件属性
|
||||
*/
|
||||
export interface ConnectErrorAlertProps {
|
||||
/** 错误类型 */
|
||||
type: ConnectErrorType;
|
||||
/** 错误消息 */
|
||||
message: string;
|
||||
/** 重试回调 */
|
||||
onRetry?: () => void;
|
||||
/** 关闭回调 */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect 错误提示 Alert 组件
|
||||
*
|
||||
* 用于在 UI 中内联显示错误信息,适用于需要持久显示的错误场景。
|
||||
*
|
||||
* @param props - 组件属性
|
||||
*/
|
||||
export function ConnectErrorAlert({
|
||||
type,
|
||||
message,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
}: ConnectErrorAlertProps) {
|
||||
const config = getErrorConfig(type, onRetry);
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 错误图标 */}
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800">{config.title}</h4>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
{config.description(message)}
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="text-red-700 border-red-300 hover:bg-red-100"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="text-red-600 hover:bg-red-100"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectErrorAlert;
|
||||
@@ -0,0 +1,32 @@
|
||||
# connect
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
ProxyCast Connect 前端组件,实现中转商 API Key 添加确认流程。
|
||||
使用 shadcn/ui 组件库和 TailwindCSS 进行样式管理。
|
||||
|
||||
## 功能概述
|
||||
|
||||
1. **确认弹窗** - 显示中转商信息和脱敏 API Key
|
||||
2. **品牌展示** - 展示中转商 Logo、名称、描述
|
||||
3. **警告提示** - 未验证中转商警告
|
||||
4. **错误提示** - Deep Link 解析、Registry 加载、API Key 存储错误提示
|
||||
|
||||
## 文件索引
|
||||
|
||||
- `index.ts` - 组件导出入口
|
||||
- `ConnectConfirmDialog.tsx` - 确认添加 API Key 弹窗
|
||||
- `ConnectConfirmDialog.test.tsx` - Provider Display 属性测试 (Property 7)
|
||||
- `ConnectErrorToast.tsx` - 错误提示 Toast 组件和工具函数
|
||||
|
||||
## 相关需求
|
||||
|
||||
- Requirements 3.x - 连接确认弹窗
|
||||
- Requirements 6.x - 中转商品牌展示
|
||||
- Requirements 7.x - 错误处理
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @file ProxyCast Connect 组件入口
|
||||
* @description 导出 Connect 功能相关的所有组件
|
||||
* @module components/connect
|
||||
*/
|
||||
|
||||
// 组件导出
|
||||
export { ConnectConfirmDialog } from "./ConnectConfirmDialog";
|
||||
export type { ConnectConfirmDialogProps } from "./ConnectConfirmDialog";
|
||||
|
||||
// 错误提示组件导出
|
||||
// _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||
export { ConnectErrorAlert } from "./ConnectErrorToast";
|
||||
export type { ConnectErrorAlertProps } from "./ConnectErrorToast";
|
||||
|
||||
// 错误提示工具函数从 lib/utils/connectError 导出
|
||||
// 使用方式: import { showDeepLinkError } from "@/lib/utils/connectError";
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @file Connect 统计回调 Hook
|
||||
* @description 提供统计回调功能,让中转商追踪推广效果
|
||||
* @module hooks/useConnectCallback
|
||||
*
|
||||
* _Requirements: 5.3_
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* 回调状态类型
|
||||
*/
|
||||
export type CallbackStatus = "success" | "cancelled" | "error";
|
||||
|
||||
/**
|
||||
* 发送回调的参数
|
||||
*/
|
||||
export interface SendCallbackParams {
|
||||
/** 中转商 ID */
|
||||
relayId: string;
|
||||
/** API Key */
|
||||
apiKey: string;
|
||||
/** 回调状态 */
|
||||
status: CallbackStatus;
|
||||
/** 推广码(可选) */
|
||||
refCode?: string;
|
||||
/** 错误码(仅 status=error 时) */
|
||||
errorCode?: string;
|
||||
/** 错误信息(仅 status=error 时) */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useConnectCallback Hook 返回值
|
||||
*/
|
||||
export interface UseConnectCallbackReturn {
|
||||
/** 发送成功回调 */
|
||||
sendSuccessCallback: (
|
||||
relayId: string,
|
||||
apiKey: string,
|
||||
refCode?: string,
|
||||
) => Promise<boolean>;
|
||||
/** 发送取消回调 */
|
||||
sendCancelledCallback: (
|
||||
relayId: string,
|
||||
apiKey: string,
|
||||
refCode?: string,
|
||||
) => Promise<boolean>;
|
||||
/** 发送错误回调 */
|
||||
sendErrorCallback: (
|
||||
relayId: string,
|
||||
apiKey: string,
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
refCode?: string,
|
||||
) => Promise<boolean>;
|
||||
/** 通用发送回调 */
|
||||
sendCallback: (params: SendCallbackParams) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect 统计回调 Hook
|
||||
*
|
||||
* 提供统计回调功能,在用户确认/取消配置后向中转商发送回调。
|
||||
*
|
||||
* ## 功能
|
||||
*
|
||||
* - 发送成功回调(用户确认添加 Key)
|
||||
* - 发送取消回调(用户取消添加)
|
||||
* - 发送错误回调(配置失败)
|
||||
* - 异步发送,不阻塞 UI
|
||||
*
|
||||
* ## 使用示例
|
||||
*
|
||||
* ```tsx
|
||||
* function ConnectDialog() {
|
||||
* const { sendSuccessCallback, sendCancelledCallback } = useConnectCallback();
|
||||
*
|
||||
* const handleConfirm = async () => {
|
||||
* // 保存 API Key...
|
||||
* await sendSuccessCallback(relayId, apiKey, refCode);
|
||||
* };
|
||||
*
|
||||
* const handleCancel = async () => {
|
||||
* await sendCancelledCallback(relayId, apiKey, refCode);
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Hook 返回值
|
||||
*/
|
||||
export function useConnectCallback(): UseConnectCallbackReturn {
|
||||
/**
|
||||
* 通用发送回调
|
||||
*/
|
||||
const sendCallback = useCallback(
|
||||
async (params: SendCallbackParams): Promise<boolean> => {
|
||||
try {
|
||||
const result = await invoke<boolean>("send_connect_callback", {
|
||||
relayId: params.relayId,
|
||||
apiKey: params.apiKey,
|
||||
status: params.status,
|
||||
refCode: params.refCode ?? null,
|
||||
errorCode: params.errorCode ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[useConnectCallback] 回调发送${result ? "成功" : "跳过"}: relay=${params.relayId}, status=${params.status}`,
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
// 回调失败不应该影响主流程,只记录日志
|
||||
console.warn("[useConnectCallback] 发送回调失败:", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 发送成功回调
|
||||
*/
|
||||
const sendSuccessCallback = useCallback(
|
||||
async (
|
||||
relayId: string,
|
||||
apiKey: string,
|
||||
refCode?: string,
|
||||
): Promise<boolean> => {
|
||||
return sendCallback({
|
||||
relayId,
|
||||
apiKey,
|
||||
status: "success",
|
||||
refCode,
|
||||
});
|
||||
},
|
||||
[sendCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
* 发送取消回调
|
||||
*/
|
||||
const sendCancelledCallback = useCallback(
|
||||
async (
|
||||
relayId: string,
|
||||
apiKey: string,
|
||||
refCode?: string,
|
||||
): Promise<boolean> => {
|
||||
return sendCallback({
|
||||
relayId,
|
||||
apiKey,
|
||||
status: "cancelled",
|
||||
refCode,
|
||||
});
|
||||
},
|
||||
[sendCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
* 发送错误回调
|
||||
*/
|
||||
const sendErrorCallback = useCallback(
|
||||
async (
|
||||
relayId: string,
|
||||
apiKey: string,
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
refCode?: string,
|
||||
): Promise<boolean> => {
|
||||
return sendCallback({
|
||||
relayId,
|
||||
apiKey,
|
||||
status: "error",
|
||||
refCode,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
});
|
||||
},
|
||||
[sendCallback],
|
||||
);
|
||||
|
||||
return {
|
||||
sendSuccessCallback,
|
||||
sendCancelledCallback,
|
||||
sendErrorCallback,
|
||||
sendCallback,
|
||||
};
|
||||
}
|
||||
|
||||
export default useConnectCallback;
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file Relay Registry 管理 Hook
|
||||
* @description 管理中转商注册表的加载、刷新和状态
|
||||
* @module hooks/useRelayRegistry
|
||||
*
|
||||
* _Requirements: 2.1, 7.2, 7.3_
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { RelayInfo } from "./useDeepLink";
|
||||
import {
|
||||
showRegistryLoadError,
|
||||
showRegistryNoCacheError,
|
||||
} from "@/lib/utils/connectError";
|
||||
|
||||
/**
|
||||
* Registry 错误
|
||||
*/
|
||||
export interface RegistryError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* useRelayRegistry Hook 返回值
|
||||
*/
|
||||
export interface UseRelayRegistryReturn {
|
||||
/** 所有中转商列表 */
|
||||
providers: RelayInfo[];
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 错误信息 */
|
||||
error: RegistryError | null;
|
||||
/** 刷新注册表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 获取指定中转商信息 */
|
||||
getProvider: (relayId: string) => RelayInfo | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay Registry 管理 Hook
|
||||
*
|
||||
* 管理中转商注册表的加载、刷新和状态。
|
||||
*
|
||||
* ## 功能
|
||||
*
|
||||
* - 应用启动时自动加载注册表(Requirements 2.1)
|
||||
* - 加载失败时回退到缓存(Requirements 7.2)
|
||||
* - 无缓存且加载失败时显示错误(Requirements 7.3)
|
||||
* - 提供手动刷新功能
|
||||
*
|
||||
* ## 使用示例
|
||||
*
|
||||
* ```tsx
|
||||
* function App() {
|
||||
* const { providers, isLoading, error, refresh } = useRelayRegistry();
|
||||
*
|
||||
* if (error) {
|
||||
* return <ErrorMessage error={error} onRetry={refresh} />;
|
||||
* }
|
||||
*
|
||||
* return <ProviderList providers={providers} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Hook 返回值
|
||||
*/
|
||||
export function useRelayRegistry(): UseRelayRegistryReturn {
|
||||
const [providers, setProviders] = useState<RelayInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<RegistryError | null>(null);
|
||||
|
||||
/**
|
||||
* 加载中转商列表
|
||||
* _Requirements: 2.1_
|
||||
*/
|
||||
const loadProviders = useCallback(async () => {
|
||||
try {
|
||||
const list = await invoke<RelayInfo[]>("list_relay_providers");
|
||||
setProviders(list);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("[useRelayRegistry] 加载中转商列表失败:", err);
|
||||
// 不设置错误,因为可能是 Connect 模块还未初始化
|
||||
// 后端会自动处理缓存回退
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 刷新注册表
|
||||
* _Requirements: 2.5, 7.2, 7.3_
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 在调用前捕获当前 providers 长度,避免闭包问题
|
||||
const hasCache = providers.length > 0;
|
||||
|
||||
try {
|
||||
// 调用后端刷新注册表
|
||||
const count = await invoke<number>("refresh_relay_registry");
|
||||
console.log(`[useRelayRegistry] 注册表已刷新,共 ${count} 个中转商`);
|
||||
|
||||
// 重新加载列表
|
||||
await loadProviders();
|
||||
} catch (err) {
|
||||
console.error("[useRelayRegistry] 刷新注册表失败:", err);
|
||||
// _Requirements: 7.2, 7.3_
|
||||
const registryError = err as RegistryError;
|
||||
setError(registryError);
|
||||
|
||||
// 根据错误类型显示不同的 Toast
|
||||
// 检查是否有缓存数据(providers 不为空表示有缓存)
|
||||
if (hasCache) {
|
||||
// _Requirements: 7.2_ - 有缓存,显示加载失败但已回退到缓存
|
||||
showRegistryLoadError(registryError.message);
|
||||
} else {
|
||||
// _Requirements: 7.3_ - 无缓存,显示错误并允许重试
|
||||
showRegistryNoCacheError(registryError.message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadProviders, providers.length]);
|
||||
|
||||
/**
|
||||
* 获取指定中转商信息
|
||||
*/
|
||||
const getProvider = useCallback(
|
||||
(relayId: string): RelayInfo | undefined => {
|
||||
return providers.find((p) => p.id === relayId);
|
||||
},
|
||||
[providers],
|
||||
);
|
||||
|
||||
// 初始加载
|
||||
// _Requirements: 2.1_
|
||||
useEffect(() => {
|
||||
// 延迟加载,等待 Connect 模块初始化
|
||||
const timer = setTimeout(() => {
|
||||
loadProviders();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [loadProviders]);
|
||||
|
||||
return {
|
||||
providers,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
getProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export default useRelayRegistry;
|
||||
@@ -0,0 +1,37 @@
|
||||
# 工具函数库
|
||||
|
||||
本目录包含前端通用工具函数。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `apiKeyMask.ts` | API Key 脱敏工具,用于安全显示 API Key |
|
||||
| `apiKeyMask.test.ts` | API Key 脱敏属性测试 |
|
||||
| `apiKeyValidation.ts` | API Key 格式验证,各 Provider 的验证规则 |
|
||||
| `apiKeyValidation.test.ts` | API Key 格式验证属性测试 |
|
||||
| `connectError.ts` | Connect 错误处理工具函数,Toast 通知 |
|
||||
| `syntaxHighlight.ts` | 代码语法高亮工具 |
|
||||
|
||||
## 主要功能
|
||||
|
||||
### API Key 脱敏 (`apiKeyMask.ts`)
|
||||
|
||||
对 API Key 进行脱敏处理,保护敏感信息:
|
||||
- 长度 > 8:显示前 4 位 + "..." + 后 4 位
|
||||
- 长度 <= 8:返回 "****"
|
||||
|
||||
### API Key 验证 (`apiKeyValidation.ts`)
|
||||
|
||||
验证各 Provider 的 API Key 格式:
|
||||
- 前缀验证(如 OpenAI 的 `sk-`)
|
||||
- 长度验证
|
||||
- 字符验证
|
||||
|
||||
### Connect 错误处理 (`connectError.ts`)
|
||||
|
||||
ProxyCast Connect 功能的错误提示 Toast 通知:
|
||||
- `showDeepLinkError` - Deep Link 解析错误提示
|
||||
- `showRegistryLoadError` - Registry 加载失败提示(已回退到缓存)
|
||||
- `showRegistryNoCacheError` - Registry 不可用错误(无缓存)
|
||||
- `showApiKeySaveError` - API Key 保存失败提示
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @file API Key 脱敏属性测试
|
||||
* @description 测试 API Key 脱敏功能的正确性
|
||||
* @module lib/utils/apiKeyMask.test
|
||||
*
|
||||
* **Feature: proxycast-connect, Property 4: API Key Masking Format**
|
||||
* **Validates: Requirements 3.2**
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { maskApiKey } from "./apiKeyMask";
|
||||
|
||||
describe("API Key 脱敏", () => {
|
||||
/**
|
||||
* Property 4: API Key Masking Format
|
||||
*
|
||||
* *对于任意* API Key 字符串,脱敏后的版本应满足:
|
||||
* - 如果原始 Key 长度 > 8,显示前 4 位 + "..." + 后 4 位
|
||||
* - 如果原始 Key 长度 <= 8,返回 "****"
|
||||
*
|
||||
* **Validates: Requirements 3.2**
|
||||
*/
|
||||
describe("Property 4: API Key Masking Format", () => {
|
||||
test.prop([fc.string({ minLength: 9, maxLength: 200 })], { numRuns: 100 })(
|
||||
"对于长度 > 8 的 Key,应显示前 4 位 + '...' + 后 4 位",
|
||||
(apiKey: string) => {
|
||||
const masked = maskApiKey(apiKey);
|
||||
|
||||
// 验证格式:前4位 + ... + 后4位
|
||||
expect(masked).toMatch(/^.{4}\.\.\..{4}$/);
|
||||
|
||||
// 验证前 4 位正确
|
||||
expect(masked.slice(0, 4)).toBe(apiKey.slice(0, 4));
|
||||
|
||||
// 验证后 4 位正确
|
||||
expect(masked.slice(-4)).toBe(apiKey.slice(-4));
|
||||
|
||||
// 验证中间是 "..."
|
||||
expect(masked.slice(4, 7)).toBe("...");
|
||||
|
||||
// 验证总长度为 11(4 + 3 + 4)
|
||||
expect(masked.length).toBe(11);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([fc.string({ minLength: 0, maxLength: 8 })], { numRuns: 100 })(
|
||||
"对于长度 <= 8 的 Key,应返回 '****'",
|
||||
(apiKey: string) => {
|
||||
const masked = maskApiKey(apiKey);
|
||||
expect(masked).toBe("****");
|
||||
},
|
||||
);
|
||||
|
||||
// 边界测试:长度恰好为 8 和 9
|
||||
it("长度恰好为 8 的 Key 应返回 '****'", () => {
|
||||
const key8 = "12345678";
|
||||
expect(maskApiKey(key8)).toBe("****");
|
||||
});
|
||||
|
||||
it("长度恰好为 9 的 Key 应显示前 4 后 4", () => {
|
||||
const key9 = "123456789";
|
||||
const masked = maskApiKey(key9);
|
||||
expect(masked).toBe("1234...6789");
|
||||
});
|
||||
|
||||
// 空字符串测试
|
||||
it("空字符串应返回 '****'", () => {
|
||||
expect(maskApiKey("")).toBe("****");
|
||||
});
|
||||
|
||||
// 典型 API Key 格式测试
|
||||
it("典型 OpenAI Key 格式应正确脱敏", () => {
|
||||
const key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
|
||||
const masked = maskApiKey(key);
|
||||
expect(masked).toBe("sk-1...wxyz");
|
||||
});
|
||||
|
||||
it("典型 Anthropic Key 格式应正确脱敏", () => {
|
||||
const key = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz";
|
||||
const masked = maskApiKey(key);
|
||||
expect(masked).toBe("sk-a...wxyz");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file API Key 脱敏工具
|
||||
* @description 对 API Key 进行脱敏处理,保护敏感信息
|
||||
* @module lib/utils/apiKeyMask
|
||||
*
|
||||
* **Feature: proxycast-connect, Property 4: API Key Masking Format**
|
||||
* **Validates: Requirements 3.2**
|
||||
*/
|
||||
|
||||
/**
|
||||
* 对 API Key 进行脱敏处理
|
||||
*
|
||||
* 规则:
|
||||
* - 如果 Key 长度 > 8,显示前 4 位 + "..." + 后 4 位
|
||||
* - 如果 Key 长度 <= 8,返回 "****"
|
||||
*
|
||||
* @param apiKey - 原始 API Key
|
||||
* @returns 脱敏后的字符串
|
||||
*
|
||||
* @example
|
||||
* maskApiKey("sk-1234567890abcdef") // "sk-1...cdef"
|
||||
* maskApiKey("short") // "****"
|
||||
* maskApiKey("12345678") // "****"
|
||||
* maskApiKey("123456789") // "1234...6789"
|
||||
*/
|
||||
export function maskApiKey(apiKey: string): string {
|
||||
if (apiKey.length <= 8) {
|
||||
return "****";
|
||||
}
|
||||
return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file Connect 错误处理工具函数
|
||||
* @description 提供 ProxyCast Connect 功能的错误提示 Toast 通知工具函数
|
||||
* @module lib/utils/connectError
|
||||
*
|
||||
* _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||
*/
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Connect 错误类型
|
||||
*/
|
||||
export type ConnectErrorType =
|
||||
| "deep_link_parse"
|
||||
| "registry_load"
|
||||
| "registry_no_cache"
|
||||
| "api_key_save";
|
||||
|
||||
/**
|
||||
* Connect 错误信息
|
||||
*/
|
||||
export interface ConnectErrorInfo {
|
||||
/** 错误类型 */
|
||||
type: ConnectErrorType;
|
||||
/** 错误消息 */
|
||||
message: string;
|
||||
/** 原始错误代码(可选) */
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误配置
|
||||
*/
|
||||
interface ErrorConfig {
|
||||
title: string;
|
||||
description: (message: string) => string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误配置
|
||||
*/
|
||||
function getErrorConfig(
|
||||
type: ConnectErrorType,
|
||||
onRetry?: () => void,
|
||||
): ErrorConfig {
|
||||
switch (type) {
|
||||
case "deep_link_parse":
|
||||
// _Requirements: 7.1_
|
||||
return {
|
||||
title: "链接解析失败",
|
||||
description: (msg) => msg || "Deep Link 格式无效,请检查链接是否正确",
|
||||
};
|
||||
|
||||
case "registry_load":
|
||||
// _Requirements: 7.2_
|
||||
return {
|
||||
title: "注册表加载失败",
|
||||
description: (msg) =>
|
||||
msg || "无法从远程加载中转商注册表,已使用本地缓存",
|
||||
};
|
||||
|
||||
case "registry_no_cache":
|
||||
// _Requirements: 7.3_
|
||||
return {
|
||||
title: "注册表不可用",
|
||||
description: (msg) =>
|
||||
msg || "无法加载中转商注册表且没有本地缓存,请检查网络连接",
|
||||
action: onRetry
|
||||
? {
|
||||
label: "重试",
|
||||
onClick: onRetry,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
case "api_key_save":
|
||||
// _Requirements: 7.4_
|
||||
return {
|
||||
title: "保存失败",
|
||||
description: (msg) => msg || "API Key 保存失败,请重试",
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
title: "操作失败",
|
||||
description: (msg) => msg || "发生未知错误",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Connect 错误 Toast
|
||||
*
|
||||
* 根据错误类型显示对应的错误提示。
|
||||
*
|
||||
* ## 错误类型
|
||||
*
|
||||
* - `deep_link_parse`: Deep Link 解析错误(Requirements 7.1)
|
||||
* - `registry_load`: Registry 加载失败,已回退到缓存(Requirements 7.2)
|
||||
* - `registry_no_cache`: Registry 无缓存且加载失败(Requirements 7.3)
|
||||
* - `api_key_save`: API Key 存储失败(Requirements 7.4)
|
||||
*
|
||||
* @param error - 错误信息
|
||||
* @param onRetry - 重试回调(仅用于 registry_no_cache 类型)
|
||||
*/
|
||||
export function showConnectError(
|
||||
error: ConnectErrorInfo,
|
||||
onRetry?: () => void,
|
||||
): void {
|
||||
const config = getErrorConfig(error.type, onRetry);
|
||||
|
||||
// 使用 sonner 的 error toast
|
||||
toast.error(config.title, {
|
||||
description: config.description(error.message),
|
||||
duration: error.type === "registry_no_cache" ? 10000 : 5000,
|
||||
action: config.action
|
||||
? {
|
||||
label: config.action.label,
|
||||
onClick: config.action.onClick,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Deep Link 解析错误
|
||||
* _Requirements: 7.1_
|
||||
*
|
||||
* @param message - 错误消息
|
||||
* @param code - 错误代码
|
||||
*/
|
||||
export function showDeepLinkError(message: string, code?: string): void {
|
||||
showConnectError({
|
||||
type: "deep_link_parse",
|
||||
message,
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Registry 加载失败(已回退到缓存)
|
||||
* _Requirements: 7.2_
|
||||
*
|
||||
* @param message - 错误消息
|
||||
*/
|
||||
export function showRegistryLoadError(message?: string): void {
|
||||
showConnectError({
|
||||
type: "registry_load",
|
||||
message: message || "无法从远程加载中转商注册表,已使用本地缓存",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Registry 不可用错误(无缓存)
|
||||
* _Requirements: 7.3_
|
||||
*
|
||||
* @param message - 错误消息
|
||||
* @param onRetry - 重试回调
|
||||
*/
|
||||
export function showRegistryNoCacheError(
|
||||
message?: string,
|
||||
onRetry?: () => void,
|
||||
): void {
|
||||
showConnectError(
|
||||
{
|
||||
type: "registry_no_cache",
|
||||
message: message || "无法加载中转商注册表且没有本地缓存",
|
||||
},
|
||||
onRetry,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 API Key 保存失败错误
|
||||
* _Requirements: 7.4_
|
||||
*
|
||||
* @param message - 错误消息
|
||||
*/
|
||||
export function showApiKeySaveError(message?: string): void {
|
||||
showConnectError({
|
||||
type: "api_key_save",
|
||||
message: message || "API Key 保存失败,请重试",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误配置(供组件使用)
|
||||
*/
|
||||
export { getErrorConfig };
|
||||
Reference in New Issue
Block a user