chore: bump version to v0.30.0

This commit is contained in:
coso
2026-01-06 00:26:16 +08:00
parent ef8ac824c6
commit 4965f6d385
33 changed files with 4653 additions and 89 deletions
@@ -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) | 插件示例项目 |
+112
View File
@@ -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) 作为二进制插件的完整示例。
+169
View File
@@ -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. **异步处理** - 复杂逻辑放到后台队列
+534
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.29.0",
"version": "0.30.0",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -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
+15 -12
View File
@@ -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);
}
};
+51
View File
@@ -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)
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+373
View File
@@ -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
);
}
}
}
+40
View File
@@ -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,
};
+795
View File
@@ -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(&registry_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
);
}
}
}
}
+418
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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;
+32
View File
@@ -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 - 错误处理
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+17
View File
@@ -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";
+192
View File
@@ -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;
+158
View File
@@ -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;
+37
View File
@@ -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 保存失败提示
+86
View File
@@ -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");
});
});
});
+31
View File
@@ -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)}`;
}
+194
View File
@@ -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 };