mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
release: 1.0.0 snapshot (ea3b0e5)
channel: release version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T13:17:53Z
This commit is contained in:
+66
@@ -0,0 +1,66 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE / Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
.history/
|
||||
.kiro/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Local agent/editor rules
|
||||
.windsurfrules
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.err
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.ant-license.json
|
||||
|
||||
# Build outputs
|
||||
bin/
|
||||
build/bin/
|
||||
build/dist/
|
||||
chrome/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Runtime binaries (must be versioned for release/dev convenience)
|
||||
!bin/
|
||||
!bin/xray.exe
|
||||
!bin/sing-box.exe
|
||||
|
||||
# Publish artifacts
|
||||
publish/staging/
|
||||
publish/output/
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
snapshots/
|
||||
app.db
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
proxies.yaml
|
||||
profiles.yaml
|
||||
|
||||
# Frontend deps/cache
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# Test outputs / coverage
|
||||
coverage/
|
||||
*.out
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,42 @@
|
||||
# Ant-Chrome 部署与配置指南
|
||||
|
||||
本指南将帮助你快速部署 Ant-Chrome 并集成 `fingerprint-chromium` 引擎。
|
||||
|
||||
## 1. 自动化说明
|
||||
我们已经实现了**浏览器路径自动检测**功能。你只需要按照下方的目录结构放置文件,程序在启动时会自动识别并配置 `chrome.exe` 的路径,无需在系统设置中手动填写。
|
||||
|
||||
## 2. 目录结构
|
||||
建议的部署目录结构如下:
|
||||
|
||||
```text
|
||||
Ant-Chrome/
|
||||
├── news-platform.exe (由 wails build 生成的主程序)
|
||||
├── config.yaml (自动生成的配置文件)
|
||||
├── chrome/ (新建此文件夹,用于存放浏览器引擎)
|
||||
│ ├── chrome.exe (核心二进制文件)
|
||||
│ ├── chrome_proxy.exe
|
||||
│ ├── locales/
|
||||
│ ├── resources.pak
|
||||
│ └── ... (其他 Chromium 依赖文件)
|
||||
└── profiles/ (自动创建,用于存放各实例的用户数据)
|
||||
```
|
||||
|
||||
## 3. 如何集成 fingerprint-chromium
|
||||
根据你提供的 [fingerprint-chromium 文档](https://github.com/adryfish/fingerprint-chromium/blob/142.0.7444.175/README-ZH.md),请按以下步骤操作:
|
||||
|
||||
1. **下载**: 访问 GitHub Release 页面,下载适合 Windows 的 **ZIP** 版本(例如 `chrome-win.zip`)。
|
||||
2. **解压**: 将 ZIP 包中的所有内容解压。
|
||||
3. **复制**: 将解压出的所有文件(确保包含 `chrome.exe`)复制到 Ant-Chrome 根目录下的 `chrome/` 文件夹中。
|
||||
|
||||
## 4. 指纹功能使用
|
||||
Ant-Chrome 已经完美对接了 `fingerprint-chromium` 的命令行参数:
|
||||
|
||||
- **自动种子**: 如果你在实例配置中没有指定 `--fingerprint` 参数,Ant-Chrome 会根据 `profileId` 自动生成一个固定的 32 位整数种子,确保每个实例都有唯一且稳定的指纹。
|
||||
- **自定义指纹**: 你可以在“实例列表 -> 编辑 -> 指纹参数”中手动添加如下参数:
|
||||
- `--fingerprint=123456` (手动指定种子)
|
||||
- `--fingerprint-platform=windows` (模拟系统)
|
||||
- `--fingerprint-brand=Edge` (模拟浏览器品牌)
|
||||
|
||||
## 5. 常见问题
|
||||
- **无法启动**: 请确保 `chrome/` 文件夹下包含完整的 Chromium 运行环境(不仅仅是 `chrome.exe`,还有 `.pak` 文件和 `locales` 目录)。
|
||||
- **路径映射**: 如果你想使用非标准路径,依然可以在主程序的“系统设置”中手动修改。
|
||||
@@ -0,0 +1,209 @@
|
||||
# Ant Browser
|
||||
|
||||
> 面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。
|
||||
|
||||
[](https://github.com/black-ant/Ant-Browser/releases)
|
||||
[](https://github.com/black-ant/Ant-Browser/releases)
|
||||
[](https://github.com/black-ant/Ant-Browser/issues)
|
||||
|
||||
## 推荐内核项目
|
||||
|
||||
Ant Browser 当前推荐配套使用的浏览器内核,来源于开源项目 [fingerprint-chromium](https://github.com/adryfish/fingerprint-chromium)。
|
||||
|
||||
如果你正在寻找可直接下载和维护的指纹内核版本,建议先查看它的 Releases 页面:
|
||||
|
||||
- <https://github.com/adryfish/fingerprint-chromium/releases>
|
||||
|
||||
这个项目为 Ant Browser 的内核准备提供了直接可用的基础来源,这里先对原项目做明确推荐与致谢。
|
||||
|
||||
Ant Browser 的目标很明确:在一台 Windows 设备上,帮助用户稳定管理多个彼此隔离的浏览器实例,并配合代理池、浏览器内核和快捷启动能力完成日常运营或测试工作。
|
||||
|
||||
## 目录
|
||||
|
||||
- [项目简介](#项目简介)
|
||||
- [核心特性](#核心特性)
|
||||
- [界面预览](#界面预览)
|
||||
- [快速开始](#快速开始)
|
||||
- [常用操作](#常用操作)
|
||||
- [常见问题](#常见问题)
|
||||
- [Roadmap](#roadmap)
|
||||
- [贡献](#贡献)
|
||||
- [支持与反馈](#支持与反馈)
|
||||
- [License](#license)
|
||||
|
||||
## 项目简介
|
||||
|
||||
Ant Browser 适合以下场景:
|
||||
|
||||
- 多账号环境隔离
|
||||
- 跨境电商与社媒账号运营
|
||||
- 需要独立代理出口的本地测试
|
||||
- 需要统一管理浏览器内核和实例配置的团队
|
||||
|
||||
这个项目当前提供的核心价值是:
|
||||
|
||||
- 给每个账号分配独立浏览器实例
|
||||
- 给每个实例绑定独立代理
|
||||
- 统一管理浏览器内核、标签、关键字和快捷打开码
|
||||
- 在本地保存配置和运行数据,便于自主控制
|
||||
|
||||
## 源码分支说明
|
||||
|
||||
- `master`:面向开发者的干净基线分支,不提交 `data/app.db`、实例目录或其他用户数据。首次启动时会自动初始化空数据库。
|
||||
- `user_data`:在 `master` 基础上额外提交一份 `data/app.db` 测试快照,便于演示、联调和复现问题。
|
||||
- 代理运行时 `bin/xray.exe`、`bin/sing-box.exe` 已随源码仓库提供;开发和发布打包不需要再单独下载这些运行时文件。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- 实例隔离管理:支持创建、编辑、启动、停止、重启、克隆和删除浏览器实例
|
||||
- 代理池配置:支持统一维护代理节点,并将代理分配到具体实例
|
||||
- 多协议支持:支持常见代理配置方式,并支持导入 Clash
|
||||
- 内核管理:支持维护多个 Chrome 内核版本,并设置默认内核
|
||||
- 快捷启动:支持通过实例 Code 和 `Ctrl + K` 快速打开目标实例
|
||||
- 标签与检索:支持按标签、关键字、状态、代理、内核、分组进行筛选
|
||||
- 本地化存储:配置和实例数据保存在本地,适合长期使用和备份
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 1. 控制台
|
||||
|
||||
<img src="images/readme/001-首页.png" alt="控制台" width="100%" />
|
||||
|
||||
对应功能点:
|
||||
|
||||
- 查看实例总数、运行中实例、代理节点数量和内核版本
|
||||
- 从首页快速进入 `实例列表`、`代理池配置`、`内核管理`、`系统设置`
|
||||
- 查看客户端版本、运行环境、数据存储和当前实例运行状态
|
||||
|
||||
### 2. 实例列表
|
||||
|
||||
<img src="images/readme/002-实例列表.png" alt="实例列表" width="100%" />
|
||||
|
||||
对应功能点:
|
||||
|
||||
- 统一查看和管理所有浏览器实例
|
||||
- 按状态、代理、内核、分组、关键字筛选实例
|
||||
- 支持 `新建配置`、启动、停止、重启、配置、克隆、删除
|
||||
- 给实例分配快捷打开码,后续可以直接快速启动
|
||||
|
||||
### 3. 代理池配置
|
||||
|
||||
<img src="images/readme/003-设置代理池.png" alt="代理池配置" width="100%" />
|
||||
|
||||
对应功能点:
|
||||
|
||||
- 统一管理代理节点
|
||||
- 支持按协议、分组筛选代理
|
||||
- 支持手动维护代理和导入 Clash
|
||||
- 支持查看延迟、IP 健康并挑选可用节点
|
||||
|
||||
### 4. 代理生效验证
|
||||
|
||||
<img src="images/readme/004-自定义代理.png" alt="代理生效验证" width="100%" />
|
||||
|
||||
对应功能点:
|
||||
|
||||
- 启动实例后访问 IP 检测网站验证代理是否真正生效
|
||||
- 检查 IP 地区、ASN、运营商和风险值等信息
|
||||
- 用于确认当前实例是否已经走目标代理出口
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- 操作系统:Windows 10 / Windows 11(64 位)
|
||||
- 建议内存:8 GB 及以上
|
||||
- 建议磁盘空间:2 GB 以上
|
||||
|
||||
### 下载与运行
|
||||
|
||||
1. 前往 Releases 页面下载最新版本:<https://github.com/black-ant/Ant-Browser/releases>
|
||||
2. 安装版直接运行 `AntBrowser-Setup-*.exe`
|
||||
3. 便携版解压后运行 `ant-chrome.exe`
|
||||
|
||||
### 从源码运行
|
||||
|
||||
1. 开发默认使用 `master` 分支;该分支不带测试用户数据,适合作为日常开发基线。
|
||||
2. 如需带测试库的演示环境,请切换到 `user_data` 分支。
|
||||
3. 执行 `bat\dev.bat` 或直接使用 `wails dev` 启动项目。
|
||||
4. 仓库已内置 `bin/xray.exe` 和 `bin/sing-box.exe`,不需要额外下载代理运行时。
|
||||
|
||||
### 准备浏览器内核
|
||||
|
||||
代理运行时已经随仓库提供,你只需要准备浏览器内核。
|
||||
|
||||
1. 打开应用,进入 `指纹浏览器 > 内核管理`
|
||||
2. 优先使用应用内下载功能准备内核
|
||||
3. 如果手动准备内核,请确保目录下存在 `chrome.exe`
|
||||
|
||||
建议目录结构:
|
||||
|
||||
```text
|
||||
chrome/
|
||||
chrom-142/
|
||||
chrome.exe
|
||||
...
|
||||
```
|
||||
|
||||
### 第一次使用建议流程
|
||||
|
||||
1. 在 `代理池配置` 中先导入或新增可用代理节点
|
||||
2. 在 `实例列表` 中点击 `新建配置`
|
||||
3. 选择实例名称、内核、代理、标签和需要的启动参数
|
||||
4. 返回实例列表,点击启动按钮运行实例
|
||||
5. 打开 IP 检测网站,确认代理结果是否符合预期
|
||||
|
||||
## 常用操作
|
||||
|
||||
| 目标 | 入口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新建浏览器实例 | `实例列表 > 新建配置` | 创建一个新的独立浏览器环境 |
|
||||
| 配置代理池 | `代理池配置` | 维护代理节点并检查延迟、健康状态 |
|
||||
| 绑定实例代理 | `实例编辑页` | 给指定实例分配目标代理节点 |
|
||||
| 启动实例 | `实例列表` | 单击启动按钮即可运行目标实例 |
|
||||
| 快速打开实例 | `Ctrl + K` | 可按 Code、实例名、标签、关键字快速检索 |
|
||||
| 管理浏览器内核 | `内核管理` | 新增、编辑、删除和设置默认内核 |
|
||||
| 验证代理结果 | 启动实例后访问 IP 检测网站 | 核对 IP、地区、ASN、风险值 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 应用无法启动怎么办?
|
||||
|
||||
先检查浏览器内核路径是否有效,并确认目标目录下存在 `chrome.exe`。
|
||||
|
||||
### 2. 实例启动了但代理没有生效怎么办?
|
||||
|
||||
先检查代理节点本身是否可用,再确认该实例已经正确绑定代理。建议启动后访问 IP 检测网站复核当前出口。
|
||||
|
||||
### 3. 实例太多,怎么快速找到目标实例?
|
||||
|
||||
可以在 `实例列表` 中按状态、代理、内核、分组、关键字筛选,也可以通过 `Ctrl + K` 使用实例 Code 或名称快速启动。
|
||||
|
||||
### 4. 多个账号怎么避免串号?
|
||||
|
||||
建议采用一账号一实例、一实例一稳定代理的方式,不要混用浏览器环境,也不要频繁切换同一实例的出口 IP。
|
||||
|
||||
## Roadmap
|
||||
|
||||
- 完善自动化模块能力
|
||||
- 持续补充使用文档和接口说明
|
||||
- 增强实例模板、批量管理和检索体验
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎通过 Issue 和 Pull Request 参与改进。
|
||||
|
||||
- Bug 反馈:请附带版本号、系统版本、复现步骤和截图
|
||||
- 功能建议:请说明业务场景、预期行为和现有问题
|
||||
- 文档优化:欢迎直接提交 README、教程和截图说明相关改进
|
||||
|
||||
如果是较大改动,建议先开 Issue 对齐需求再提交 PR。
|
||||
|
||||
## 支持与反馈
|
||||
|
||||
- Releases:<https://github.com/black-ant/Ant-Browser/releases>
|
||||
- Issues:<https://github.com/black-ant/Ant-Browser/issues>
|
||||
|
||||
## License
|
||||
|
||||
当前仓库暂未附带独立的 `LICENSE` 文件,后续会补充。
|
||||
+1168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/backup"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackupScope = backup.Scope
|
||||
type BackupManifest = backup.Manifest
|
||||
|
||||
// BackupGetScopeDefinition 返回当前环境下的备份范围定义(第一阶段:范围与包格式)。
|
||||
func (a *App) BackupGetScopeDefinition() (BackupScope, error) {
|
||||
return backup.BuildScope(backup.BuildOptions{
|
||||
AppRoot: a.appRoot,
|
||||
Config: a.config,
|
||||
})
|
||||
}
|
||||
|
||||
// BackupGetManifestTemplate 返回 manifest 结构预览(不执行实际导出)。
|
||||
func (a *App) BackupGetManifestTemplate() (BackupManifest, error) {
|
||||
scope, err := a.BackupGetScopeDefinition()
|
||||
if err != nil {
|
||||
return BackupManifest{}, err
|
||||
}
|
||||
appName := "Ant Browser"
|
||||
if a.config != nil {
|
||||
if name := strings.TrimSpace(a.config.App.Name); name != "" {
|
||||
appName = name
|
||||
}
|
||||
}
|
||||
return backup.BuildManifest(scope, appName, "1.0.0", time.Now()), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBackupEnsureZipSuffix(t *testing.T) {
|
||||
if got := backupEnsureZipSuffix("c:/tmp/a.zip"); got != "c:/tmp/a.zip" {
|
||||
t.Fatalf("zip 后缀重复追加: %s", got)
|
||||
}
|
||||
if got := backupEnsureZipSuffix("c:/tmp/a"); got != "c:/tmp/a.zip" {
|
||||
t.Fatalf("zip 后缀追加失败: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupMergeConfigDedup(t *testing.T) {
|
||||
current := config.DefaultConfig()
|
||||
current.App.MaxProfileLimit = 12
|
||||
current.App.UsedCDKeys = []string{"A1", "B2"}
|
||||
current.Browser.DefaultBookmarks = []config.BrowserBookmark{
|
||||
{Name: "Google", URL: "https://www.google.com/"},
|
||||
}
|
||||
current.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyName: "P1", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
current.Browser.Cores = []config.BrowserCore{
|
||||
{CoreId: "c1", CoreName: "C1", CorePath: "chrome/c1"},
|
||||
}
|
||||
current.Browser.Profiles = []config.BrowserProfileConfig{
|
||||
{ProfileId: "u1", ProfileName: "U1", UserDataDir: "u1"},
|
||||
}
|
||||
|
||||
incoming := config.DefaultConfig()
|
||||
incoming.App.UsedCDKeys = []string{"b2", "C3"}
|
||||
incoming.Browser.DefaultBookmarks = []config.BrowserBookmark{
|
||||
{Name: "Google Dup", URL: "https://www.google.com/"},
|
||||
{Name: "ChatGPT", URL: "https://chatgpt.com/"},
|
||||
}
|
||||
incoming.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyName: "P1 Dup", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
{ProxyId: "p2", ProxyName: "P2", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
}
|
||||
incoming.Browser.Cores = []config.BrowserCore{
|
||||
{CoreId: "c1", CoreName: "C1 Dup", CorePath: "chrome/c1"},
|
||||
{CoreId: "c2", CoreName: "C2", CorePath: "chrome/c2"},
|
||||
}
|
||||
incoming.Browser.Profiles = []config.BrowserProfileConfig{
|
||||
{ProfileId: "u1", ProfileName: "U1 Dup", UserDataDir: "u1"},
|
||||
{ProfileId: "u2", ProfileName: "U2", UserDataDir: "u2"},
|
||||
}
|
||||
|
||||
merged := backupMergeConfig(current, incoming)
|
||||
if merged == nil {
|
||||
t.Fatalf("merged 为空")
|
||||
}
|
||||
|
||||
if merged.App.MaxProfileLimit != 12 {
|
||||
t.Fatalf("license limit 不应被导入配置改写: got=%d", merged.App.MaxProfileLimit)
|
||||
}
|
||||
if len(merged.App.UsedCDKeys) != 2 {
|
||||
t.Fatalf("used cd keys 不应被导入配置改写: %+v", merged.App.UsedCDKeys)
|
||||
}
|
||||
if len(merged.Browser.DefaultBookmarks) != 2 {
|
||||
t.Fatalf("bookmarks 判重失败: %+v", merged.Browser.DefaultBookmarks)
|
||||
}
|
||||
if len(merged.Browser.Proxies) != 2 {
|
||||
t.Fatalf("proxies 判重失败: %+v", merged.Browser.Proxies)
|
||||
}
|
||||
if len(merged.Browser.Cores) != 2 {
|
||||
t.Fatalf("cores 判重失败: %+v", merged.Browser.Cores)
|
||||
}
|
||||
if len(merged.Browser.Profiles) != 2 {
|
||||
t.Fatalf("profiles 判重失败: %+v", merged.Browser.Profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupSyncDirConflictAndOverwrite(t *testing.T) {
|
||||
src := filepath.Join(t.TempDir(), "src")
|
||||
dst := filepath.Join(t.TempDir(), "dst")
|
||||
if err := os.MkdirAll(src, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srcFile := filepath.Join(src, "a.txt")
|
||||
dstFile := filepath.Join(dst, "a.txt")
|
||||
if err := os.WriteFile(srcFile, []byte("new-content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dstFile, []byte("old-content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats := &backupMergeStats{}
|
||||
if err := backupSyncDir(src, dst, false, stats, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Conflicts != 1 || stats.Imported != 0 {
|
||||
t.Fatalf("非覆盖模式统计异常: %+v", stats)
|
||||
}
|
||||
got, err := os.ReadFile(dstFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "old-content" {
|
||||
t.Fatalf("非覆盖模式不应改写目标文件: %s", string(got))
|
||||
}
|
||||
|
||||
stats2 := &backupMergeStats{}
|
||||
if err := backupSyncDir(src, dst, true, stats2, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats2.Imported != 1 {
|
||||
t.Fatalf("覆盖模式导入统计异常: %+v", stats2)
|
||||
}
|
||||
got2, err := os.ReadFile(dstFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got2) != "new-content" {
|
||||
t.Fatalf("覆盖模式应改写目标文件: %s", string(got2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
)
|
||||
|
||||
type BrowserBookmark = config.BrowserBookmark
|
||||
|
||||
var defaultBookmarkList = []BrowserBookmark{
|
||||
{Name: "Google", URL: "https://www.google.com/"},
|
||||
{Name: "Gmail", URL: "https://mail.google.com/"},
|
||||
{Name: "Claude", URL: "https://claude.ai/"},
|
||||
{Name: "ChatGPT", URL: "https://chatgpt.com/"},
|
||||
{Name: "YouTube", URL: "https://www.youtube.com/"},
|
||||
}
|
||||
|
||||
// BookmarkList 获取默认书签列表(优先 SQLite,降级 config.yaml)
|
||||
func (a *App) BookmarkList() []BrowserBookmark {
|
||||
if a.browserMgr.BookmarkDAO != nil {
|
||||
list, err := a.browserMgr.BookmarkDAO.List()
|
||||
if err == nil && len(list) > 0 {
|
||||
return list
|
||||
}
|
||||
}
|
||||
if len(a.config.Browser.DefaultBookmarks) > 0 {
|
||||
return append([]BrowserBookmark{}, a.config.Browser.DefaultBookmarks...)
|
||||
}
|
||||
return append([]BrowserBookmark{}, defaultBookmarkList...)
|
||||
}
|
||||
|
||||
// BookmarkSave 保存默认书签列表(优先 SQLite,降级 config.yaml)
|
||||
func (a *App) BookmarkSave(items []BrowserBookmark) error {
|
||||
log := logger.New("Bookmark")
|
||||
valid := make([]BrowserBookmark, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Name != "" && item.URL != "" {
|
||||
valid = append(valid, item)
|
||||
}
|
||||
}
|
||||
|
||||
if a.browserMgr.BookmarkDAO != nil {
|
||||
if err := a.browserMgr.BookmarkDAO.ReplaceAll(valid); err != nil {
|
||||
log.Error("书签保存到数据库失败", logger.F("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
log.Info("书签已保存到数据库", logger.F("count", len(valid)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级:写入 config.yaml
|
||||
a.config.Browser.DefaultBookmarks = valid
|
||||
if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil {
|
||||
log.Error("书签保存失败", logger.F("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
log.Info("书签已保存到 config.yaml", logger.F("count", len(valid)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// BookmarkReset 恢复默认书签
|
||||
func (a *App) BookmarkReset() error {
|
||||
return a.BookmarkSave(append([]BrowserBookmark{}, defaultBookmarkList...))
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Cookie 管理 API(通过 CDP)
|
||||
// ============================================================================
|
||||
|
||||
// CookieInfo 表示单条浏览器 Cookie
|
||||
type CookieInfo struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
Expires float64 `json:"expires"`
|
||||
HttpOnly bool `json:"httpOnly"`
|
||||
Secure bool `json:"secure"`
|
||||
SameSite string `json:"sameSite"`
|
||||
}
|
||||
|
||||
// cdpTarget 表示 /json 接口返回的调试目标
|
||||
type cdpTarget struct {
|
||||
WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// cdpMessage 是 CDP 协议消息结构
|
||||
type cdpMessage struct {
|
||||
Id int `json:"id"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// cdpResponse 是 CDP 协议响应结构
|
||||
type cdpResponse struct {
|
||||
Id int `json:"id"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// cdpCall 向指定 debugPort 发送单次 CDP 命令并返回 result 字段
|
||||
func cdpCall(debugPort int, method string, params map[string]any) (map[string]any, error) {
|
||||
// 1. 获取 WebSocket 调试地址
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/json", debugPort))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CDP /json 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var targets []cdpTarget
|
||||
if err := json.Unmarshal(body, &targets); err != nil || len(targets) == 0 {
|
||||
return nil, fmt.Errorf("CDP targets 解析失败或为空")
|
||||
}
|
||||
|
||||
wsURL := ""
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WebSocketDebuggerUrl != "" {
|
||||
wsURL = t.WebSocketDebuggerUrl
|
||||
break
|
||||
}
|
||||
}
|
||||
if wsURL == "" && targets[0].WebSocketDebuggerUrl != "" {
|
||||
wsURL = targets[0].WebSocketDebuggerUrl
|
||||
}
|
||||
if wsURL == "" {
|
||||
return nil, fmt.Errorf("未找到可用的 WebSocket 调试地址")
|
||||
}
|
||||
|
||||
// 2. 建立 WebSocket 连接
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("WebSocket 连接失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
// 3. 发送 CDP 命令
|
||||
msg := cdpMessage{Id: 1, Method: method, Params: params}
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
return nil, fmt.Errorf("CDP 命令发送失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 等待响应
|
||||
var cdpResp cdpResponse
|
||||
if err := conn.ReadJSON(&cdpResp); err != nil {
|
||||
return nil, fmt.Errorf("CDP 响应读取失败: %w", err)
|
||||
}
|
||||
if cdpResp.Error != nil {
|
||||
return nil, fmt.Errorf("CDP 错误: %s", cdpResp.Error.Message)
|
||||
}
|
||||
return cdpResp.Result, nil
|
||||
}
|
||||
|
||||
// getDebugPort 获取运行中实例的调试端口
|
||||
func (a *App) getDebugPort(profileId string) (int, error) {
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
if !profile.Running || profile.DebugPort == 0 {
|
||||
return 0, fmt.Errorf("实例未运行或调试端口不可用")
|
||||
}
|
||||
return profile.DebugPort, nil
|
||||
}
|
||||
|
||||
// BrowserGetCookies 通过 CDP 获取实例所有 Cookie
|
||||
func (a *App) BrowserGetCookies(profileId string) ([]CookieInfo, error) {
|
||||
debugPort, err := a.getDebugPort(profileId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := cdpCall(debugPort, "Network.getAllCookies", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cookiesRaw, ok := result["cookies"]
|
||||
if !ok {
|
||||
return []CookieInfo{}, nil
|
||||
}
|
||||
|
||||
// 通过 JSON 二次解析
|
||||
data, _ := json.Marshal(cookiesRaw)
|
||||
var cookies []CookieInfo
|
||||
if err := json.Unmarshal(data, &cookies); err != nil {
|
||||
return nil, fmt.Errorf("Cookie 解析失败: %w", err)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// BrowserClearCookies 通过 CDP 清除实例所有 Cookie
|
||||
func (a *App) BrowserClearCookies(profileId string) error {
|
||||
debugPort, err := a.getDebugPort(profileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cdpCall(debugPort, "Network.clearBrowserCookies", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// BrowserExportCookies 导出 Netscape 格式 Cookie 字符串
|
||||
func (a *App) BrowserExportCookies(profileId string) (string, error) {
|
||||
cookies, err := a.BrowserGetCookies(profileId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# Netscape HTTP Cookie File\n")
|
||||
sb.WriteString("# Generated by BrowserManager\n\n")
|
||||
|
||||
for _, c := range cookies {
|
||||
includeSubdomains := "FALSE"
|
||||
if strings.HasPrefix(c.Domain, ".") {
|
||||
includeSubdomains = "TRUE"
|
||||
}
|
||||
secure := "FALSE"
|
||||
if c.Secure {
|
||||
secure = "TRUE"
|
||||
}
|
||||
expires := int64(c.Expires)
|
||||
if expires < 0 {
|
||||
expires = 0
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
|
||||
c.Domain, includeSubdomains, c.Path, secure, expires, c.Name, c.Value))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 分组类型别名 (保持 Wails 绑定兼容)
|
||||
// ============================================================================
|
||||
|
||||
type BrowserGroup = browser.Group
|
||||
type BrowserGroupInput = browser.GroupInput
|
||||
type BrowserGroupWithCount = browser.GroupWithCount
|
||||
|
||||
// ============================================================================
|
||||
// 分组管理 API
|
||||
// ============================================================================
|
||||
|
||||
// ListGroups 获取所有分组(带实例计数)
|
||||
func (a *App) ListGroups() []BrowserGroupWithCount {
|
||||
log := logger.New("Group")
|
||||
if a.browserMgr.GroupDAO == nil {
|
||||
log.Error("GroupDAO 未初始化")
|
||||
return []BrowserGroupWithCount{}
|
||||
}
|
||||
|
||||
groups, err := a.browserMgr.GroupDAO.List()
|
||||
if err != nil {
|
||||
log.Error("获取分组列表失败", logger.F("error", err))
|
||||
return []BrowserGroupWithCount{}
|
||||
}
|
||||
|
||||
// 统计每个分组的实例数量
|
||||
profiles, _ := a.browserMgr.ProfileDAO.List()
|
||||
countMap := make(map[string]int)
|
||||
for _, p := range profiles {
|
||||
if p.GroupId != "" {
|
||||
countMap[p.GroupId]++
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]BrowserGroupWithCount, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
result = append(result, BrowserGroupWithCount{
|
||||
Group: *g,
|
||||
InstanceCount: countMap[g.GroupId],
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CreateGroup 创建分组
|
||||
func (a *App) CreateGroup(input BrowserGroupInput) (*BrowserGroup, error) {
|
||||
log := logger.New("Group")
|
||||
if a.browserMgr.GroupDAO == nil {
|
||||
return nil, fmt.Errorf("GroupDAO 未初始化")
|
||||
}
|
||||
|
||||
group, err := a.browserMgr.GroupDAO.Create(input)
|
||||
if err != nil {
|
||||
log.Error("创建分组失败", logger.F("error", err))
|
||||
return nil, err
|
||||
}
|
||||
log.Info("分组已创建", logger.F("group_id", group.GroupId), logger.F("group_name", group.GroupName))
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// UpdateGroup 更新分组
|
||||
func (a *App) UpdateGroup(groupId string, input BrowserGroupInput) (*BrowserGroup, error) {
|
||||
log := logger.New("Group")
|
||||
if a.browserMgr.GroupDAO == nil {
|
||||
return nil, fmt.Errorf("GroupDAO 未初始化")
|
||||
}
|
||||
|
||||
group, err := a.browserMgr.GroupDAO.Update(groupId, input)
|
||||
if err != nil {
|
||||
log.Error("更新分组失败", logger.F("group_id", groupId), logger.F("error", err))
|
||||
return nil, err
|
||||
}
|
||||
log.Info("分组已更新", logger.F("group_id", groupId))
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// DeleteGroup 删除分组
|
||||
func (a *App) DeleteGroup(groupId string) error {
|
||||
log := logger.New("Group")
|
||||
if a.browserMgr.GroupDAO == nil {
|
||||
return fmt.Errorf("GroupDAO 未初始化")
|
||||
}
|
||||
|
||||
if err := a.browserMgr.GroupDAO.Delete(groupId); err != nil {
|
||||
log.Error("删除分组失败", logger.F("group_id", groupId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
log.Info("分组已删除", logger.F("group_id", groupId))
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveInstancesToGroup 批量移动实例到分组
|
||||
func (a *App) MoveInstancesToGroup(profileIds []string, groupId string) error {
|
||||
log := logger.New("Group")
|
||||
dao, ok := a.browserMgr.ProfileDAO.(*browser.SQLiteProfileDAO)
|
||||
if !ok {
|
||||
return fmt.Errorf("ProfileDAO 不支持批量移动")
|
||||
}
|
||||
|
||||
if err := dao.MoveToGroup(profileIds, groupId); err != nil {
|
||||
log.Error("批量移动实例失败", logger.F("count", len(profileIds)), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
log.Info("实例已移动到分组", logger.F("count", len(profileIds)), logger.F("group_id", groupId))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 浏览器实例管理 API
|
||||
// ============================================================================
|
||||
|
||||
func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) {
|
||||
return a.browserInstanceStartInternal(profileId, nil, nil, false)
|
||||
}
|
||||
|
||||
// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库)
|
||||
func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) {
|
||||
return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs)
|
||||
}
|
||||
|
||||
func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) {
|
||||
log := logger.New("Browser")
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
err := fmt.Errorf("实例启动失败:未找到实例配置(ID=%s)。请刷新列表后重试。", profileId)
|
||||
log.Error("实例不存在", logger.F("profile_id", profileId), logger.F("reason", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
if profile.Running {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
proxyChanged := a.browserMgr.ApplyDefaults(profile)
|
||||
if proxyChanged {
|
||||
_ = a.browserMgr.SaveProfiles()
|
||||
}
|
||||
|
||||
chromeBinaryPath, err := a.browserMgr.ResolveChromeBinary(profile)
|
||||
if err != nil {
|
||||
startErr := fmt.Errorf("实例启动失败:%w", err)
|
||||
log.Error("内核路径解析失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error()))
|
||||
profile.LastError = startErr.Error()
|
||||
return profile, startErr
|
||||
}
|
||||
|
||||
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
|
||||
if err := os.MkdirAll(userDataDir, 0755); err != nil {
|
||||
startErr := fmt.Errorf("实例启动失败:无法创建用户数据目录 %s。原因:%w。请检查目录权限或路径配置。", userDataDir, err)
|
||||
log.Error("用户数据目录创建失败", logger.F("profile_id", profileId), logger.F("dir", userDataDir), logger.F("error", err), logger.F("reason", startErr.Error()))
|
||||
profile.LastError = startErr.Error()
|
||||
return profile, startErr
|
||||
}
|
||||
// 每次启动时合并默认书签(已存在的 URL 不重复添加)
|
||||
if err := browser.EnsureDefaultBookmarks(userDataDir, a.BookmarkList()); err != nil {
|
||||
log.Error("默认书签写入失败", logger.F("error", err.Error()))
|
||||
}
|
||||
|
||||
proxies := a.getLatestProxies()
|
||||
acquiredXrayBridgeKey := ""
|
||||
releaseXrayBridge := false
|
||||
defer func() {
|
||||
if releaseXrayBridge && acquiredXrayBridgeKey != "" && a.xrayMgr != nil {
|
||||
a.xrayMgr.ReleaseBridge(acquiredXrayBridgeKey)
|
||||
}
|
||||
}()
|
||||
|
||||
// 解析实际代理配置(可能来自 proxyId 引用)
|
||||
resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig)
|
||||
if profile.ProxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, profile.ProxyId) {
|
||||
resolvedProxyConfig = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
effectiveProxy := resolvedProxyConfig
|
||||
log.Info("代理配置检查",
|
||||
logger.F("profile_id", profileId),
|
||||
logger.F("proxy_id", profile.ProxyId),
|
||||
logger.F("profile_proxy_config", profile.ProxyConfig),
|
||||
logger.F("resolved_proxy_config", resolvedProxyConfig),
|
||||
)
|
||||
if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported {
|
||||
startErr := fmt.Errorf("实例启动失败:%s", errorMsg)
|
||||
profile.LastError = startErr.Error()
|
||||
log.Error("代理配置无效", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId), logger.F("error", errorMsg), logger.F("reason", startErr.Error()))
|
||||
return profile, startErr
|
||||
}
|
||||
|
||||
if proxy.IsSingBoxProtocol(resolvedProxyConfig) {
|
||||
// hysteria2 / tuic → sing-box 桥接
|
||||
socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId)
|
||||
if bridgeErr != nil {
|
||||
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box.exe 是否存在,以及本地端口是否被占用。", bridgeErr)
|
||||
log.Error("代理桥接失败(sing-box)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
|
||||
profile.LastError = startErr.Error()
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{
|
||||
"profileId": profileId,
|
||||
"profileName": profile.ProfileName,
|
||||
"error": startErr.Error(),
|
||||
})
|
||||
}
|
||||
return profile, startErr
|
||||
}
|
||||
effectiveProxy = socksURL
|
||||
log.Info("sing-box 桥接成功", logger.F("socks_url", socksURL))
|
||||
} else if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) {
|
||||
// vmess / vless / trojan / ss → xray 桥接
|
||||
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId)
|
||||
if bridgeErr != nil {
|
||||
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray.exe 是否存在,以及本地端口是否被占用。", bridgeErr)
|
||||
log.Error("代理桥接失败(xray)", logger.F("error", bridgeErr.Error()), logger.F("reason", startErr.Error()))
|
||||
profile.LastError = startErr.Error()
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "proxy:bridge:failed", map[string]interface{}{
|
||||
"profileId": profileId,
|
||||
"profileName": profile.ProfileName,
|
||||
"error": startErr.Error(),
|
||||
})
|
||||
}
|
||||
return profile, startErr
|
||||
}
|
||||
acquiredXrayBridgeKey = bridgeKey
|
||||
releaseXrayBridge = bridgeKey != ""
|
||||
effectiveProxy = socksURL
|
||||
log.Info("xray 桥接成功", logger.F("socks_url", socksURL))
|
||||
}
|
||||
|
||||
debugPort, err := nextAvailablePort()
|
||||
if err != nil {
|
||||
startErr := fmt.Errorf("实例启动失败:本地调试端口分配失败。原因:%v。请关闭占用端口的程序后重试。", err)
|
||||
log.Error("调试端口分配失败", logger.F("profile_id", profileId), logger.F("error", err), logger.F("reason", startErr.Error()))
|
||||
profile.LastError = startErr.Error()
|
||||
return profile, startErr
|
||||
}
|
||||
|
||||
args := []string{
|
||||
fmt.Sprintf("--user-data-dir=%s", userDataDir),
|
||||
fmt.Sprintf("--remote-debugging-port=%d", debugPort),
|
||||
"--disable-session-crashed-bubble",
|
||||
}
|
||||
|
||||
hasFingerprint := false
|
||||
for _, arg := range profile.FingerprintArgs {
|
||||
if strings.HasPrefix(arg, "--fingerprint=") {
|
||||
hasFingerprint = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFingerprint {
|
||||
seed := 0
|
||||
for _, char := range profile.ProfileId {
|
||||
seed = (seed << 5) - seed + int(char)
|
||||
}
|
||||
if seed < 0 {
|
||||
seed = -seed
|
||||
}
|
||||
args = append(args, fmt.Sprintf("--fingerprint=%d", seed))
|
||||
}
|
||||
|
||||
if effectiveProxy == "direct://" {
|
||||
// 强制直连,覆盖系统全局代理
|
||||
args = append(args, "--proxy-server=direct://")
|
||||
} else if effectiveProxy != "" {
|
||||
args = append(args, fmt.Sprintf("--proxy-server=%s", effectiveProxy))
|
||||
}
|
||||
args = append(args, profile.FingerprintArgs...)
|
||||
args = append(args, profile.LaunchArgs...)
|
||||
args = append(args, normalizeNonEmptyStrings(extraLaunchArgs)...)
|
||||
|
||||
if normalizedURLs := normalizeNonEmptyStrings(startURLs); len(normalizedURLs) > 0 {
|
||||
args = append(args, normalizedURLs...)
|
||||
} else if !skipDefaultStartURLs {
|
||||
args = browser.BuildLaunchArgs(args, profile)
|
||||
}
|
||||
|
||||
cmd := exec.Command(chromeBinaryPath, args...)
|
||||
hideWindow(cmd)
|
||||
cmd.Dir = filepath.Dir(chromeBinaryPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
startErr := fmt.Errorf("%s", describeChromeProcessStartError(chromeBinaryPath, err))
|
||||
log.Error("浏览器进程启动失败", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error()))
|
||||
profile.LastError = startErr.Error()
|
||||
return profile, startErr
|
||||
}
|
||||
if err := waitBrowserDebugPortReady(debugPort, browserStartReadyTimeout); err != nil {
|
||||
startErr := fmt.Errorf("%s", describeBrowserReadyTimeout(debugPort, browserStartReadyTimeout))
|
||||
log.Error("浏览器启动未就绪", logger.F("profile_id", profileId), logger.F("chrome", chromeBinaryPath), logger.F("error", err), logger.F("reason", startErr.Error()))
|
||||
_ = a.stopProcessCmd(cmd)
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
profile.LastError = startErr.Error()
|
||||
return profile, startErr
|
||||
}
|
||||
|
||||
a.browserMgr.BrowserProcesses[profileId] = cmd
|
||||
profile.Running = true
|
||||
profile.DebugPort = debugPort
|
||||
profile.Pid = cmd.Process.Pid
|
||||
profile.LastStartAt = time.Now().Format(time.RFC3339)
|
||||
profile.LastError = ""
|
||||
if acquiredXrayBridgeKey != "" {
|
||||
a.bindProfileXrayBridge(profileId, acquiredXrayBridgeKey)
|
||||
releaseXrayBridge = false
|
||||
}
|
||||
|
||||
log.Info("实例启动", logger.F("profile_id", profileId), logger.F("debug_port", debugPort), logger.F("pid", profile.Pid), logger.F("proxy", effectiveProxy), logger.F("args", strings.Join(args, " ")))
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "browser:instance:started", profileId)
|
||||
}
|
||||
|
||||
go a.waitBrowserProcess(profileId, cmd)
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (a *App) BrowserInstanceStop(profileId string) (*BrowserProfile, error) {
|
||||
log := logger.New("Browser")
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
cmd := a.browserMgr.BrowserProcesses[profileId]
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
if err := a.stopBrowserProcess(cmd); err != nil {
|
||||
log.Error("实例停止失败", logger.F("profile_id", profileId), logger.F("error", err))
|
||||
profile.LastError = err.Error()
|
||||
return profile, err
|
||||
}
|
||||
}
|
||||
|
||||
profile.Running = false
|
||||
profile.LastStopAt = time.Now().Format(time.RFC3339)
|
||||
delete(a.browserMgr.BrowserProcesses, profileId)
|
||||
a.releaseProfileXrayBridge(profileId)
|
||||
|
||||
log.Info("实例停止", logger.F("profile_id", profileId))
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (a *App) BrowserInstanceRestart(profileId string) (*BrowserProfile, error) {
|
||||
if _, err := a.BrowserInstanceStop(profileId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.BrowserInstanceStart(profileId)
|
||||
}
|
||||
|
||||
// BrowserProfileBatchSetTags 批量为实例设置标签(追加模式:将 tags 加入已有标签;replace 模式:直接替换)
|
||||
func (a *App) BrowserProfileBatchSetTags(profileIds []string, tags []string, replace bool) error {
|
||||
log := logger.New("Browser")
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
for _, profileId := range profileIds {
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if replace {
|
||||
profile.Tags = tags
|
||||
} else {
|
||||
// 追加去重
|
||||
existing := make(map[string]struct{})
|
||||
for _, t := range profile.Tags {
|
||||
existing[t] = struct{}{}
|
||||
}
|
||||
for _, t := range tags {
|
||||
if _, ok := existing[t]; !ok {
|
||||
profile.Tags = append(profile.Tags, t)
|
||||
existing[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if a.browserMgr.ProfileDAO != nil {
|
||||
if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil {
|
||||
log.Error("批量设置标签失败", logger.F("profile_id", profileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BrowserProfileBatchRemoveTags 批量从实例移除指定标签
|
||||
func (a *App) BrowserProfileBatchRemoveTags(profileIds []string, tags []string) error {
|
||||
log := logger.New("Browser")
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
removeSet := make(map[string]struct{})
|
||||
for _, t := range tags {
|
||||
removeSet[t] = struct{}{}
|
||||
}
|
||||
|
||||
for _, profileId := range profileIds {
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
filtered := profile.Tags[:0]
|
||||
for _, t := range profile.Tags {
|
||||
if _, ok := removeSet[t]; !ok {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
profile.Tags = filtered
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if a.browserMgr.ProfileDAO != nil {
|
||||
if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil {
|
||||
log.Error("批量移除标签失败", logger.F("profile_id", profileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BrowserRenameTag 重命名所有实例中的指定标签
|
||||
func (a *App) BrowserRenameTag(oldName string, newName string) error {
|
||||
log := logger.New("Browser")
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return fmt.Errorf("标签名称不能为空")
|
||||
}
|
||||
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
|
||||
changedCount := 0
|
||||
for profileId, profile := range a.browserMgr.Profiles {
|
||||
tagChanged := false
|
||||
var newTags []string
|
||||
for _, t := range profile.Tags {
|
||||
if strings.EqualFold(t, oldName) {
|
||||
newTags = append(newTags, newName)
|
||||
tagChanged = true
|
||||
} else {
|
||||
newTags = append(newTags, t)
|
||||
}
|
||||
}
|
||||
|
||||
if tagChanged {
|
||||
// 去重
|
||||
uniqueTags := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, t := range newTags {
|
||||
if _, ok := seen[t]; !ok {
|
||||
uniqueTags = append(uniqueTags, t)
|
||||
seen[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
profile.Tags = uniqueTags
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
if a.browserMgr.ProfileDAO != nil {
|
||||
if err := a.browserMgr.ProfileDAO.Upsert(profile); err != nil {
|
||||
log.Error("重命名标签保存失败", logger.F("profile_id", profileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
changedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if changedCount > 0 && a.browserMgr.ProfileDAO == nil {
|
||||
if err := a.browserMgr.SaveProfiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if changedCount > 0 {
|
||||
log.Info("重命名标签成功", logger.F("old", oldName), logger.F("new", newName), logger.F("changed_profiles", changedCount))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) BrowserInstanceStatus(profileId string) (*BrowserProfile, error) {
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (a *App) BrowserInstanceOpenUrl(profileId string, targetUrl string) bool {
|
||||
a.browserMgr.Mutex.Lock()
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
a.browserMgr.Mutex.Unlock()
|
||||
if !exists || !profile.Running {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) BrowserInstanceGetTabs(profileId string) []BrowserTab {
|
||||
return []BrowserTab{
|
||||
{TabId: "tab-1", Title: "新标签页", Url: "about:blank", Active: true},
|
||||
{TabId: "tab-2", Title: "示例站点", Url: "https://example.com", Active: false},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) waitBrowserProcess(profileId string, cmd *exec.Cmd) {
|
||||
err := cmd.Wait()
|
||||
|
||||
a.browserMgr.Mutex.Lock()
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
wasRunning := exists && profile.Running
|
||||
if exists {
|
||||
profile.Running = false
|
||||
profile.LastStopAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
delete(a.browserMgr.BrowserProcesses, profileId)
|
||||
a.browserMgr.Mutex.Unlock()
|
||||
a.releaseProfileXrayBridge(profileId)
|
||||
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 进程是正常退出(用户手动关闭)还是异常崩溃
|
||||
if wasRunning && err != nil {
|
||||
log := logger.New("Browser")
|
||||
// 异常退出,推送崩溃通知
|
||||
profileName := profileId
|
||||
if exists {
|
||||
profileName = profile.ProfileName
|
||||
profile.LastError = fmt.Sprintf("实例运行异常退出:%s", err.Error())
|
||||
}
|
||||
log.Error("浏览器进程异常退出", logger.F("profile_id", profileId), logger.F("profile_name", profileName), logger.F("error", err))
|
||||
runtime.EventsEmit(a.ctx, "browser:instance:crashed", map[string]interface{}{
|
||||
"profileId": profileId,
|
||||
"profileName": profileName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
runtime.EventsEmit(a.ctx, "browser:instance:stopped", profileId)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNonEmptyStrings(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
v := strings.TrimSpace(item)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) stopBrowserProcess(cmd *exec.Cmd) error {
|
||||
return a.stopProcessCmd(cmd)
|
||||
}
|
||||
|
||||
func (a *App) stopProcessCmd(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Windows 下优先非强制 taskkill,尽量让 Chromium 走正常退出路径,减少“恢复页面”提示。
|
||||
if stdruntime.GOOS == "windows" {
|
||||
pid := cmd.Process.Pid
|
||||
if pid > 0 {
|
||||
softKillCmd := exec.Command("taskkill", "/PID", fmt.Sprintf("%d", pid), "/T")
|
||||
hideWindow(softKillCmd)
|
||||
if err := softKillCmd.Run(); err == nil {
|
||||
if waitProcessExitWindows(pid, 3*time.Second) {
|
||||
return nil
|
||||
}
|
||||
forceKillCmd := exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid), "/T")
|
||||
hideWindow(forceKillCmd)
|
||||
if forceErr := forceKillCmd.Run(); forceErr == nil {
|
||||
_ = waitProcessExitWindows(pid, 2*time.Second)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := cmd.Process.Kill()
|
||||
if err == nil || isProcessAlreadyFinished(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isProcessAlreadyFinished(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "process already finished") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "not found") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "no process") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "不存在") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitProcessExitWindows(pid int, timeout time.Duration) bool {
|
||||
if pid <= 0 {
|
||||
return true
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
alive, err := isProcessAliveWindows(pid)
|
||||
if err == nil && !alive {
|
||||
return true
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
alive, err := isProcessAliveWindows(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !alive
|
||||
}
|
||||
|
||||
func isProcessAliveWindows(pid int) (bool, error) {
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH")
|
||||
hideWindow(cmd)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line == "" {
|
||||
return false, nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(line), "INFO:") {
|
||||
return false, nil
|
||||
}
|
||||
token := fmt.Sprintf("\",\"%d\",", pid)
|
||||
return strings.Contains(line, token), nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const browserStartReadyTimeout = 10 * time.Second
|
||||
|
||||
func waitBrowserDebugPortReady(debugPort int, timeout time.Duration) error {
|
||||
address := fmt.Sprintf("127.0.0.1:%d", debugPort)
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", address, 250*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Errorf("浏览器进程未在 %s 内完成启动,调试端口 %d 未就绪", timeout.Round(time.Second), debugPort)
|
||||
}
|
||||
|
||||
func describeChromeProcessStartError(chromeBinaryPath string, err error) string {
|
||||
raw := strings.TrimSpace(err.Error())
|
||||
lower := strings.ToLower(raw)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "access is denied"),
|
||||
strings.Contains(lower, "permission denied"),
|
||||
strings.Contains(raw, "拒绝访问"):
|
||||
return fmt.Sprintf("实例启动失败:系统拒绝启动浏览器进程。可执行文件:%s。请检查文件权限、杀毒软件拦截,或尝试以管理员身份运行。", chromeBinaryPath)
|
||||
case strings.Contains(lower, "not a valid win32 application"),
|
||||
strings.Contains(raw, "不是有效的 win32 应用程序"),
|
||||
strings.Contains(raw, "不是有效的 Win32 应用程序"),
|
||||
strings.Contains(raw, "bad exe format"):
|
||||
return fmt.Sprintf("实例启动失败:当前浏览器内核与系统架构不兼容。可执行文件:%s。请更换为可用的 Windows 64 位 Chrome 内核。", chromeBinaryPath)
|
||||
case strings.Contains(raw, "系统找不到指定的文件"),
|
||||
strings.Contains(lower, "file not found"),
|
||||
strings.Contains(lower, "no such file"),
|
||||
strings.Contains(lower, "cannot find the file"):
|
||||
return fmt.Sprintf("实例启动失败:浏览器可执行文件不存在。可执行文件:%s。请检查内核路径是否正确,或重新下载内核。", chromeBinaryPath)
|
||||
case strings.Contains(raw, "目录名称无效"),
|
||||
strings.Contains(lower, "directory name is invalid"):
|
||||
return fmt.Sprintf("实例启动失败:浏览器工作目录无效。当前目录:%s。请检查内核路径配置是否正确。", chromeBinaryPath)
|
||||
default:
|
||||
return fmt.Sprintf("实例启动失败:浏览器进程拉起失败。可执行文件:%s。原因:%s。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", chromeBinaryPath, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func describeBrowserReadyTimeout(debugPort int, timeout time.Duration) string {
|
||||
return fmt.Sprintf("实例启动失败:浏览器进程已拉起,但在 %s 内未完成就绪,调试端口 %d 未开启。请检查内核文件是否完整、启动参数是否正确,或是否被安全软件拦截。", timeout.Round(time.Second), debugPort)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDescribeChromeProcessStartError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "file not found",
|
||||
err: fmt.Errorf("fork/exec C:\\chrome.exe: The system cannot find the file specified."),
|
||||
want: "浏览器可执行文件不存在",
|
||||
},
|
||||
{
|
||||
name: "access denied",
|
||||
err: fmt.Errorf("fork/exec C:\\chrome.exe: Access is denied."),
|
||||
want: "系统拒绝启动浏览器进程",
|
||||
},
|
||||
{
|
||||
name: "invalid win32",
|
||||
err: fmt.Errorf("%%1 is not a valid Win32 application"),
|
||||
want: "与系统架构不兼容",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := describeChromeProcessStartError(`C:\chrome.exe`, tt.err)
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Fatalf("expected %q to contain %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeBrowserReadyTimeout(t *testing.T) {
|
||||
got := describeBrowserReadyTimeout(9222, 10*time.Second)
|
||||
if !strings.Contains(got, "调试端口 9222 未开启") {
|
||||
t.Fatalf("unexpected timeout message: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StartInstance 实现 launchcode.BrowserStarter 接口
|
||||
func (a *App) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
return a.BrowserInstanceStart(profileId)
|
||||
}
|
||||
|
||||
// StartInstanceWithParams 实现 launchcode.BrowserStarterWithParams 接口
|
||||
func (a *App) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
|
||||
return a.BrowserInstanceStartWithParams(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs)
|
||||
}
|
||||
|
||||
// BrowserProfileGetCode 获取实例的 LaunchCode(Wails 绑定)
|
||||
func (a *App) BrowserProfileGetCode(profileId string) (string, error) {
|
||||
if a.launchCodeSvc == nil {
|
||||
return "", nil
|
||||
}
|
||||
return a.launchCodeSvc.EnsureCode(profileId)
|
||||
}
|
||||
|
||||
// BrowserProfileRegenerateCode 重新生成实例的 LaunchCode(Wails 绑定)
|
||||
func (a *App) BrowserProfileRegenerateCode(profileId string) (string, error) {
|
||||
if a.launchCodeSvc == nil {
|
||||
return "", nil
|
||||
}
|
||||
return a.launchCodeSvc.RegenerateCode(profileId)
|
||||
}
|
||||
|
||||
// BrowserProfileSetCode 自定义设置实例 LaunchCode(Wails 绑定)
|
||||
func (a *App) BrowserProfileSetCode(profileId string, code string) (string, error) {
|
||||
if a.launchCodeSvc == nil {
|
||||
return "", nil
|
||||
}
|
||||
return a.launchCodeSvc.SetCode(profileId, code)
|
||||
}
|
||||
|
||||
// BrowserInstanceStartByCode 通过 LaunchCode 启动实例(Wails 绑定)
|
||||
func (a *App) BrowserInstanceStartByCode(code string) (*browser.Profile, error) {
|
||||
if a.launchCodeSvc == nil {
|
||||
return nil, fmt.Errorf("launch code service not initialized")
|
||||
}
|
||||
profileId, err := a.launchCodeSvc.Resolve(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.BrowserInstanceStart(profileId)
|
||||
}
|
||||
|
||||
// GetLaunchServerInfo 返回 LaunchServer 的当前监听信息(Wails 绑定)
|
||||
func (a *App) GetLaunchServerInfo() map[string]interface{} {
|
||||
preferredPort := 0
|
||||
if a.config != nil {
|
||||
preferredPort = a.config.LaunchServer.Port
|
||||
}
|
||||
|
||||
actualPort := 0
|
||||
if a.launchServer != nil {
|
||||
actualPort = a.launchServer.Port()
|
||||
}
|
||||
|
||||
info := map[string]interface{}{
|
||||
"host": "127.0.0.1",
|
||||
"preferredPort": preferredPort,
|
||||
"port": actualPort,
|
||||
"ready": actualPort > 0,
|
||||
}
|
||||
if actualPort > 0 {
|
||||
info["baseUrl"] = fmt.Sprintf("http://127.0.0.1:%d", actualPort)
|
||||
} else {
|
||||
info["baseUrl"] = ""
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// 确保编译器检查 App 实现了 BrowserStarter 接口
|
||||
var _ launchcode.BrowserStarter = (*App)(nil)
|
||||
var _ launchcode.BrowserStarterWithParams = (*App)(nil)
|
||||
@@ -0,0 +1,162 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LicenseStatus 授权状态
|
||||
type LicenseStatus struct {
|
||||
MaxLimit int `json:"maxLimit"`
|
||||
UsedCount int `json:"usedCount"`
|
||||
UsedKeys []string `json:"usedKeys"`
|
||||
}
|
||||
|
||||
// GetLicenseStatus 获取当前授权状态(给前端使用)
|
||||
func (a *App) GetLicenseStatus() LicenseStatus {
|
||||
profilesCount := 0
|
||||
if a.browserMgr != nil {
|
||||
profilesCount = len(a.browserMgr.List())
|
||||
}
|
||||
usedKeys := a.config.App.UsedCDKeys
|
||||
if usedKeys == nil {
|
||||
usedKeys = []string{}
|
||||
}
|
||||
|
||||
return LicenseStatus{
|
||||
MaxLimit: a.config.App.MaxProfileLimit,
|
||||
UsedCount: profilesCount,
|
||||
UsedKeys: usedKeys,
|
||||
}
|
||||
}
|
||||
|
||||
// RedeemCDKey 验证并核销兑换码,成功返回新配置
|
||||
func (a *App) RedeemCDKey(cdkey string) error {
|
||||
if a.config == nil {
|
||||
a.config = DefaultConfig()
|
||||
}
|
||||
cdkey = strings.ToUpper(cdkey)
|
||||
// 去除所有可能的不小心复制进去的空格、制表符、换行符
|
||||
cdkey = strings.ReplaceAll(cdkey, " ", "")
|
||||
cdkey = strings.ReplaceAll(cdkey, "\t", "")
|
||||
cdkey = strings.ReplaceAll(cdkey, "\n", "")
|
||||
cdkey = strings.ReplaceAll(cdkey, "\r", "")
|
||||
|
||||
if cdkey == "" {
|
||||
return fmt.Errorf("兑换码不能为空")
|
||||
}
|
||||
|
||||
// 1. 基本校验机制(非常简单:比如前缀必须是 ANT-,并且后面加上一个特定的哈希位能匹配)
|
||||
// 生成规则我们在 keygen 里实现。校验规则:
|
||||
// 假设 cdkey 长这样: ANT-XXXX-XXXX-XXXX-XXXX-CHECKSUM
|
||||
// 为了最简单的极简方案,我们这就不搞太复杂的非对称,纯用带盐的 SHA256 截断作为校验和。
|
||||
if !strings.HasPrefix(cdkey, "ANT-") {
|
||||
return fmt.Errorf("无效的兑换码格式")
|
||||
}
|
||||
|
||||
parts := strings.Split(cdkey, "-")
|
||||
if len(parts) < 3 {
|
||||
return fmt.Errorf("无效的兑换码长度")
|
||||
}
|
||||
|
||||
// 验证校验和
|
||||
checksumIndex := len(parts) - 1
|
||||
payload := strings.Join(parts[:checksumIndex], "-") // "ANT-XXXX-XXXX..."
|
||||
expectedChecksum := generateChecksum(payload)
|
||||
actualChecksum := parts[checksumIndex]
|
||||
|
||||
if actualChecksum != expectedChecksum {
|
||||
return fmt.Errorf("无效的兑换码 (Checksum Error)")
|
||||
}
|
||||
|
||||
// 2. 防重放校验
|
||||
for _, usedKey := range a.config.App.UsedCDKeys {
|
||||
if usedKey == cdkey {
|
||||
return fmt.Errorf("该兑换码已被使用过")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 兑现与本地保存
|
||||
a.config.App.MaxProfileLimit += 3
|
||||
a.config.App.UsedCDKeys = append(a.config.App.UsedCDKeys, cdkey)
|
||||
|
||||
configPath := a.resolveAppPath("config.yaml")
|
||||
if _, _, err := reconcileConfigWithLocalLicense(configPath, a.config); err != nil {
|
||||
return fmt.Errorf("保存本机额度状态失败: %v", err)
|
||||
}
|
||||
if err := a.config.Save(configPath); err != nil {
|
||||
return fmt.Errorf("保存配置失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateChecksum 生成简易校验和
|
||||
func generateChecksum(payload string) string {
|
||||
salt := "ANT-LITE-KEY-SALT-VER-1"
|
||||
hash := sha256.Sum256([]byte(payload + salt))
|
||||
return strings.ToUpper(hex.EncodeToString(hash[:])[0:8]) // 取前8位作为校验
|
||||
}
|
||||
|
||||
// RedeemGithubStar 给予用户一个 github star 的一次性奖励
|
||||
func (a *App) RedeemGithubStar() error {
|
||||
if a.config == nil {
|
||||
a.config = DefaultConfig()
|
||||
}
|
||||
cdkey := "GITHUB_STAR_REWARD"
|
||||
// 防重复领取
|
||||
for _, usedKey := range a.config.App.UsedCDKeys {
|
||||
if usedKey == cdkey {
|
||||
return fmt.Errorf("您已经领取过 GitHub Star 的赠送额度啦!")
|
||||
}
|
||||
}
|
||||
|
||||
// 兑现与本地保存: 增加 3 个
|
||||
a.config.App.MaxProfileLimit += 3
|
||||
a.config.App.UsedCDKeys = append(a.config.App.UsedCDKeys, cdkey)
|
||||
|
||||
configPath := a.resolveAppPath("config.yaml")
|
||||
if _, _, err := reconcileConfigWithLocalLicense(configPath, a.config); err != nil {
|
||||
return fmt.Errorf("保存本机额度状态失败: %v", err)
|
||||
}
|
||||
if err := a.config.Save(configPath); err != nil {
|
||||
return fmt.Errorf("保存配置失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateCDKeys 供内部隐藏管理员页面使用的发卡器接口
|
||||
func (a *App) GenerateCDKeys(count int) ([]string, error) {
|
||||
if count <= 0 || count > 1000 {
|
||||
return nil, fmt.Errorf("生成数量无效 (1-1000)")
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
var keys []string
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
// A basic random 16-char string ABCDEFGH-IJKLMNOP...
|
||||
charset := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 16)
|
||||
for j := range b {
|
||||
b[j] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
|
||||
part1 := string(b[0:4])
|
||||
part2 := string(b[4:8])
|
||||
part3 := string(b[8:12])
|
||||
part4 := string(b[12:16])
|
||||
|
||||
payload := fmt.Sprintf("ANT-%s-%s-%s-%s", part1, part2, part3, part4)
|
||||
checksum := generateChecksum(payload)
|
||||
|
||||
keys = append(keys, fmt.Sprintf("%s-%s", payload, checksum))
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// appRootAbs 返回应用根目录的绝对路径,优先使用 App 注入的 appRoot。
|
||||
func (a *App) appRootAbs() string {
|
||||
root := strings.TrimSpace(a.appRoot)
|
||||
if root == "" {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
root = cwd
|
||||
}
|
||||
}
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
return abs
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// appDataDir 返回 data 根目录绝对路径。
|
||||
func (a *App) appDataDir() string {
|
||||
return a.resolveAppPath("data")
|
||||
}
|
||||
|
||||
// appChromeDir 返回 chrome 根目录绝对路径。
|
||||
func (a *App) appChromeDir() string {
|
||||
return a.resolveAppPath("chrome")
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/logger"
|
||||
)
|
||||
|
||||
const maxRemoteProfileConfigBytes = 512 * 1024
|
||||
|
||||
// FetchRemoteAuthorProfile 拉取远程作者配置 JSON,供前端与本地默认配置合并。
|
||||
func (a *App) FetchRemoteAuthorProfile(rawURL string, timeoutMs int) (map[string]interface{}, error) {
|
||||
targetURL := strings.TrimSpace(rawURL)
|
||||
if targetURL == "" {
|
||||
return nil, fmt.Errorf("远程作者配置地址不能为空")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(targetURL)
|
||||
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||||
return nil, fmt.Errorf("远程作者配置地址无效")
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("远程作者配置仅支持 HTTP/HTTPS 地址")
|
||||
}
|
||||
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = 3000
|
||||
}
|
||||
if timeoutMs > 15000 {
|
||||
timeoutMs = 15000
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建远程作者配置请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "AntBrowser/1.0 profile-fetch")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log := logger.New("ProfileConfig")
|
||||
if isTimeoutError(err) {
|
||||
log.Warn("远程作者配置请求超时", logger.F("url", targetURL), logger.F("timeout_ms", timeoutMs))
|
||||
return nil, fmt.Errorf("远程作者配置请求超时")
|
||||
}
|
||||
log.Warn("远程作者配置请求失败", logger.F("url", targetURL), logger.F("error", err))
|
||||
return nil, fmt.Errorf("拉取远程作者配置失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("远程作者配置返回异常状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(io.LimitReader(resp.Body, maxRemoteProfileConfigBytes))
|
||||
decoder.UseNumber()
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("解析远程作者配置失败: %w", err)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("远程作者配置为空")
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClashSubscriptionBytes = 8 * 1024 * 1024
|
||||
clashSubscriptionTimeout = 25 * time.Second
|
||||
)
|
||||
|
||||
// BrowserProxyFetchClashByURL 拉取 Clash 订阅 URL,并返回可直接导入的 YAML 文本与建议配置。
|
||||
func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("订阅 URL 不能为空")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil || parsedURL.Host == "" {
|
||||
return nil, fmt.Errorf("URL 格式无效")
|
||||
}
|
||||
scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return nil, fmt.Errorf("仅支持 http/https URL")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "clash-verge/2.0 ant-chrome/1.0")
|
||||
req.Header.Set("Accept", "application/yaml,text/yaml,text/plain,*/*")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: clashSubscriptionTimeout,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("拉取订阅失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("拉取订阅失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxClashSubscriptionBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取订阅内容失败: %w", err)
|
||||
}
|
||||
if len(body) > maxClashSubscriptionBytes {
|
||||
return nil, fmt.Errorf("订阅内容过大(超过 8MB)")
|
||||
}
|
||||
|
||||
content, payload, err := normalizeClashSubscriptionContent(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyCount := clashProxyCount(payload)
|
||||
if proxyCount <= 0 {
|
||||
return nil, fmt.Errorf("未检测到可导入的 proxies 节点")
|
||||
}
|
||||
|
||||
dnsYAML := extractClashDNSYAML(payload)
|
||||
suggestedGroup := suggestClashGroupName(payload, parsedURL.Hostname())
|
||||
|
||||
return map[string]interface{}{
|
||||
"url": parsedURL.String(),
|
||||
"content": content,
|
||||
"proxyCount": proxyCount,
|
||||
"dnsServers": dnsYAML,
|
||||
"suggestedGroup": suggestedGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeClashSubscriptionContent(body []byte) (string, interface{}, error) {
|
||||
baseText := strings.TrimSpace(strings.ReplaceAll(string(body), "\r\n", "\n"))
|
||||
if baseText == "" {
|
||||
return "", nil, fmt.Errorf("订阅内容为空")
|
||||
}
|
||||
|
||||
tryTexts := make([]string, 0, 4)
|
||||
tryTexts = append(tryTexts, baseText)
|
||||
|
||||
if unescaped, err := url.QueryUnescape(baseText); err == nil {
|
||||
unescaped = strings.TrimSpace(strings.ReplaceAll(unescaped, "\r\n", "\n"))
|
||||
if unescaped != "" && unescaped != baseText {
|
||||
tryTexts = append(tryTexts, unescaped)
|
||||
}
|
||||
}
|
||||
|
||||
if decoded, ok := decodeBase64Text(baseText); ok {
|
||||
tryTexts = append(tryTexts, decoded)
|
||||
}
|
||||
|
||||
for _, text := range tryTexts {
|
||||
payload, ok := parseClashPayload(text)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if clashProxyCount(payload) > 0 {
|
||||
return text, payload, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("URL 内容不是有效 Clash YAML(需包含 proxies)")
|
||||
}
|
||||
|
||||
func decodeBase64Text(raw string) (string, bool) {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
if candidate == "" {
|
||||
return "", false
|
||||
}
|
||||
// 一些订阅会返回 URL-safe base64 或缺少 padding,这里都尝试一遍。
|
||||
padded := candidate
|
||||
if mod := len(padded) % 4; mod != 0 {
|
||||
padded += strings.Repeat("=", 4-mod)
|
||||
}
|
||||
|
||||
encoders := []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
for _, enc := range encoders {
|
||||
if data, err := enc.DecodeString(candidate); err == nil {
|
||||
decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n"))
|
||||
if decoded != "" {
|
||||
return decoded, true
|
||||
}
|
||||
}
|
||||
if data, err := enc.DecodeString(padded); err == nil {
|
||||
decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n"))
|
||||
if decoded != "" {
|
||||
return decoded, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseClashPayload(text string) (interface{}, bool) {
|
||||
var payload interface{}
|
||||
if err := yaml.Unmarshal([]byte(text), &payload); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func clashProxyCount(payload interface{}) int {
|
||||
if m := toStringMap(payload); m != nil {
|
||||
if arr, ok := m["proxies"].([]interface{}); ok {
|
||||
return len(arr)
|
||||
}
|
||||
if arr, ok := m["proxy"].([]interface{}); ok {
|
||||
return len(arr)
|
||||
}
|
||||
if arr, ok := m["Proxy"].([]interface{}); ok {
|
||||
return len(arr)
|
||||
}
|
||||
}
|
||||
if arr, ok := payload.([]interface{}); ok {
|
||||
return len(arr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractClashDNSYAML(payload interface{}) string {
|
||||
m := toStringMap(payload)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
dnsRaw, exists := m["dns"]
|
||||
if !exists || dnsRaw == nil {
|
||||
return ""
|
||||
}
|
||||
data, err := yaml.Marshal(map[string]interface{}{
|
||||
"dns": dnsRaw,
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func suggestClashGroupName(payload interface{}, fallbackHost string) string {
|
||||
fallbackHost = strings.TrimSpace(fallbackHost)
|
||||
m := toStringMap(payload)
|
||||
if m != nil {
|
||||
if groups, ok := m["proxy-groups"].([]interface{}); ok {
|
||||
for _, item := range groups {
|
||||
if groupMap := toStringMap(item); groupMap != nil {
|
||||
if name := strings.TrimSpace(getMapString(groupMap, "name")); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(fallbackHost), "www.") {
|
||||
fallbackHost = fallbackHost[4:]
|
||||
}
|
||||
return fallbackHost
|
||||
}
|
||||
|
||||
func toStringMap(value interface{}) map[string]interface{} {
|
||||
switch m := value.(type) {
|
||||
case map[string]interface{}:
|
||||
return m
|
||||
case map[interface{}]interface{}:
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
key := fmt.Sprint(k)
|
||||
out[key] = v
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getMapString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := m[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReloadConfigLoadsFromDisk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.App.Name = "Reload-Test-App"
|
||||
if err := cfg.Save(filepath.Join(root, "config.yaml")); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp(root)
|
||||
app.config = config.DefaultConfig()
|
||||
|
||||
if err := app.ReloadConfig(); err != nil {
|
||||
t.Fatalf("ReloadConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
if app.config == nil {
|
||||
t.Fatalf("ReloadConfig 后 config 为空")
|
||||
}
|
||||
if app.config.App.Name != "Reload-Test-App" {
|
||||
t.Fatalf("ReloadConfig 未生效,got=%q", app.config.App.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadConfigKeepsLocalLicenseState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
if err := cfg.Save(filepath.Join(root, "config.yaml")); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
if err := saveLocalLicenseState(filepath.Join(root, "config.yaml"), &localLicenseState{
|
||||
MaxProfileLimit: 9,
|
||||
UsedCDKeys: []string{"ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE", "GITHUB_STAR_REWARD"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入本机额度状态失败: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp(root)
|
||||
app.config = config.DefaultConfig()
|
||||
|
||||
if err := app.ReloadConfig(); err != nil {
|
||||
t.Fatalf("ReloadConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
if app.config.App.MaxProfileLimit != 9 {
|
||||
t.Fatalf("ReloadConfig 未恢复本机额度状态: got=%d", app.config.App.MaxProfileLimit)
|
||||
}
|
||||
if len(app.config.App.UsedCDKeys) != 2 {
|
||||
t.Fatalf("ReloadConfig 未恢复兑换记录: %+v", app.config.App.UsedCDKeys)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
stdruntime "runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type browserProcessSnapshot struct {
|
||||
profileID string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (a *App) stopRuntimeServices() {
|
||||
a.stopServicesOnce.Do(func() {
|
||||
log := logger.New("App")
|
||||
|
||||
a.stopAllBrowserProcessesForExit(log)
|
||||
|
||||
if a.xrayMgr != nil {
|
||||
a.xrayMgr.StopAll()
|
||||
}
|
||||
a.clearProfileXrayBridges()
|
||||
|
||||
if a.singboxMgr != nil {
|
||||
a.singboxMgr.StopAll()
|
||||
}
|
||||
|
||||
if a.clashMgr != nil {
|
||||
a.clashMgr.StopAll()
|
||||
}
|
||||
|
||||
if a.speedScheduler != nil {
|
||||
a.speedScheduler.Stop()
|
||||
a.speedScheduler = nil
|
||||
}
|
||||
|
||||
if a.launchServer != nil {
|
||||
if err := a.launchServer.Stop(); err != nil {
|
||||
log.Error("LaunchServer 关闭失败", logger.F("error", err))
|
||||
}
|
||||
a.launchServer = nil
|
||||
}
|
||||
|
||||
if err := killResidualRuntimeProcesses(a.appRoot); err != nil {
|
||||
log.Error("退出前清理残留进程失败", logger.F("error", err.Error()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) finalizeShutdown() {
|
||||
a.finalizeOnce.Do(func() {
|
||||
if a.db != nil {
|
||||
a.db.Close()
|
||||
a.db = nil
|
||||
}
|
||||
if err := logger.Close(); err != nil {
|
||||
fmt.Printf("关闭日志系统失败: %v\n", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopAllBrowserProcessesForExit(log *logger.Logger) {
|
||||
if a.browserMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stoppedAt := time.Now().Format(time.RFC3339)
|
||||
|
||||
a.browserMgr.Mutex.Lock()
|
||||
processes := make([]browserProcessSnapshot, 0, len(a.browserMgr.BrowserProcesses))
|
||||
for profileID, cmd := range a.browserMgr.BrowserProcesses {
|
||||
if profile, ok := a.browserMgr.Profiles[profileID]; ok && profile != nil {
|
||||
profile.Running = false
|
||||
profile.LastStopAt = stoppedAt
|
||||
}
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
processes = append(processes, browserProcessSnapshot{
|
||||
profileID: profileID,
|
||||
cmd: cmd,
|
||||
})
|
||||
}
|
||||
}
|
||||
a.browserMgr.BrowserProcesses = make(map[string]*exec.Cmd)
|
||||
a.browserMgr.Mutex.Unlock()
|
||||
|
||||
if len(processes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range processes {
|
||||
wg.Add(1)
|
||||
go func(item browserProcessSnapshot) {
|
||||
defer wg.Done()
|
||||
|
||||
pid := 0
|
||||
if item.cmd != nil && item.cmd.Process != nil {
|
||||
pid = item.cmd.Process.Pid
|
||||
}
|
||||
log.Info("退出前关闭浏览器实例", logger.F("profile_id", item.profileID), logger.F("pid", pid))
|
||||
if err := stopProcessCmdForShutdown(item.cmd); err != nil {
|
||||
log.Error("退出前关闭浏览器实例失败", logger.F("profile_id", item.profileID), logger.F("pid", pid), logger.F("error", err.Error()))
|
||||
}
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func stopProcessCmdForShutdown(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
if pid > 0 {
|
||||
if err := forceKillProcessTree(pid); err == nil || isProcessAlreadyFinished(err) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := cmd.Process.Kill()
|
||||
if err == nil || isProcessAlreadyFinished(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func forceKillProcessTree(pid int) error {
|
||||
if pid <= 0 {
|
||||
return nil
|
||||
}
|
||||
if stdruntime.GOOS != "windows" {
|
||||
return fmt.Errorf("force kill process tree unsupported on %s", stdruntime.GOOS)
|
||||
}
|
||||
|
||||
killCmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", pid))
|
||||
hideWindow(killCmd)
|
||||
err := killCmd.Run()
|
||||
if err == nil {
|
||||
_ = waitProcessExitWindows(pid, 1500*time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
if waitProcessExitWindows(pid, 300*time.Millisecond) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 实例数据快照 API
|
||||
// ============================================================================
|
||||
|
||||
// SnapshotInfo 快照元数据
|
||||
type SnapshotInfo struct {
|
||||
SnapshotId string `json:"snapshotId"`
|
||||
ProfileId string `json:"profileId"`
|
||||
Name string `json:"name"`
|
||||
SizeMB float64 `json:"sizeMB"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
FilePath string `json:"filePath,omitempty"`
|
||||
}
|
||||
|
||||
// snapshotDir 返回指定实例的快照目录路径(存放在 data/snapshots 下)
|
||||
func (a *App) snapshotDir(profileId string) (string, error) {
|
||||
dir := filepath.Join(a.resolveAppPath("data"), "snapshots", profileId)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// zipDir 递归压缩 src 目录为 dest zip 文件
|
||||
func zipDir(src, dest string) error {
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
defer w.Close()
|
||||
|
||||
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 统一使用正斜杠
|
||||
rel = filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
_, err = w.Create(rel + "/")
|
||||
return err
|
||||
}
|
||||
fw, err := w.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(fw, file)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// unzipTo 解压 src zip 文件到 dest 目录
|
||||
func unzipTo(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
target := filepath.Join(dest, filepath.FromSlash(f.Name))
|
||||
// 防止 zip slip
|
||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) &&
|
||||
filepath.Clean(target) != filepath.Clean(dest) {
|
||||
return fmt.Errorf("非法路径: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProfileForSnapshot 获取实例信息(加锁)
|
||||
func (a *App) getProfileForSnapshot(profileId string) (*BrowserProfile, error) {
|
||||
a.browserMgr.Mutex.Lock()
|
||||
defer a.browserMgr.Mutex.Unlock()
|
||||
profile, exists := a.browserMgr.Profiles[profileId]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("实例不存在: %s", profileId)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// BrowserSnapshotCreate 创建快照
|
||||
func (a *App) BrowserSnapshotCreate(profileId, name string) (SnapshotInfo, error) {
|
||||
profile, err := a.getProfileForSnapshot(profileId)
|
||||
if err != nil {
|
||||
return SnapshotInfo{}, err
|
||||
}
|
||||
if profile.Running {
|
||||
return SnapshotInfo{}, fmt.Errorf("请先停止实例再创建快照")
|
||||
}
|
||||
|
||||
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
|
||||
if _, err := os.Stat(userDataDir); os.IsNotExist(err) {
|
||||
return SnapshotInfo{}, fmt.Errorf("用户数据目录不存在,无法创建快照")
|
||||
}
|
||||
|
||||
snapDir, err := a.snapshotDir(profileId)
|
||||
if err != nil {
|
||||
return SnapshotInfo{}, err
|
||||
}
|
||||
|
||||
snapshotId := uuid.NewString()
|
||||
safeName := strings.ReplaceAll(name, string(os.PathSeparator), "_")
|
||||
zipPath := filepath.Join(snapDir, snapshotId+"_"+safeName+".zip")
|
||||
metaPath := filepath.Join(snapDir, snapshotId+"_"+safeName+".meta.json")
|
||||
|
||||
if err := zipDir(userDataDir, zipPath); err != nil {
|
||||
return SnapshotInfo{}, fmt.Errorf("压缩失败: %w", err)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(zipPath)
|
||||
if err != nil {
|
||||
return SnapshotInfo{}, err
|
||||
}
|
||||
sizeMB := float64(fi.Size()) / 1024 / 1024
|
||||
|
||||
info := SnapshotInfo{
|
||||
SnapshotId: snapshotId,
|
||||
ProfileId: profileId,
|
||||
Name: name,
|
||||
SizeMB: sizeMB,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
FilePath: zipPath,
|
||||
}
|
||||
|
||||
metaData, _ := json.Marshal(info)
|
||||
if err := os.WriteFile(metaPath, metaData, 0644); err != nil {
|
||||
return SnapshotInfo{}, err
|
||||
}
|
||||
|
||||
// 返回给前端时不暴露 FilePath
|
||||
info.FilePath = ""
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// BrowserSnapshotList 列出实例的所有快照
|
||||
func (a *App) BrowserSnapshotList(profileId string) ([]SnapshotInfo, error) {
|
||||
snapDir, err := a.snapshotDir(profileId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(snapDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []SnapshotInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var list []SnapshotInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(snapDir, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var info SnapshotInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
continue
|
||||
}
|
||||
info.FilePath = ""
|
||||
list = append(list, info)
|
||||
}
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].CreatedAt > list[j].CreatedAt
|
||||
})
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// BrowserSnapshotRestore 恢复快照
|
||||
func (a *App) BrowserSnapshotRestore(profileId, snapshotId string) error {
|
||||
profile, err := a.getProfileForSnapshot(profileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile.Running {
|
||||
return fmt.Errorf("请先停止实例再恢复快照")
|
||||
}
|
||||
|
||||
snapDir, err := a.snapshotDir(profileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 找到对应 meta.json
|
||||
metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = metaPath
|
||||
|
||||
userDataDir := a.browserMgr.ResolveUserDataDir(profile)
|
||||
if err := os.RemoveAll(userDataDir); err != nil {
|
||||
return fmt.Errorf("清空用户数据目录失败: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(userDataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return unzipTo(zipPath, userDataDir)
|
||||
}
|
||||
|
||||
// BrowserSnapshotDelete 删除快照
|
||||
func (a *App) BrowserSnapshotDelete(profileId, snapshotId string) error {
|
||||
snapDir, err := a.snapshotDir(profileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metaPath, zipPath, err := findSnapshotFiles(snapDir, snapshotId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(zipPath)
|
||||
_ = os.Remove(metaPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// findSnapshotFiles 在快照目录中找到指定 snapshotId 的 meta 和 zip 路径
|
||||
func findSnapshotFiles(snapDir, snapshotId string) (metaPath, zipPath string, err error) {
|
||||
entries, err := os.ReadDir(snapDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), snapshotId) && strings.HasSuffix(entry.Name(), ".meta.json") {
|
||||
metaPath = filepath.Join(snapDir, entry.Name())
|
||||
zipPath = strings.TrimSuffix(metaPath, ".meta.json") + ".zip"
|
||||
if _, err := os.Stat(zipPath); err != nil {
|
||||
return "", "", fmt.Errorf("快照文件不存在: %s", zipPath)
|
||||
}
|
||||
return metaPath, zipPath, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("快照不存在: %s", snapshotId)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
// resolveAppPath 将相对路径解析为绝对路径(基于 appRoot)。
|
||||
// 如果传入的已经是绝对路径则直接返回。
|
||||
func (a *App) resolveAppPath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
if a.appRoot != "" {
|
||||
return filepath.Join(a.appRoot, p)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return filepath.Join(cwd, p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func generateUUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func nextAvailablePort() (int, error) {
|
||||
// 二次验证策略:分配端口后立即再次绑定确认未被抢占,最多重试 10 次
|
||||
for i := 0; i < 10; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
// 短暂等待 OS 释放端口
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
// 二次验证端口未被其他进程抢占
|
||||
v, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v.Close()
|
||||
return port, nil
|
||||
}
|
||||
return 0, fmt.Errorf("无法分配可用端口")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 内核初始化
|
||||
// ============================================================================
|
||||
|
||||
func (a *App) ensureDefaultCores() {
|
||||
log := logger.New("Browser")
|
||||
|
||||
// 扫描 chrome/ 目录,无论配置是否已有内核都执行一次,确保新增子目录被发现
|
||||
detected := a.scanChromeDir("chrome")
|
||||
|
||||
if len(a.config.Browser.Cores) == 0 {
|
||||
// 配置为空:直接用扫描结果,或兜底写一个占位
|
||||
if len(detected) > 0 {
|
||||
a.config.Browser.Cores = detected
|
||||
} else {
|
||||
a.config.Browser.Cores = []browser.Core{}
|
||||
}
|
||||
if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil {
|
||||
log.Error("内核配置初始化失败", logger.F("error", err))
|
||||
return
|
||||
}
|
||||
log.Info("内核配置初始化完成", logger.F("count", len(a.config.Browser.Cores)))
|
||||
return
|
||||
}
|
||||
|
||||
// 配置已有内核:将扫描到的新目录追加进去(不覆盖已有的)
|
||||
changed := false
|
||||
for _, newCore := range detected {
|
||||
exists := false
|
||||
for _, existing := range a.config.Browser.Cores {
|
||||
if existing.CorePath == newCore.CorePath {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
a.config.Browser.Cores = append(a.config.Browser.Cores, newCore)
|
||||
log.Info("发现新内核,已注册", logger.F("path", newCore.CorePath))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := a.config.Save(a.resolveAppPath("config.yaml")); err != nil {
|
||||
log.Error("新内核注册保存失败", logger.F("error", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) autoDetectCores() {
|
||||
log := logger.New("Browser")
|
||||
// ensureDefaultCores 已完成扫描注册,这里只做路径有效性日志。
|
||||
// SQLite 模式下以内核表为准,避免与 config.yaml 历史条目不一致。
|
||||
cores := a.config.Browser.Cores
|
||||
if a.browserMgr != nil {
|
||||
cores = a.browserMgr.ListCores()
|
||||
}
|
||||
for _, core := range cores {
|
||||
result := a.browserMgr.ValidateCorePath(core.CorePath)
|
||||
if result.Valid {
|
||||
log.Debug("内核路径有效", logger.F("core_id", core.CoreId), logger.F("path", core.CorePath))
|
||||
} else {
|
||||
log.Warn("内核路径无效", logger.F("core_id", core.CoreId), logger.F("path", core.CorePath), logger.F("message", result.Message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanChromeDir 扫描指定目录,将包含 chrome.exe 的子文件夹识别为内核。
|
||||
// 如果目录本身包含 chrome.exe(旧版单内核结构),则直接返回该目录作为内核。
|
||||
func (a *App) scanChromeDir(chromeRoot string) []browser.Core {
|
||||
log := logger.New("Browser")
|
||||
|
||||
baseDir := a.resolveAppPath(chromeRoot)
|
||||
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果根目录本身就有 chrome.exe,视为单内核结构
|
||||
if _, err := os.Stat(filepath.Join(baseDir, "chrome.exe")); err == nil {
|
||||
return []browser.Core{
|
||||
{
|
||||
CoreId: "default",
|
||||
CoreName: "默认内核",
|
||||
CorePath: chromeRoot,
|
||||
IsDefault: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描子文件夹
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
log.Warn("扫描 chrome 目录失败", logger.F("path", baseDir), logger.F("error", err.Error()))
|
||||
return nil
|
||||
}
|
||||
|
||||
var cores []browser.Core
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
subPath := filepath.Join(chromeRoot, entry.Name())
|
||||
absExe := filepath.Join(baseDir, entry.Name(), "chrome.exe")
|
||||
if _, err := os.Stat(absExe); err != nil {
|
||||
continue // 没有 chrome.exe,跳过
|
||||
}
|
||||
isDefault := len(cores) == 0
|
||||
cores = append(cores, browser.Core{
|
||||
CoreId: fmt.Sprintf("core-%s", entry.Name()),
|
||||
CoreName: fmt.Sprintf("Chrome %s", entry.Name()),
|
||||
CorePath: subPath,
|
||||
IsDefault: isDefault,
|
||||
})
|
||||
log.Debug("发现内核", logger.F("name", entry.Name()), logger.F("path", subPath))
|
||||
}
|
||||
return cores
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 代理数据加载
|
||||
// ============================================================================
|
||||
|
||||
// loadProxies 启动时加载代理数据。
|
||||
// 优先从 ProxyDAO(SQLite)读取;若 DAO 未注入则降级到 proxies.yaml,最后降级到 config.yaml。
|
||||
func (a *App) loadProxies() {
|
||||
log := logger.New("Browser")
|
||||
|
||||
builtins := []browser.Proxy{
|
||||
{ProxyId: "__direct__", ProxyName: "直连(不走代理)", ProxyConfig: "direct://"},
|
||||
{ProxyId: "__local__", ProxyName: "本地代理", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
ensureBuiltins := func(list []browser.Proxy) []browser.Proxy {
|
||||
for _, b := range builtins {
|
||||
found := false
|
||||
for _, p := range list {
|
||||
if p.ProxyId == b.ProxyId {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
list = append([]browser.Proxy{b}, list...)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// 优先从 SQLite 读取
|
||||
if a.browserMgr.ProxyDAO != nil {
|
||||
list, err := a.browserMgr.ProxyDAO.List()
|
||||
if err != nil {
|
||||
log.Error("从数据库读取代理失败", logger.F("error", err.Error()))
|
||||
} else if len(list) > 0 {
|
||||
a.config.Browser.Proxies = list
|
||||
log.Info("代理数据从数据库加载完成", logger.F("count", len(list)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:从 proxies.yaml 加载
|
||||
loaded, err := config.LoadProxies(a.resolveAppPath("proxies.yaml"))
|
||||
if err != nil {
|
||||
log.Warn("读取 proxies.yaml 失败", logger.F("error", err.Error()))
|
||||
}
|
||||
if loaded != nil {
|
||||
proxies := ensureBuiltins(loaded)
|
||||
a.config.Browser.Proxies = proxies
|
||||
log.Info("代理数据从 proxies.yaml 加载完成", logger.F("count", len(proxies)))
|
||||
return
|
||||
}
|
||||
|
||||
// 最终降级:使用 config.yaml 中的数据
|
||||
proxies := ensureBuiltins(a.config.Browser.Proxies)
|
||||
a.config.Browser.Proxies = proxies
|
||||
log.Info("代理数据使用 config.yaml 默认值", logger.F("count", len(proxies)))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
appconfig "ant-chrome/backend/internal/config"
|
||||
apptray "ant-chrome/backend/internal/tray"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config = appconfig.Config
|
||||
type TrayCallbacks = apptray.Callbacks
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
cfg, err := appconfig.Load(path)
|
||||
configChanged := false
|
||||
repairedConfig := false
|
||||
if err == nil {
|
||||
if changed, _, syncErr := reconcileConfigWithLocalLicense(path, cfg); syncErr != nil {
|
||||
return cfg, syncErr
|
||||
} else {
|
||||
configChanged = changed
|
||||
}
|
||||
if configChanged {
|
||||
if saveErr := cfg.Save(path); saveErr != nil {
|
||||
return cfg, fmt.Errorf("写回配置失败: %w", saveErr)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 配置文件存在但内容损坏时,自动备份并重建默认配置,避免启动阶段反复报错。
|
||||
if data, readErr := os.ReadFile(path); readErr == nil && len(data) > 0 {
|
||||
backupPath := fmt.Sprintf("%s.broken-%s", path, time.Now().Format("20060102-150405"))
|
||||
if writeErr := os.WriteFile(backupPath, data, 0644); writeErr != nil {
|
||||
return appconfig.DefaultConfig(), fmt.Errorf("加载配置失败: %w;备份损坏配置失败: %v", err, writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
defaultCfg := appconfig.DefaultConfig()
|
||||
repairedConfig = true
|
||||
if changed, _, syncErr := reconcileConfigWithLocalLicense(path, defaultCfg); syncErr != nil {
|
||||
return defaultCfg, syncErr
|
||||
} else {
|
||||
configChanged = changed
|
||||
}
|
||||
if repairedConfig || configChanged {
|
||||
if saveErr := os.MkdirAll(filepath.Dir(path), 0755); saveErr != nil {
|
||||
return defaultCfg, fmt.Errorf("加载配置失败: %w;创建配置目录失败: %v", err, saveErr)
|
||||
}
|
||||
if saveErr := defaultCfg.Save(path); saveErr != nil {
|
||||
return defaultCfg, fmt.Errorf("加载配置失败: %w;重建默认配置失败: %v", err, saveErr)
|
||||
}
|
||||
}
|
||||
return defaultCfg, nil
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return appconfig.DefaultConfig()
|
||||
}
|
||||
|
||||
func RunTray(cb TrayCallbacks) {
|
||||
apptray.Run(cb)
|
||||
}
|
||||
|
||||
func QuitTray() {
|
||||
apptray.Quit()
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// PackageFormat 标识导出包格式类型。
|
||||
PackageFormat = "ant-chrome-full-backup"
|
||||
// ManifestVersion 标识 manifest.json 的结构版本。
|
||||
ManifestVersion = 1
|
||||
)
|
||||
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategorySystemConfig Category = "system_config"
|
||||
CategoryAppData Category = "app_data"
|
||||
CategoryBrowserData Category = "browser_data"
|
||||
CategoryCoreData Category = "core_data"
|
||||
CategoryLogs Category = "logs"
|
||||
)
|
||||
|
||||
type EntryType string
|
||||
|
||||
const (
|
||||
EntryTypeFile EntryType = "file"
|
||||
EntryTypeDir EntryType = "dir"
|
||||
)
|
||||
|
||||
// ScopeEntry 描述一个需要进入备份包的源条目。
|
||||
type ScopeEntry struct {
|
||||
ID string `json:"id"`
|
||||
Category Category `json:"category"`
|
||||
EntryType EntryType `json:"entryType"`
|
||||
Required bool `json:"required"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
ArchivePath string `json:"archivePath"`
|
||||
Exists bool `json:"exists"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Scope 为导出范围定义。
|
||||
type Scope struct {
|
||||
Format string `json:"format"`
|
||||
ManifestVersion int `json:"manifestVersion"`
|
||||
AppRoot string `json:"appRoot"`
|
||||
Entries []ScopeEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// Manifest 用于写入 zip 根目录下的 manifest.json。
|
||||
type Manifest struct {
|
||||
Format string `json:"format"`
|
||||
ManifestVersion int `json:"manifestVersion"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
App ManifestAppInfo `json:"app"`
|
||||
Entries []ManifestEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type ManifestAppInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// ManifestEntry 为写入 manifest 的条目(不包含本机绝对路径)。
|
||||
type ManifestEntry struct {
|
||||
ID string `json:"id"`
|
||||
Category Category `json:"category"`
|
||||
EntryType EntryType `json:"entryType"`
|
||||
Required bool `json:"required"`
|
||||
ArchivePath string `json:"archivePath"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type BuildOptions struct {
|
||||
AppRoot string
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// BuildScope 构建第一阶段的导出范围定义(不执行实际导出)。
|
||||
func BuildScope(opts BuildOptions) (Scope, error) {
|
||||
appRoot := strings.TrimSpace(opts.AppRoot)
|
||||
if appRoot == "" {
|
||||
return Scope{}, fmt.Errorf("app root 不能为空")
|
||||
}
|
||||
appRootAbs, err := filepath.Abs(appRoot)
|
||||
if err != nil {
|
||||
return Scope{}, fmt.Errorf("解析 app root 失败: %w", err)
|
||||
}
|
||||
|
||||
cfg := opts.Config
|
||||
if cfg == nil {
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
builder := newScopeBuilder(appRootAbs)
|
||||
|
||||
builder.add(ScopeEntry{
|
||||
ID: "system_config_main",
|
||||
Category: CategorySystemConfig,
|
||||
EntryType: EntryTypeFile,
|
||||
Required: true,
|
||||
SourcePath: resolvePath(appRootAbs, "config.yaml"),
|
||||
ArchivePath: "payload/system/config.yaml",
|
||||
Description: "主配置文件",
|
||||
})
|
||||
|
||||
builder.add(ScopeEntry{
|
||||
ID: "system_config_proxies",
|
||||
Category: CategorySystemConfig,
|
||||
EntryType: EntryTypeFile,
|
||||
Required: false,
|
||||
SourcePath: resolvePath(appRootAbs, "proxies.yaml"),
|
||||
ArchivePath: "payload/system/proxies.yaml",
|
||||
Description: "代理配置文件(存在时导出)",
|
||||
})
|
||||
|
||||
appDataRoot := resolvePath(appRootAbs, "data")
|
||||
builder.add(ScopeEntry{
|
||||
ID: "app_data_root",
|
||||
Category: CategoryAppData,
|
||||
EntryType: EntryTypeDir,
|
||||
Required: true,
|
||||
SourcePath: appDataRoot,
|
||||
ArchivePath: "payload/app/data/",
|
||||
Description: "应用数据目录(含数据库、快照及默认浏览器数据)",
|
||||
})
|
||||
|
||||
userDataRootSetting := strings.TrimSpace(cfg.Browser.UserDataRoot)
|
||||
if userDataRootSetting == "" {
|
||||
userDataRootSetting = "data"
|
||||
}
|
||||
userDataRoot := resolvePath(appRootAbs, userDataRootSetting)
|
||||
builder.add(ScopeEntry{
|
||||
ID: "browser_user_data_root",
|
||||
Category: CategoryBrowserData,
|
||||
EntryType: EntryTypeDir,
|
||||
Required: true,
|
||||
SourcePath: userDataRoot,
|
||||
ArchivePath: "payload/browser/user-data/",
|
||||
Description: "浏览器用户数据根目录(若与 data 重合则自动去重)",
|
||||
})
|
||||
|
||||
chromeRoot := resolvePath(appRootAbs, "chrome")
|
||||
builder.add(ScopeEntry{
|
||||
ID: "browser_core_root",
|
||||
Category: CategoryCoreData,
|
||||
EntryType: EntryTypeDir,
|
||||
Required: false,
|
||||
SourcePath: chromeRoot,
|
||||
ArchivePath: "payload/browser/cores/chrome/",
|
||||
Description: "默认内核目录",
|
||||
})
|
||||
|
||||
corePaths := collectExtraCorePaths(cfg.Browser.Cores, appRootAbs, chromeRoot)
|
||||
for idx, corePath := range corePaths {
|
||||
coreID := fmt.Sprintf("external-%02d", idx+1)
|
||||
builder.add(ScopeEntry{
|
||||
ID: "browser_core_external_" + coreID,
|
||||
Category: CategoryCoreData,
|
||||
EntryType: EntryTypeDir,
|
||||
Required: false,
|
||||
SourcePath: corePath,
|
||||
ArchivePath: "payload/browser/cores/external/" + coreID + "/",
|
||||
Description: "额外内核目录(来自配置 cores)",
|
||||
})
|
||||
}
|
||||
|
||||
dbType := strings.TrimSpace(cfg.Database.Type)
|
||||
if dbType == "" || strings.EqualFold(dbType, "sqlite") {
|
||||
dbPath := strings.TrimSpace(cfg.Database.SQLite.Path)
|
||||
if dbPath == "" {
|
||||
dbPath = "data/app.db"
|
||||
}
|
||||
dbAbs := resolvePath(appRootAbs, dbPath)
|
||||
builder.add(ScopeEntry{
|
||||
ID: "database_sqlite_main",
|
||||
Category: CategoryAppData,
|
||||
EntryType: EntryTypeFile,
|
||||
Required: true,
|
||||
SourcePath: dbAbs,
|
||||
ArchivePath: "payload/app/database/app.db",
|
||||
Description: "SQLite 主数据库(若已被 data 覆盖则自动去重)",
|
||||
})
|
||||
builder.add(ScopeEntry{
|
||||
ID: "database_sqlite_wal",
|
||||
Category: CategoryAppData,
|
||||
EntryType: EntryTypeFile,
|
||||
Required: false,
|
||||
SourcePath: dbAbs + "-wal",
|
||||
ArchivePath: "payload/app/database/app.db-wal",
|
||||
Description: "SQLite WAL 文件(存在时导出)",
|
||||
})
|
||||
builder.add(ScopeEntry{
|
||||
ID: "database_sqlite_shm",
|
||||
Category: CategoryAppData,
|
||||
EntryType: EntryTypeFile,
|
||||
Required: false,
|
||||
SourcePath: dbAbs + "-shm",
|
||||
ArchivePath: "payload/app/database/app.db-shm",
|
||||
Description: "SQLite SHM 文件(存在时导出)",
|
||||
})
|
||||
}
|
||||
|
||||
logDir := detectLogDir(appRootAbs, strings.TrimSpace(cfg.Logging.FilePath))
|
||||
if logDir != "" {
|
||||
builder.add(ScopeEntry{
|
||||
ID: "logs_root",
|
||||
Category: CategoryLogs,
|
||||
EntryType: EntryTypeDir,
|
||||
Required: false,
|
||||
SourcePath: logDir,
|
||||
ArchivePath: "payload/app/logs/",
|
||||
Description: "日志目录(存在时导出)",
|
||||
})
|
||||
}
|
||||
|
||||
scope := Scope{
|
||||
Format: PackageFormat,
|
||||
ManifestVersion: ManifestVersion,
|
||||
AppRoot: appRootAbs,
|
||||
Entries: builder.entries,
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
// BuildManifest 根据 Scope 生成 manifest 结构体。
|
||||
func BuildManifest(scope Scope, appName, appVersion string, createdAt time.Time) Manifest {
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now()
|
||||
}
|
||||
name := strings.TrimSpace(appName)
|
||||
if name == "" {
|
||||
name = "Ant Browser"
|
||||
}
|
||||
version := strings.TrimSpace(appVersion)
|
||||
if version == "" {
|
||||
version = "1.0.0"
|
||||
}
|
||||
|
||||
entries := make([]ManifestEntry, 0, len(scope.Entries))
|
||||
for _, item := range scope.Entries {
|
||||
entries = append(entries, ManifestEntry{
|
||||
ID: item.ID,
|
||||
Category: item.Category,
|
||||
EntryType: item.EntryType,
|
||||
Required: item.Required,
|
||||
ArchivePath: item.ArchivePath,
|
||||
Description: item.Description,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].ID < entries[j].ID
|
||||
})
|
||||
|
||||
return Manifest{
|
||||
Format: PackageFormat,
|
||||
ManifestVersion: ManifestVersion,
|
||||
CreatedAt: createdAt.UTC().Format(time.RFC3339),
|
||||
App: ManifestAppInfo{
|
||||
Name: name,
|
||||
Version: version,
|
||||
},
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func collectExtraCorePaths(cores []config.BrowserCore, appRootAbs, defaultChromeRoot string) []string {
|
||||
result := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, core := range cores {
|
||||
corePath := strings.TrimSpace(core.CorePath)
|
||||
if corePath == "" {
|
||||
continue
|
||||
}
|
||||
coreAbs := resolvePath(appRootAbs, corePath)
|
||||
if isPathWithin(coreAbs, defaultChromeRoot) {
|
||||
continue
|
||||
}
|
||||
key := normalizeForCompare(coreAbs)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, coreAbs)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func detectLogDir(appRootAbs, logPath string) string {
|
||||
if logPath == "" {
|
||||
return ""
|
||||
}
|
||||
resolved := resolvePath(appRootAbs, logPath)
|
||||
dir := filepath.Dir(resolved)
|
||||
if strings.TrimSpace(dir) == "" || dir == "." {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(dir)
|
||||
}
|
||||
|
||||
type scopeBuilder struct {
|
||||
entries []ScopeEntry
|
||||
}
|
||||
|
||||
func newScopeBuilder(_ string) *scopeBuilder {
|
||||
return &scopeBuilder{
|
||||
entries: make([]ScopeEntry, 0, 12),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *scopeBuilder) add(entry ScopeEntry) {
|
||||
if strings.TrimSpace(entry.SourcePath) == "" {
|
||||
return
|
||||
}
|
||||
entry.SourcePath = filepath.Clean(entry.SourcePath)
|
||||
entry.ArchivePath = filepath.ToSlash(strings.TrimSpace(entry.ArchivePath))
|
||||
if entry.ArchivePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 已有目录覆盖时,直接跳过,避免重复导出同一文件。
|
||||
if b.isCoveredByExisting(entry.SourcePath) {
|
||||
return
|
||||
}
|
||||
|
||||
for i, existing := range b.entries {
|
||||
if samePath(existing.SourcePath, entry.SourcePath) {
|
||||
if entry.Required && !existing.Required {
|
||||
b.entries[i].Required = true
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
entry.Exists = pathExists(entry.SourcePath)
|
||||
b.entries = append(b.entries, entry)
|
||||
sort.SliceStable(b.entries, func(i, j int) bool {
|
||||
return b.entries[i].ID < b.entries[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func (b *scopeBuilder) isCoveredByExisting(candidate string) bool {
|
||||
for _, existing := range b.entries {
|
||||
switch existing.EntryType {
|
||||
case EntryTypeDir:
|
||||
if isPathWithin(candidate, existing.SourcePath) {
|
||||
return true
|
||||
}
|
||||
case EntryTypeFile:
|
||||
if samePath(candidate, existing.SourcePath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolvePath(appRoot, p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return filepath.Clean(appRoot)
|
||||
}
|
||||
if filepath.IsAbs(p) {
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
return filepath.Clean(filepath.Join(appRoot, p))
|
||||
}
|
||||
|
||||
func pathExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
return normalizeForCompare(a) == normalizeForCompare(b)
|
||||
}
|
||||
|
||||
func isPathWithin(path, dir string) bool {
|
||||
p := normalizeForCompare(path)
|
||||
d := normalizeForCompare(dir)
|
||||
if p == d {
|
||||
return true
|
||||
}
|
||||
if d == "" || p == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasSuffix(d, string(filepath.Separator)) {
|
||||
d += string(filepath.Separator)
|
||||
}
|
||||
return strings.HasPrefix(p, d)
|
||||
}
|
||||
|
||||
func normalizeForCompare(p string) string {
|
||||
normalized := filepath.Clean(strings.TrimSpace(p))
|
||||
if runtime.GOOS == "windows" {
|
||||
normalized = strings.ToLower(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildScope_DefaultConfigKeepsCoreEntries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
|
||||
scope, err := BuildScope(BuildOptions{
|
||||
AppRoot: tempDir,
|
||||
Config: cfg,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildScope 返回错误: %v", err)
|
||||
}
|
||||
|
||||
if scope.Format != PackageFormat {
|
||||
t.Fatalf("format 不正确: %s", scope.Format)
|
||||
}
|
||||
if scope.ManifestVersion != ManifestVersion {
|
||||
t.Fatalf("manifestVersion 不正确: %d", scope.ManifestVersion)
|
||||
}
|
||||
|
||||
ids := make(map[string]ScopeEntry)
|
||||
for _, e := range scope.Entries {
|
||||
ids[e.ID] = e
|
||||
}
|
||||
|
||||
assertEntry(t, ids, "system_config_main")
|
||||
assertEntry(t, ids, "system_config_proxies")
|
||||
assertEntry(t, ids, "app_data_root")
|
||||
assertEntry(t, ids, "browser_core_root")
|
||||
|
||||
if _, ok := ids["database_sqlite_main"]; ok {
|
||||
t.Fatalf("默认配置下 database_sqlite_main 应被 app_data_root 覆盖,不应单独出现")
|
||||
}
|
||||
if _, ok := ids["browser_user_data_root"]; ok {
|
||||
t.Fatalf("默认配置下 browser_user_data_root 与 app_data_root 重合,不应重复出现")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScope_CustomPathsIncludeNonOverlappingEntries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.DefaultConfig()
|
||||
|
||||
cfg.Browser.UserDataRoot = "profiles"
|
||||
cfg.Database.SQLite.Path = "db/main.db"
|
||||
cfg.Logging.FilePath = "runtime/logs/app.log"
|
||||
cfg.Browser.Cores = []config.BrowserCore{
|
||||
{
|
||||
CoreId: "core-external-a",
|
||||
CoreName: "External Core A",
|
||||
CorePath: "external-core-a",
|
||||
},
|
||||
}
|
||||
|
||||
scope, err := BuildScope(BuildOptions{
|
||||
AppRoot: tempDir,
|
||||
Config: cfg,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildScope 返回错误: %v", err)
|
||||
}
|
||||
|
||||
ids := make(map[string]ScopeEntry)
|
||||
for _, e := range scope.Entries {
|
||||
ids[e.ID] = e
|
||||
}
|
||||
|
||||
assertEntry(t, ids, "browser_user_data_root")
|
||||
assertEntry(t, ids, "database_sqlite_main")
|
||||
assertEntry(t, ids, "database_sqlite_wal")
|
||||
assertEntry(t, ids, "database_sqlite_shm")
|
||||
assertEntry(t, ids, "logs_root")
|
||||
assertEntry(t, ids, "browser_core_external_external-01")
|
||||
|
||||
dbEntry := ids["database_sqlite_main"]
|
||||
expectedDB := filepath.Join(tempDir, "db", "main.db")
|
||||
if dbEntry.SourcePath != expectedDB {
|
||||
t.Fatalf("database source path 不匹配: got=%s want=%s", dbEntry.SourcePath, expectedDB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifest_StripsSourcePath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
scope, err := BuildScope(BuildOptions{
|
||||
AppRoot: tempDir,
|
||||
Config: config.DefaultConfig(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildScope 返回错误: %v", err)
|
||||
}
|
||||
|
||||
at := time.Date(2026, 3, 2, 12, 0, 0, 0, time.UTC)
|
||||
manifest := BuildManifest(scope, "Ant Browser", "1.0.0", at)
|
||||
|
||||
if manifest.CreatedAt != "2026-03-02T12:00:00Z" {
|
||||
t.Fatalf("CreatedAt 不匹配: %s", manifest.CreatedAt)
|
||||
}
|
||||
if manifest.App.Name != "Ant Browser" {
|
||||
t.Fatalf("manifest app name 不正确: %s", manifest.App.Name)
|
||||
}
|
||||
if manifest.App.Version != "1.0.0" {
|
||||
t.Fatalf("manifest app version 不正确: %s", manifest.App.Version)
|
||||
}
|
||||
|
||||
for _, item := range manifest.Entries {
|
||||
if item.ArchivePath == "" {
|
||||
t.Fatalf("manifest entry 缺少 archivePath: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertEntry(t *testing.T, entries map[string]ScopeEntry, id string) {
|
||||
t.Helper()
|
||||
if _, ok := entries[id]; !ok {
|
||||
t.Fatalf("缺少 scope entry: %s", id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
// BookmarkDAO 默认书签持久化接口
|
||||
type BookmarkDAO interface {
|
||||
List() ([]config.BrowserBookmark, error)
|
||||
ReplaceAll(bookmarks []config.BrowserBookmark) error
|
||||
}
|
||||
|
||||
// SQLiteBookmarkDAO 基于 SQLite 的 BookmarkDAO 实现
|
||||
type SQLiteBookmarkDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteBookmarkDAO 创建 SQLiteBookmarkDAO
|
||||
func NewSQLiteBookmarkDAO(db *sql.DB) *SQLiteBookmarkDAO {
|
||||
return &SQLiteBookmarkDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有默认书签,按 sort_order 升序
|
||||
func (d *SQLiteBookmarkDAO) List() ([]config.BrowserBookmark, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT name, url FROM browser_bookmarks ORDER BY sort_order ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询书签列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []config.BrowserBookmark
|
||||
for rows.Next() {
|
||||
var b config.BrowserBookmark
|
||||
if err := rows.Scan(&b.Name, &b.URL); err != nil {
|
||||
return nil, fmt.Errorf("读取书签行失败: %w", err)
|
||||
}
|
||||
list = append(list, b)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceAll 原子替换全部书签(事务保证)
|
||||
func (d *SQLiteBookmarkDAO) ReplaceAll(bookmarks []config.BrowserBookmark) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM browser_bookmarks`); err != nil {
|
||||
return fmt.Errorf("清空书签失败: %w", err)
|
||||
}
|
||||
for i, b := range bookmarks {
|
||||
if b.Name == "" || b.URL == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO browser_bookmarks (name, url, sort_order) VALUES (?, ?, ?)`,
|
||||
b.Name, b.URL, i,
|
||||
); err != nil {
|
||||
return fmt.Errorf("插入书签失败: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chromiumEpoch 是 Chrome FILETIME 的起始时间(1601-01-01 UTC)
|
||||
var chromiumEpoch = time.Date(1601, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func toChromiumTime(t time.Time) string {
|
||||
return fmt.Sprintf("%d", t.Sub(chromiumEpoch).Microseconds())
|
||||
}
|
||||
|
||||
// EnsureDefaultBookmarks 将默认书签合并到书签栏(已存在的 URL 不重复添加)
|
||||
func EnsureDefaultBookmarks(userDataDir string, bookmarks []config.BrowserBookmark) error {
|
||||
if len(bookmarks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
profileDir := filepath.Join(userDataDir, "Default")
|
||||
if err := os.MkdirAll(profileDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建 profile 目录失败: %w", err)
|
||||
}
|
||||
|
||||
bookmarksPath := filepath.Join(profileDir, "Bookmarks")
|
||||
|
||||
// 尝试读取已有书签文件
|
||||
var root map[string]interface{}
|
||||
if data, err := os.ReadFile(bookmarksPath); err == nil {
|
||||
_ = json.Unmarshal(data, &root)
|
||||
}
|
||||
|
||||
now := toChromiumTime(time.Now())
|
||||
|
||||
// 初始化空结构
|
||||
if root == nil {
|
||||
root = newEmptyBookmarkRoot(now)
|
||||
}
|
||||
|
||||
// 取出 bookmark_bar children,收集已有 URL 集合
|
||||
barChildren, existingURLs := extractBarChildren(root)
|
||||
|
||||
// 计算当前最大 id,用于分配新 id
|
||||
maxID := findMaxID(root)
|
||||
|
||||
// 把不存在的默认书签追加进去
|
||||
for _, b := range bookmarks {
|
||||
if existingURLs[b.URL] {
|
||||
continue
|
||||
}
|
||||
maxID++
|
||||
barChildren = append(barChildren, map[string]interface{}{
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"guid": bookmarkGUID(b.URL),
|
||||
"id": fmt.Sprintf("%d", maxID),
|
||||
"meta_info": map[string]string{"power_bookmark_meta": ""},
|
||||
"name": b.Name,
|
||||
"type": "url",
|
||||
"url": b.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// 写回
|
||||
roots := root["roots"].(map[string]interface{})
|
||||
bar := roots["bookmark_bar"].(map[string]interface{})
|
||||
bar["children"] = barChildren
|
||||
bar["date_modified"] = now
|
||||
roots["bookmark_bar"] = bar
|
||||
root["roots"] = roots
|
||||
|
||||
out, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化书签失败: %w", err)
|
||||
}
|
||||
return os.WriteFile(bookmarksPath, out, 0644)
|
||||
}
|
||||
|
||||
// newEmptyBookmarkRoot 构建一个空的书签根结构
|
||||
func newEmptyBookmarkRoot(now string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"checksum": "",
|
||||
"version": 1,
|
||||
"roots": map[string]interface{}{
|
||||
"bookmark_bar": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"date_modified": now,
|
||||
"guid": "0bc5d13f-2cba-5d74-951f-3f233fe6c908",
|
||||
"id": "1",
|
||||
"name": "书签栏",
|
||||
"type": "folder",
|
||||
},
|
||||
"other": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"date_modified": "0",
|
||||
"guid": "82b081ec-3dd3-529c-8475-ab6c344590dd",
|
||||
"id": "2",
|
||||
"name": "其他书签",
|
||||
"type": "folder",
|
||||
},
|
||||
"synced": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"date_added": now,
|
||||
"date_last_used": "0",
|
||||
"date_modified": "0",
|
||||
"guid": "4cf2e351-0e85-532b-bb37-df045d8f8d0f",
|
||||
"id": "3",
|
||||
"name": "移动设备书签",
|
||||
"type": "folder",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// extractBarChildren 从根结构中提取书签栏 children 和已有 URL 集合
|
||||
func extractBarChildren(root map[string]interface{}) ([]interface{}, map[string]bool) {
|
||||
existing := map[string]bool{}
|
||||
var children []interface{}
|
||||
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
root["roots"] = map[string]interface{}{
|
||||
"bookmark_bar": map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"type": "folder",
|
||||
"name": "书签栏",
|
||||
},
|
||||
}
|
||||
return children, existing
|
||||
}
|
||||
|
||||
bar, ok := roots["bookmark_bar"].(map[string]interface{})
|
||||
if !ok {
|
||||
roots["bookmark_bar"] = map[string]interface{}{
|
||||
"children": []interface{}{},
|
||||
"type": "folder",
|
||||
"name": "书签栏",
|
||||
}
|
||||
root["roots"] = roots
|
||||
return children, existing
|
||||
}
|
||||
|
||||
if c, ok := bar["children"].([]interface{}); ok {
|
||||
children = c
|
||||
collectURLs(c, existing)
|
||||
}
|
||||
return children, existing
|
||||
}
|
||||
|
||||
// collectURLs 递归收集所有书签 URL
|
||||
func collectURLs(nodes []interface{}, out map[string]bool) {
|
||||
for _, n := range nodes {
|
||||
node, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if node["type"] == "url" {
|
||||
if u, ok := node["url"].(string); ok {
|
||||
out[u] = true
|
||||
}
|
||||
} else if node["type"] == "folder" {
|
||||
if sub, ok := node["children"].([]interface{}); ok {
|
||||
collectURLs(sub, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findMaxID 遍历整个书签树找到最大数字 id
|
||||
func findMaxID(root map[string]interface{}) int {
|
||||
max := 0
|
||||
roots, ok := root["roots"].(map[string]interface{})
|
||||
if !ok {
|
||||
return max
|
||||
}
|
||||
for _, v := range roots {
|
||||
if folder, ok := v.(map[string]interface{}); ok {
|
||||
scanMaxID(folder, &max)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func scanMaxID(node map[string]interface{}, max *int) {
|
||||
if idStr, ok := node["id"].(string); ok {
|
||||
var n int
|
||||
fmt.Sscanf(idStr, "%d", &n)
|
||||
if n > *max {
|
||||
*max = n
|
||||
}
|
||||
}
|
||||
if children, ok := node["children"].([]interface{}); ok {
|
||||
for _, c := range children {
|
||||
if child, ok := c.(map[string]interface{}); ok {
|
||||
scanMaxID(child, max)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bookmarkGUID 根据 URL 生成稳定伪 GUID
|
||||
func bookmarkGUID(url string) string {
|
||||
h := uint64(14695981039346656037)
|
||||
for _, c := range url {
|
||||
h ^= uint64(c)
|
||||
h *= 1099511628211
|
||||
}
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
h&0xffffffff, (h>>32)&0xffff,
|
||||
(h>>48)&0x0fff|0x4000,
|
||||
(h>>16)&0x3fff|0x8000,
|
||||
h&0xffffffffffff,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package browser
|
||||
|
||||
// BuildLaunchArgs 构建启动参数
|
||||
func BuildLaunchArgs(args []string, profile *Profile) []string {
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetCore 根据 coreId 获取内核配置
|
||||
func (m *Manager) GetCore(coreId string) (Core, bool) {
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
if coreId == "" {
|
||||
return Core{}, false
|
||||
}
|
||||
for _, core := range m.Config.Browser.Cores {
|
||||
if strings.EqualFold(core.CoreId, coreId) {
|
||||
return core, true
|
||||
}
|
||||
}
|
||||
return Core{}, false
|
||||
}
|
||||
|
||||
// GetDefaultCore 获取默认内核
|
||||
func (m *Manager) GetDefaultCore() (Core, bool) {
|
||||
for _, core := range m.Config.Browser.Cores {
|
||||
if core.IsDefault {
|
||||
return core, true
|
||||
}
|
||||
}
|
||||
if len(m.Config.Browser.Cores) > 0 {
|
||||
return m.Config.Browser.Cores[0], true
|
||||
}
|
||||
return Core{}, false
|
||||
}
|
||||
|
||||
// ResolveCoreExecutable 解析内核可执行文件路径
|
||||
func (m *Manager) ResolveCoreExecutable(core Core) (string, error) {
|
||||
corePath := strings.TrimSpace(core.CorePath)
|
||||
if corePath == "" {
|
||||
return "", fmt.Errorf("浏览器内核路径为空,请在“内核管理”中补充内核目录")
|
||||
}
|
||||
|
||||
baseDir := m.ResolveRelativePath(corePath)
|
||||
|
||||
exePath := filepath.Join(baseDir, "chrome.exe")
|
||||
if _, err := os.Stat(exePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("浏览器内核目录无效:未找到 chrome.exe(%s)。请检查内核目录是否完整或重新下载内核", exePath)
|
||||
}
|
||||
return "", fmt.Errorf("浏览器内核目录不可访问:%s。原因:%v", exePath, err)
|
||||
}
|
||||
|
||||
return exePath, nil
|
||||
}
|
||||
|
||||
// ValidateCorePath 验证内核路径是否有效
|
||||
func (m *Manager) ValidateCorePath(corePath string) CoreValidateResult {
|
||||
corePath = strings.TrimSpace(corePath)
|
||||
if corePath == "" {
|
||||
return CoreValidateResult{Valid: false, Message: "路径不能为空"}
|
||||
}
|
||||
|
||||
baseDir := m.ResolveRelativePath(corePath)
|
||||
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
return CoreValidateResult{Valid: false, Message: fmt.Sprintf("目录不存在: %s", baseDir)}
|
||||
}
|
||||
|
||||
exePath := filepath.Join(baseDir, "chrome.exe")
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
return CoreValidateResult{Valid: false, Message: fmt.Sprintf("chrome.exe 不存在: %s", exePath)}
|
||||
}
|
||||
|
||||
return CoreValidateResult{Valid: true, Message: fmt.Sprintf("路径有效: %s", exePath)}
|
||||
}
|
||||
|
||||
// ListCores 获取所有内核配置
|
||||
func (m *Manager) ListCores() []Core {
|
||||
if m.CoreDAO != nil {
|
||||
cores, err := m.CoreDAO.List()
|
||||
if err == nil {
|
||||
// 同步到内存 config,供其他逻辑使用
|
||||
m.Config.Browser.Cores = cores
|
||||
return cores
|
||||
}
|
||||
}
|
||||
return m.Config.Browser.Cores
|
||||
}
|
||||
|
||||
// SaveCore 保存内核配置(新增或更新)
|
||||
func (m *Manager) SaveCore(input CoreInput) error {
|
||||
log := logger.New("Browser")
|
||||
coreId := strings.TrimSpace(input.CoreId)
|
||||
coreName := strings.TrimSpace(input.CoreName)
|
||||
corePath := strings.TrimSpace(input.CorePath)
|
||||
|
||||
if coreName == "" {
|
||||
return fmt.Errorf("内核名称不能为空")
|
||||
}
|
||||
if corePath == "" {
|
||||
return fmt.Errorf("内核路径不能为空")
|
||||
}
|
||||
|
||||
if m.CoreDAO != nil {
|
||||
if coreId == "" {
|
||||
coreId = uuid.NewString()
|
||||
}
|
||||
if input.IsDefault {
|
||||
if err := m.CoreDAO.SetDefault(""); err != nil {
|
||||
// SetDefault 空串只清除,忽略错误
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
core := Core{CoreId: coreId, CoreName: coreName, CorePath: corePath, IsDefault: input.IsDefault}
|
||||
if err := m.CoreDAO.Upsert(core); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步内存
|
||||
m.syncCoresFromDAO()
|
||||
log.Info("内核配置保存", logger.F("core_id", coreId), logger.F("core_name", coreName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级:写 config.yaml
|
||||
existingIndex := -1
|
||||
for i, core := range m.Config.Browser.Cores {
|
||||
if coreId != "" && strings.EqualFold(core.CoreId, coreId) {
|
||||
existingIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingIndex >= 0 {
|
||||
m.Config.Browser.Cores[existingIndex].CoreName = coreName
|
||||
m.Config.Browser.Cores[existingIndex].CorePath = corePath
|
||||
if input.IsDefault {
|
||||
m.clearDefaultCore()
|
||||
m.Config.Browser.Cores[existingIndex].IsDefault = true
|
||||
}
|
||||
} else {
|
||||
if coreId == "" {
|
||||
coreId = uuid.NewString()
|
||||
}
|
||||
newCore := Core{CoreId: coreId, CoreName: coreName, CorePath: corePath,
|
||||
IsDefault: input.IsDefault || len(m.Config.Browser.Cores) == 0}
|
||||
if newCore.IsDefault {
|
||||
m.clearDefaultCore()
|
||||
}
|
||||
m.Config.Browser.Cores = append(m.Config.Browser.Cores, newCore)
|
||||
}
|
||||
log.Info("内核配置保存(文件)", logger.F("core_id", coreId))
|
||||
return m.Config.Save(m.ResolveRelativePath("config.yaml"))
|
||||
}
|
||||
|
||||
// DeleteCore 删除内核配置
|
||||
func (m *Manager) DeleteCore(coreId string) error {
|
||||
log := logger.New("Browser")
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
if coreId == "" {
|
||||
return fmt.Errorf("内核ID不能为空")
|
||||
}
|
||||
|
||||
if m.CoreDAO != nil {
|
||||
if err := m.CoreDAO.Delete(coreId); err != nil {
|
||||
return err
|
||||
}
|
||||
m.syncCoresFromDAO()
|
||||
log.Info("内核配置删除", logger.F("core_id", coreId))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级
|
||||
index := -1
|
||||
for i, core := range m.Config.Browser.Cores {
|
||||
if strings.EqualFold(core.CoreId, coreId) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return fmt.Errorf("内核不存在: %s", coreId)
|
||||
}
|
||||
wasDefault := m.Config.Browser.Cores[index].IsDefault
|
||||
m.Config.Browser.Cores = append(m.Config.Browser.Cores[:index], m.Config.Browser.Cores[index+1:]...)
|
||||
if wasDefault && len(m.Config.Browser.Cores) > 0 {
|
||||
m.Config.Browser.Cores[0].IsDefault = true
|
||||
}
|
||||
log.Info("内核配置删除(文件)", logger.F("core_id", coreId))
|
||||
return m.Config.Save(m.ResolveRelativePath("config.yaml"))
|
||||
}
|
||||
|
||||
// SetDefaultCore 设置默认内核
|
||||
func (m *Manager) SetDefaultCore(coreId string) error {
|
||||
log := logger.New("Browser")
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
if coreId == "" {
|
||||
return fmt.Errorf("内核ID不能为空")
|
||||
}
|
||||
|
||||
if m.CoreDAO != nil {
|
||||
if err := m.CoreDAO.SetDefault(coreId); err != nil {
|
||||
return err
|
||||
}
|
||||
m.syncCoresFromDAO()
|
||||
log.Info("设置默认内核", logger.F("core_id", coreId))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级
|
||||
found := false
|
||||
for i := range m.Config.Browser.Cores {
|
||||
if strings.EqualFold(m.Config.Browser.Cores[i].CoreId, coreId) {
|
||||
m.Config.Browser.Cores[i].IsDefault = true
|
||||
found = true
|
||||
} else {
|
||||
m.Config.Browser.Cores[i].IsDefault = false
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("内核不存在: %s", coreId)
|
||||
}
|
||||
log.Info("设置默认内核(文件)", logger.F("core_id", coreId))
|
||||
return m.Config.Save(m.ResolveRelativePath("config.yaml"))
|
||||
}
|
||||
|
||||
// syncCoresFromDAO 从 DAO 同步内核列表到内存 config
|
||||
func (m *Manager) syncCoresFromDAO() {
|
||||
if m.CoreDAO == nil {
|
||||
return
|
||||
}
|
||||
if cores, err := m.CoreDAO.List(); err == nil {
|
||||
m.Config.Browser.Cores = cores
|
||||
}
|
||||
}
|
||||
|
||||
// clearDefaultCore 清除所有默认标记
|
||||
func (m *Manager) clearDefaultCore() {
|
||||
for i := range m.Config.Browser.Cores {
|
||||
m.Config.Browser.Cores[i].IsDefault = false
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveChromeBinary 解析 Chrome 二进制路径(简化版)
|
||||
func (m *Manager) ResolveChromeBinary(profile *Profile) (string, error) {
|
||||
log := logger.New("Browser")
|
||||
coreId := strings.TrimSpace(profile.CoreId)
|
||||
|
||||
var core Core
|
||||
var found bool
|
||||
|
||||
if coreId != "" {
|
||||
core, found = m.GetCore(coreId)
|
||||
}
|
||||
if !found {
|
||||
core, found = m.GetDefaultCore()
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("未配置可用浏览器内核。请先在“内核管理”中添加内核并设置默认内核")
|
||||
}
|
||||
|
||||
exePath, err := m.ResolveCoreExecutable(core)
|
||||
if err != nil {
|
||||
log.Error("内核路径解析失败", logger.F("core_id", core.CoreId), logger.F("error", err.Error()))
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Debug("使用内核", logger.F("core_id", core.CoreId), logger.F("path", exePath))
|
||||
return exePath, nil
|
||||
}
|
||||
|
||||
// GetChromeVersion 从 manifest.json 读取 Chrome 版本号
|
||||
func (m *Manager) GetChromeVersion(corePath string) string {
|
||||
corePath = strings.TrimSpace(corePath)
|
||||
if corePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
baseDir := m.ResolveRelativePath(corePath)
|
||||
|
||||
// 尝试读取 manifest.json 或 *.manifest 文件
|
||||
manifestPath := filepath.Join(baseDir, "manifest.json")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
// 尝试查找 *.manifest 文件
|
||||
matches, _ := filepath.Glob(filepath.Join(baseDir, "*.manifest"))
|
||||
if len(matches) > 0 {
|
||||
// 从文件名提取版本号,如 "142.0.7444.175.manifest"
|
||||
baseName := filepath.Base(matches[0])
|
||||
version := strings.TrimSuffix(baseName, ".manifest")
|
||||
if version != "" {
|
||||
return version
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
var manifest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return manifest.Version
|
||||
}
|
||||
|
||||
// CountInstancesByCore 统计使用指定内核的实例数量
|
||||
func (m *Manager) CountInstancesByCore(coreId string) int {
|
||||
coreId = strings.TrimSpace(coreId)
|
||||
count := 0
|
||||
for _, profile := range m.Config.Browser.Profiles {
|
||||
profileCoreId := strings.TrimSpace(profile.CoreId)
|
||||
// 如果实例的 CoreId 为空,则使用默认内核
|
||||
if profileCoreId == "" {
|
||||
defaultCore, found := m.GetDefaultCore()
|
||||
if found && strings.EqualFold(defaultCore.CoreId, coreId) {
|
||||
count++
|
||||
}
|
||||
} else if strings.EqualFold(profileCoreId, coreId) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetCoresExtendedInfo 获取所有内核的扩展信息
|
||||
func (m *Manager) GetCoresExtendedInfo() []CoreExtendedInfo {
|
||||
cores := m.ListCores()
|
||||
result := make([]CoreExtendedInfo, 0, len(cores))
|
||||
for _, core := range cores {
|
||||
info := CoreExtendedInfo{
|
||||
CoreId: core.CoreId,
|
||||
ChromeVersion: m.GetChromeVersion(core.CorePath),
|
||||
InstanceCount: m.CountInstancesByCore(core.CoreId),
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoreDAO 内核配置持久化接口
|
||||
type CoreDAO interface {
|
||||
List() ([]Core, error)
|
||||
Upsert(core Core) error
|
||||
Delete(coreId string) error
|
||||
SetDefault(coreId string) error
|
||||
}
|
||||
|
||||
// SQLiteCoreDAO 基于 SQLite 的 CoreDAO 实现
|
||||
type SQLiteCoreDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteCoreDAO 创建 SQLiteCoreDAO
|
||||
func NewSQLiteCoreDAO(db *sql.DB) *SQLiteCoreDAO {
|
||||
return &SQLiteCoreDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有内核,按 sort_order 升序
|
||||
func (d *SQLiteCoreDAO) List() ([]Core, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT core_id, core_name, core_path, is_default
|
||||
FROM browser_cores ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询内核列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []Core
|
||||
for rows.Next() {
|
||||
var c Core
|
||||
var isDefault int
|
||||
if err := rows.Scan(&c.CoreId, &c.CoreName, &c.CorePath, &isDefault); err != nil {
|
||||
return nil, fmt.Errorf("读取内核行失败: %w", err)
|
||||
}
|
||||
c.IsDefault = isDefault == 1
|
||||
list = append(list, c)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert 新增或更新内核配置
|
||||
func (d *SQLiteCoreDAO) Upsert(core Core) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
isDefault := 0
|
||||
if core.IsDefault {
|
||||
isDefault = 1
|
||||
}
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO browser_cores (core_id, core_name, core_path, is_default, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(core_id) DO UPDATE SET
|
||||
core_name = excluded.core_name,
|
||||
core_path = excluded.core_path,
|
||||
is_default = excluded.is_default`,
|
||||
core.CoreId, core.CoreName, core.CorePath, isDefault, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存内核配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除内核配置
|
||||
func (d *SQLiteCoreDAO) Delete(coreId string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_cores WHERE core_id = ?`, coreId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除内核配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDefault 设置默认内核(先清除所有默认标记,再设置指定内核)
|
||||
func (d *SQLiteCoreDAO) SetDefault(coreId string) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`UPDATE browser_cores SET is_default = 0`); err != nil {
|
||||
return fmt.Errorf("清除默认内核失败: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE browser_cores SET is_default = 1 WHERE core_id = ?`, coreId); err != nil {
|
||||
return fmt.Errorf("设置默认内核失败: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// DownloadProgress 进度信息载体
|
||||
type DownloadProgress struct {
|
||||
Phase string `json:"phase"` // "downloading" 或 "extracting" 或 "done" 或 "error"
|
||||
Progress int `json:"progress"` // 进度百分比 0-100
|
||||
Message string `json:"message"` // 附加详情
|
||||
}
|
||||
|
||||
type coreDownloadWriter struct {
|
||||
writeFunc func(p []byte) (n int, err error)
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (cw *coreDownloadWriter) Write(p []byte) (int, error) {
|
||||
select {
|
||||
case <-cw.ctx.Done():
|
||||
return 0, cw.ctx.Err()
|
||||
default:
|
||||
}
|
||||
return cw.writeFunc(p)
|
||||
}
|
||||
|
||||
// readWindowsSystemProxy 从 Windows 注册表读取当前系统代理(WinINet,Clash 就是写这里)
|
||||
// 返回格式如 "http://127.0.0.1:7890" 或 "socks5://127.0.0.1:7891"
|
||||
func readWindowsSystemProxy() (string, error) {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER,
|
||||
`Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
|
||||
registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
enabled, _, err := k.GetIntegerValue("ProxyEnable")
|
||||
if err != nil || enabled == 0 {
|
||||
return "", fmt.Errorf("系统代理未启用")
|
||||
}
|
||||
|
||||
proxyServer, _, err := k.GetStringValue("ProxyServer")
|
||||
if err != nil || proxyServer == "" {
|
||||
return "", fmt.Errorf("代理地址为空")
|
||||
}
|
||||
|
||||
// proxyServer 格式可能是 "127.0.0.1:7890" 或 "http=..;https=.." 多协议格式
|
||||
// 如果不含协议前缀,默认给 http://
|
||||
if !strings.Contains(proxyServer, ":") {
|
||||
return "", fmt.Errorf("无效的代理格式: %s", proxyServer)
|
||||
}
|
||||
if !strings.HasPrefix(proxyServer, "http") && !strings.HasPrefix(proxyServer, "socks") {
|
||||
return "http://" + proxyServer, nil
|
||||
}
|
||||
return proxyServer, nil
|
||||
}
|
||||
|
||||
// DownloadAndExtractCore 执行异步下载解压并在过程中发送事件
|
||||
func (m *Manager) DownloadAndExtractCore(ctx context.Context, coreName string, targetUrl string, proxyConfig string) {
|
||||
log := logger.New("Browser")
|
||||
t := time.Now()
|
||||
|
||||
sendEvent := func(phase string, progress int, msg string) {
|
||||
runtime.EventsEmit(ctx, "download:progress", DownloadProgress{
|
||||
Phase: phase,
|
||||
Progress: progress,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("downloading", 0, "开始解析地址并创建下载请求: "+targetUrl)
|
||||
|
||||
// 1. 检查名称重复
|
||||
coreName = strings.TrimSpace(coreName)
|
||||
for _, c := range m.ListCores() {
|
||||
if strings.EqualFold(c.CoreName, coreName) || filepath.Base(c.CorePath) == coreName {
|
||||
sendEvent("error", 0, "名称已存在,请换一个名称")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 确保外层 chrome/ 目录存在
|
||||
chromeDir := m.ResolveRelativePath("chrome")
|
||||
if err := os.MkdirAll(chromeDir, 0755); err != nil {
|
||||
sendEvent("error", 0, "创建 chrome 目录失败")
|
||||
return
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(chromeDir, coreName)
|
||||
if _, err := os.Stat(targetDir); !os.IsNotExist(err) {
|
||||
sendEvent("error", 0, "同名文件夹已存在: "+coreName)
|
||||
return
|
||||
}
|
||||
// 2. 准备 HttpClient(优先从 Windows 注册表读取真实系统代理,而非仅靠环境变量)
|
||||
transport := &http.Transport{}
|
||||
if proxyConfig == "__system__" {
|
||||
// http.ProxyFromEnvironment 只读环境变量,而 Clash 的全局代理写在 Windows 注册表里
|
||||
// 必须直接读取注册表才能拿到正确的代理地址
|
||||
if sysProxy, rErr := readWindowsSystemProxy(); rErr == nil && sysProxy != "" {
|
||||
if proxyURL, pErr := url.Parse(sysProxy); pErr == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
sendEvent("downloading", 0, "已从系统注册表读取代理: "+sysProxy)
|
||||
} else {
|
||||
// 解析失败则回退到环境变量
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
} else {
|
||||
// 没有系统代理配置或读取失败,尝试环境变量兜底
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
sendEvent("downloading", 0, "系统注册表无代理配置,使用环境变量兜底")
|
||||
}
|
||||
} else if proxyConfig != "" && proxyConfig != "direct://" && proxyConfig != "__direct__" {
|
||||
if proxyURL, pErr := url.Parse(proxyConfig); pErr == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
} else {
|
||||
sendEvent("error", 0, "代理地址解析失败: "+pErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 0, // 取消全局超时,依靠 context 和分片连接维持
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(chromeDir, "download_*.zip")
|
||||
if err != nil {
|
||||
sendEvent("error", 0, "创建临时文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
tempFilePath := tempFile.Name()
|
||||
defer func() {
|
||||
tempFile.Close()
|
||||
os.Remove(tempFilePath) // 清理临时文件
|
||||
}()
|
||||
|
||||
sendEvent("downloading", 0, "开始分析下载链接(检测多线程支持)...")
|
||||
|
||||
err = doConcurrentDownload(ctx, client, targetUrl, tempFile, sendEvent)
|
||||
if err != nil {
|
||||
sendEvent("error", 0, "下载失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tempFile.Close() // 解压前先关闭写句柄
|
||||
sendEvent("extracting", 0, "下载完成,正在准备解压文件...")
|
||||
log.Info("内核下载完成", logger.F("url", targetUrl), logger.F("temp", tempFilePath), logger.F("cost", time.Since(t).String()))
|
||||
|
||||
// 3. 执行解压,并剥离顶层文件夹
|
||||
if err := extractZipAndStripRoot(tempFilePath, targetDir, func(p int, msg string) {
|
||||
sendEvent("extracting", p, msg)
|
||||
}); err != nil {
|
||||
os.RemoveAll(targetDir) // 删除不完整的解压文件
|
||||
sendEvent("error", 0, "解压失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 将新内核配置入库
|
||||
corePath := filepath.Join("chrome", coreName)
|
||||
if m.ValidateCorePath(corePath).Valid {
|
||||
newCore := CoreInput{
|
||||
CoreId: uuid.NewString(), // 使用固定的 UUID 或生成新的
|
||||
CoreName: coreName,
|
||||
CorePath: corePath,
|
||||
IsDefault: len(m.ListCores()) == 0, // 如果没有其他内核,这设为默认
|
||||
}
|
||||
if err := m.SaveCore(newCore); err != nil {
|
||||
sendEvent("error", 0, "保存配置入库失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
sendEvent("done", 100, "内核下载与配置成功!")
|
||||
log.Info("内核下载配置入库成功", logger.F("core_name", coreName))
|
||||
} else {
|
||||
os.RemoveAll(targetDir) // 删除不正确的解压内容
|
||||
sendEvent("error", 0, "解压后在目录未找到 chrome.exe 执行文件,请检查压缩包内容!")
|
||||
}
|
||||
}
|
||||
|
||||
// extractZipAndStripRoot 解压 ZIP 包,如果其所有文件全被同一个根目录包裹,则剥离这层根目录解压至 dest
|
||||
// progressCb 为进度回调 (0-100%, statusType_msg)
|
||||
func extractZipAndStripRoot(zipPath, dest string, progressCb func(int, string)) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if len(r.File) == 0 {
|
||||
return fmt.Errorf("空的压缩包")
|
||||
}
|
||||
|
||||
// 探测是否存在单一顶层目录
|
||||
var rootPrefix string
|
||||
hasCommonRoot := true
|
||||
|
||||
for _, f := range r.File {
|
||||
cleanName := filepath.ToSlash(f.Name)
|
||||
parts := strings.SplitN(cleanName, "/", 2)
|
||||
|
||||
// 检查空名称文件,理论上不该有
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if rootPrefix == "" {
|
||||
rootPrefix = parts[0] + "/"
|
||||
} else if !strings.HasPrefix(cleanName, rootPrefix) && cleanName != strings.TrimSuffix(rootPrefix, "/") {
|
||||
hasCommonRoot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dest, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
totalFiles := len(r.File)
|
||||
for i, f := range r.File {
|
||||
// 报告进度 (逢 5% 更新一下)
|
||||
percent := int((float64(i) / float64(totalFiles)) * 100)
|
||||
if i%50 == 0 {
|
||||
progressCb(percent, fmt.Sprintf("正在解压文件 %d / %d...", i+1, totalFiles))
|
||||
}
|
||||
|
||||
cleanName := filepath.ToSlash(f.Name)
|
||||
if hasCommonRoot {
|
||||
if cleanName == rootPrefix || cleanName == strings.TrimSuffix(rootPrefix, "/") {
|
||||
// 忽略外包装本层目录条目
|
||||
continue
|
||||
}
|
||||
cleanName = strings.TrimPrefix(cleanName, rootPrefix)
|
||||
}
|
||||
|
||||
if cleanName == "" || cleanName == "/" {
|
||||
continue
|
||||
}
|
||||
|
||||
fpath := filepath.Join(dest, filepath.FromSlash(cleanName))
|
||||
// 防止 Zip Slip 漏洞
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("非法文件路径: %s", fpath)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开解压文件写入失败 %s: %v", fpath, err)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("读取压缩包文件失败 %s: %v", f.Name, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入文件流失败 %s: %v", fpath, err)
|
||||
}
|
||||
}
|
||||
|
||||
progressCb(100, "解压完成!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func doConcurrentDownload(ctx context.Context, client *http.Client, targetUrl string, tempFile *os.File, sendEvent func(string, int, string)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("HTTP状态码异常: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var totalSize int64 = resp.ContentLength
|
||||
supportRange := resp.StatusCode == http.StatusPartialContent
|
||||
|
||||
if supportRange {
|
||||
cr := resp.Header.Get("Content-Range")
|
||||
if cr != "" {
|
||||
parts := strings.Split(cr, "/")
|
||||
if len(parts) == 2 {
|
||||
fmt.Sscanf(parts[1], "%d", &totalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if totalSize <= 0 || !supportRange {
|
||||
sendEvent("downloading", 0, "服务器不支持多线程,回退至单流下载...")
|
||||
return doSingleThreadDownload(ctx, client, targetUrl, tempFile, totalSize, sendEvent)
|
||||
}
|
||||
|
||||
sendEvent("downloading", 0, fmt.Sprintf("支持多线程分片下载,总大小 %.2f MB", float64(totalSize)/1024/1024))
|
||||
|
||||
if err := tempFile.Truncate(totalSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
numWorkers := 8
|
||||
chunkSize := totalSize / int64(numWorkers)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var downloaded int64
|
||||
var mu sync.Mutex
|
||||
var lastTick time.Time
|
||||
var downloadErr error
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
start := int64(i) * chunkSize
|
||||
end := start + chunkSize - 1
|
||||
if i == numWorkers-1 {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(part int, start, end int64) {
|
||||
defer wg.Done()
|
||||
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if downloadErr == nil {
|
||||
downloadErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
|
||||
pResp, err := client.Do(req)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
buf := make([]byte, 256*1024)
|
||||
var written int64
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
pResp.Body.Close()
|
||||
return
|
||||
}
|
||||
n, rErr := pResp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
tempFile.WriteAt(buf[:n], start+written)
|
||||
written += int64(n)
|
||||
|
||||
mu.Lock()
|
||||
downloaded += int64(n)
|
||||
if time.Since(lastTick) > time.Second {
|
||||
percent := int((float64(downloaded) / float64(totalSize)) * 100)
|
||||
sendEvent("downloading", percent, fmt.Sprintf("并行下载中... %.2f MB / %.2f MB", float64(downloaded)/1024/1024, float64(totalSize)/1024/1024))
|
||||
lastTick = time.Now()
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
if rErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rErr != nil {
|
||||
mu.Lock()
|
||||
if downloadErr == nil {
|
||||
downloadErr = rErr
|
||||
}
|
||||
mu.Unlock()
|
||||
pResp.Body.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
pResp.Body.Close()
|
||||
return
|
||||
}
|
||||
}(i, start, end)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return downloadErr
|
||||
}
|
||||
|
||||
func doSingleThreadDownload(ctx context.Context, client *http.Client, targetUrl string, tempFile *os.File, totalSize int64, sendEvent func(string, int, string)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP状态码异常: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var downloaded int64
|
||||
var lastTick time.Time
|
||||
|
||||
pw := &coreDownloadWriter{
|
||||
writeFunc: func(p []byte) (n int, err error) {
|
||||
n, err = tempFile.Write(p)
|
||||
if n > 0 {
|
||||
downloaded += int64(n)
|
||||
if totalSize > 0 && time.Since(lastTick) > time.Second {
|
||||
percent := int((float64(downloaded) / float64(totalSize)) * 100)
|
||||
sendEvent("downloading", percent, fmt.Sprintf("单流下载中... %.2f MB / %.2f MB", float64(downloaded)/1024/1024, float64(totalSize)/1024/1024))
|
||||
lastTick = time.Now()
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
},
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024*1024)
|
||||
_, err = io.CopyBuffer(pw, resp.Body, buf)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetProxyConfigById 根据代理 ID 获取代理配置
|
||||
func (m *Manager) GetProxyConfigById(proxyId string) (string, bool) {
|
||||
proxyId = strings.TrimSpace(proxyId)
|
||||
if proxyId == "" {
|
||||
return "", false
|
||||
}
|
||||
if m.ProxyDAO != nil {
|
||||
if list, err := m.ProxyDAO.List(); err == nil {
|
||||
for _, item := range list {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
return strings.TrimSpace(item.ProxyConfig), true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range m.Config.Browser.Proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
return strings.TrimSpace(item.ProxyConfig), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ResolveUserDataDir 解析用户数据目录
|
||||
func (m *Manager) ResolveUserDataDir(profile *Profile) string {
|
||||
userDataDir := strings.TrimSpace(profile.UserDataDir)
|
||||
if userDataDir == "" {
|
||||
userDataDir = profile.ProfileId
|
||||
}
|
||||
if filepath.IsAbs(userDataDir) {
|
||||
return userDataDir
|
||||
}
|
||||
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
|
||||
if root == "" {
|
||||
root = "data"
|
||||
}
|
||||
root = m.ResolveRelativePath(root)
|
||||
return filepath.Join(root, userDataDir)
|
||||
}
|
||||
|
||||
// MigrateConfig 迁移旧配置到新格式
|
||||
func (m *Manager) MigrateConfig() bool {
|
||||
log := logger.New("Browser")
|
||||
|
||||
// 如果存在 environments 但没有 cores,执行迁移
|
||||
if len(m.Config.Browser.Environments) > 0 && len(m.Config.Browser.Cores) == 0 {
|
||||
log.Info("检测到旧配置格式,开始迁移")
|
||||
|
||||
for _, env := range m.Config.Browser.Environments {
|
||||
m.Config.Browser.Cores = append(m.Config.Browser.Cores, Core{
|
||||
CoreId: env.CoreId,
|
||||
CoreName: env.CoreName,
|
||||
CorePath: env.CorePath,
|
||||
IsDefault: env.IsDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// 清空旧字段
|
||||
m.Config.Browser.Environments = nil
|
||||
m.Config.Browser.ChromeBinaryPath = ""
|
||||
m.Config.Browser.CoreRoot = ""
|
||||
m.Config.Browser.DefaultCoreId = ""
|
||||
m.Config.Browser.DefaultConnectorType = ""
|
||||
|
||||
if err := m.Config.Save(m.ResolveRelativePath("config.yaml")); err != nil {
|
||||
log.Error("配置迁移保存失败", logger.F("error", err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
log.Info("配置迁移完成", logger.F("cores_count", len(m.Config.Browser.Cores)))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type proxyDAOStub struct {
|
||||
list []Proxy
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *proxyDAOStub) List() ([]Proxy, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return append([]Proxy{}, s.list...), nil
|
||||
}
|
||||
|
||||
func (s *proxyDAOStub) ListByGroup(string) ([]Proxy, error) { return nil, nil }
|
||||
func (s *proxyDAOStub) ListGroups() ([]string, error) { return nil, nil }
|
||||
func (s *proxyDAOStub) Upsert(Proxy) error { return nil }
|
||||
func (s *proxyDAOStub) Delete(string) error { return nil }
|
||||
func (s *proxyDAOStub) DeleteAll() error { return nil }
|
||||
func (s *proxyDAOStub) UpdateSpeedResult(string, bool, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *proxyDAOStub) UpdateIPHealthResult(string, string) error { return nil }
|
||||
|
||||
func TestGetProxyConfigByIdPreferDAO(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "pool-1", ProxyConfig: "http://127.0.0.1:9999"},
|
||||
}
|
||||
|
||||
mgr := NewManager(cfg, "")
|
||||
mgr.ProxyDAO = &proxyDAOStub{
|
||||
list: []Proxy{
|
||||
{ProxyId: "pool-1", ProxyConfig: "socks5://127.0.0.1:1080"},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := mgr.GetProxyConfigById("pool-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected proxy to be found")
|
||||
}
|
||||
if got != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("expected dao proxy config, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProxyConfigByIdFallbackToConfig(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Browser.Proxies = []config.BrowserProxy{
|
||||
{ProxyId: "pool-2", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
mgr := NewManager(cfg, "")
|
||||
mgr.ProxyDAO = &proxyDAOStub{err: errors.New("dao unavailable")}
|
||||
|
||||
got, ok := mgr.GetProxyConfigById("pool-2")
|
||||
if !ok {
|
||||
t.Fatalf("expected proxy to be found in config fallback")
|
||||
}
|
||||
if got != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("unexpected proxy config: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GroupDAO 分组数据访问接口
|
||||
type GroupDAO interface {
|
||||
List() ([]*Group, error)
|
||||
GetById(groupId string) (*Group, error)
|
||||
Create(input GroupInput) (*Group, error)
|
||||
Update(groupId string, input GroupInput) (*Group, error)
|
||||
Delete(groupId string) error
|
||||
GetChildren(parentId string) ([]*Group, error)
|
||||
MoveChildren(fromGroupId, toGroupId string) error
|
||||
}
|
||||
|
||||
// SQLiteGroupDAO 基于 SQLite 的 GroupDAO 实现
|
||||
type SQLiteGroupDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteGroupDAO 创建 SQLiteGroupDAO
|
||||
func NewSQLiteGroupDAO(db *sql.DB) *SQLiteGroupDAO {
|
||||
return &SQLiteGroupDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有分组
|
||||
func (d *SQLiteGroupDAO) List() ([]*Group, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at
|
||||
FROM browser_groups ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询分组列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Group
|
||||
for rows.Next() {
|
||||
g, err := scanGroup(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, g)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetById 根据 groupId 查询单个分组
|
||||
func (d *SQLiteGroupDAO) GetById(groupId string) (*Group, error) {
|
||||
row := d.db.QueryRow(`
|
||||
SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at
|
||||
FROM browser_groups WHERE group_id = ?`, groupId)
|
||||
g, err := scanGroup(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("分组不存在: %s", groupId)
|
||||
}
|
||||
return g, err
|
||||
}
|
||||
|
||||
// Create 创建分组
|
||||
func (d *SQLiteGroupDAO) Create(input GroupInput) (*Group, error) {
|
||||
if input.GroupName == "" {
|
||||
return nil, errors.New("分组名称不能为空")
|
||||
}
|
||||
// 验证父分组存在性
|
||||
if input.ParentId != "" {
|
||||
_, err := d.GetById(input.ParentId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("父分组不存在: %s", input.ParentId)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
group := &Group{
|
||||
GroupId: uuid.New().String(),
|
||||
GroupName: input.GroupName,
|
||||
ParentId: input.ParentId,
|
||||
SortOrder: input.SortOrder,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO browser_groups (group_id, group_name, parent_id, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
group.GroupId, group.GroupName, group.ParentId, group.SortOrder, group.CreatedAt, group.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建分组失败: %w", err)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// Update 更新分组
|
||||
func (d *SQLiteGroupDAO) Update(groupId string, input GroupInput) (*Group, error) {
|
||||
if input.GroupName == "" {
|
||||
return nil, errors.New("分组名称不能为空")
|
||||
}
|
||||
// 检查分组是否存在
|
||||
existing, err := d.GetById(groupId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 验证父分组存在性
|
||||
if input.ParentId != "" {
|
||||
_, err := d.GetById(input.ParentId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("父分组不存在: %s", input.ParentId)
|
||||
}
|
||||
// 检查循环引用
|
||||
if err := d.checkCircularReference(groupId, input.ParentId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err = d.db.Exec(`
|
||||
UPDATE browser_groups SET group_name = ?, parent_id = ?, sort_order = ?, updated_at = ?
|
||||
WHERE group_id = ?`,
|
||||
input.GroupName, input.ParentId, input.SortOrder, now, groupId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新分组失败: %w", err)
|
||||
}
|
||||
|
||||
existing.GroupName = input.GroupName
|
||||
existing.ParentId = input.ParentId
|
||||
existing.SortOrder = input.SortOrder
|
||||
existing.UpdatedAt = now
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Delete 删除分组(级联处理:子分组和实例移动到父分组)
|
||||
func (d *SQLiteGroupDAO) Delete(groupId string) error {
|
||||
group, err := d.GetById(groupId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 将子分组移动到父分组
|
||||
if err := d.MoveChildren(groupId, group.ParentId); err != nil {
|
||||
return err
|
||||
}
|
||||
// 将该分组下的实例移动到父分组
|
||||
_, err = d.db.Exec(`UPDATE browser_profiles SET group_id = ? WHERE group_id = ?`, group.ParentId, groupId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("移动实例失败: %w", err)
|
||||
}
|
||||
// 删除分组
|
||||
_, err = d.db.Exec(`DELETE FROM browser_groups WHERE group_id = ?`, groupId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChildren 获取子分组
|
||||
func (d *SQLiteGroupDAO) GetChildren(parentId string) ([]*Group, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT group_id, group_name, parent_id, sort_order, created_at, updated_at
|
||||
FROM browser_groups WHERE parent_id = ? ORDER BY sort_order ASC`, parentId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询子分组失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Group
|
||||
for rows.Next() {
|
||||
g, err := scanGroup(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, g)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// MoveChildren 将子分组移动到新的父分组
|
||||
func (d *SQLiteGroupDAO) MoveChildren(fromGroupId, toGroupId string) error {
|
||||
_, err := d.db.Exec(`UPDATE browser_groups SET parent_id = ? WHERE parent_id = ?`, toGroupId, fromGroupId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("移动子分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCircularReference 检查循环引用
|
||||
func (d *SQLiteGroupDAO) checkCircularReference(groupId, newParentId string) error {
|
||||
if newParentId == groupId {
|
||||
return errors.New("不能将分组设为自己的子分组")
|
||||
}
|
||||
// 遍历祖先链检查是否包含 groupId
|
||||
currentId := newParentId
|
||||
visited := make(map[string]bool)
|
||||
for currentId != "" {
|
||||
if visited[currentId] {
|
||||
return errors.New("检测到循环引用")
|
||||
}
|
||||
visited[currentId] = true
|
||||
if currentId == groupId {
|
||||
return errors.New("不能将分组设为自己的后代分组")
|
||||
}
|
||||
parent, err := d.GetById(currentId)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
currentId = parent.ParentId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanGroup 扫描分组行
|
||||
func scanGroup(s scanner) (*Group, error) {
|
||||
var g Group
|
||||
err := s.Scan(&g.GroupId, &g.GroupName, &g.ParentId, &g.SortOrder, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// InitData 初始化浏览器数据
|
||||
func (m *Manager) InitData() {
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
if m.Profiles == nil {
|
||||
m.Profiles = make(map[string]*Profile)
|
||||
}
|
||||
if m.BrowserProcesses == nil {
|
||||
m.BrowserProcesses = make(map[string]*exec.Cmd)
|
||||
}
|
||||
if m.XrayBridges == nil {
|
||||
m.XrayBridges = make(map[string]*XrayBridge)
|
||||
}
|
||||
// 执行配置迁移
|
||||
m.MigrateConfig()
|
||||
if len(m.Profiles) > 0 {
|
||||
return
|
||||
}
|
||||
m.loadProfiles()
|
||||
}
|
||||
|
||||
func (m *Manager) loadProfiles() {
|
||||
log := logger.New("Browser")
|
||||
|
||||
// 优先从 DAO(SQLite)加载
|
||||
if m.ProfileDAO != nil {
|
||||
profiles, err := m.ProfileDAO.List()
|
||||
if err != nil {
|
||||
log.Error("从数据库加载实例配置失败", logger.F("error", err))
|
||||
} else {
|
||||
// SQLite 模式:无论是否为空都直接使用,不自动创建默认实例
|
||||
for _, p := range profiles {
|
||||
m.Profiles[p.ProfileId] = p
|
||||
}
|
||||
if len(profiles) > 0 {
|
||||
log.Info("实例配置从数据库加载完成", logger.F("count", len(profiles)))
|
||||
} else {
|
||||
log.Info("实例表为空,用户可手动创建新实例")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:从 config.yaml 加载(仅在无 SQLite 时使用)
|
||||
if len(m.Config.Browser.Profiles) == 0 {
|
||||
// 不自动创建默认实例,保持空列表
|
||||
log.Info("实例配置为空,用户可手动创建新实例")
|
||||
return
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for _, item := range m.Config.Browser.Profiles {
|
||||
profileId := strings.TrimSpace(item.ProfileId)
|
||||
if profileId == "" {
|
||||
continue
|
||||
}
|
||||
createdAt := strings.TrimSpace(item.CreatedAt)
|
||||
if createdAt == "" {
|
||||
createdAt = now
|
||||
}
|
||||
updatedAt := strings.TrimSpace(item.UpdatedAt)
|
||||
if updatedAt == "" {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
m.Profiles[profileId] = &Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: item.ProfileName,
|
||||
UserDataDir: item.UserDataDir,
|
||||
CoreId: item.CoreId,
|
||||
FingerprintArgs: append([]string{}, item.FingerprintArgs...),
|
||||
ProxyId: item.ProxyId,
|
||||
ProxyConfig: item.ProxyConfig,
|
||||
LaunchArgs: append([]string{}, item.LaunchArgs...),
|
||||
Tags: append([]string{}, item.Tags...),
|
||||
Keywords: append([]string{}, item.Keywords...),
|
||||
Running: false,
|
||||
DebugPort: 0,
|
||||
Pid: 0,
|
||||
LastError: "",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
log.Info("浏览器配置从文件加载完成", logger.F("count", len(m.Profiles)))
|
||||
}
|
||||
|
||||
// SaveProfiles 保存所有实例配置(DAO 模式:逐条 upsert)
|
||||
func (m *Manager) SaveProfiles() error {
|
||||
log := logger.New("Browser")
|
||||
if m.ProfileDAO != nil {
|
||||
for _, profile := range m.Profiles {
|
||||
if err := m.ProfileDAO.Upsert(profile); err != nil {
|
||||
log.Error("实例配置持久化失败", logger.F("profile_id", profile.ProfileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Info("实例配置持久化成功", logger.F("count", len(m.Profiles)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 降级:写回 config.yaml
|
||||
profiles := make([]ProfileConfig, 0, len(m.Profiles))
|
||||
for _, profile := range m.Profiles {
|
||||
profiles = append(profiles, ProfileConfig{
|
||||
ProfileId: profile.ProfileId,
|
||||
ProfileName: profile.ProfileName,
|
||||
UserDataDir: profile.UserDataDir,
|
||||
CoreId: profile.CoreId,
|
||||
FingerprintArgs: append([]string{}, profile.FingerprintArgs...),
|
||||
ProxyId: profile.ProxyId,
|
||||
ProxyConfig: profile.ProxyConfig,
|
||||
LaunchArgs: append([]string{}, profile.LaunchArgs...),
|
||||
Tags: append([]string{}, profile.Tags...),
|
||||
Keywords: append([]string{}, profile.Keywords...),
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
})
|
||||
}
|
||||
m.Config.Browser.Profiles = profiles
|
||||
if err := m.Config.Save(m.ResolveRelativePath("config.yaml")); err != nil {
|
||||
log.Error("浏览器配置持久化失败", logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
log.Info("浏览器配置持久化成功(文件)", logger.F("count", len(profiles)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取配置列表
|
||||
func (m *Manager) List() []Profile {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
list := make([]Profile, 0, len(m.Profiles))
|
||||
for _, profile := range m.Profiles {
|
||||
p := *profile
|
||||
if m.CodeProvider != nil {
|
||||
if code, err := m.CodeProvider.EnsureCode(p.ProfileId); err == nil {
|
||||
p.LaunchCode = code
|
||||
}
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
// 按 ProfileId 排序,保持稳定顺序
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].ProfileId < list[j].ProfileId
|
||||
})
|
||||
log.Info("浏览器配置列表查询", logger.F("count", len(list)))
|
||||
return list
|
||||
}
|
||||
|
||||
// ListByTag 按标签筛选配置列表
|
||||
func (m *Manager) ListByTag(tag string) []Profile {
|
||||
tag = strings.TrimSpace(tag)
|
||||
all := m.List()
|
||||
if tag == "" {
|
||||
return all
|
||||
}
|
||||
result := make([]Profile, 0)
|
||||
for _, p := range all {
|
||||
for _, t := range p.Tags {
|
||||
if strings.EqualFold(t, tag) {
|
||||
result = append(result, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllTags 获取所有已使用的标签(去重排序)
|
||||
func (m *Manager) GetAllTags() []string {
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range m.Profiles {
|
||||
for _, t := range p.Tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
seen[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags := make([]string, 0, len(seen))
|
||||
for t := range seen {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
// Create 创建配置
|
||||
func (m *Manager) Create(input ProfileInput) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
|
||||
// Check Profile Limit
|
||||
if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit {
|
||||
return nil, fmt.Errorf("实例数量已达上限 (%d个),无法创建新的实例。请兑换额度后重试!", m.Config.App.MaxProfileLimit)
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
profileId := uuid.NewString()
|
||||
userDataDir := strings.TrimSpace(input.UserDataDir)
|
||||
if userDataDir == "" {
|
||||
userDataDir = profileId
|
||||
}
|
||||
proxyConfig := strings.TrimSpace(input.ProxyConfig)
|
||||
proxyId := strings.TrimSpace(input.ProxyId)
|
||||
if proxyId != "" {
|
||||
if resolved, ok := m.GetProxyConfigById(proxyId); ok {
|
||||
proxyConfig = resolved
|
||||
} else {
|
||||
log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", proxyId))
|
||||
}
|
||||
}
|
||||
coreId := strings.TrimSpace(input.CoreId)
|
||||
if coreId == "" {
|
||||
if defaultCore, ok := m.GetDefaultCore(); ok {
|
||||
coreId = defaultCore.CoreId
|
||||
}
|
||||
}
|
||||
if proxyConfig == "" && m.Config.Browser.DefaultProxy != "" {
|
||||
proxyConfig = m.Config.Browser.DefaultProxy
|
||||
}
|
||||
profile := &Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: input.ProfileName,
|
||||
UserDataDir: userDataDir,
|
||||
CoreId: coreId,
|
||||
FingerprintArgs: input.FingerprintArgs,
|
||||
ProxyId: proxyId,
|
||||
ProxyConfig: proxyConfig,
|
||||
LaunchArgs: input.LaunchArgs,
|
||||
Tags: input.Tags,
|
||||
Keywords: append([]string{}, input.Keywords...),
|
||||
GroupId: strings.TrimSpace(input.GroupId),
|
||||
Running: false,
|
||||
DebugPort: 0,
|
||||
Pid: 0,
|
||||
LastError: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.Profiles[profileId] = profile
|
||||
log.Info("浏览器配置创建", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName))
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.CodeProvider != nil {
|
||||
if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil {
|
||||
profile.LaunchCode = code
|
||||
}
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// Update 更新配置
|
||||
func (m *Manager) Update(profileId string, input ProfileInput) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
profile, exists := m.Profiles[profileId]
|
||||
if !exists {
|
||||
log.Error("浏览器配置不存在", logger.F("profile_id", profileId))
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
profile.ProfileName = input.ProfileName
|
||||
profile.UserDataDir = input.UserDataDir
|
||||
profile.CoreId = input.CoreId
|
||||
profile.FingerprintArgs = input.FingerprintArgs
|
||||
profile.ProxyId = strings.TrimSpace(input.ProxyId)
|
||||
if profile.ProxyId != "" {
|
||||
if resolved, ok := m.GetProxyConfigById(profile.ProxyId); ok {
|
||||
profile.ProxyConfig = resolved
|
||||
} else {
|
||||
profile.ProxyConfig = ""
|
||||
log.Error("代理绑定失败", logger.F("profile_id", profileId), logger.F("proxy_id", profile.ProxyId))
|
||||
}
|
||||
} else {
|
||||
profile.ProxyConfig = input.ProxyConfig
|
||||
}
|
||||
profile.LaunchArgs = input.LaunchArgs
|
||||
profile.Tags = input.Tags
|
||||
profile.Keywords = append([]string{}, input.Keywords...)
|
||||
profile.GroupId = strings.TrimSpace(input.GroupId)
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
log.Info("浏览器配置更新", logger.F("profile_id", profileId), logger.F("profile_name", input.ProfileName))
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// Delete 删除配置
|
||||
func (m *Manager) Delete(profileId string) error {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
if _, exists := m.Profiles[profileId]; !exists {
|
||||
log.Error("浏览器配置不存在", logger.F("profile_id", profileId))
|
||||
return fmt.Errorf("profile not found")
|
||||
}
|
||||
delete(m.Profiles, profileId)
|
||||
log.Info("浏览器配置删除", logger.F("profile_id", profileId))
|
||||
|
||||
// DAO 删除
|
||||
if m.ProfileDAO != nil {
|
||||
if err := m.ProfileDAO.Delete(profileId); err != nil {
|
||||
log.Error("数据库删除实例失败", logger.F("profile_id", profileId), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.CodeProvider != nil {
|
||||
_ = m.CodeProvider.Remove(profileId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyDefaults 应用默认配置
|
||||
func (m *Manager) ApplyDefaults(profile *Profile) bool {
|
||||
log := logger.New("Browser")
|
||||
if profile.FingerprintArgs == nil || len(profile.FingerprintArgs) == 0 {
|
||||
profile.FingerprintArgs = append([]string{}, m.Config.Browser.DefaultFingerprintArgs...)
|
||||
}
|
||||
if profile.LaunchArgs == nil || len(profile.LaunchArgs) == 0 {
|
||||
profile.LaunchArgs = append([]string{}, m.Config.Browser.DefaultLaunchArgs...)
|
||||
}
|
||||
if strings.TrimSpace(profile.UserDataDir) == "" {
|
||||
profile.UserDataDir = profile.ProfileId
|
||||
}
|
||||
if strings.TrimSpace(profile.CoreId) == "" {
|
||||
if defaultCore, ok := m.GetDefaultCore(); ok {
|
||||
profile.CoreId = defaultCore.CoreId
|
||||
}
|
||||
}
|
||||
proxyChanged := false
|
||||
if profile.ProxyId != "" {
|
||||
if proxyConfig, ok := m.GetProxyConfigById(profile.ProxyId); ok {
|
||||
if proxyConfig != "" && profile.ProxyConfig != proxyConfig {
|
||||
profile.ProxyConfig = proxyConfig
|
||||
proxyChanged = true
|
||||
}
|
||||
} else {
|
||||
log.Error("实例代理未找到", logger.F("profile_id", profile.ProfileId), logger.F("proxy_id", profile.ProxyId))
|
||||
}
|
||||
}
|
||||
if profile.ProxyConfig == "" && m.Config.Browser.DefaultProxy != "" {
|
||||
profile.ProxyConfig = m.Config.Browser.DefaultProxy
|
||||
proxyChanged = true
|
||||
}
|
||||
return proxyChanged
|
||||
}
|
||||
|
||||
// Copy 复制实例配置(除指纹参数外全部复制,指纹使用默认值生成新种子)
|
||||
func (m *Manager) Copy(profileId string, newName string) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
|
||||
// Check Profile Limit
|
||||
if m.Config.App.MaxProfileLimit > 0 && len(m.Profiles) >= m.Config.App.MaxProfileLimit {
|
||||
log.Error("复制实例失败: 达到数量上限", logger.F("limit", m.Config.App.MaxProfileLimit))
|
||||
return nil, fmt.Errorf("实例数量已达上限 (%d个),无法复制实例。请兑换额度后重试!", m.Config.App.MaxProfileLimit)
|
||||
}
|
||||
|
||||
src, exists := m.Profiles[profileId]
|
||||
if !exists {
|
||||
log.Error("源实例不存在", logger.F("profile_id", profileId))
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
newId := uuid.NewString()
|
||||
|
||||
// 处理名称
|
||||
profileName := strings.TrimSpace(newName)
|
||||
if profileName == "" {
|
||||
profileName = src.ProfileName + " (副本)"
|
||||
}
|
||||
|
||||
// 复制配置,指纹参数使用默认值(新种子)
|
||||
profile := &Profile{
|
||||
ProfileId: newId,
|
||||
ProfileName: profileName,
|
||||
UserDataDir: newId, // 新的用户数据目录
|
||||
CoreId: src.CoreId,
|
||||
FingerprintArgs: append([]string{}, m.Config.Browser.DefaultFingerprintArgs...), // 使用默认指纹(新种子)
|
||||
ProxyId: src.ProxyId,
|
||||
ProxyConfig: src.ProxyConfig,
|
||||
LaunchArgs: append([]string{}, src.LaunchArgs...),
|
||||
Tags: append([]string{}, src.Tags...),
|
||||
Keywords: append([]string{}, src.Keywords...),
|
||||
GroupId: src.GroupId, // 复制分组
|
||||
Running: false,
|
||||
DebugPort: 0,
|
||||
Pid: 0,
|
||||
LastError: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
m.Profiles[newId] = profile
|
||||
log.Info("实例复制成功", logger.F("src_id", profileId), logger.F("new_id", newId), logger.F("new_name", profileName))
|
||||
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.CodeProvider != nil {
|
||||
if code, err := m.CodeProvider.EnsureCode(profile.ProfileId); err == nil {
|
||||
profile.LaunchCode = code
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// SetKeywords 设置实例关键字(独立接口,不影响其他字段)
|
||||
func (m *Manager) SetKeywords(profileId string, keywords []string) (*Profile, error) {
|
||||
log := logger.New("Browser")
|
||||
m.InitData()
|
||||
m.Mutex.Lock()
|
||||
defer m.Mutex.Unlock()
|
||||
profile, exists := m.Profiles[profileId]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
profile.Keywords = append([]string{}, keywords...)
|
||||
profile.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
log.Info("关键字更新", logger.F("profile_id", profileId))
|
||||
if err := m.SaveProfiles(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// copyKeywords 深拷贝 keywords map
|
||||
func copyKeywords(src map[string]string) map[string]string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProfileDAO 实例配置持久化接口
|
||||
type ProfileDAO interface {
|
||||
List() ([]*Profile, error)
|
||||
GetById(profileId string) (*Profile, error)
|
||||
Upsert(profile *Profile) error
|
||||
Delete(profileId string) error
|
||||
}
|
||||
|
||||
// SQLiteProfileDAO 基于 SQLite 的 ProfileDAO 实现
|
||||
type SQLiteProfileDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteProfileDAO 创建 SQLiteProfileDAO
|
||||
func NewSQLiteProfileDAO(db *sql.DB) *SQLiteProfileDAO {
|
||||
return &SQLiteProfileDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有实例配置,按创建时间升序
|
||||
func (d *SQLiteProfileDAO) List() ([]*Profile, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT profile_id, profile_name, user_data_dir, core_id,
|
||||
fingerprint_args, proxy_id, proxy_config, launch_args,
|
||||
tags, keywords, group_id, created_at, updated_at
|
||||
FROM browser_profiles ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询实例列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Profile
|
||||
for rows.Next() {
|
||||
p, err := scanProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetById 根据 profileId 查询单个实例
|
||||
func (d *SQLiteProfileDAO) GetById(profileId string) (*Profile, error) {
|
||||
row := d.db.QueryRow(`
|
||||
SELECT profile_id, profile_name, user_data_dir, core_id,
|
||||
fingerprint_args, proxy_id, proxy_config, launch_args,
|
||||
tags, keywords, group_id, created_at, updated_at
|
||||
FROM browser_profiles WHERE profile_id = ?`, profileId)
|
||||
p, err := scanProfile(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("实例不存在: %s", profileId)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// Upsert 新增或更新实例配置
|
||||
func (d *SQLiteProfileDAO) Upsert(profile *Profile) error {
|
||||
fingerprintArgs, _ := json.Marshal(profile.FingerprintArgs)
|
||||
launchArgs, _ := json.Marshal(profile.LaunchArgs)
|
||||
tags, _ := json.Marshal(profile.Tags)
|
||||
keywords, _ := json.Marshal(profile.Keywords)
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if profile.CreatedAt == "" {
|
||||
profile.CreatedAt = now
|
||||
}
|
||||
if profile.UpdatedAt == "" {
|
||||
profile.UpdatedAt = now
|
||||
}
|
||||
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO browser_profiles
|
||||
(profile_id, profile_name, user_data_dir, core_id, fingerprint_args,
|
||||
proxy_id, proxy_config, launch_args, tags, keywords, group_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
profile_name = excluded.profile_name,
|
||||
user_data_dir = excluded.user_data_dir,
|
||||
core_id = excluded.core_id,
|
||||
fingerprint_args = excluded.fingerprint_args,
|
||||
proxy_id = excluded.proxy_id,
|
||||
proxy_config = excluded.proxy_config,
|
||||
launch_args = excluded.launch_args,
|
||||
tags = excluded.tags,
|
||||
keywords = excluded.keywords,
|
||||
group_id = excluded.group_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
profile.ProfileId, profile.ProfileName, profile.UserDataDir, profile.CoreId,
|
||||
string(fingerprintArgs), profile.ProxyId, profile.ProxyConfig,
|
||||
string(launchArgs), string(tags), string(keywords), profile.GroupId,
|
||||
profile.CreatedAt, profile.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存实例配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除实例配置
|
||||
func (d *SQLiteProfileDAO) Delete(profileId string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_profiles WHERE profile_id = ?`, profileId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除实例配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByGroup 按分组筛选实例
|
||||
// groupId 为空字符串时返回未分组的实例
|
||||
// includeChildren=true 时同时包含 childGroupIds 中的子分组实例
|
||||
func (d *SQLiteProfileDAO) ListByGroup(groupId string, includeChildren bool, childGroupIds []string) ([]*Profile, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if includeChildren && len(childGroupIds) > 0 {
|
||||
// 构建 IN 子句,包含当前分组和所有子分组
|
||||
allIds := append([]string{groupId}, childGroupIds...)
|
||||
inClause := ""
|
||||
args := make([]interface{}, len(allIds))
|
||||
for i, id := range allIds {
|
||||
if i > 0 {
|
||||
inClause += ","
|
||||
}
|
||||
inClause += "?"
|
||||
args[i] = id
|
||||
}
|
||||
rows, err = d.db.Query(fmt.Sprintf(`
|
||||
SELECT profile_id, profile_name, user_data_dir, core_id,
|
||||
fingerprint_args, proxy_id, proxy_config, launch_args,
|
||||
tags, keywords, group_id, created_at, updated_at
|
||||
FROM browser_profiles WHERE group_id IN (%s) ORDER BY created_at ASC`, inClause), args...)
|
||||
} else {
|
||||
// 仅查询指定分组
|
||||
rows, err = d.db.Query(`
|
||||
SELECT profile_id, profile_name, user_data_dir, core_id,
|
||||
fingerprint_args, proxy_id, proxy_config, launch_args,
|
||||
tags, keywords, group_id, created_at, updated_at
|
||||
FROM browser_profiles WHERE group_id = ? ORDER BY created_at ASC`, groupId)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("按分组查询实例失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Profile
|
||||
for rows.Next() {
|
||||
p, err := scanProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// MoveToGroup 批量移动实例到分组
|
||||
func (d *SQLiteProfileDAO) MoveToGroup(profileIds []string, groupId string) error {
|
||||
if len(profileIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
inClause := ""
|
||||
args := make([]interface{}, len(profileIds)+1)
|
||||
args[0] = groupId
|
||||
for i, id := range profileIds {
|
||||
if i > 0 {
|
||||
inClause += ","
|
||||
}
|
||||
inClause += "?"
|
||||
args[i+1] = id
|
||||
}
|
||||
_, err := d.db.Exec(fmt.Sprintf(`UPDATE browser_profiles SET group_id = ? WHERE profile_id IN (%s)`, inClause), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("批量移动实例失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanner 统一扫描接口,兼容 *sql.Row 和 *sql.Rows
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanProfile(s scanner) (*Profile, error) {
|
||||
var (
|
||||
fingerprintArgsJSON, launchArgsJSON, tagsJSON, keywordsJSON string
|
||||
p Profile
|
||||
)
|
||||
err := s.Scan(
|
||||
&p.ProfileId, &p.ProfileName, &p.UserDataDir, &p.CoreId,
|
||||
&fingerprintArgsJSON, &p.ProxyId, &p.ProxyConfig,
|
||||
&launchArgsJSON, &tagsJSON, &keywordsJSON, &p.GroupId,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(fingerprintArgsJSON), &p.FingerprintArgs)
|
||||
_ = json.Unmarshal([]byte(launchArgsJSON), &p.LaunchArgs)
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
|
||||
_ = json.Unmarshal([]byte(keywordsJSON), &p.Keywords)
|
||||
if p.FingerprintArgs == nil {
|
||||
p.FingerprintArgs = []string{}
|
||||
}
|
||||
if p.LaunchArgs == nil {
|
||||
p.LaunchArgs = []string{}
|
||||
}
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
if p.Keywords == nil {
|
||||
p.Keywords = []string{}
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProxyDAO 代理列表持久化接口
|
||||
type ProxyDAO interface {
|
||||
List() ([]Proxy, error)
|
||||
ListByGroup(groupName string) ([]Proxy, error)
|
||||
ListGroups() ([]string, error)
|
||||
Upsert(proxy Proxy) error
|
||||
Delete(proxyId string) error
|
||||
DeleteAll() error
|
||||
UpdateSpeedResult(proxyId string, ok bool, latencyMs int64, testedAt string) error
|
||||
UpdateIPHealthResult(proxyId string, healthJSON string) error
|
||||
}
|
||||
|
||||
// SQLiteProxyDAO 基于 SQLite 的 ProxyDAO 实现
|
||||
type SQLiteProxyDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteProxyDAO 创建 SQLiteProxyDAO
|
||||
func NewSQLiteProxyDAO(db *sql.DB) *SQLiteProxyDAO {
|
||||
return &SQLiteProxyDAO{db: db}
|
||||
}
|
||||
|
||||
// List 查询所有代理,按 sort_order 升序
|
||||
func (d *SQLiteProxyDAO) List() ([]Proxy, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT proxy_id, proxy_name, proxy_config, dns_servers, COALESCE(group_name, ''),
|
||||
COALESCE(source_id, ''), COALESCE(source_url, ''), COALESCE(source_name_prefix, ''),
|
||||
COALESCE(source_auto_refresh, 0), COALESCE(source_refresh_interval_m, 0), COALESCE(source_last_refresh_at, ''),
|
||||
COALESCE(last_latency_ms, -1), COALESCE(last_test_ok, 0), COALESCE(last_tested_at, ''),
|
||||
COALESCE(last_ip_health_json, ''),
|
||||
sort_order
|
||||
FROM browser_proxies ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询代理列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProxies(rows)
|
||||
}
|
||||
|
||||
// ListByGroup 按分组名称查询代理
|
||||
func (d *SQLiteProxyDAO) ListByGroup(groupName string) ([]Proxy, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT proxy_id, proxy_name, proxy_config, dns_servers, COALESCE(group_name, ''),
|
||||
COALESCE(source_id, ''), COALESCE(source_url, ''), COALESCE(source_name_prefix, ''),
|
||||
COALESCE(source_auto_refresh, 0), COALESCE(source_refresh_interval_m, 0), COALESCE(source_last_refresh_at, ''),
|
||||
COALESCE(last_latency_ms, -1), COALESCE(last_test_ok, 0), COALESCE(last_tested_at, ''),
|
||||
COALESCE(last_ip_health_json, ''),
|
||||
sort_order
|
||||
FROM browser_proxies WHERE group_name = ?
|
||||
ORDER BY sort_order ASC, created_at ASC`, groupName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("按分组查询代理失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProxies(rows)
|
||||
}
|
||||
|
||||
// ListGroups 获取所有非空分组名称(去重)
|
||||
func (d *SQLiteProxyDAO) ListGroups() ([]string, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT DISTINCT group_name FROM browser_proxies
|
||||
WHERE group_name != '' ORDER BY group_name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询代理分组失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []string
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert 新增或更新代理
|
||||
func (d *SQLiteProxyDAO) Upsert(proxy Proxy) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
autoRefreshInt := 0
|
||||
if proxy.SourceAutoRefresh {
|
||||
autoRefreshInt = 1
|
||||
}
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO browser_proxies (
|
||||
proxy_id, proxy_name, proxy_config, dns_servers, group_name,
|
||||
source_id, source_url, source_name_prefix, source_auto_refresh, source_refresh_interval_m, source_last_refresh_at,
|
||||
sort_order, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(proxy_id) DO UPDATE SET
|
||||
proxy_name = excluded.proxy_name,
|
||||
proxy_config = excluded.proxy_config,
|
||||
dns_servers = excluded.dns_servers,
|
||||
group_name = excluded.group_name,
|
||||
source_id = excluded.source_id,
|
||||
source_url = excluded.source_url,
|
||||
source_name_prefix = excluded.source_name_prefix,
|
||||
source_auto_refresh = excluded.source_auto_refresh,
|
||||
source_refresh_interval_m = excluded.source_refresh_interval_m,
|
||||
source_last_refresh_at = excluded.source_last_refresh_at,
|
||||
sort_order = excluded.sort_order`,
|
||||
proxy.ProxyId, proxy.ProxyName, proxy.ProxyConfig, proxy.DnsServers, proxy.GroupName,
|
||||
proxy.SourceID, proxy.SourceURL, proxy.SourceNamePrefix, autoRefreshInt, proxy.SourceRefreshIntervalM, proxy.SourceLastRefreshAt,
|
||||
proxy.SortOrder, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存代理失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除单个代理
|
||||
func (d *SQLiteProxyDAO) Delete(proxyId string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_proxies WHERE proxy_id = ?`, proxyId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除代理失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAll 清空代理表(批量保存前使用)
|
||||
func (d *SQLiteProxyDAO) DeleteAll() error {
|
||||
_, err := d.db.Exec(`DELETE FROM browser_proxies`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("清空代理表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSpeedResult 更新单个代理的测速结果
|
||||
func (d *SQLiteProxyDAO) UpdateSpeedResult(proxyId string, ok bool, latencyMs int64, testedAt string) error {
|
||||
okInt := 0
|
||||
if ok {
|
||||
okInt = 1
|
||||
}
|
||||
_, err := d.db.Exec(`
|
||||
UPDATE browser_proxies SET last_latency_ms=?, last_test_ok=?, last_tested_at=?
|
||||
WHERE proxy_id=?`, latencyMs, okInt, testedAt, proxyId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新测速结果失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIPHealthResult 更新单个代理的 IP 健康检测结果(JSON 字符串)
|
||||
func (d *SQLiteProxyDAO) UpdateIPHealthResult(proxyId string, healthJSON string) error {
|
||||
_, err := d.db.Exec(`
|
||||
UPDATE browser_proxies SET last_ip_health_json=?
|
||||
WHERE proxy_id=?`, healthJSON, proxyId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新 IP 健康结果失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanProxies(rows *sql.Rows) ([]Proxy, error) {
|
||||
var list []Proxy
|
||||
for rows.Next() {
|
||||
var p Proxy
|
||||
var okInt int
|
||||
var autoRefreshInt int
|
||||
if err := rows.Scan(
|
||||
&p.ProxyId, &p.ProxyName, &p.ProxyConfig, &p.DnsServers, &p.GroupName,
|
||||
&p.SourceID, &p.SourceURL, &p.SourceNamePrefix, &autoRefreshInt, &p.SourceRefreshIntervalM, &p.SourceLastRefreshAt,
|
||||
&p.LastLatencyMs, &okInt, &p.LastTestedAt, &p.LastIPHealthJSON, &p.SortOrder,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取代理行失败: %w", err)
|
||||
}
|
||||
p.LastTestOk = okInt == 1
|
||||
p.SourceAutoRefresh = autoRefreshInt == 1
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SpeedTestFunc 执行单个代理测速的函数类型
|
||||
type SpeedTestFunc func(proxyId string) (ok bool, latencyMs int64, err string)
|
||||
|
||||
// ProxySpeedScheduler 代理测速定时调度器
|
||||
type ProxySpeedScheduler struct {
|
||||
dao ProxyDAO
|
||||
testFn SpeedTestFunc
|
||||
interval time.Duration
|
||||
concLimit int
|
||||
stopCh chan struct{}
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewProxySpeedScheduler 创建调度器,interval 为测速间隔,concLimit 为并发数
|
||||
func NewProxySpeedScheduler(dao ProxyDAO, testFn SpeedTestFunc, interval time.Duration, concLimit int) *ProxySpeedScheduler {
|
||||
if concLimit <= 0 {
|
||||
concLimit = 5
|
||||
}
|
||||
return &ProxySpeedScheduler{
|
||||
dao: dao,
|
||||
testFn: testFn,
|
||||
interval: interval,
|
||||
concLimit: concLimit,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动定时任务(非阻塞)
|
||||
func (s *ProxySpeedScheduler) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running {
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
go s.loop()
|
||||
}
|
||||
|
||||
// Stop 停止定时任务
|
||||
func (s *ProxySpeedScheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// RunOnce 立即执行一轮测速(可手动触发)
|
||||
func (s *ProxySpeedScheduler) RunOnce() {
|
||||
go s.runAll()
|
||||
}
|
||||
|
||||
func (s *ProxySpeedScheduler) loop() {
|
||||
// 启动后延迟 10s 跑第一轮,避免影响启动速度
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
s.runAll()
|
||||
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runAll()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProxySpeedScheduler) runAll() {
|
||||
proxies, err := s.dao.List()
|
||||
if err != nil || len(proxies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, s.concLimit)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, p := range proxies {
|
||||
// 跳过直连(无意义测速)
|
||||
if p.ProxyConfig == "direct://" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(proxyId string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
ok, latencyMs, _ := s.testFn(proxyId)
|
||||
testedAt := time.Now().Format(time.RFC3339)
|
||||
_ = s.dao.UpdateSpeedResult(proxyId, ok, latencyMs, testedAt)
|
||||
}(p.ProxyId)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Profile 浏览器配置文件
|
||||
type Profile struct {
|
||||
ProfileId string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
UserDataDir string `json:"userDataDir"`
|
||||
CoreId string `json:"coreId"`
|
||||
FingerprintArgs []string `json:"fingerprintArgs"`
|
||||
ProxyId string `json:"proxyId"`
|
||||
ProxyConfig string `json:"proxyConfig"`
|
||||
LaunchArgs []string `json:"launchArgs"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords []string `json:"keywords"`
|
||||
GroupId string `json:"groupId"` // 所属分组ID
|
||||
LaunchCode string `json:"launchCode"`
|
||||
Running bool `json:"running"`
|
||||
DebugPort int `json:"debugPort"`
|
||||
Pid int `json:"pid"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
LastStartAt string `json:"lastStartAt"`
|
||||
LastStopAt string `json:"lastStopAt"`
|
||||
}
|
||||
|
||||
// ProfileInput 创建/更新配置文件的输入
|
||||
type ProfileInput struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
UserDataDir string `json:"userDataDir"`
|
||||
CoreId string `json:"coreId"`
|
||||
FingerprintArgs []string `json:"fingerprintArgs"`
|
||||
ProxyId string `json:"proxyId"`
|
||||
ProxyConfig string `json:"proxyConfig"`
|
||||
LaunchArgs []string `json:"launchArgs"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords []string `json:"keywords"`
|
||||
GroupId string `json:"groupId"` // 所属分组ID
|
||||
}
|
||||
|
||||
// Tab 浏览器标签页
|
||||
type Tab struct {
|
||||
TabId string `json:"tabId"`
|
||||
Title string `json:"title"`
|
||||
Url string `json:"url"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// Settings 浏览器全局设置
|
||||
type Settings struct {
|
||||
UserDataRoot string `json:"userDataRoot"`
|
||||
DefaultFingerprintArgs []string `json:"defaultFingerprintArgs"`
|
||||
DefaultLaunchArgs []string `json:"defaultLaunchArgs"`
|
||||
DefaultProxy string `json:"defaultProxy"`
|
||||
}
|
||||
|
||||
// CoreInput 内核配置输入
|
||||
type CoreInput struct {
|
||||
CoreId string `json:"coreId"`
|
||||
CoreName string `json:"coreName"`
|
||||
CorePath string `json:"corePath"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
// CoreValidateResult 内核路径验证结果
|
||||
type CoreValidateResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CoreExtendedInfo 内核扩展信息
|
||||
type CoreExtendedInfo struct {
|
||||
CoreId string `json:"coreId"`
|
||||
ChromeVersion string `json:"chromeVersion"`
|
||||
InstanceCount int `json:"instanceCount"`
|
||||
}
|
||||
|
||||
// Group 实例分组
|
||||
type Group struct {
|
||||
GroupId string `json:"groupId"`
|
||||
GroupName string `json:"groupName"`
|
||||
ParentId string `json:"parentId"` // 空字符串表示根级分组
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GroupInput 创建/更新分组的输入
|
||||
type GroupInput struct {
|
||||
GroupName string `json:"groupName"`
|
||||
ParentId string `json:"parentId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
// GroupWithCount 带实例计数的分组
|
||||
type GroupWithCount struct {
|
||||
Group
|
||||
InstanceCount int `json:"instanceCount"`
|
||||
}
|
||||
|
||||
// 类型别名
|
||||
type Proxy = config.BrowserProxy
|
||||
type Core = config.BrowserCore
|
||||
type Environment = config.BrowserEnvironment
|
||||
type ProfileConfig = config.BrowserProfileConfig
|
||||
|
||||
// CodeProvider 提供 LaunchCode 的接口(由 launchcode.LaunchCodeService 实现)
|
||||
type CodeProvider interface {
|
||||
EnsureCode(profileId string) (string, error)
|
||||
Remove(profileId string) error
|
||||
}
|
||||
|
||||
// Manager 浏览器管理器
|
||||
type Manager struct {
|
||||
Config *config.Config
|
||||
AppRoot string // 应用根目录,所有相对路径基于此解析(生产=exe目录,dev=项目根目录)
|
||||
Profiles map[string]*Profile
|
||||
Mutex sync.Mutex
|
||||
BrowserProcesses map[string]*exec.Cmd
|
||||
XrayBridges map[string]*XrayBridge
|
||||
CodeProvider CodeProvider
|
||||
|
||||
// DAO 层(注入后使用 SQLite 存储,未注入时降级到 config.yaml)
|
||||
ProfileDAO ProfileDAO
|
||||
ProxyDAO ProxyDAO
|
||||
CoreDAO CoreDAO
|
||||
BookmarkDAO BookmarkDAO
|
||||
GroupDAO GroupDAO
|
||||
}
|
||||
|
||||
// XrayBridge Xray 桥接进程
|
||||
type XrayBridge struct {
|
||||
NodeKey string
|
||||
Port int
|
||||
Cmd *exec.Cmd
|
||||
Pid int
|
||||
Running bool
|
||||
LastError string
|
||||
}
|
||||
|
||||
// NewManager 创建浏览器管理器
|
||||
func NewManager(cfg *config.Config, appRoot string) *Manager {
|
||||
return &Manager{
|
||||
Config: cfg,
|
||||
AppRoot: appRoot,
|
||||
Profiles: make(map[string]*Profile),
|
||||
BrowserProcesses: make(map[string]*exec.Cmd),
|
||||
XrayBridges: make(map[string]*XrayBridge),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRelativePath 将相对路径解析为绝对路径(基于 AppRoot)。
|
||||
// 如果传入的已经是绝对路径则直接返回。
|
||||
func (m *Manager) ResolveRelativePath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
if m.AppRoot != "" {
|
||||
return filepath.Join(m.AppRoot, p)
|
||||
}
|
||||
// 兜底:使用 CWD
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
return filepath.Join(cwd, p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LaunchServerConfig Launch HTTP 服务配置
|
||||
type LaunchServerConfig struct {
|
||||
// Port <= 0 时自动分配随机可用端口(推荐)。
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// Config 应用配置
|
||||
type Config struct {
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
App AppConfig `yaml:"app"`
|
||||
Runtime RuntimeConfig `yaml:"runtime"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Browser BrowserConfig `yaml:"browser"`
|
||||
LaunchServer LaunchServerConfig `yaml:"launch_server"`
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
SQLite SQLiteConfig `yaml:"sqlite"`
|
||||
}
|
||||
|
||||
// SQLiteConfig SQLite 配置
|
||||
type SQLiteConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// AppConfig 应用配置
|
||||
type AppConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Window WindowConfig `yaml:"window"`
|
||||
MaxProfileLimit int `yaml:"max_profile_limit"`
|
||||
UsedCDKeys []string `yaml:"used_cd_keys"`
|
||||
}
|
||||
|
||||
// WindowConfig 窗口配置
|
||||
type WindowConfig struct {
|
||||
Width int `yaml:"width"`
|
||||
Height int `yaml:"height"`
|
||||
MinWidth int `yaml:"min_width"`
|
||||
MinHeight int `yaml:"min_height"`
|
||||
}
|
||||
|
||||
// RuntimeConfig 运行时配置
|
||||
type RuntimeConfig struct {
|
||||
MaxMemoryMB int `yaml:"max_memory_mb"` // 最大内存限制(MB)
|
||||
GCPercent int `yaml:"gc_percent"` // GC 触发百分比
|
||||
}
|
||||
|
||||
type BrowserBookmark struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
}
|
||||
|
||||
type BrowserConfig struct {
|
||||
UserDataRoot string `yaml:"user_data_root"`
|
||||
DefaultFingerprintArgs []string `yaml:"default_fingerprint_args"`
|
||||
DefaultLaunchArgs []string `yaml:"default_launch_args"`
|
||||
DefaultProxy string `yaml:"default_proxy"`
|
||||
DefaultBookmarks []BrowserBookmark `yaml:"default_bookmarks,omitempty"`
|
||||
Cores []BrowserCore `yaml:"cores,omitempty"`
|
||||
Proxies []BrowserProxy `yaml:"proxies,omitempty"`
|
||||
Profiles []BrowserProfileConfig `yaml:"profiles,omitempty"`
|
||||
// 废弃字段,保留用于迁移
|
||||
ChromeBinaryPath string `yaml:"chrome_binary_path,omitempty"`
|
||||
ClashBinaryPath string `yaml:"clash_binary_path,omitempty"`
|
||||
XrayBinaryPath string `yaml:"xray_binary_path,omitempty"`
|
||||
SingBoxBinaryPath string `yaml:"singbox_binary_path,omitempty"`
|
||||
CoreRoot string `yaml:"core_root,omitempty"`
|
||||
DefaultCoreId string `yaml:"default_core_id,omitempty"`
|
||||
DefaultConnectorType string `yaml:"default_connector_type,omitempty"`
|
||||
Environments []BrowserEnvironment `yaml:"environments,omitempty"`
|
||||
}
|
||||
|
||||
type BrowserCore struct {
|
||||
CoreId string `yaml:"core_id" json:"coreId"`
|
||||
CoreName string `yaml:"core_name" json:"coreName"`
|
||||
CorePath string `yaml:"core_path" json:"corePath"`
|
||||
IsDefault bool `yaml:"is_default" json:"isDefault"`
|
||||
}
|
||||
|
||||
type BrowserProxy struct {
|
||||
ProxyId string `yaml:"proxy_id" json:"proxyId"`
|
||||
ProxyName string `yaml:"proxy_name" json:"proxyName"`
|
||||
ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"`
|
||||
DnsServers string `yaml:"dns_servers,omitempty" json:"dnsServers,omitempty"`
|
||||
GroupName string `yaml:"group_name,omitempty" json:"groupName,omitempty"`
|
||||
SortOrder int `yaml:"sort_order,omitempty" json:"sortOrder,omitempty"`
|
||||
SourceID string `yaml:"source_id,omitempty" json:"sourceId,omitempty"`
|
||||
SourceURL string `yaml:"source_url,omitempty" json:"sourceUrl,omitempty"`
|
||||
// URL 导入时的名称前缀,用于后续自动刷新时重建同名策略
|
||||
SourceNamePrefix string `yaml:"source_name_prefix,omitempty" json:"sourceNamePrefix,omitempty"`
|
||||
// URL 导入自动刷新开关与间隔(分钟)
|
||||
SourceAutoRefresh bool `yaml:"source_auto_refresh,omitempty" json:"sourceAutoRefresh,omitempty"`
|
||||
SourceRefreshIntervalM int `yaml:"source_refresh_interval_m,omitempty" json:"sourceRefreshIntervalM,omitempty"`
|
||||
SourceLastRefreshAt string `yaml:"source_last_refresh_at,omitempty" json:"sourceLastRefreshAt,omitempty"`
|
||||
// 测速结果(运行时字段,不写入 yaml)
|
||||
LastLatencyMs int64 `yaml:"-" json:"lastLatencyMs"`
|
||||
LastTestOk bool `yaml:"-" json:"lastTestOk"`
|
||||
LastTestedAt string `yaml:"-" json:"lastTestedAt"`
|
||||
// IP 健康检测原始结果(运行时字段,不写入 yaml)
|
||||
LastIPHealthJSON string `yaml:"-" json:"lastIPHealthJson,omitempty"`
|
||||
}
|
||||
|
||||
type BrowserEnvironment struct {
|
||||
CoreId string `yaml:"core_id" json:"coreId"`
|
||||
CoreName string `yaml:"core_name" json:"coreName"`
|
||||
CorePath string `yaml:"core_path" json:"corePath"`
|
||||
ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"`
|
||||
ConnectorType string `yaml:"connector_type" json:"connectorType"`
|
||||
IsDefault bool `yaml:"is_default" json:"isDefault"`
|
||||
}
|
||||
|
||||
type BrowserProfileConfig struct {
|
||||
ProfileId string `yaml:"profile_id" json:"profileId"`
|
||||
ProfileName string `yaml:"profile_name" json:"profileName"`
|
||||
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
|
||||
CoreId string `yaml:"core_id" json:"coreId"`
|
||||
FingerprintArgs []string `yaml:"fingerprint_args" json:"fingerprintArgs"`
|
||||
ProxyId string `yaml:"proxy_id" json:"proxyId"`
|
||||
ProxyConfig string `yaml:"proxy_config" json:"proxyConfig"`
|
||||
LaunchArgs []string `yaml:"launch_args" json:"launchArgs"`
|
||||
Tags []string `yaml:"tags" json:"tags"`
|
||||
Keywords []string `yaml:"keywords,omitempty" json:"keywords,omitempty"`
|
||||
CreatedAt string `yaml:"created_at" json:"createdAt"`
|
||||
UpdatedAt string `yaml:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// LoggingConfig 日志配置
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
FileEnabled bool `yaml:"file_enabled"`
|
||||
FilePath string `yaml:"file_path"`
|
||||
Format string `yaml:"format"` // "text" or "json"
|
||||
|
||||
// 性能配置
|
||||
BufferSize int `yaml:"buffer_size"` // 缓冲区大小(KB)
|
||||
AsyncQueueSize int `yaml:"async_queue_size"` // 异步队列大小
|
||||
FlushIntervalMs int `yaml:"flush_interval_ms"` // 刷新间隔(毫秒)
|
||||
|
||||
// 分片配置
|
||||
Rotation RotationConfig `yaml:"rotation"`
|
||||
|
||||
// 方法拦截配置
|
||||
Interceptor InterceptorConfig `yaml:"interceptor"`
|
||||
}
|
||||
|
||||
// RotationConfig 日志分片配置
|
||||
type RotationConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
MaxSizeMB int `yaml:"max_size_mb"` // 单文件最大大小(MB)
|
||||
MaxAge int `yaml:"max_age"` // 保留天数
|
||||
MaxBackups int `yaml:"max_backups"` // 保留文件数
|
||||
TimeInterval string `yaml:"time_interval"` // 时间间隔: "daily", "hourly"
|
||||
}
|
||||
|
||||
// InterceptorConfig 方法拦截器配置
|
||||
type InterceptorConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
LogParameters bool `yaml:"log_parameters"` // 是否记录参数
|
||||
LogResults bool `yaml:"log_results"` // 是否记录返回值
|
||||
SensitiveFields []string `yaml:"sensitive_fields"` // 敏感字段(脱敏)
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
func Load(configPath string) (*Config, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
normalizeConfig(&config)
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// normalizeConfig 对历史配置进行字段补齐,不覆盖用户已配置值。
|
||||
func normalizeConfig(config *Config) {
|
||||
defaultConfig := DefaultConfig()
|
||||
|
||||
if strings.TrimSpace(config.Database.Type) == "" {
|
||||
config.Database.Type = defaultConfig.Database.Type
|
||||
}
|
||||
if strings.TrimSpace(config.Database.SQLite.Path) == "" {
|
||||
config.Database.SQLite.Path = defaultConfig.Database.SQLite.Path
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.App.Name) == "" {
|
||||
config.App.Name = defaultConfig.App.Name
|
||||
}
|
||||
if config.App.Window.Width <= 0 {
|
||||
config.App.Window.Width = defaultConfig.App.Window.Width
|
||||
}
|
||||
if config.App.Window.Height <= 0 {
|
||||
config.App.Window.Height = defaultConfig.App.Window.Height
|
||||
}
|
||||
if config.App.Window.MinWidth <= 0 {
|
||||
config.App.Window.MinWidth = defaultConfig.App.Window.MinWidth
|
||||
}
|
||||
if config.App.Window.MinHeight <= 0 {
|
||||
config.App.Window.MinHeight = defaultConfig.App.Window.MinHeight
|
||||
}
|
||||
if config.App.UsedCDKeys == nil {
|
||||
config.App.UsedCDKeys = []string{}
|
||||
}
|
||||
|
||||
// 兼容老版本配置: 如果之前没有 max_profile_limit,它会被解析成 0,
|
||||
// 若用户在 0 状态下兑换了额度(比如 0+3=3),基础的 3 额度会被覆盖。
|
||||
// 这里通过统计兑换记录重新保底验证它的额度即可修复。
|
||||
expectedLimit := defaultConfig.App.MaxProfileLimit
|
||||
for _, k := range config.App.UsedCDKeys {
|
||||
if k == "GITHUB_STAR_REWARD" {
|
||||
expectedLimit += 3
|
||||
} else {
|
||||
expectedLimit += 3
|
||||
}
|
||||
}
|
||||
if config.App.MaxProfileLimit < expectedLimit {
|
||||
config.App.MaxProfileLimit = expectedLimit
|
||||
}
|
||||
|
||||
if config.Runtime.MaxMemoryMB <= 0 {
|
||||
config.Runtime.MaxMemoryMB = defaultConfig.Runtime.MaxMemoryMB
|
||||
}
|
||||
if config.Runtime.GCPercent <= 0 {
|
||||
config.Runtime.GCPercent = defaultConfig.Runtime.GCPercent
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.Logging.Level) == "" {
|
||||
config.Logging.Level = defaultConfig.Logging.Level
|
||||
}
|
||||
if isLegacyDefaultLogPath(config.Logging.FilePath) || strings.TrimSpace(config.Logging.FilePath) == "" {
|
||||
config.Logging.FilePath = defaultConfig.Logging.FilePath
|
||||
}
|
||||
if strings.TrimSpace(config.Logging.Format) == "" {
|
||||
config.Logging.Format = defaultConfig.Logging.Format
|
||||
}
|
||||
if config.Logging.BufferSize <= 0 {
|
||||
config.Logging.BufferSize = defaultConfig.Logging.BufferSize
|
||||
}
|
||||
if config.Logging.AsyncQueueSize <= 0 {
|
||||
config.Logging.AsyncQueueSize = defaultConfig.Logging.AsyncQueueSize
|
||||
}
|
||||
if config.Logging.FlushIntervalMs <= 0 {
|
||||
config.Logging.FlushIntervalMs = defaultConfig.Logging.FlushIntervalMs
|
||||
}
|
||||
if config.Logging.Rotation.MaxSizeMB <= 0 {
|
||||
config.Logging.Rotation.MaxSizeMB = defaultConfig.Logging.Rotation.MaxSizeMB
|
||||
}
|
||||
if config.Logging.Rotation.MaxAge <= 0 {
|
||||
config.Logging.Rotation.MaxAge = defaultConfig.Logging.Rotation.MaxAge
|
||||
}
|
||||
if config.Logging.Rotation.MaxBackups <= 0 {
|
||||
config.Logging.Rotation.MaxBackups = defaultConfig.Logging.Rotation.MaxBackups
|
||||
}
|
||||
if strings.TrimSpace(config.Logging.Rotation.TimeInterval) == "" {
|
||||
config.Logging.Rotation.TimeInterval = defaultConfig.Logging.Rotation.TimeInterval
|
||||
}
|
||||
|
||||
interceptorAllZero := !config.Logging.Interceptor.Enabled &&
|
||||
!config.Logging.Interceptor.LogParameters &&
|
||||
!config.Logging.Interceptor.LogResults &&
|
||||
config.Logging.Interceptor.SensitiveFields == nil
|
||||
if interceptorAllZero {
|
||||
config.Logging.Interceptor = cloneInterceptorConfig(defaultConfig.Logging.Interceptor)
|
||||
} else if config.Logging.Interceptor.SensitiveFields == nil {
|
||||
config.Logging.Interceptor.SensitiveFields = append([]string{}, defaultConfig.Logging.Interceptor.SensitiveFields...)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.Browser.UserDataRoot) == "" {
|
||||
config.Browser.UserDataRoot = defaultConfig.Browser.UserDataRoot
|
||||
}
|
||||
if len(config.Browser.DefaultFingerprintArgs) == 0 {
|
||||
config.Browser.DefaultFingerprintArgs = append([]string{}, defaultConfig.Browser.DefaultFingerprintArgs...)
|
||||
}
|
||||
if len(config.Browser.DefaultLaunchArgs) == 0 {
|
||||
config.Browser.DefaultLaunchArgs = append([]string{}, defaultConfig.Browser.DefaultLaunchArgs...)
|
||||
}
|
||||
if config.Browser.DefaultBookmarks == nil {
|
||||
config.Browser.DefaultBookmarks = []BrowserBookmark{}
|
||||
}
|
||||
if config.Browser.Cores == nil {
|
||||
config.Browser.Cores = []BrowserCore{}
|
||||
}
|
||||
if config.Browser.Proxies == nil {
|
||||
config.Browser.Proxies = []BrowserProxy{}
|
||||
}
|
||||
if config.Browser.Profiles == nil {
|
||||
config.Browser.Profiles = []BrowserProfileConfig{}
|
||||
}
|
||||
|
||||
if config.LaunchServer.Port < 0 {
|
||||
config.LaunchServer.Port = defaultConfig.LaunchServer.Port
|
||||
}
|
||||
}
|
||||
|
||||
func cloneInterceptorConfig(src InterceptorConfig) InterceptorConfig {
|
||||
dst := src
|
||||
dst.SensitiveFields = append([]string{}, src.SensitiveFields...)
|
||||
return dst
|
||||
}
|
||||
|
||||
func isLegacyDefaultLogPath(path string) bool {
|
||||
return strings.EqualFold(filepath.ToSlash(strings.TrimSpace(path)), "logs/app.log")
|
||||
}
|
||||
|
||||
// DefaultConfig 返回默认配置
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Database: DatabaseConfig{
|
||||
Type: "sqlite",
|
||||
SQLite: SQLiteConfig{
|
||||
Path: "data/app.db",
|
||||
},
|
||||
},
|
||||
App: AppConfig{
|
||||
Name: "Ant Browser",
|
||||
Window: WindowConfig{
|
||||
Width: 1750,
|
||||
Height: 1000,
|
||||
MinWidth: 1200,
|
||||
MinHeight: 700,
|
||||
},
|
||||
MaxProfileLimit: 3,
|
||||
UsedCDKeys: []string{},
|
||||
},
|
||||
Runtime: RuntimeConfig{
|
||||
MaxMemoryMB: 1024, // 默认 1GB
|
||||
GCPercent: 100, // 默认 100%
|
||||
},
|
||||
Browser: BrowserConfig{
|
||||
UserDataRoot: "data",
|
||||
DefaultFingerprintArgs: []string{"--fingerprint-brand=Chrome", "--fingerprint-platform=windows"},
|
||||
DefaultLaunchArgs: []string{"--disable-sync", "--no-first-run"},
|
||||
DefaultProxy: "",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
FileEnabled: false,
|
||||
FilePath: "data/logs/app.log",
|
||||
Format: "text",
|
||||
BufferSize: 4, // 4KB
|
||||
AsyncQueueSize: 1000,
|
||||
FlushIntervalMs: 1000, // 1秒
|
||||
Rotation: RotationConfig{
|
||||
Enabled: false,
|
||||
MaxSizeMB: 100,
|
||||
MaxAge: 7,
|
||||
MaxBackups: 5,
|
||||
TimeInterval: "daily",
|
||||
},
|
||||
Interceptor: InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
LogResults: true,
|
||||
SensitiveFields: []string{"password", "token", "secret"},
|
||||
},
|
||||
},
|
||||
LaunchServer: LaunchServerConfig{
|
||||
Port: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Save 保存配置到文件
|
||||
func (c *Config) Save(configPath string) error {
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化配置失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyStore 代理数据文件结构
|
||||
type ProxyStore struct {
|
||||
Proxies []BrowserProxy `yaml:"proxies"`
|
||||
}
|
||||
|
||||
// LoadProxies 从独立文件加载代理列表
|
||||
func LoadProxies(path string) ([]BrowserProxy, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取代理文件失败: %w", err)
|
||||
}
|
||||
var store ProxyStore
|
||||
if err := yaml.Unmarshal(data, &store); err != nil {
|
||||
return nil, fmt.Errorf("解析代理文件失败: %w", err)
|
||||
}
|
||||
return store.Proxies, nil
|
||||
}
|
||||
|
||||
// SaveProxies 将代理列表保存到独立文件
|
||||
func SaveProxies(path string, proxies []BrowserProxy) error {
|
||||
store := ProxyStore{Proxies: proxies}
|
||||
data, err := yaml.Marshal(store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化代理数据失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("写入代理文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadBackfillsLegacyConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
legacyConfig := `
|
||||
app:
|
||||
used_cd_keys:
|
||||
- GITHUB_STAR_REWARD
|
||||
logging: {}
|
||||
browser: {}
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Database.Type != "sqlite" {
|
||||
t.Fatalf("Database.Type 未补齐: got=%q", cfg.Database.Type)
|
||||
}
|
||||
if cfg.Database.SQLite.Path != "data/app.db" {
|
||||
t.Fatalf("Database.SQLite.Path 未补齐: got=%q", cfg.Database.SQLite.Path)
|
||||
}
|
||||
if cfg.App.Name != "Ant Browser" {
|
||||
t.Fatalf("App.Name 未补齐: got=%q", cfg.App.Name)
|
||||
}
|
||||
if cfg.App.MaxProfileLimit != 6 {
|
||||
t.Fatalf("MaxProfileLimit 计算错误: got=%d want=6", cfg.App.MaxProfileLimit)
|
||||
}
|
||||
if cfg.Runtime.MaxMemoryMB != 1024 || cfg.Runtime.GCPercent != 100 {
|
||||
t.Fatalf("Runtime 未补齐: got=%+v", cfg.Runtime)
|
||||
}
|
||||
if cfg.Logging.Level != "info" || cfg.Logging.FilePath != "data/logs/app.log" {
|
||||
t.Fatalf("Logging 基础字段未补齐: got=%+v", cfg.Logging)
|
||||
}
|
||||
if !cfg.Logging.Interceptor.Enabled || !cfg.Logging.Interceptor.LogParameters || !cfg.Logging.Interceptor.LogResults {
|
||||
t.Fatalf("Interceptor 默认值未补齐: got=%+v", cfg.Logging.Interceptor)
|
||||
}
|
||||
if len(cfg.Logging.Interceptor.SensitiveFields) == 0 {
|
||||
t.Fatalf("Interceptor.SensitiveFields 未补齐")
|
||||
}
|
||||
if cfg.Browser.UserDataRoot != "data" {
|
||||
t.Fatalf("Browser.UserDataRoot 未补齐: got=%q", cfg.Browser.UserDataRoot)
|
||||
}
|
||||
if len(cfg.Browser.DefaultFingerprintArgs) == 0 || len(cfg.Browser.DefaultLaunchArgs) == 0 {
|
||||
t.Fatalf("Browser 默认启动参数未补齐")
|
||||
}
|
||||
if cfg.Browser.Cores == nil || cfg.Browser.Proxies == nil || cfg.Browser.Profiles == nil {
|
||||
t.Fatalf("Browser 列表字段应初始化为空切片")
|
||||
}
|
||||
if cfg.LaunchServer.Port != 0 {
|
||||
t.Fatalf("LaunchServer.Port 未补齐: got=%d", cfg.LaunchServer.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPreservesExplicitConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
customConfig := `
|
||||
database:
|
||||
type: sqlite
|
||||
sqlite:
|
||||
path: custom/app.db
|
||||
app:
|
||||
name: Custom App
|
||||
window:
|
||||
width: 1400
|
||||
height: 800
|
||||
min_width: 900
|
||||
min_height: 600
|
||||
max_profile_limit: 20
|
||||
used_cd_keys: []
|
||||
runtime:
|
||||
max_memory_mb: 2048
|
||||
gc_percent: 80
|
||||
logging:
|
||||
level: debug
|
||||
file_enabled: true
|
||||
file_path: custom.log
|
||||
format: json
|
||||
buffer_size: 8
|
||||
async_queue_size: 2000
|
||||
flush_interval_ms: 500
|
||||
rotation:
|
||||
enabled: true
|
||||
max_size_mb: 10
|
||||
max_age: 3
|
||||
max_backups: 2
|
||||
time_interval: hourly
|
||||
interceptor:
|
||||
enabled: false
|
||||
log_parameters: false
|
||||
log_results: false
|
||||
sensitive_fields: []
|
||||
browser:
|
||||
user_data_root: custom_data
|
||||
default_fingerprint_args:
|
||||
- --fingerprint-brand=Edge
|
||||
default_launch_args:
|
||||
- --start-maximized
|
||||
default_proxy: direct://
|
||||
default_bookmarks: []
|
||||
cores: []
|
||||
proxies: []
|
||||
profiles: []
|
||||
launch_server:
|
||||
port: 30000
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(customConfig), 0o644); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
if cfg.App.Name != "Custom App" || cfg.App.MaxProfileLimit != 20 {
|
||||
t.Fatalf("App 显式配置被覆盖: got=%+v", cfg.App)
|
||||
}
|
||||
if cfg.Database.SQLite.Path != "custom/app.db" {
|
||||
t.Fatalf("Database.SQLite.Path 显式配置被覆盖: got=%q", cfg.Database.SQLite.Path)
|
||||
}
|
||||
if cfg.Runtime.MaxMemoryMB != 2048 || cfg.Runtime.GCPercent != 80 {
|
||||
t.Fatalf("Runtime 显式配置被覆盖: got=%+v", cfg.Runtime)
|
||||
}
|
||||
if cfg.Logging.Level != "debug" || cfg.Logging.Format != "json" || !cfg.Logging.FileEnabled {
|
||||
t.Fatalf("Logging 显式配置被覆盖: got=%+v", cfg.Logging)
|
||||
}
|
||||
if cfg.Logging.Interceptor.Enabled {
|
||||
t.Fatalf("Interceptor.Enabled 显式 false 被覆盖")
|
||||
}
|
||||
if len(cfg.Browser.DefaultFingerprintArgs) != 1 || cfg.Browser.DefaultFingerprintArgs[0] != "--fingerprint-brand=Edge" {
|
||||
t.Fatalf("Browser.DefaultFingerprintArgs 显式配置被覆盖: got=%v", cfg.Browser.DefaultFingerprintArgs)
|
||||
}
|
||||
if cfg.Browser.UserDataRoot != "custom_data" || cfg.Browser.DefaultProxy != "direct://" {
|
||||
t.Fatalf("Browser 显式配置被覆盖: got=%+v", cfg.Browser)
|
||||
}
|
||||
if cfg.LaunchServer.Port != 30000 {
|
||||
t.Fatalf("LaunchServer.Port 显式配置被覆盖: got=%d", cfg.LaunchServer.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMigratesLegacyRootLogPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
legacyConfig := `
|
||||
logging:
|
||||
file_path: logs/app.log
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Logging.FilePath != "data/logs/app.log" {
|
||||
t.Fatalf("legacy 根目录日志路径未迁移: got=%q", cfg.Logging.FilePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DB 数据库连接
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// migration 单个版本迁移
|
||||
type migration struct {
|
||||
version int // 版本号,单调递增,永不修改
|
||||
desc string // 描述,便于日志追踪
|
||||
stmts []string
|
||||
}
|
||||
|
||||
// migrations 所有版本迁移,按 version 升序排列
|
||||
// 规则:
|
||||
// - 只能追加新版本,绝对不能修改已有版本
|
||||
// - version 从 1 开始,每次发布新版本时递增
|
||||
// - 每个 version 对应一批幂等的 DDL 语句
|
||||
var migrations = []migration{
|
||||
{
|
||||
version: 1,
|
||||
desc: "初始化核心表结构",
|
||||
stmts: []string{
|
||||
`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS browser_profiles (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
profile_name TEXT NOT NULL,
|
||||
user_data_dir TEXT NOT NULL DEFAULT '',
|
||||
core_id TEXT NOT NULL DEFAULT '',
|
||||
fingerprint_args TEXT NOT NULL DEFAULT '[]',
|
||||
proxy_id TEXT NOT NULL DEFAULT '',
|
||||
proxy_config TEXT NOT NULL DEFAULT '',
|
||||
launch_args TEXT NOT NULL DEFAULT '[]',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
keywords TEXT NOT NULL DEFAULT '[]',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_browser_profiles_created_at ON browser_profiles(created_at)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS browser_proxies (
|
||||
proxy_id TEXT PRIMARY KEY,
|
||||
proxy_name TEXT NOT NULL,
|
||||
proxy_config TEXT NOT NULL,
|
||||
dns_servers TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS browser_cores (
|
||||
core_id TEXT PRIMARY KEY,
|
||||
core_name TEXT NOT NULL,
|
||||
core_path TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS browser_bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
desc: "添加实例分组支持",
|
||||
stmts: []string{
|
||||
`CREATE TABLE IF NOT EXISTS browser_groups (
|
||||
group_id TEXT PRIMARY KEY,
|
||||
group_name TEXT NOT NULL,
|
||||
parent_id TEXT DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_browser_groups_parent_id ON browser_groups(parent_id)`,
|
||||
`ALTER TABLE browser_profiles ADD COLUMN group_id TEXT DEFAULT ''`,
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
desc: "代理表添加分组和测速字段",
|
||||
stmts: []string{
|
||||
`ALTER TABLE browser_proxies ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN last_latency_ms INTEGER NOT NULL DEFAULT -1`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN last_test_ok INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN last_tested_at TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
desc: "代理表添加 IP 健康结果字段",
|
||||
stmts: []string{
|
||||
`ALTER TABLE browser_proxies ADD COLUMN last_ip_health_json TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
desc: "代理表添加 URL 来源与自动刷新字段",
|
||||
stmts: []string{
|
||||
`ALTER TABLE browser_proxies ADD COLUMN source_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN source_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN source_name_prefix TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN source_auto_refresh INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN source_refresh_interval_m INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE browser_proxies ADD COLUMN source_last_refresh_at TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
},
|
||||
// ── 新版本在此追加,格式:
|
||||
// {
|
||||
// version: 4,
|
||||
// desc: "描述本次变更",
|
||||
// stmts: []string{
|
||||
// `ALTER TABLE xxx ADD COLUMN yyy TEXT NOT NULL DEFAULT ''`,
|
||||
// },
|
||||
// },
|
||||
}
|
||||
|
||||
// NewDB 创建新的数据库连接
|
||||
func NewDB(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(1)
|
||||
conn.SetMaxIdleConns(1)
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// WAL 模式:写不阻塞读
|
||||
if _, err := conn.Exec(`PRAGMA journal_mode=WAL`); err != nil {
|
||||
return nil, fmt.Errorf("设置 WAL 模式失败: %w", err)
|
||||
}
|
||||
// 开启外键约束
|
||||
if _, err := conn.Exec(`PRAGMA foreign_keys=ON`); err != nil {
|
||||
return nil, fmt.Errorf("开启外键约束失败: %w", err)
|
||||
}
|
||||
|
||||
return &DB{conn: conn}, nil
|
||||
}
|
||||
|
||||
// GetConn 获取数据库连接
|
||||
func (db *DB) GetConn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func (db *DB) Close() error {
|
||||
if db.conn != nil {
|
||||
return db.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Migrate 执行版本化迁移
|
||||
// 原理:维护 schema_migrations 表记录已执行版本,每次启动只执行未执行的版本
|
||||
func (db *DB) Migrate() error {
|
||||
// 确保版本记录表存在
|
||||
if _, err := db.conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
desc TEXT NOT NULL DEFAULT '',
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("创建 schema_migrations 表失败: %w", err)
|
||||
}
|
||||
|
||||
// 查询已执行的最大版本号
|
||||
var currentVersion int
|
||||
row := db.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`)
|
||||
if err := row.Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("查询当前 schema 版本失败: %w", err)
|
||||
}
|
||||
|
||||
// 按版本顺序执行未执行的迁移
|
||||
for _, m := range migrations {
|
||||
if m.version <= currentVersion {
|
||||
continue // 已执行,跳过
|
||||
}
|
||||
|
||||
// 每个版本在事务内执行,保证原子性
|
||||
if err := db.applyMigration(m); err != nil {
|
||||
return fmt.Errorf("迁移版本 %d (%s) 失败: %w", m.version, m.desc, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMigration 在事务内执行单个版本的所有语句,并记录版本号
|
||||
func (db *DB) applyMigration(m migration) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, stmt := range m.stmts {
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
// ALTER TABLE 添加已存在列时忽略(兼容从旧版本直接升级的情况)
|
||||
if isColumnExistsError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("执行语句失败 [%s]: %w", truncate(stmt, 60), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录版本号
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_migrations (version, desc) VALUES (?, ?)`,
|
||||
m.version, m.desc,
|
||||
); err != nil {
|
||||
return fmt.Errorf("记录迁移版本失败: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// isColumnExistsError 检查是否是列已存在的错误(SQLite 错误信息)
|
||||
func isColumnExistsError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "duplicate column") || strings.Contains(s, "already exists")
|
||||
}
|
||||
|
||||
// truncate 截断字符串用于日志展示
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LaunchCodeDAO Launch Code 持久化接口
|
||||
type LaunchCodeDAO interface {
|
||||
// FindProfileId 根据 code 查询 profileId
|
||||
FindProfileId(code string) (string, error)
|
||||
// FindCode 根据 profileId 查询 code
|
||||
FindCode(profileId string) (string, error)
|
||||
// Upsert 保存或更新映射
|
||||
Upsert(profileId, code string) error
|
||||
// Delete 删除映射(实例删除时调用)
|
||||
Delete(profileId string) error
|
||||
// LoadAll 加载所有映射(启动时用),返回 profileId -> code 的 map
|
||||
LoadAll() (map[string]string, error)
|
||||
}
|
||||
|
||||
// SQLiteLaunchCodeDAO 基于 SQLite 的 LaunchCodeDAO 实现
|
||||
type SQLiteLaunchCodeDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteLaunchCodeDAO 创建 SQLiteLaunchCodeDAO
|
||||
func NewSQLiteLaunchCodeDAO(db *sql.DB) *SQLiteLaunchCodeDAO {
|
||||
return &SQLiteLaunchCodeDAO{db: db}
|
||||
}
|
||||
|
||||
// FindProfileId 根据 code 查询 profileId
|
||||
func (d *SQLiteLaunchCodeDAO) FindProfileId(code string) (string, error) {
|
||||
var profileId string
|
||||
err := d.db.QueryRow(
|
||||
`SELECT profile_id FROM launch_codes WHERE code = ?`, code,
|
||||
).Scan(&profileId)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("launch code not found: %s", code)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询 launch code 失败: %w", err)
|
||||
}
|
||||
return profileId, nil
|
||||
}
|
||||
|
||||
// FindCode 根据 profileId 查询 code
|
||||
func (d *SQLiteLaunchCodeDAO) FindCode(profileId string) (string, error) {
|
||||
var code string
|
||||
err := d.db.QueryRow(
|
||||
`SELECT code FROM launch_codes WHERE profile_id = ?`, profileId,
|
||||
).Scan(&code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询 profile code 失败: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// Upsert 保存或更新 profileId <-> code 映射
|
||||
func (d *SQLiteLaunchCodeDAO) Upsert(profileId, code string) error {
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
_, err := d.db.Exec(
|
||||
`INSERT INTO launch_codes (profile_id, code, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
code = excluded.code,
|
||||
updated_at = excluded.updated_at`,
|
||||
profileId, code, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存 launch code 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 profileId 对应的映射
|
||||
func (d *SQLiteLaunchCodeDAO) Delete(profileId string) error {
|
||||
_, err := d.db.Exec(
|
||||
`DELETE FROM launch_codes WHERE profile_id = ?`, profileId,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除 launch code 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll 加载所有映射,返回 profileId -> code 的 map
|
||||
func (d *SQLiteLaunchCodeDAO) LoadAll() (map[string]string, error) {
|
||||
rows, err := d.db.Query(`SELECT profile_id, code FROM launch_codes`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 launch codes 失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var profileId, code string
|
||||
if err := rows.Scan(&profileId, &code); err != nil {
|
||||
return nil, fmt.Errorf("读取 launch code 行失败: %w", err)
|
||||
}
|
||||
result[profileId] = code
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历 launch codes 失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemoryLaunchCodeDAO 基于内存的 LaunchCodeDAO 实现,仅用于测试
|
||||
type MemoryLaunchCodeDAO struct {
|
||||
mu sync.RWMutex
|
||||
profileToCode map[string]string
|
||||
codeToProfile map[string]string
|
||||
}
|
||||
|
||||
// NewMemoryLaunchCodeDAO 创建内存 DAO
|
||||
func NewMemoryLaunchCodeDAO() *MemoryLaunchCodeDAO {
|
||||
return &MemoryLaunchCodeDAO{
|
||||
profileToCode: make(map[string]string),
|
||||
codeToProfile: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) FindProfileId(code string) (string, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
profileId, ok := d.codeToProfile[code]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("launch code not found: %s", code)
|
||||
}
|
||||
return profileId, nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) FindCode(profileId string) (string, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
code, ok := d.profileToCode[profileId]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) Upsert(profileId, code string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
// 清理旧 code 的反向映射
|
||||
if oldCode, ok := d.profileToCode[profileId]; ok {
|
||||
delete(d.codeToProfile, oldCode)
|
||||
}
|
||||
d.profileToCode[profileId] = code
|
||||
d.codeToProfile[code] = profileId
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) Delete(profileId string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if code, ok := d.profileToCode[profileId]; ok {
|
||||
delete(d.codeToProfile, code)
|
||||
delete(d.profileToCode, profileId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) LoadAll() (map[string]string, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
result := make(map[string]string, len(d.profileToCode))
|
||||
for k, v := range d.profileToCode {
|
||||
result[k] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
)
|
||||
|
||||
// BrowserStarter 浏览器启动接口(由 App 层实现并注入)
|
||||
type BrowserStarter interface {
|
||||
StartInstance(profileId string) (*browser.Profile, error)
|
||||
}
|
||||
|
||||
// LaunchRequestParams 支持外部自动化透传的一次性启动参数
|
||||
type LaunchRequestParams struct {
|
||||
LaunchArgs []string `json:"launchArgs"`
|
||||
StartURLs []string `json:"startUrls"`
|
||||
SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"`
|
||||
}
|
||||
|
||||
// LaunchRequest POST /api/launch 的请求体
|
||||
type LaunchRequest struct {
|
||||
Code string `json:"code"`
|
||||
LaunchRequestParams
|
||||
}
|
||||
|
||||
// BrowserStarterWithParams 可选接口:支持带参数启动实例
|
||||
type BrowserStarterWithParams interface {
|
||||
StartInstanceWithParams(profileId string, params LaunchRequestParams) (*browser.Profile, error)
|
||||
}
|
||||
|
||||
// LaunchCallRecord 接口调用记录
|
||||
type LaunchCallRecord struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
ClientIP string `json:"clientIp"`
|
||||
Code string `json:"code"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Params LaunchRequestParams `json:"params"`
|
||||
OK bool `json:"ok"`
|
||||
Status int `json:"status"`
|
||||
Error string `json:"error"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
// LaunchServer 本地 HTTP 唤起服务
|
||||
type LaunchServer struct {
|
||||
service *LaunchCodeService
|
||||
starter BrowserStarter
|
||||
browserMgr *browser.Manager
|
||||
port int
|
||||
server *http.Server
|
||||
mu sync.Mutex
|
||||
logMu sync.Mutex
|
||||
callLogs []LaunchCallRecord
|
||||
}
|
||||
|
||||
// NewLaunchServer 创建 LaunchServer
|
||||
func NewLaunchServer(service *LaunchCodeService, starter BrowserStarter, mgr *browser.Manager, port int) *LaunchServer {
|
||||
return &LaunchServer{
|
||||
service: service,
|
||||
starter: starter,
|
||||
browserMgr: mgr,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 非阻塞启动 HTTP 服务。
|
||||
// 规则:
|
||||
// - port <= 0:自动分配随机可用端口
|
||||
// - port > 0:优先使用指定端口;若被占用则回退到随机可用端口
|
||||
func (s *LaunchServer) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", s.handleHealth)
|
||||
mux.HandleFunc("/api/launch", s.handleLaunchWithBody)
|
||||
mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs)
|
||||
mux.HandleFunc("/api/launch/", s.handleLaunch)
|
||||
|
||||
handler := s.localhostMiddleware(mux)
|
||||
|
||||
preferredPort := s.port
|
||||
ln, port, usedFallbackRandom, err := bindLaunchListener(preferredPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.port = port
|
||||
s.server = &http.Server{Handler: handler}
|
||||
s.mu.Unlock()
|
||||
|
||||
log := logger.New("LaunchServer")
|
||||
if preferredPort <= 0 {
|
||||
log.Info("LaunchServer 使用随机端口", logger.F("port", port))
|
||||
} else if usedFallbackRandom {
|
||||
log.Warn("LaunchServer 首选端口不可用,已切换随机端口",
|
||||
logger.F("preferred_port", preferredPort),
|
||||
logger.F("port", port),
|
||||
)
|
||||
}
|
||||
log.Info("LaunchServer 已启动", logger.F("port", port))
|
||||
|
||||
go func() {
|
||||
if serveErr := s.server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
|
||||
log.Error("LaunchServer 异常退出", logger.F("error", serveErr.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindLaunchListener(preferredPort int) (net.Listener, int, bool, error) {
|
||||
if preferredPort <= 0 {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, 0, false, fmt.Errorf("自动分配端口失败: %w", err)
|
||||
}
|
||||
port, err := listenerPort(ln)
|
||||
if err != nil {
|
||||
_ = ln.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return ln, port, false, nil
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(preferredPort))
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err == nil {
|
||||
return ln, preferredPort, false, nil
|
||||
}
|
||||
|
||||
fallbackLn, fallbackErr := net.Listen("tcp", "127.0.0.1:0")
|
||||
if fallbackErr != nil {
|
||||
return nil, 0, false, fmt.Errorf("端口 %d 不可用且自动分配失败: %w", preferredPort, err)
|
||||
}
|
||||
port, portErr := listenerPort(fallbackLn)
|
||||
if portErr != nil {
|
||||
_ = fallbackLn.Close()
|
||||
return nil, 0, false, portErr
|
||||
}
|
||||
return fallbackLn, port, true, nil
|
||||
}
|
||||
|
||||
func listenerPort(ln net.Listener) (int, error) {
|
||||
if ln == nil {
|
||||
return 0, fmt.Errorf("listener is nil")
|
||||
}
|
||||
if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok {
|
||||
return tcpAddr.Port, nil
|
||||
}
|
||||
|
||||
_, rawPort, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析监听地址失败: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(rawPort)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析端口失败: %w", err)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// Stop 优雅关闭(5 秒超时)
|
||||
func (s *LaunchServer) Stop() error {
|
||||
s.mu.Lock()
|
||||
srv := s.server
|
||||
s.mu.Unlock()
|
||||
|
||||
if srv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Port 返回实际绑定的端口
|
||||
func (s *LaunchServer) Port() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.port
|
||||
}
|
||||
|
||||
// localhostMiddleware 只允许 127.0.0.1 访问
|
||||
func (s *LaunchServer) localhostMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil || host != "127.0.0.1" {
|
||||
writeJSON(w, http.StatusForbidden, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "forbidden: only localhost is allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleHealth GET /api/health
|
||||
func (s *LaunchServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// handleLaunch GET /api/launch/{code}
|
||||
func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) {
|
||||
startAt := time.Now()
|
||||
clientIP := remoteIP(r.RemoteAddr)
|
||||
if r.Method != http.MethodGet {
|
||||
msg := "method not allowed"
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimPrefix(r.URL.Path, "/api/launch/")
|
||||
if strings.TrimSpace(code) == "" {
|
||||
msg := "launch code not found"
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
profile, status, errMsg := s.launchByCode(code, LaunchRequestParams{})
|
||||
if errMsg != "" {
|
||||
writeJSON(w, status, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": errMsg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, false, status, errMsg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"profileId": profile.ProfileId,
|
||||
"profileName": profile.ProfileName,
|
||||
"pid": profile.Pid,
|
||||
"debugPort": profile.DebugPort,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt)
|
||||
}
|
||||
|
||||
// handleLaunchWithBody POST /api/launch
|
||||
func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Request) {
|
||||
startAt := time.Now()
|
||||
clientIP := remoteIP(r.RemoteAddr)
|
||||
if r.Method != http.MethodPost {
|
||||
msg := "method not allowed"
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
var req LaunchRequest
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
msg := "invalid request body"
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Code) == "" {
|
||||
msg := "code is required"
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
req.LaunchArgs = normalizeStringSlice(req.LaunchArgs)
|
||||
req.StartURLs = normalizeStringSlice(req.StartURLs)
|
||||
profile, status, errMsg := s.launchByCode(req.Code, req.LaunchRequestParams)
|
||||
if errMsg != "" {
|
||||
writeJSON(w, status, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": errMsg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, false, status, errMsg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"profileId": profile.ProfileId,
|
||||
"profileName": profile.ProfileName,
|
||||
"pid": profile.Pid,
|
||||
"debugPort": profile.DebugPort,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt)
|
||||
}
|
||||
|
||||
// handleLaunchLogs GET /api/launch/logs?limit=50
|
||||
func (s *LaunchServer) handleLaunchLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > 200 {
|
||||
n = 200
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
items := s.listLaunchLogs(limit)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LaunchServer) launchByCode(code string, params LaunchRequestParams) (*browser.Profile, int, string) {
|
||||
profileId, err := s.service.Resolve(strings.TrimSpace(code))
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, "launch code not found"
|
||||
}
|
||||
|
||||
var profile *browser.Profile
|
||||
if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok {
|
||||
profile, err = starterWithParams.StartInstanceWithParams(profileId, params)
|
||||
} else {
|
||||
profile, err = s.starter.StartInstance(profileId)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
return profile, http.StatusOK, ""
|
||||
}
|
||||
|
||||
// writeJSON 写入 JSON 响应
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// NewTestHandler 返回不含 localhost 限制的 handler,仅供测试使用
|
||||
func NewTestHandler(s *LaunchServer) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", s.handleHealth)
|
||||
mux.HandleFunc("/api/launch", s.handleLaunchWithBody)
|
||||
mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs)
|
||||
mux.HandleFunc("/api/launch/", s.handleLaunch)
|
||||
return mux
|
||||
}
|
||||
|
||||
func normalizeStringSlice(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
v := strings.TrimSpace(item)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LaunchServer) appendLaunchLog(method, path, clientIP, code string, params LaunchRequestParams, ok bool, status int, errMsg, profileID, profileName string, startAt time.Time) {
|
||||
entry := LaunchCallRecord{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Method: method,
|
||||
Path: path,
|
||||
ClientIP: clientIP,
|
||||
Code: strings.TrimSpace(code),
|
||||
ProfileID: profileID,
|
||||
ProfileName: profileName,
|
||||
Params: params,
|
||||
OK: ok,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
DurationMs: time.Since(startAt).Milliseconds(),
|
||||
}
|
||||
|
||||
s.logMu.Lock()
|
||||
s.callLogs = append(s.callLogs, entry)
|
||||
if len(s.callLogs) > 500 {
|
||||
s.callLogs = append([]LaunchCallRecord(nil), s.callLogs[len(s.callLogs)-500:]...)
|
||||
}
|
||||
s.logMu.Unlock()
|
||||
|
||||
log := logger.New("LaunchServer")
|
||||
if ok {
|
||||
log.Info("Launch API 调用", logger.F("method", method), logger.F("path", path), logger.F("code", entry.Code), logger.F("profile_id", profileID), logger.F("status", status), logger.F("duration_ms", entry.DurationMs))
|
||||
return
|
||||
}
|
||||
log.Warn("Launch API 调用失败", logger.F("method", method), logger.F("path", path), logger.F("code", entry.Code), logger.F("status", status), logger.F("error", errMsg), logger.F("duration_ms", entry.DurationMs))
|
||||
}
|
||||
|
||||
func (s *LaunchServer) listLaunchLogs(limit int) []LaunchCallRecord {
|
||||
s.logMu.Lock()
|
||||
defer s.logMu.Unlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > len(s.callLogs) {
|
||||
limit = len(s.callLogs)
|
||||
}
|
||||
if limit == 0 {
|
||||
return []LaunchCallRecord{}
|
||||
}
|
||||
|
||||
out := make([]LaunchCallRecord, 0, limit)
|
||||
for i := len(s.callLogs) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
out = append(out, s.callLogs[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func remoteIP(remoteAddr string) string {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return remoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
func TestLaunchServerStartWithAutoPort(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
srv := launchcode.NewLaunchServer(svc, nil, nil, 0)
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("Start 失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
port := srv.Port()
|
||||
if port <= 0 {
|
||||
t.Fatalf("自动端口分配失败: got=%d", port)
|
||||
}
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
|
||||
if err != nil {
|
||||
t.Fatalf("健康检查请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("健康检查状态码错误: got=%d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchServerFallbackToRandomPortWhenPreferredIsBusy(t *testing.T) {
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("占用端口失败: %v", err)
|
||||
}
|
||||
defer occupied.Close()
|
||||
|
||||
busyPort := occupied.Addr().(*net.TCPAddr).Port
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
srv := launchcode.NewLaunchServer(svc, nil, nil, busyPort)
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("Start 失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
actualPort := srv.Port()
|
||||
if actualPort <= 0 {
|
||||
t.Fatalf("随机回退端口无效: got=%d", actualPort)
|
||||
}
|
||||
if actualPort == busyPort {
|
||||
t.Fatalf("期望回退到随机端口,但仍使用了被占用端口: %d", actualPort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const codeLen = 6
|
||||
const maxRetries = 10
|
||||
const customCodeMinLen = 4
|
||||
const customCodeMaxLen = 32
|
||||
|
||||
var customCodePattern = regexp.MustCompile(`^[A-Z0-9_-]+$`)
|
||||
|
||||
// LaunchCodeService 负责 Launch Code 的生成、缓存与管理
|
||||
type LaunchCodeService struct {
|
||||
dao LaunchCodeDAO
|
||||
codeToProfile map[string]string
|
||||
profileToCode map[string]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewLaunchCodeService 创建 LaunchCodeService
|
||||
func NewLaunchCodeService(dao LaunchCodeDAO) *LaunchCodeService {
|
||||
return &LaunchCodeService{
|
||||
dao: dao,
|
||||
codeToProfile: make(map[string]string),
|
||||
profileToCode: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCode 为 profile 生成并持久化 code(幂等:已有则直接返回)
|
||||
func (s *LaunchCodeService) EnsureCode(profileId string) (string, error) {
|
||||
s.mu.RLock()
|
||||
if code, ok := s.profileToCode[profileId]; ok {
|
||||
s.mu.RUnlock()
|
||||
return code, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
code, err := s.generateUniqueCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.dao.Upsert(profileId, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
s.mu.Unlock()
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// SetCode 为指定 profile 设置自定义 launch code。
|
||||
// code 会自动 trim 并转为大写;格式限制为 4-32 位,字符集 [A-Z0-9_-]。
|
||||
func (s *LaunchCodeService) SetCode(profileId, code string) (string, error) {
|
||||
code = normalizeCode(code)
|
||||
if err := validateCustomCode(code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if old, ok := s.profileToCode[profileId]; ok && old == code {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
if ownerProfile, exists := s.codeToProfile[code]; exists && ownerProfile != profileId {
|
||||
return "", fmt.Errorf("launch code already exists")
|
||||
}
|
||||
|
||||
if err := s.dao.Upsert(profileId, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if old, ok := s.profileToCode[profileId]; ok {
|
||||
delete(s.codeToProfile, old)
|
||||
}
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// RegenerateCode 重新生成 code(废弃旧 code)
|
||||
func (s *LaunchCodeService) RegenerateCode(profileId string) (string, error) {
|
||||
s.mu.Lock()
|
||||
if oldCode, ok := s.profileToCode[profileId]; ok {
|
||||
delete(s.codeToProfile, oldCode)
|
||||
delete(s.profileToCode, profileId)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
code, err := s.generateUniqueCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.dao.Upsert(profileId, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
s.mu.Unlock()
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// Resolve 根据 code 查找 profileId(仅查内存缓存)
|
||||
func (s *LaunchCodeService) Resolve(code string) (string, error) {
|
||||
code = normalizeCode(code)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
profileId, ok := s.codeToProfile[code]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("launch code not found: %s", code)
|
||||
}
|
||||
return profileId, nil
|
||||
}
|
||||
|
||||
// Remove 删除 profile 对应的 code(同时清理内存缓存和数据库)
|
||||
func (s *LaunchCodeService) Remove(profileId string) error {
|
||||
s.mu.Lock()
|
||||
if code, ok := s.profileToCode[profileId]; ok {
|
||||
delete(s.codeToProfile, code)
|
||||
delete(s.profileToCode, profileId)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return s.dao.Delete(profileId)
|
||||
}
|
||||
|
||||
// LoadAll 启动时从数据库加载所有映射到内存
|
||||
func (s *LaunchCodeService) LoadAll() error {
|
||||
profileToCode, err := s.dao.LoadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.profileToCode = make(map[string]string, len(profileToCode))
|
||||
s.codeToProfile = make(map[string]string, len(profileToCode))
|
||||
|
||||
for profileId, code := range profileToCode {
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateUniqueCode 生成一个在内存缓存中唯一的 code
|
||||
func (s *LaunchCodeService) generateUniqueCode() (string, error) {
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
code, err := randomCode()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 launch code 失败: %w", err)
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
_, exists := s.codeToProfile[code]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("无法在 %d 次重试内生成唯一 launch code", maxRetries)
|
||||
}
|
||||
|
||||
// randomCode 使用 crypto/rand 生成一个随机 6 位字符串
|
||||
func randomCode() (string, error) {
|
||||
buf := make([]byte, codeLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, codeLen)
|
||||
for i, b := range buf {
|
||||
result[i] = charset[int(b)%len(charset)]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func normalizeCode(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func validateCustomCode(code string) error {
|
||||
if len(code) < customCodeMinLen || len(code) > customCodeMaxLen {
|
||||
return fmt.Errorf("launch code must be %d-%d characters", customCodeMinLen, customCodeMaxLen)
|
||||
}
|
||||
if !customCodePattern.MatchString(code) {
|
||||
return fmt.Errorf("launch code format invalid: only A-Z, 0-9, _ and - are allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TextFormatter 文本格式化器
|
||||
// 将日志条目格式化为结构化文本格式
|
||||
type TextFormatter struct {
|
||||
// TimestampFormat 时间戳格式,默认为 "2006-01-02 15:04:05.000"
|
||||
TimestampFormat string
|
||||
}
|
||||
|
||||
// NewTextFormatter 创建新的文本格式化器
|
||||
func NewTextFormatter() *TextFormatter {
|
||||
return &TextFormatter{
|
||||
TimestampFormat: "2006-01-02 15:04:05.000",
|
||||
}
|
||||
}
|
||||
|
||||
// Format 格式化日志条目为文本格式
|
||||
// 输出格式: [timestamp] [level] [component] message | field1=value1 field2=value2
|
||||
func (f *TextFormatter) Format(entry *LogEntry) ([]byte, error) {
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("log entry is nil")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// 时间戳
|
||||
timestampFormat := f.TimestampFormat
|
||||
if timestampFormat == "" {
|
||||
timestampFormat = "2006-01-02 15:04:05.000"
|
||||
}
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(entry.Timestamp.Format(timestampFormat))
|
||||
sb.WriteString("] ")
|
||||
|
||||
// 级别
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(entry.Level.String())
|
||||
sb.WriteString("] ")
|
||||
|
||||
// 组件
|
||||
sb.WriteString("[")
|
||||
if entry.Component != "" {
|
||||
sb.WriteString(entry.Component)
|
||||
} else {
|
||||
sb.WriteString("-")
|
||||
}
|
||||
sb.WriteString("] ")
|
||||
|
||||
// 消息
|
||||
sb.WriteString(entry.Message)
|
||||
|
||||
// 收集所有额外字段
|
||||
extraFields := make([]string, 0)
|
||||
|
||||
// 请求ID
|
||||
if entry.RequestID != "" {
|
||||
extraFields = append(extraFields, fmt.Sprintf("request_id=%s", entry.RequestID))
|
||||
}
|
||||
|
||||
// 方法名
|
||||
if entry.Method != "" {
|
||||
extraFields = append(extraFields, fmt.Sprintf("method=%s", entry.Method))
|
||||
}
|
||||
|
||||
// 执行耗时
|
||||
if entry.Duration > 0 {
|
||||
extraFields = append(extraFields, fmt.Sprintf("duration_ms=%d", entry.Duration))
|
||||
}
|
||||
|
||||
// 调用位置
|
||||
if entry.CallerFile != "" {
|
||||
caller := entry.CallerFile
|
||||
if entry.CallerLine > 0 {
|
||||
caller = fmt.Sprintf("%s:%d", entry.CallerFile, entry.CallerLine)
|
||||
}
|
||||
extraFields = append(extraFields, fmt.Sprintf("caller=%s", caller))
|
||||
}
|
||||
|
||||
// 错误信息
|
||||
if entry.Error != "" {
|
||||
extraFields = append(extraFields, fmt.Sprintf("error=%s", entry.Error))
|
||||
}
|
||||
|
||||
// 扩展字段(按key排序以保证输出稳定)
|
||||
if len(entry.Fields) > 0 {
|
||||
keys := make([]string, 0, len(entry.Fields))
|
||||
for k := range entry.Fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
extraFields = append(extraFields, fmt.Sprintf("%s=%v", k, entry.Fields[k]))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有额外字段,添加分隔符和字段
|
||||
if len(extraFields) > 0 {
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(strings.Join(extraFields, " "))
|
||||
}
|
||||
|
||||
// 添加换行符
|
||||
sb.WriteString("\n")
|
||||
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
// JSONFormatter JSON格式化器
|
||||
// 将日志条目格式化为JSON格式
|
||||
type JSONFormatter struct {
|
||||
// PrettyPrint 是否美化输出(带缩进)
|
||||
PrettyPrint bool
|
||||
}
|
||||
|
||||
// NewJSONFormatter 创建新的JSON格式化器
|
||||
func NewJSONFormatter() *JSONFormatter {
|
||||
return &JSONFormatter{
|
||||
PrettyPrint: false,
|
||||
}
|
||||
}
|
||||
|
||||
// jsonLogEntry 用于JSON序列化的内部结构
|
||||
// 确保字段顺序和格式符合要求
|
||||
type jsonLogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Component string `json:"component"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// Format 格式化日志条目为JSON格式
|
||||
func (f *JSONFormatter) Format(entry *LogEntry) ([]byte, error) {
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("log entry is nil")
|
||||
}
|
||||
|
||||
// 构建调用位置字符串
|
||||
var caller string
|
||||
if entry.CallerFile != "" {
|
||||
if entry.CallerLine > 0 {
|
||||
caller = fmt.Sprintf("%s:%d", entry.CallerFile, entry.CallerLine)
|
||||
} else {
|
||||
caller = entry.CallerFile
|
||||
}
|
||||
}
|
||||
|
||||
// 创建JSON结构
|
||||
jsonEntry := jsonLogEntry{
|
||||
Timestamp: entry.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"),
|
||||
Level: entry.Level.String(),
|
||||
Component: entry.Component,
|
||||
Message: entry.Message,
|
||||
RequestID: entry.RequestID,
|
||||
Method: entry.Method,
|
||||
DurationMs: entry.Duration,
|
||||
Caller: caller,
|
||||
Error: entry.Error,
|
||||
Fields: entry.Fields,
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
if f.PrettyPrint {
|
||||
data, err = json.MarshalIndent(jsonEntry, "", " ")
|
||||
} else {
|
||||
data, err = json.Marshal(jsonEntry)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal log entry to JSON: %w", err)
|
||||
}
|
||||
|
||||
// 添加换行符
|
||||
data = append(data, '\n')
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// genLevel 生成随机日志级别
|
||||
func genLevel() gopter.Gen {
|
||||
return gen.IntRange(0, 3).Map(func(i int) Level {
|
||||
return Level(i)
|
||||
})
|
||||
}
|
||||
|
||||
// genLogEntry 生成随机 LogEntry
|
||||
func genLogEntry() gopter.Gen {
|
||||
return gopter.CombineGens(
|
||||
genLevel(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.Int64Range(0, 10000),
|
||||
).Map(func(values []interface{}) *LogEntry {
|
||||
level := values[0].(Level)
|
||||
component := values[1].(string)
|
||||
message := values[2].(string)
|
||||
requestID := values[3].(string)
|
||||
method := values[4].(string)
|
||||
duration := values[5].(int64)
|
||||
|
||||
entry := &LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Component: component,
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
Method: method,
|
||||
Duration: duration,
|
||||
}
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty13_JSONFormatValidity 属性测试:JSON 格式有效性
|
||||
// **Property 13: JSON Format Validity**
|
||||
// **Validates: Requirements 6.2**
|
||||
// *For any* log entry when JSON format is configured, the output SHALL be valid JSON
|
||||
// that can be parsed without error.
|
||||
func TestProperty13_JSONFormatValidity(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
formatter := NewJSONFormatter()
|
||||
|
||||
properties.Property("JSON output is always valid JSON", prop.ForAll(
|
||||
func(entry *LogEntry) bool {
|
||||
// Format the entry
|
||||
data, err := formatter.Format(entry)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's valid JSON by attempting to unmarshal
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
genLogEntry(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty12_StructuredLogFieldCompleteness 属性测试:结构化日志字段完整性
|
||||
// **Property 12: Structured Log Field Completeness**
|
||||
// **Validates: Requirements 6.1**
|
||||
// *For any* log entry, the output SHALL contain: timestamp (ISO 8601), level
|
||||
// (DEBUG/INFO/WARN/ERROR), component name, and message.
|
||||
func TestProperty12_StructuredLogFieldCompleteness(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
textFormatter := NewTextFormatter()
|
||||
jsonFormatter := NewJSONFormatter()
|
||||
|
||||
// Test TextFormatter field completeness
|
||||
properties.Property("TextFormatter output contains all required fields", prop.ForAll(
|
||||
func(entry *LogEntry) bool {
|
||||
data, err := textFormatter.Format(entry)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
output := string(data)
|
||||
|
||||
// Check timestamp format (YYYY-MM-DD HH:MM:SS.mmm)
|
||||
if !strings.Contains(output, "[") || !strings.Contains(output, "]") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check level is present (DEBUG/INFO/WARN/ERROR)
|
||||
levelStr := entry.Level.String()
|
||||
if !strings.Contains(output, "["+levelStr+"]") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check component is present (or "-" if empty)
|
||||
if entry.Component != "" {
|
||||
if !strings.Contains(output, "["+entry.Component+"]") {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if !strings.Contains(output, "[-]") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check message is present
|
||||
if !strings.Contains(output, entry.Message) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
genLogEntry(),
|
||||
))
|
||||
|
||||
// Test JSONFormatter field completeness
|
||||
properties.Property("JSONFormatter output contains all required fields", prop.ForAll(
|
||||
func(entry *LogEntry) bool {
|
||||
data, err := jsonFormatter.Format(entry)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check timestamp exists and is in ISO 8601 format
|
||||
timestamp, ok := result["timestamp"].(string)
|
||||
if !ok || timestamp == "" {
|
||||
return false
|
||||
}
|
||||
// Verify timestamp can be parsed as ISO 8601
|
||||
_, err = time.Parse("2006-01-02T15:04:05.000Z07:00", timestamp)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check level exists and is valid
|
||||
level, ok := result["level"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
validLevels := map[string]bool{"DEBUG": true, "INFO": true, "WARN": true, "ERROR": true}
|
||||
if !validLevels[level] {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check component exists
|
||||
if _, ok := result["component"]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check message exists
|
||||
if _, ok := result["message"]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
genLogEntry(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestTextFormatterBasic 基础单元测试:TextFormatter
|
||||
func TestTextFormatterBasic(t *testing.T) {
|
||||
formatter := NewTextFormatter()
|
||||
testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
entry := &LogEntry{
|
||||
Timestamp: testTime,
|
||||
Level: INFO,
|
||||
Component: "TestComponent",
|
||||
Message: "Test message",
|
||||
}
|
||||
|
||||
data, err := formatter.Format(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
output := string(data)
|
||||
|
||||
// Verify basic structure
|
||||
if !strings.Contains(output, "[2024-01-15 10:30:00.000]") {
|
||||
t.Errorf("Timestamp not found in output: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "[INFO]") {
|
||||
t.Errorf("Level not found in output: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "[TestComponent]") {
|
||||
t.Errorf("Component not found in output: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "Test message") {
|
||||
t.Errorf("Message not found in output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONFormatterBasic 基础单元测试:JSONFormatter
|
||||
func TestJSONFormatterBasic(t *testing.T) {
|
||||
formatter := NewJSONFormatter()
|
||||
testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
entry := &LogEntry{
|
||||
Timestamp: testTime,
|
||||
Level: INFO,
|
||||
Component: "TestComponent",
|
||||
Message: "Test message",
|
||||
RequestID: "req-123",
|
||||
Method: "TestMethod",
|
||||
Duration: 150,
|
||||
}
|
||||
|
||||
data, err := formatter.Format(entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if result["level"] != "INFO" {
|
||||
t.Errorf("Level should be 'INFO', got %v", result["level"])
|
||||
}
|
||||
if result["component"] != "TestComponent" {
|
||||
t.Errorf("Component should be 'TestComponent', got %v", result["component"])
|
||||
}
|
||||
if result["message"] != "Test message" {
|
||||
t.Errorf("Message should be 'Test message', got %v", result["message"])
|
||||
}
|
||||
if result["request_id"] != "req-123" {
|
||||
t.Errorf("RequestID should be 'req-123', got %v", result["request_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatterNilEntry 测试 nil entry 处理
|
||||
func TestFormatterNilEntry(t *testing.T) {
|
||||
textFormatter := NewTextFormatter()
|
||||
jsonFormatter := NewJSONFormatter()
|
||||
|
||||
_, err := textFormatter.Format(nil)
|
||||
if err == nil {
|
||||
t.Error("TextFormatter should return error for nil entry")
|
||||
}
|
||||
|
||||
_, err = jsonFormatter.Format(nil)
|
||||
if err == nil {
|
||||
t.Error("JSONFormatter should return error for nil entry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// InterceptorConfig 拦截器配置
|
||||
type InterceptorConfig struct {
|
||||
Enabled bool
|
||||
LogParameters bool
|
||||
LogResults bool
|
||||
SensitiveFields []string
|
||||
}
|
||||
|
||||
// MethodInterceptor 方法拦截器
|
||||
// 用于自动记录方法调用的 AOP 组件
|
||||
type MethodInterceptor struct {
|
||||
logger *Logger
|
||||
config InterceptorConfig
|
||||
sensitiveFields map[string]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// CallContext 调用上下文
|
||||
type CallContext struct {
|
||||
RequestID string
|
||||
MethodName string
|
||||
StartTime time.Time
|
||||
Parameters []interface{}
|
||||
}
|
||||
|
||||
// NewMethodInterceptor 创建新的方法拦截器
|
||||
func NewMethodInterceptor(logger *Logger, config InterceptorConfig) *MethodInterceptor {
|
||||
sensitiveFields := make(map[string]bool)
|
||||
for _, field := range config.SensitiveFields {
|
||||
sensitiveFields[strings.ToLower(field)] = true
|
||||
}
|
||||
|
||||
return &MethodInterceptor{
|
||||
logger: logger,
|
||||
config: config,
|
||||
sensitiveFields: sensitiveFields,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateRequestID 生成唯一的请求 ID
|
||||
func GenerateRequestID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// WrapFunc 包装无参数无返回值的函数
|
||||
func (m *MethodInterceptor) WrapFunc(name string, fn func()) func() {
|
||||
if !m.config.Enabled {
|
||||
return fn
|
||||
}
|
||||
|
||||
return func() {
|
||||
ctx := m.beforeCall(name, nil)
|
||||
defer m.afterCallRecover(ctx, nil, nil)
|
||||
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
// WrapFuncWithError 包装返回 error 的函数
|
||||
func (m *MethodInterceptor) WrapFuncWithError(name string, fn func() error) func() error {
|
||||
if !m.config.Enabled {
|
||||
return fn
|
||||
}
|
||||
|
||||
return func() error {
|
||||
ctx := m.beforeCall(name, nil)
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
m.afterCallRecover(ctx, nil, err)
|
||||
}()
|
||||
|
||||
err = fn()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// WrapFuncResult 包装有返回值的函数(使用 interface{})
|
||||
func (m *MethodInterceptor) WrapFuncResult(name string, fn func() interface{}) func() interface{} {
|
||||
if !m.config.Enabled {
|
||||
return fn
|
||||
}
|
||||
|
||||
return func() interface{} {
|
||||
ctx := m.beforeCall(name, nil)
|
||||
var result interface{}
|
||||
|
||||
defer func() {
|
||||
m.afterCallRecover(ctx, result, nil)
|
||||
}()
|
||||
|
||||
result = fn()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// WrapFuncResultError 包装有返回值和 error 的函数
|
||||
func (m *MethodInterceptor) WrapFuncResultError(name string, fn func() (interface{}, error)) func() (interface{}, error) {
|
||||
if !m.config.Enabled {
|
||||
return fn
|
||||
}
|
||||
|
||||
return func() (interface{}, error) {
|
||||
ctx := m.beforeCall(name, nil)
|
||||
var result interface{}
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
m.afterCallRecover(ctx, result, err)
|
||||
}()
|
||||
|
||||
result, err = fn()
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
// WrapMethod1Arg 包装单参数方法
|
||||
func (m *MethodInterceptor) WrapMethod1Arg(name string, fn func(interface{}) interface{}) func(interface{}) interface{} {
|
||||
if !m.config.Enabled {
|
||||
return fn
|
||||
}
|
||||
|
||||
return func(p interface{}) interface{} {
|
||||
ctx := m.beforeCall(name, []interface{}{p})
|
||||
var result interface{}
|
||||
|
||||
defer func() {
|
||||
m.afterCallRecover(ctx, result, nil)
|
||||
}()
|
||||
|
||||
result = fn(p)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// WrapMethod1ArgError 包装单参数返回 error 的方法
|
||||
func (m *MethodInterceptor) WrapMethod1ArgError(name string, fn func(interface{}) (interface{}, error)) func(interface{}) (interface{}, error) {
|
||||
if !m.config.Enabled {
|
||||
return fn
|
||||
}
|
||||
|
||||
return func(p interface{}) (interface{}, error) {
|
||||
ctx := m.beforeCall(name, []interface{}{p})
|
||||
var result interface{}
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
m.afterCallRecover(ctx, result, err)
|
||||
}()
|
||||
|
||||
result, err = fn(p)
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
// beforeCall 方法调用前的处理
|
||||
func (m *MethodInterceptor) beforeCall(methodName string, params []interface{}) *CallContext {
|
||||
ctx := &CallContext{
|
||||
RequestID: GenerateRequestID(),
|
||||
MethodName: methodName,
|
||||
StartTime: time.Now(),
|
||||
Parameters: params,
|
||||
}
|
||||
|
||||
// 记录方法入口日志
|
||||
entry := NewLogEntry(INFO, "interceptor", fmt.Sprintf("Method call started: %s", methodName))
|
||||
entry.WithRequestID(ctx.RequestID)
|
||||
entry.WithMethod(methodName)
|
||||
|
||||
// 添加参数信息
|
||||
if m.config.LogParameters && len(params) > 0 {
|
||||
maskedParams := m.maskSensitiveParams(params)
|
||||
entry.WithFields(map[string]interface{}{
|
||||
"parameters": maskedParams,
|
||||
})
|
||||
}
|
||||
|
||||
// 添加调用位置
|
||||
if file, line := m.getCaller(); file != "" {
|
||||
entry.WithCaller(file, line)
|
||||
}
|
||||
|
||||
m.safeLog(entry)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// afterCallRecover 方法调用后的处理(带 panic 恢复)
|
||||
func (m *MethodInterceptor) afterCallRecover(ctx *CallContext, result interface{}, err error) {
|
||||
// 捕获 panic,确保日志错误不影响业务
|
||||
if r := recover(); r != nil {
|
||||
m.handlePanic(ctx, r)
|
||||
// 重新抛出 panic,让业务代码处理
|
||||
panic(r)
|
||||
}
|
||||
|
||||
m.afterCall(ctx, result, err)
|
||||
}
|
||||
|
||||
// afterCall 方法调用后的处理
|
||||
func (m *MethodInterceptor) afterCall(ctx *CallContext, result interface{}, err error) {
|
||||
duration := time.Since(ctx.StartTime).Milliseconds()
|
||||
|
||||
var entry *LogEntry
|
||||
if err != nil {
|
||||
// 错误情况
|
||||
entry = NewLogEntry(ERROR, "interceptor", fmt.Sprintf("Method call failed: %s", ctx.MethodName))
|
||||
entry.WithError(err.Error())
|
||||
|
||||
// 获取堆栈信息
|
||||
stack := m.getStackTrace()
|
||||
if stack != "" {
|
||||
if entry.Fields == nil {
|
||||
entry.Fields = make(map[string]interface{})
|
||||
}
|
||||
entry.Fields["stack_trace"] = stack
|
||||
}
|
||||
} else {
|
||||
// 成功情况
|
||||
entry = NewLogEntry(INFO, "interceptor", fmt.Sprintf("Method call completed: %s", ctx.MethodName))
|
||||
|
||||
// 记录返回结果
|
||||
if m.config.LogResults && result != nil {
|
||||
maskedResult := m.maskSensitiveValue("result", result)
|
||||
if entry.Fields == nil {
|
||||
entry.Fields = make(map[string]interface{})
|
||||
}
|
||||
entry.Fields["result"] = maskedResult
|
||||
}
|
||||
}
|
||||
|
||||
entry.WithRequestID(ctx.RequestID)
|
||||
entry.WithMethod(ctx.MethodName)
|
||||
entry.WithDuration(duration)
|
||||
|
||||
m.safeLog(entry)
|
||||
}
|
||||
|
||||
// handlePanic 处理 panic
|
||||
func (m *MethodInterceptor) handlePanic(ctx *CallContext, panicValue interface{}) {
|
||||
duration := time.Since(ctx.StartTime).Milliseconds()
|
||||
|
||||
entry := NewLogEntry(ERROR, "interceptor", fmt.Sprintf("Method call panicked: %s", ctx.MethodName))
|
||||
entry.WithRequestID(ctx.RequestID)
|
||||
entry.WithMethod(ctx.MethodName)
|
||||
entry.WithDuration(duration)
|
||||
entry.WithError(fmt.Sprintf("panic: %v", panicValue))
|
||||
|
||||
// 获取堆栈信息
|
||||
stack := m.getStackTrace()
|
||||
if stack != "" {
|
||||
if entry.Fields == nil {
|
||||
entry.Fields = make(map[string]interface{})
|
||||
}
|
||||
entry.Fields["stack_trace"] = stack
|
||||
}
|
||||
|
||||
m.safeLog(entry)
|
||||
}
|
||||
|
||||
// safeLog 安全地记录日志(捕获所有错误)
|
||||
func (m *MethodInterceptor) safeLog(entry *LogEntry) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// 日志系统出错,静默处理,不影响业务
|
||||
fmt.Printf("[INTERCEPTOR ERROR] Failed to log: %v\n", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// maskSensitiveParams 对敏感参数进行脱敏
|
||||
func (m *MethodInterceptor) maskSensitiveParams(params []interface{}) []interface{} {
|
||||
if len(m.sensitiveFields) == 0 {
|
||||
return params
|
||||
}
|
||||
|
||||
masked := make([]interface{}, len(params))
|
||||
for i, param := range params {
|
||||
masked[i] = m.maskValue(param)
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// maskValue 对值进行脱敏处理
|
||||
func (m *MethodInterceptor) maskValue(value interface{}) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(value)
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
return m.maskMap(v)
|
||||
case reflect.Struct:
|
||||
return m.maskStruct(v)
|
||||
case reflect.Ptr:
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return m.maskValue(v.Elem().Interface())
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// maskMap 对 map 进行脱敏
|
||||
func (m *MethodInterceptor) maskMap(v reflect.Value) interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
key := fmt.Sprintf("%v", iter.Key().Interface())
|
||||
val := iter.Value().Interface()
|
||||
|
||||
if m.isSensitiveField(key) {
|
||||
result[key] = "***"
|
||||
} else {
|
||||
result[key] = m.maskValue(val)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// maskStruct 对结构体进行脱敏
|
||||
func (m *MethodInterceptor) maskStruct(v reflect.Value) interface{} {
|
||||
result := make(map[string]interface{})
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := field.Name
|
||||
fieldValue := v.Field(i).Interface()
|
||||
|
||||
if m.isSensitiveField(fieldName) {
|
||||
result[fieldName] = "***"
|
||||
} else {
|
||||
result[fieldName] = m.maskValue(fieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// maskSensitiveValue 对单个值进行脱敏(用于返回值)
|
||||
func (m *MethodInterceptor) maskSensitiveValue(fieldName string, value interface{}) interface{} {
|
||||
if m.isSensitiveField(fieldName) {
|
||||
return "***"
|
||||
}
|
||||
return m.maskValue(value)
|
||||
}
|
||||
|
||||
// isSensitiveField 检查字段是否为敏感字段
|
||||
func (m *MethodInterceptor) isSensitiveField(fieldName string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sensitiveFields[strings.ToLower(fieldName)]
|
||||
}
|
||||
|
||||
// AddSensitiveField 添加敏感字段
|
||||
func (m *MethodInterceptor) AddSensitiveField(fieldName string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sensitiveFields[strings.ToLower(fieldName)] = true
|
||||
}
|
||||
|
||||
// RemoveSensitiveField 移除敏感字段
|
||||
func (m *MethodInterceptor) RemoveSensitiveField(fieldName string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.sensitiveFields, strings.ToLower(fieldName))
|
||||
}
|
||||
|
||||
// getCaller 获取调用位置
|
||||
func (m *MethodInterceptor) getCaller() (string, int) {
|
||||
// 跳过拦截器内部的调用栈
|
||||
for i := 3; i < 10; i++ {
|
||||
_, file, line, ok := runtime.Caller(i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
// 跳过拦截器自身的文件
|
||||
if !strings.Contains(file, "interceptor.go") {
|
||||
// 只保留文件名
|
||||
parts := strings.Split(file, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1], line
|
||||
}
|
||||
return file, line
|
||||
}
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// getStackTrace 获取堆栈信息
|
||||
func (m *MethodInterceptor) getStackTrace() string {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
// SetEnabled 设置拦截器启用状态
|
||||
func (m *MethodInterceptor) SetEnabled(enabled bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.config.Enabled = enabled
|
||||
}
|
||||
|
||||
// IsEnabled 检查拦截器是否启用
|
||||
func (m *MethodInterceptor) IsEnabled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.config.Enabled
|
||||
}
|
||||
|
||||
// GetConfig 获取拦截器配置
|
||||
func (m *MethodInterceptor) GetConfig() InterceptorConfig {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.config
|
||||
}
|
||||
|
||||
// Intercept 通用拦截方法,用于手动记录方法调用
|
||||
// 返回 CallContext 用于后续调用 Complete 或 Fail
|
||||
func (m *MethodInterceptor) Intercept(methodName string, params ...interface{}) *CallContext {
|
||||
if !m.config.Enabled {
|
||||
return &CallContext{
|
||||
RequestID: GenerateRequestID(),
|
||||
MethodName: methodName,
|
||||
StartTime: time.Now(),
|
||||
Parameters: params,
|
||||
}
|
||||
}
|
||||
return m.beforeCall(methodName, params)
|
||||
}
|
||||
|
||||
// Complete 标记方法调用成功完成
|
||||
func (m *MethodInterceptor) Complete(ctx *CallContext, result interface{}) {
|
||||
if !m.config.Enabled {
|
||||
return
|
||||
}
|
||||
m.afterCall(ctx, result, nil)
|
||||
}
|
||||
|
||||
// Fail 标记方法调用失败
|
||||
func (m *MethodInterceptor) Fail(ctx *CallContext, err error) {
|
||||
if !m.config.Enabled {
|
||||
return
|
||||
}
|
||||
m.afterCall(ctx, nil, err)
|
||||
}
|
||||
|
||||
// GetRequestID 获取调用上下文的请求 ID
|
||||
func (ctx *CallContext) GetRequestID() string {
|
||||
return ctx.RequestID
|
||||
}
|
||||
|
||||
// GetMethodName 获取调用上下文的方法名
|
||||
func (ctx *CallContext) GetMethodName() string {
|
||||
return ctx.MethodName
|
||||
}
|
||||
|
||||
// GetDuration 获取调用耗时(毫秒)
|
||||
func (ctx *CallContext) GetDuration() int64 {
|
||||
return time.Since(ctx.StartTime).Milliseconds()
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// TestProperty3_RequestIDUniqueness 属性测试:请求 ID 唯一性
|
||||
// **Property 3: Request ID Uniqueness**
|
||||
// **Validates: Requirements 2.4**
|
||||
// *For any* sequence of N method calls through the interceptor, all N generated
|
||||
// request IDs SHALL be unique (no duplicates).
|
||||
func TestProperty3_RequestIDUniqueness(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("All generated request IDs are unique", prop.ForAll(
|
||||
func(n int) bool {
|
||||
if n <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < n; i++ {
|
||||
id := GenerateRequestID()
|
||||
if ids[id] {
|
||||
// Duplicate found
|
||||
return false
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
return true
|
||||
},
|
||||
gen.IntRange(1, 1000),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty3_RequestIDUniqueness_Concurrent 并发场景下的请求 ID 唯一性
|
||||
func TestProperty3_RequestIDUniqueness_Concurrent(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Concurrent request ID generation produces unique IDs", prop.ForAll(
|
||||
func(goroutines int, idsPerGoroutine int) bool {
|
||||
if goroutines <= 0 || idsPerGoroutine <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
ids := make(map[string]bool)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < idsPerGoroutine; i++ {
|
||||
id := GenerateRequestID()
|
||||
mu.Lock()
|
||||
if ids[id] {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
ids[id] = true
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify total count matches expected
|
||||
expectedCount := goroutines * idsPerGoroutine
|
||||
return len(ids) == expectedCount
|
||||
},
|
||||
gen.IntRange(1, 10),
|
||||
gen.IntRange(1, 100),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty4_SensitiveFieldMasking 属性测试:敏感字段脱敏
|
||||
// **Property 4: Sensitive Field Masking**
|
||||
// **Validates: Requirements 2.5**
|
||||
// *For any* log entry containing fields configured as sensitive, the logged value
|
||||
// SHALL be masked (e.g., "***") and SHALL NOT contain the original value.
|
||||
func TestProperty4_SensitiveFieldMasking(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
// Test map masking
|
||||
properties.Property("Sensitive fields in maps are masked", prop.ForAll(
|
||||
func(sensitiveField string, sensitiveValue string, normalField string, normalValue string) bool {
|
||||
// Skip empty field names
|
||||
if sensitiveField == "" || normalField == "" {
|
||||
return true
|
||||
}
|
||||
// Ensure fields are different
|
||||
if sensitiveField == normalField {
|
||||
return true
|
||||
}
|
||||
|
||||
interceptor := NewMethodInterceptor(nil, InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
SensitiveFields: []string{sensitiveField},
|
||||
})
|
||||
|
||||
input := map[string]interface{}{
|
||||
sensitiveField: sensitiveValue,
|
||||
normalField: normalValue,
|
||||
}
|
||||
|
||||
masked := interceptor.maskValue(input)
|
||||
maskedMap, ok := masked.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sensitive field should be masked
|
||||
if maskedMap[sensitiveField] != "***" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normal field should not be masked
|
||||
if maskedMap[normalField] != normalValue {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }),
|
||||
gen.AlphaString(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty4_SensitiveFieldMasking_CaseInsensitive 测试大小写不敏感
|
||||
func TestProperty4_SensitiveFieldMasking_CaseInsensitive(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Sensitive field matching is case-insensitive", prop.ForAll(
|
||||
func(fieldName string, value string) bool {
|
||||
if fieldName == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Configure with lowercase
|
||||
interceptor := NewMethodInterceptor(nil, InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
SensitiveFields: []string{fieldName},
|
||||
})
|
||||
|
||||
// Test with various case variations
|
||||
variations := []string{
|
||||
fieldName,
|
||||
toUpperCase(fieldName),
|
||||
toLowerCase(fieldName),
|
||||
mixedCase(fieldName),
|
||||
}
|
||||
|
||||
for _, variant := range variations {
|
||||
input := map[string]interface{}{
|
||||
variant: value,
|
||||
}
|
||||
|
||||
masked := interceptor.maskValue(input)
|
||||
maskedMap, ok := masked.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// All variations should be masked
|
||||
if maskedMap[variant] != "***" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 }),
|
||||
gen.AlphaString(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// Helper functions for case conversion
|
||||
func toUpperCase(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
result[i] = c - 32
|
||||
} else {
|
||||
result[i] = c
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func toLowerCase(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
result[i] = c + 32
|
||||
} else {
|
||||
result[i] = c
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func mixedCase(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if i%2 == 0 {
|
||||
if c >= 'a' && c <= 'z' {
|
||||
result[i] = c - 32
|
||||
} else {
|
||||
result[i] = c
|
||||
}
|
||||
} else {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
result[i] = c + 32
|
||||
} else {
|
||||
result[i] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// TestProperty4_SensitiveFieldMasking_NestedStructures 测试嵌套结构脱敏
|
||||
func TestProperty4_SensitiveFieldMasking_NestedStructures(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Sensitive fields in nested maps are masked", prop.ForAll(
|
||||
func(sensitiveValue string) bool {
|
||||
interceptor := NewMethodInterceptor(nil, InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
SensitiveFields: []string{"password", "token", "secret"},
|
||||
})
|
||||
|
||||
// Create nested structure
|
||||
input := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "testuser",
|
||||
"password": sensitiveValue,
|
||||
},
|
||||
"auth": map[string]interface{}{
|
||||
"token": sensitiveValue,
|
||||
},
|
||||
}
|
||||
|
||||
masked := interceptor.maskValue(input)
|
||||
maskedMap, ok := masked.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check nested password is masked
|
||||
userMap, ok := maskedMap["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if userMap["password"] != "***" {
|
||||
return false
|
||||
}
|
||||
if userMap["name"] != "testuser" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check nested token is masked
|
||||
authMap, ok := maskedMap["auth"].(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if authMap["token"] != "***" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
gen.AlphaString(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty11_LoggerFaultIsolation 属性测试:日志系统错误隔离
|
||||
// **Property 11: Logger Fault Isolation**
|
||||
// **Validates: Requirements 5.3**
|
||||
// *For any* error occurring in the logging system (file write failure, formatter
|
||||
// error, etc.), the wrapped business method SHALL still execute and return normally.
|
||||
func TestProperty11_LoggerFaultIsolation(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Business method executes normally even when logger is nil", prop.ForAll(
|
||||
func(input int) bool {
|
||||
// Create interceptor with nil logger (simulates logger failure)
|
||||
interceptor := NewMethodInterceptor(nil, InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
LogResults: true,
|
||||
})
|
||||
|
||||
// Wrap a simple function
|
||||
expectedResult := input * 2
|
||||
wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} {
|
||||
return input * 2
|
||||
})
|
||||
|
||||
// Execute wrapped function
|
||||
result := wrappedFn()
|
||||
|
||||
// Verify business logic executed correctly
|
||||
return result == expectedResult
|
||||
},
|
||||
gen.Int(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty11_LoggerFaultIsolation_WithError 测试返回错误的方法
|
||||
func TestProperty11_LoggerFaultIsolation_WithError(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Error-returning method works with nil logger", prop.ForAll(
|
||||
func(shouldError bool) bool {
|
||||
interceptor := NewMethodInterceptor(nil, InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
LogResults: true,
|
||||
})
|
||||
|
||||
var expectedErr error
|
||||
if shouldError {
|
||||
expectedErr = errors.New("test error")
|
||||
}
|
||||
|
||||
wrappedFn := interceptor.WrapFuncWithError("TestMethod", func() error {
|
||||
return expectedErr
|
||||
})
|
||||
|
||||
// Execute wrapped function
|
||||
resultErr := wrappedFn()
|
||||
|
||||
// Verify error is returned correctly
|
||||
if shouldError {
|
||||
return resultErr != nil && resultErr.Error() == "test error"
|
||||
}
|
||||
return resultErr == nil
|
||||
},
|
||||
gen.Bool(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// faultyWriter 模拟故障的写入器
|
||||
type faultyWriter struct {
|
||||
shouldPanic bool
|
||||
}
|
||||
|
||||
func (w *faultyWriter) Write(entry *LogEntry) error {
|
||||
if w.shouldPanic {
|
||||
panic("simulated writer panic")
|
||||
}
|
||||
return errors.New("simulated write error")
|
||||
}
|
||||
|
||||
func (w *faultyWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestProperty11_LoggerFaultIsolation_FaultyWriter 测试故障写入器
|
||||
func TestProperty11_LoggerFaultIsolation_FaultyWriter(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Business method executes normally with faulty writer", prop.ForAll(
|
||||
func(input string) bool {
|
||||
// Create a logger with faulty writer
|
||||
logger := &Logger{
|
||||
level: INFO,
|
||||
writers: []Writer{&faultyWriter{shouldPanic: false}},
|
||||
}
|
||||
|
||||
interceptor := NewMethodInterceptor(logger, InterceptorConfig{
|
||||
Enabled: true,
|
||||
LogParameters: true,
|
||||
LogResults: true,
|
||||
})
|
||||
|
||||
// Wrap a simple function
|
||||
expectedResult := "processed: " + input
|
||||
wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} {
|
||||
return "processed: " + input
|
||||
})
|
||||
|
||||
// Execute wrapped function
|
||||
result := wrappedFn()
|
||||
|
||||
// Verify business logic executed correctly
|
||||
return result == expectedResult
|
||||
},
|
||||
gen.AlphaString(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty11_LoggerFaultIsolation_DisabledInterceptor 测试禁用的拦截器
|
||||
func TestProperty11_LoggerFaultIsolation_DisabledInterceptor(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("Disabled interceptor passes through without modification", prop.ForAll(
|
||||
func(input int) bool {
|
||||
interceptor := NewMethodInterceptor(nil, InterceptorConfig{
|
||||
Enabled: false,
|
||||
})
|
||||
|
||||
expectedResult := input * 3
|
||||
wrappedFn := interceptor.WrapFuncResult("TestMethod", func() interface{} {
|
||||
return input * 3
|
||||
})
|
||||
|
||||
result := wrappedFn()
|
||||
return result == expectedResult
|
||||
},
|
||||
gen.Int(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Writer 日志写入器接口
|
||||
// 负责将日志写入不同目标(控制台、文件等)
|
||||
type Writer interface {
|
||||
// Write 写入日志条目
|
||||
Write(entry *LogEntry) error
|
||||
// Close 关闭写入器,释放资源
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Formatter 日志格式化器接口
|
||||
// 负责将日志条目格式化为字节数组
|
||||
type Formatter interface {
|
||||
// Format 格式化日志条目
|
||||
Format(entry *LogEntry) ([]byte, error)
|
||||
}
|
||||
|
||||
// RotationPolicy 日志分片策略接口
|
||||
// 定义何时触发日志文件分片
|
||||
type RotationPolicy interface {
|
||||
// ShouldRotate 判断是否应该触发分片
|
||||
ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool
|
||||
// GetRotatedFileName 获取分片后的文件名
|
||||
GetRotatedFileName(baseName string, timestamp time.Time) string
|
||||
}
|
||||
|
||||
// LogEntry 日志条目
|
||||
// 包含日志记录的所有必要信息
|
||||
type LogEntry struct {
|
||||
// Timestamp 日志时间戳
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Level 日志级别
|
||||
Level Level `json:"level"`
|
||||
// Component 组件名称
|
||||
Component string `json:"component"`
|
||||
// Message 日志消息
|
||||
Message string `json:"message"`
|
||||
// Fields 扩展字段
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
// RequestID 请求ID,用于链路追踪
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
// Method 方法名(方法调用日志)
|
||||
Method string `json:"method,omitempty"`
|
||||
// Duration 执行耗时(毫秒)
|
||||
Duration int64 `json:"duration_ms,omitempty"`
|
||||
// CallerFile 调用者文件
|
||||
CallerFile string `json:"caller_file,omitempty"`
|
||||
// CallerLine 调用者行号
|
||||
CallerLine int `json:"caller_line,omitempty"`
|
||||
// Error 错误信息
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Caller 返回格式化的调用位置字符串
|
||||
func (e *LogEntry) Caller() string {
|
||||
if e.CallerFile == "" {
|
||||
return ""
|
||||
}
|
||||
if e.CallerLine > 0 {
|
||||
return e.CallerFile + ":" + string(rune(e.CallerLine+'0'))
|
||||
}
|
||||
return e.CallerFile
|
||||
}
|
||||
|
||||
// ToJSON 将日志条目序列化为JSON字节数组
|
||||
func (e *LogEntry) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保Level以字符串形式输出
|
||||
func (e *LogEntry) MarshalJSON() ([]byte, error) {
|
||||
type Alias LogEntry
|
||||
return json.Marshal(&struct {
|
||||
Level string `json:"level"`
|
||||
*Alias
|
||||
}{
|
||||
Level: e.Level.String(),
|
||||
Alias: (*Alias)(e),
|
||||
})
|
||||
}
|
||||
|
||||
// NewLogEntry 创建新的日志条目
|
||||
func NewLogEntry(level Level, component, message string) *LogEntry {
|
||||
return &LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Component: component,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// WithFields 添加扩展字段
|
||||
func (e *LogEntry) WithFields(fields map[string]interface{}) *LogEntry {
|
||||
e.Fields = fields
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRequestID 添加请求ID
|
||||
func (e *LogEntry) WithRequestID(requestID string) *LogEntry {
|
||||
e.RequestID = requestID
|
||||
return e
|
||||
}
|
||||
|
||||
// WithMethod 添加方法名
|
||||
func (e *LogEntry) WithMethod(method string) *LogEntry {
|
||||
e.Method = method
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDuration 添加执行耗时
|
||||
func (e *LogEntry) WithDuration(duration int64) *LogEntry {
|
||||
e.Duration = duration
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCaller 添加调用位置
|
||||
func (e *LogEntry) WithCaller(file string, line int) *LogEntry {
|
||||
e.CallerFile = file
|
||||
e.CallerLine = line
|
||||
return e
|
||||
}
|
||||
|
||||
// WithError 添加错误信息
|
||||
func (e *LogEntry) WithError(err string) *LogEntry {
|
||||
e.Error = err
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLogEntryJSONSerialization 测试 LogEntry JSON 序列化
|
||||
func TestLogEntryJSONSerialization(t *testing.T) {
|
||||
// 创建测试时间
|
||||
testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
// 创建完整的 LogEntry
|
||||
entry := &LogEntry{
|
||||
Timestamp: testTime,
|
||||
Level: INFO,
|
||||
Component: "TestComponent",
|
||||
Message: "Test message",
|
||||
Fields: map[string]interface{}{"key1": "value1", "key2": 123},
|
||||
RequestID: "req-12345",
|
||||
Method: "TestMethod",
|
||||
Duration: 150,
|
||||
CallerFile: "test.go",
|
||||
CallerLine: 42,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
// 序列化
|
||||
data, err := entry.ToJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("ToJSON failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证是有效的 JSON
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证必需字段存在
|
||||
requiredFields := []string{"timestamp", "level", "component", "message"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := result[field]; !ok {
|
||||
t.Errorf("Required field %q missing from JSON output", field)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 Level 以字符串形式输出
|
||||
if level, ok := result["level"].(string); !ok || level != "INFO" {
|
||||
t.Errorf("Level should be string 'INFO', got %v", result["level"])
|
||||
}
|
||||
|
||||
// 验证 Component
|
||||
if component, ok := result["component"].(string); !ok || component != "TestComponent" {
|
||||
t.Errorf("Component should be 'TestComponent', got %v", result["component"])
|
||||
}
|
||||
|
||||
// 验证 Message
|
||||
if message, ok := result["message"].(string); !ok || message != "Test message" {
|
||||
t.Errorf("Message should be 'Test message', got %v", result["message"])
|
||||
}
|
||||
|
||||
// 验证 RequestID
|
||||
if requestID, ok := result["request_id"].(string); !ok || requestID != "req-12345" {
|
||||
t.Errorf("RequestID should be 'req-12345', got %v", result["request_id"])
|
||||
}
|
||||
|
||||
// 验证 Method
|
||||
if method, ok := result["method"].(string); !ok || method != "TestMethod" {
|
||||
t.Errorf("Method should be 'TestMethod', got %v", result["method"])
|
||||
}
|
||||
|
||||
// 验证 Duration
|
||||
if duration, ok := result["duration_ms"].(float64); !ok || duration != 150 {
|
||||
t.Errorf("Duration should be 150, got %v", result["duration_ms"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogEntryJSONSerializationAllLevels 测试所有日志级别的序列化
|
||||
func TestLogEntryJSONSerializationAllLevels(t *testing.T) {
|
||||
levels := []struct {
|
||||
level Level
|
||||
expected string
|
||||
}{
|
||||
{DEBUG, "DEBUG"},
|
||||
{INFO, "INFO"},
|
||||
{WARN, "WARN"},
|
||||
{ERROR, "ERROR"},
|
||||
}
|
||||
|
||||
for _, tc := range levels {
|
||||
t.Run(tc.expected, func(t *testing.T) {
|
||||
entry := NewLogEntry(tc.level, "TestComponent", "Test message")
|
||||
data, err := entry.ToJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("ToJSON failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if level, ok := result["level"].(string); !ok || level != tc.expected {
|
||||
t.Errorf("Level should be %q, got %v", tc.expected, result["level"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogEntryOmitEmptyFields 测试空字段不输出
|
||||
func TestLogEntryOmitEmptyFields(t *testing.T) {
|
||||
// 创建只有必需字段的 LogEntry
|
||||
entry := NewLogEntry(INFO, "TestComponent", "Test message")
|
||||
|
||||
data, err := entry.ToJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("ToJSON failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("JSON unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证可选字段不存在(omitempty)
|
||||
optionalFields := []string{"fields", "request_id", "method", "error"}
|
||||
for _, field := range optionalFields {
|
||||
if val, ok := result[field]; ok && val != "" {
|
||||
t.Errorf("Optional field %q should be omitted when empty, got %v", field, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogEntryWithMethods 测试链式方法
|
||||
func TestLogEntryWithMethods(t *testing.T) {
|
||||
entry := NewLogEntry(INFO, "TestComponent", "Test message").
|
||||
WithRequestID("req-123").
|
||||
WithMethod("TestMethod").
|
||||
WithDuration(100).
|
||||
WithCaller("test.go", 10).
|
||||
WithFields(map[string]interface{}{"key": "value"})
|
||||
|
||||
if entry.RequestID != "req-123" {
|
||||
t.Errorf("RequestID should be 'req-123', got %q", entry.RequestID)
|
||||
}
|
||||
if entry.Method != "TestMethod" {
|
||||
t.Errorf("Method should be 'TestMethod', got %q", entry.Method)
|
||||
}
|
||||
if entry.Duration != 100 {
|
||||
t.Errorf("Duration should be 100, got %d", entry.Duration)
|
||||
}
|
||||
if entry.CallerFile != "test.go" {
|
||||
t.Errorf("CallerFile should be 'test.go', got %q", entry.CallerFile)
|
||||
}
|
||||
if entry.CallerLine != 10 {
|
||||
t.Errorf("CallerLine should be 10, got %d", entry.CallerLine)
|
||||
}
|
||||
if entry.Fields["key"] != "value" {
|
||||
t.Errorf("Fields['key'] should be 'value', got %v", entry.Fields["key"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level 日志级别
|
||||
type Level int
|
||||
|
||||
const (
|
||||
DEBUG Level = iota
|
||||
INFO
|
||||
WARN
|
||||
ERROR
|
||||
)
|
||||
|
||||
// String 返回日志级别的字符串表示
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case DEBUG:
|
||||
return "DEBUG"
|
||||
case INFO:
|
||||
return "INFO"
|
||||
case WARN:
|
||||
return "WARN"
|
||||
case ERROR:
|
||||
return "ERROR"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel 解析日志级别字符串
|
||||
func ParseLevel(levelStr string) Level {
|
||||
switch strings.ToLower(levelStr) {
|
||||
case "debug":
|
||||
return DEBUG
|
||||
case "info":
|
||||
return INFO
|
||||
case "warn", "warning":
|
||||
return WARN
|
||||
case "error":
|
||||
return ERROR
|
||||
default:
|
||||
return INFO
|
||||
}
|
||||
}
|
||||
|
||||
// Field 结构化日志字段
|
||||
type Field struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// LoggerConfig 日志配置
|
||||
type LoggerConfig struct {
|
||||
Level string
|
||||
FileEnabled bool
|
||||
FilePath string
|
||||
Format string // "text" or "json"
|
||||
BufferSize int // 缓冲区大小(KB)
|
||||
AsyncQueueSize int // 异步队列大小
|
||||
FlushIntervalMs int // 刷新间隔(毫秒)
|
||||
|
||||
// 分片配置
|
||||
Rotation RotationConfig
|
||||
}
|
||||
|
||||
// RotationConfig 日志分片配置
|
||||
type RotationConfig struct {
|
||||
Enabled bool
|
||||
MaxSizeMB int // 单文件最大大小(MB)
|
||||
MaxAge int // 保留天数
|
||||
MaxBackups int // 保留文件数
|
||||
TimeInterval string // 时间间隔: "daily", "hourly"
|
||||
}
|
||||
|
||||
// Logger 日志记录器
|
||||
type Logger struct {
|
||||
level Level
|
||||
component string
|
||||
ctx context.Context
|
||||
|
||||
// 写入器
|
||||
writers []Writer
|
||||
consoleWriter Writer
|
||||
fileWriter *FileWriter
|
||||
|
||||
// 分片管理器
|
||||
rotationManager *RotationManager
|
||||
|
||||
// 并发安全
|
||||
mu sync.RWMutex
|
||||
|
||||
// 文件写入失败标志
|
||||
fileWriteFailed bool
|
||||
}
|
||||
|
||||
// 全局日志实例
|
||||
var (
|
||||
globalLogger *Logger
|
||||
globalMu sync.RWMutex
|
||||
)
|
||||
|
||||
// DefaultLoggerConfig 返回默认日志配置
|
||||
func DefaultLoggerConfig() LoggerConfig {
|
||||
return LoggerConfig{
|
||||
Level: "info",
|
||||
FileEnabled: false,
|
||||
FilePath: "data/logs/app.log",
|
||||
Format: "text",
|
||||
BufferSize: 4, // 4KB
|
||||
AsyncQueueSize: 1000,
|
||||
FlushIntervalMs: 1000, // 1秒
|
||||
Rotation: RotationConfig{
|
||||
Enabled: false,
|
||||
MaxSizeMB: 100,
|
||||
MaxAge: 7,
|
||||
MaxBackups: 5,
|
||||
TimeInterval: "daily",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Init 初始化全局日志(简单版本,仅控制台输出)
|
||||
func Init(ctx context.Context, levelStr string) {
|
||||
InitWithConfig(ctx, LoggerConfig{
|
||||
Level: levelStr,
|
||||
FileEnabled: false,
|
||||
Format: "text",
|
||||
})
|
||||
}
|
||||
|
||||
// InitWithConfig 使用配置初始化全局日志
|
||||
func InitWithConfig(ctx context.Context, config LoggerConfig) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
// 解析日志级别,无效级别使用默认 INFO
|
||||
level := ParseLevel(config.Level)
|
||||
if config.Level != "" && level == INFO && strings.ToLower(config.Level) != "info" {
|
||||
// 无效级别,记录警告(使用 fmt 因为 logger 还未初始化)
|
||||
fmt.Printf("[WARN] Invalid log level '%s', using default 'INFO'\n", config.Level)
|
||||
}
|
||||
|
||||
// 创建格式化器
|
||||
var formatter Formatter
|
||||
switch strings.ToLower(config.Format) {
|
||||
case "json":
|
||||
formatter = NewJSONFormatter()
|
||||
default:
|
||||
formatter = NewTextFormatter()
|
||||
}
|
||||
|
||||
// 创建控制台写入器
|
||||
consoleWriter := NewConsoleWriter(formatter)
|
||||
|
||||
logger := &Logger{
|
||||
level: level,
|
||||
ctx: ctx,
|
||||
writers: []Writer{consoleWriter, globalMemoryWriter},
|
||||
consoleWriter: consoleWriter,
|
||||
}
|
||||
|
||||
// 如果启用文件日志,创建文件写入器
|
||||
if config.FileEnabled && config.FilePath != "" {
|
||||
fileWriter, rotationManager, err := createFileWriterWithRotation(config, formatter)
|
||||
if err != nil {
|
||||
// 文件写入器创建失败,回退到仅控制台输出
|
||||
fmt.Printf("[WARN] Failed to create file writer: %v, falling back to console only\n", err)
|
||||
logger.fileWriteFailed = true
|
||||
} else {
|
||||
logger.fileWriter = fileWriter
|
||||
logger.rotationManager = rotationManager
|
||||
logger.writers = append(logger.writers, fileWriter)
|
||||
}
|
||||
}
|
||||
|
||||
globalLogger = logger
|
||||
}
|
||||
|
||||
// createFileWriterWithRotation 创建带分片功能的文件写入器
|
||||
func createFileWriterWithRotation(config LoggerConfig, formatter Formatter) (*FileWriter, *RotationManager, error) {
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(config.FilePath)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算缓冲区大小(KB -> 字节)
|
||||
bufferSize := config.BufferSize * 1024
|
||||
if bufferSize <= 0 {
|
||||
bufferSize = 4 * 1024 // 默认 4KB
|
||||
}
|
||||
|
||||
// 计算刷新间隔
|
||||
flushInterval := time.Duration(config.FlushIntervalMs) * time.Millisecond
|
||||
if flushInterval <= 0 {
|
||||
flushInterval = time.Second
|
||||
}
|
||||
|
||||
// 异步队列大小
|
||||
asyncQueueSize := config.AsyncQueueSize
|
||||
if asyncQueueSize <= 0 {
|
||||
asyncQueueSize = 1000
|
||||
}
|
||||
|
||||
fileConfig := FileWriterConfig{
|
||||
FilePath: config.FilePath,
|
||||
BufferSize: bufferSize,
|
||||
FlushInterval: flushInterval,
|
||||
AsyncQueueSize: asyncQueueSize,
|
||||
}
|
||||
|
||||
// 使用异步文件写入器
|
||||
fileWriter, err := NewAsyncFileWriter(fileConfig, formatter)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 创建分片管理器(如果启用)
|
||||
var rotationManager *RotationManager
|
||||
if config.Rotation.Enabled {
|
||||
rotationPolicy := createRotationPolicy(config.Rotation)
|
||||
rotationManager = NewRotationManager(RotationManagerConfig{
|
||||
BasePath: config.FilePath,
|
||||
MaxBackups: config.Rotation.MaxBackups,
|
||||
MaxAge: config.Rotation.MaxAge,
|
||||
Policy: rotationPolicy,
|
||||
})
|
||||
}
|
||||
|
||||
return fileWriter, rotationManager, nil
|
||||
}
|
||||
|
||||
// createRotationPolicy 根据配置创建分片策略
|
||||
func createRotationPolicy(config RotationConfig) RotationPolicy {
|
||||
var policies []RotationPolicy
|
||||
|
||||
// 时间分片策略
|
||||
if config.TimeInterval != "" {
|
||||
var interval TimeInterval
|
||||
switch strings.ToLower(config.TimeInterval) {
|
||||
case "hourly":
|
||||
interval = Hourly
|
||||
default:
|
||||
interval = Daily
|
||||
}
|
||||
policies = append(policies, NewTimeRotationPolicy(interval))
|
||||
}
|
||||
|
||||
// 大小分片策略
|
||||
if config.MaxSizeMB > 0 {
|
||||
policies = append(policies, NewSizeRotationPolicyMB(config.MaxSizeMB))
|
||||
}
|
||||
|
||||
// 如果有多个策略,使用组合策略
|
||||
if len(policies) > 1 {
|
||||
return NewCompositeRotationPolicy(policies...)
|
||||
} else if len(policies) == 1 {
|
||||
return policies[0]
|
||||
}
|
||||
|
||||
// 默认按天分片
|
||||
return NewTimeRotationPolicy(Daily)
|
||||
}
|
||||
|
||||
// Close 关闭全局日志
|
||||
func Close() error {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalLogger == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, writer := range globalLogger.writers {
|
||||
if err := writer.Close(); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
globalLogger = nil
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// New 创建新的日志记录器
|
||||
func New(component string) *Logger {
|
||||
globalMu.RLock()
|
||||
defer globalMu.RUnlock()
|
||||
|
||||
if globalLogger == nil {
|
||||
// 如果全局日志未初始化,创建一个默认的
|
||||
consoleWriter := NewConsoleWriter(NewTextFormatter())
|
||||
return &Logger{
|
||||
level: INFO,
|
||||
component: component,
|
||||
writers: []Writer{consoleWriter},
|
||||
consoleWriter: consoleWriter,
|
||||
}
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
level: globalLogger.level,
|
||||
component: component,
|
||||
ctx: globalLogger.ctx,
|
||||
writers: globalLogger.writers,
|
||||
consoleWriter: globalLogger.consoleWriter,
|
||||
fileWriter: globalLogger.fileWriter,
|
||||
rotationManager: globalLogger.rotationManager,
|
||||
fileWriteFailed: globalLogger.fileWriteFailed,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevel 动态设置日志级别(并发安全)
|
||||
func (l *Logger) SetLevel(level Level) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.level = level
|
||||
}
|
||||
|
||||
// SetLevelString 通过字符串动态设置日志级别
|
||||
func (l *Logger) SetLevelString(levelStr string) {
|
||||
l.SetLevel(ParseLevel(levelStr))
|
||||
}
|
||||
|
||||
// GetLevel 获取当前日志级别
|
||||
func (l *Logger) GetLevel() Level {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.level
|
||||
}
|
||||
|
||||
// SetGlobalLevel 设置全局日志级别
|
||||
func SetGlobalLevel(level Level) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalLogger != nil {
|
||||
globalLogger.mu.Lock()
|
||||
globalLogger.level = level
|
||||
globalLogger.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// SetGlobalLevelString 通过字符串设置全局日志级别
|
||||
func SetGlobalLevelString(levelStr string) {
|
||||
SetGlobalLevel(ParseLevel(levelStr))
|
||||
}
|
||||
|
||||
// Debug 记录调试日志
|
||||
func (l *Logger) Debug(msg string, fields ...Field) {
|
||||
l.mu.RLock()
|
||||
level := l.level
|
||||
l.mu.RUnlock()
|
||||
|
||||
if level <= DEBUG {
|
||||
l.log(DEBUG, msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
// Info 记录信息日志
|
||||
func (l *Logger) Info(msg string, fields ...Field) {
|
||||
l.mu.RLock()
|
||||
level := l.level
|
||||
l.mu.RUnlock()
|
||||
|
||||
if level <= INFO {
|
||||
l.log(INFO, msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
// Warn 记录警告日志
|
||||
func (l *Logger) Warn(msg string, fields ...Field) {
|
||||
l.mu.RLock()
|
||||
level := l.level
|
||||
l.mu.RUnlock()
|
||||
|
||||
if level <= WARN {
|
||||
l.log(WARN, msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
// Error 记录错误日志
|
||||
func (l *Logger) Error(msg string, fields ...Field) {
|
||||
l.mu.RLock()
|
||||
level := l.level
|
||||
l.mu.RUnlock()
|
||||
|
||||
if level <= ERROR {
|
||||
l.log(ERROR, msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
// log 内部日志记录方法
|
||||
func (l *Logger) log(level Level, msg string, fields ...Field) {
|
||||
// 创建日志条目
|
||||
entry := NewLogEntry(level, l.component, msg)
|
||||
|
||||
// 添加字段
|
||||
if len(fields) > 0 {
|
||||
fieldMap := make(map[string]interface{}, len(fields))
|
||||
for _, field := range fields {
|
||||
fieldMap[field.Key] = field.Value
|
||||
}
|
||||
entry.WithFields(fieldMap)
|
||||
}
|
||||
|
||||
// 写入所有写入器
|
||||
l.writeEntry(entry)
|
||||
}
|
||||
|
||||
// writeEntry 写入日志条目到所有写入器
|
||||
func (l *Logger) writeEntry(entry *LogEntry) {
|
||||
l.mu.RLock()
|
||||
writers := l.writers
|
||||
fileWriter := l.fileWriter
|
||||
consoleWriter := l.consoleWriter
|
||||
fileWriteFailed := l.fileWriteFailed
|
||||
l.mu.RUnlock()
|
||||
|
||||
// 如果文件写入已失败,只写入控制台
|
||||
if fileWriteFailed {
|
||||
if consoleWriter != nil {
|
||||
_ = consoleWriter.Write(entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 写入所有写入器
|
||||
for _, writer := range writers {
|
||||
if err := writer.Write(entry); err != nil {
|
||||
// 如果是文件写入器失败,标记并回退到控制台
|
||||
if writer == fileWriter {
|
||||
l.handleFileWriteError(entry, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleFileWriteError 处理文件写入错误
|
||||
func (l *Logger) handleFileWriteError(entry *LogEntry, err error) {
|
||||
l.mu.Lock()
|
||||
if !l.fileWriteFailed {
|
||||
l.fileWriteFailed = true
|
||||
// 记录错误到控制台
|
||||
fmt.Printf("[ERROR] File write failed: %v, falling back to console only\n", err)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// LogEntry 直接写入日志条目(用于拦截器等高级用法)
|
||||
func (l *Logger) LogEntry(entry *LogEntry) {
|
||||
l.mu.RLock()
|
||||
level := l.level
|
||||
l.mu.RUnlock()
|
||||
|
||||
// 检查日志级别
|
||||
if entry.Level < level {
|
||||
return
|
||||
}
|
||||
|
||||
l.writeEntry(entry)
|
||||
}
|
||||
|
||||
// WithComponent 创建带有组件名的新日志记录器
|
||||
func (l *Logger) WithComponent(component string) *Logger {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
return &Logger{
|
||||
level: l.level,
|
||||
component: component,
|
||||
ctx: l.ctx,
|
||||
writers: l.writers,
|
||||
consoleWriter: l.consoleWriter,
|
||||
fileWriter: l.fileWriter,
|
||||
rotationManager: l.rotationManager,
|
||||
fileWriteFailed: l.fileWriteFailed,
|
||||
}
|
||||
}
|
||||
|
||||
// Flush 刷新所有写入器的缓冲区
|
||||
func (l *Logger) Flush() error {
|
||||
l.mu.RLock()
|
||||
fileWriter := l.fileWriter
|
||||
l.mu.RUnlock()
|
||||
|
||||
if fileWriter != nil {
|
||||
return fileWriter.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRotationManager 获取分片管理器
|
||||
func (l *Logger) GetRotationManager() *RotationManager {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.rotationManager
|
||||
}
|
||||
|
||||
// F 创建字段的便捷函数
|
||||
func F(key string, value interface{}) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
// Fs 创建多个字段的便捷函数
|
||||
func Fs(keyValues ...interface{}) []Field {
|
||||
fields := make([]Field, 0, len(keyValues)/2)
|
||||
for i := 0; i < len(keyValues)-1; i += 2 {
|
||||
if key, ok := keyValues[i].(string); ok {
|
||||
fields = append(fields, Field{Key: key, Value: keyValues[i+1]})
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// IsFileEnabled 检查文件日志是否启用
|
||||
func (l *Logger) IsFileEnabled() bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.fileWriter != nil && !l.fileWriteFailed
|
||||
}
|
||||
|
||||
// GetWriters 获取所有写入器(用于测试)
|
||||
func (l *Logger) GetWriters() []Writer {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.writers
|
||||
}
|
||||
|
||||
// ShouldLog 检查指定级别是否应该被记录
|
||||
func (l *Logger) ShouldLog(level Level) bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return level >= l.level
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// MockWriter 用于测试的模拟写入器
|
||||
type MockWriter struct {
|
||||
entries []*LogEntry
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewMockWriter() *MockWriter {
|
||||
return &MockWriter{
|
||||
entries: make([]*LogEntry, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *MockWriter) Write(entry *LogEntry) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.entries = append(w.entries, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *MockWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *MockWriter) GetEntries() []*LogEntry {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
result := make([]*LogEntry, len(w.entries))
|
||||
copy(result, w.entries)
|
||||
return result
|
||||
}
|
||||
|
||||
func (w *MockWriter) Clear() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.entries = make([]*LogEntry, 0)
|
||||
}
|
||||
|
||||
// createTestLogger 创建用于测试的 Logger
|
||||
func createTestLogger(level Level, writer Writer) *Logger {
|
||||
return &Logger{
|
||||
level: level,
|
||||
component: "test",
|
||||
writers: []Writer{writer},
|
||||
consoleWriter: writer,
|
||||
}
|
||||
}
|
||||
|
||||
// TestProperty5_LogLevelFiltering 属性测试:日志级别过滤
|
||||
// Property 5: Log Level Filtering
|
||||
// *For any* configured log level L, all log entries with level below L SHALL NOT be written
|
||||
// to any output, and all entries with level >= L SHALL be written.
|
||||
// **Validates: Requirements 3.2**
|
||||
func TestProperty5_LogLevelFiltering(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
// 生成日志级别 (0-3: DEBUG, INFO, WARN, ERROR)
|
||||
levelGen := gen.IntRange(0, 3).Map(func(i int) Level {
|
||||
return Level(i)
|
||||
})
|
||||
|
||||
// Property: 低于配置级别的日志不应被写入
|
||||
properties.Property("logs below configured level are not written", prop.ForAll(
|
||||
func(configuredLevel Level, entryLevel Level) bool {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(configuredLevel, mockWriter)
|
||||
|
||||
// 根据 entryLevel 调用相应的日志方法
|
||||
switch entryLevel {
|
||||
case DEBUG:
|
||||
logger.Debug("test message")
|
||||
case INFO:
|
||||
logger.Info("test message")
|
||||
case WARN:
|
||||
logger.Warn("test message")
|
||||
case ERROR:
|
||||
logger.Error("test message")
|
||||
}
|
||||
|
||||
entries := mockWriter.GetEntries()
|
||||
|
||||
// 如果 entryLevel < configuredLevel,不应该有日志写入
|
||||
if entryLevel < configuredLevel {
|
||||
return len(entries) == 0
|
||||
}
|
||||
// 如果 entryLevel >= configuredLevel,应该有日志写入
|
||||
return len(entries) == 1 && entries[0].Level == entryLevel
|
||||
},
|
||||
levelGen,
|
||||
levelGen,
|
||||
))
|
||||
|
||||
// Property: 等于或高于配置级别的日志应被写入
|
||||
properties.Property("logs at or above configured level are written", prop.ForAll(
|
||||
func(configuredLevel Level) bool {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(configuredLevel, mockWriter)
|
||||
|
||||
// 写入所有级别的日志
|
||||
logger.Debug("debug message")
|
||||
logger.Info("info message")
|
||||
logger.Warn("warn message")
|
||||
logger.Error("error message")
|
||||
|
||||
entries := mockWriter.GetEntries()
|
||||
|
||||
// 计算应该写入的日志数量
|
||||
expectedCount := 0
|
||||
for level := DEBUG; level <= ERROR; level++ {
|
||||
if level >= configuredLevel {
|
||||
expectedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) != expectedCount {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证所有写入的日志级别都 >= configuredLevel
|
||||
for _, entry := range entries {
|
||||
if entry.Level < configuredLevel {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
levelGen,
|
||||
))
|
||||
|
||||
// Property: 动态修改级别后过滤行为正确
|
||||
properties.Property("dynamic level change affects filtering correctly", prop.ForAll(
|
||||
func(initialLevel Level, newLevel Level) bool {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(initialLevel, mockWriter)
|
||||
|
||||
// 使用初始级别写入日志
|
||||
logger.Info("initial info")
|
||||
initialEntries := mockWriter.GetEntries()
|
||||
|
||||
// 验证初始级别过滤
|
||||
initialExpected := INFO >= initialLevel
|
||||
if initialExpected && len(initialEntries) != 1 {
|
||||
return false
|
||||
}
|
||||
if !initialExpected && len(initialEntries) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 动态修改级别
|
||||
mockWriter.Clear()
|
||||
logger.SetLevel(newLevel)
|
||||
|
||||
// 使用新级别写入日志
|
||||
logger.Info("new info")
|
||||
newEntries := mockWriter.GetEntries()
|
||||
|
||||
// 验证新级别过滤
|
||||
newExpected := INFO >= newLevel
|
||||
if newExpected && len(newEntries) != 1 {
|
||||
return false
|
||||
}
|
||||
if !newExpected && len(newEntries) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
levelGen,
|
||||
levelGen,
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestLoggerBasic 基础功能测试
|
||||
func TestLoggerBasic(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(DEBUG, mockWriter)
|
||||
|
||||
logger.Debug("debug message")
|
||||
logger.Info("info message")
|
||||
logger.Warn("warn message")
|
||||
logger.Error("error message")
|
||||
|
||||
entries := mockWriter.GetEntries()
|
||||
if len(entries) != 4 {
|
||||
t.Errorf("expected 4 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerLevelFiltering 级别过滤测试
|
||||
func TestLoggerLevelFiltering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configLevel Level
|
||||
expectedCount int
|
||||
}{
|
||||
{"DEBUG level logs all", DEBUG, 4},
|
||||
{"INFO level filters DEBUG", INFO, 3},
|
||||
{"WARN level filters DEBUG and INFO", WARN, 2},
|
||||
{"ERROR level filters all except ERROR", ERROR, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(tt.configLevel, mockWriter)
|
||||
|
||||
logger.Debug("debug")
|
||||
logger.Info("info")
|
||||
logger.Warn("warn")
|
||||
logger.Error("error")
|
||||
|
||||
entries := mockWriter.GetEntries()
|
||||
if len(entries) != tt.expectedCount {
|
||||
t.Errorf("expected %d entries, got %d", tt.expectedCount, len(entries))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerSetLevel 动态级别修改测试
|
||||
func TestLoggerSetLevel(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(DEBUG, mockWriter)
|
||||
|
||||
// 初始级别为 DEBUG,所有日志都应该写入
|
||||
logger.Debug("debug1")
|
||||
if len(mockWriter.GetEntries()) != 1 {
|
||||
t.Error("DEBUG log should be written at DEBUG level")
|
||||
}
|
||||
|
||||
// 修改级别为 ERROR
|
||||
mockWriter.Clear()
|
||||
logger.SetLevel(ERROR)
|
||||
|
||||
logger.Debug("debug2")
|
||||
logger.Info("info2")
|
||||
logger.Warn("warn2")
|
||||
logger.Error("error2")
|
||||
|
||||
entries := mockWriter.GetEntries()
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("expected 1 entry at ERROR level, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Level != ERROR {
|
||||
t.Errorf("expected ERROR level, got %s", entries[0].Level.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerGetLevel 获取级别测试
|
||||
func TestLoggerGetLevel(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(WARN, mockWriter)
|
||||
|
||||
if logger.GetLevel() != WARN {
|
||||
t.Errorf("expected WARN level, got %s", logger.GetLevel().String())
|
||||
}
|
||||
|
||||
logger.SetLevel(DEBUG)
|
||||
if logger.GetLevel() != DEBUG {
|
||||
t.Errorf("expected DEBUG level after SetLevel, got %s", logger.GetLevel().String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerShouldLog 检查是否应该记录测试
|
||||
func TestLoggerShouldLog(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(INFO, mockWriter)
|
||||
|
||||
if logger.ShouldLog(DEBUG) {
|
||||
t.Error("DEBUG should not be logged at INFO level")
|
||||
}
|
||||
if !logger.ShouldLog(INFO) {
|
||||
t.Error("INFO should be logged at INFO level")
|
||||
}
|
||||
if !logger.ShouldLog(WARN) {
|
||||
t.Error("WARN should be logged at INFO level")
|
||||
}
|
||||
if !logger.ShouldLog(ERROR) {
|
||||
t.Error("ERROR should be logged at INFO level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerWithFields 带字段的日志测试
|
||||
func TestLoggerWithFields(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(DEBUG, mockWriter)
|
||||
|
||||
logger.Info("test message", F("key1", "value1"), F("key2", 123))
|
||||
|
||||
entries := mockWriter.GetEntries()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
|
||||
entry := entries[0]
|
||||
if entry.Fields == nil {
|
||||
t.Fatal("expected fields to be set")
|
||||
}
|
||||
if entry.Fields["key1"] != "value1" {
|
||||
t.Errorf("expected key1=value1, got %v", entry.Fields["key1"])
|
||||
}
|
||||
if entry.Fields["key2"] != 123 {
|
||||
t.Errorf("expected key2=123, got %v", entry.Fields["key2"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLevel 级别解析测试
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Level
|
||||
}{
|
||||
{"debug", DEBUG},
|
||||
{"DEBUG", DEBUG},
|
||||
{"info", INFO},
|
||||
{"INFO", INFO},
|
||||
{"warn", WARN},
|
||||
{"WARN", WARN},
|
||||
{"warning", WARN},
|
||||
{"error", ERROR},
|
||||
{"ERROR", ERROR},
|
||||
{"invalid", INFO}, // 默认为 INFO
|
||||
{"", INFO}, // 空字符串默认为 INFO
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := ParseLevel(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerInit 初始化测试
|
||||
func TestLoggerInit(t *testing.T) {
|
||||
// 保存原始全局 logger
|
||||
originalLogger := globalLogger
|
||||
defer func() {
|
||||
globalLogger = originalLogger
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
Init(ctx, "debug")
|
||||
|
||||
logger := New("test-component")
|
||||
if logger.GetLevel() != DEBUG {
|
||||
t.Errorf("expected DEBUG level, got %s", logger.GetLevel().String())
|
||||
}
|
||||
if logger.component != "test-component" {
|
||||
t.Errorf("expected component 'test-component', got %s", logger.component)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerConcurrency 并发安全测试
|
||||
func TestLoggerConcurrency(t *testing.T) {
|
||||
mockWriter := NewMockWriter()
|
||||
logger := createTestLogger(DEBUG, mockWriter)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
iterations := 100
|
||||
|
||||
// 并发写入日志
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
logger.Info("concurrent message", F("iteration", n))
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 并发修改级别
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
level := Level(n % 4)
|
||||
logger.SetLevel(level)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 验证没有 panic 发生,日志数量可能因级别变化而不同
|
||||
entries := mockWriter.GetEntries()
|
||||
t.Logf("Concurrent test wrote %d entries", len(entries))
|
||||
}
|
||||
|
||||
// BufferWriter 用于捕获输出的写入器
|
||||
type BufferWriter struct {
|
||||
buffer *bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewBufferWriter() *BufferWriter {
|
||||
return &BufferWriter{
|
||||
buffer: new(bytes.Buffer),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *BufferWriter) Write(entry *LogEntry) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
formatter := NewTextFormatter()
|
||||
data, err := formatter.Format(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.buffer.Write(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *BufferWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *BufferWriter) String() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.buffer.String()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const defaultMemoryBufferSize = 500
|
||||
|
||||
// MemoryLogEntry 内存日志条目(供前端消费)
|
||||
type MemoryLogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Component string `json:"component"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryWriter 内存环形缓冲写入器,线程安全
|
||||
type MemoryWriter struct {
|
||||
mu sync.RWMutex
|
||||
entries []MemoryLogEntry
|
||||
maxSize int
|
||||
}
|
||||
|
||||
var globalMemoryWriter *MemoryWriter
|
||||
|
||||
func init() {
|
||||
globalMemoryWriter = &MemoryWriter{
|
||||
entries: make([]MemoryLogEntry, 0, defaultMemoryBufferSize),
|
||||
maxSize: defaultMemoryBufferSize,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMemoryWriter 获取全局内存写入器
|
||||
func GetMemoryWriter() *MemoryWriter {
|
||||
return globalMemoryWriter
|
||||
}
|
||||
|
||||
func (w *MemoryWriter) Write(entry *LogEntry) error {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
item := MemoryLogEntry{
|
||||
Time: entry.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
Level: entry.Level.String(),
|
||||
Component: entry.Component,
|
||||
Message: entry.Message,
|
||||
Fields: entry.Fields,
|
||||
}
|
||||
if len(w.entries) >= w.maxSize {
|
||||
w.entries = w.entries[1:]
|
||||
}
|
||||
w.entries = append(w.entries, item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *MemoryWriter) Close() error { return nil }
|
||||
|
||||
// GetEntries 返回所有缓冲日志(最新在后)
|
||||
func (w *MemoryWriter) GetEntries() []MemoryLogEntry {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
result := make([]MemoryLogEntry, len(w.entries))
|
||||
copy(result, w.entries)
|
||||
return result
|
||||
}
|
||||
|
||||
// Clear 清空缓冲
|
||||
func (w *MemoryWriter) Clear() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.entries = w.entries[:0]
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeInterval 时间分片间隔类型
|
||||
type TimeInterval string
|
||||
|
||||
const (
|
||||
// Daily 每天分片
|
||||
Daily TimeInterval = "daily"
|
||||
// Hourly 每小时分片
|
||||
Hourly TimeInterval = "hourly"
|
||||
)
|
||||
|
||||
// TimeRotationPolicy 按时间分片策略
|
||||
// 支持按天或按小时分片
|
||||
type TimeRotationPolicy struct {
|
||||
interval TimeInterval
|
||||
lastRotate time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTimeRotationPolicy 创建时间分片策略
|
||||
func NewTimeRotationPolicy(interval TimeInterval) *TimeRotationPolicy {
|
||||
return &TimeRotationPolicy{
|
||||
interval: interval,
|
||||
lastRotate: time.Time{}, // 零值,首次检查时会初始化
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldRotate 判断是否应该触发时间分片
|
||||
func (p *TimeRotationPolicy) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool {
|
||||
if fileInfo == nil || entry == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
lastRotate := p.lastRotate
|
||||
p.mu.RUnlock()
|
||||
|
||||
entryTime := entry.Timestamp
|
||||
if entryTime.IsZero() {
|
||||
entryTime = time.Now()
|
||||
}
|
||||
|
||||
// 首次检查,使用文件修改时间作为基准
|
||||
if lastRotate.IsZero() {
|
||||
p.mu.Lock()
|
||||
p.lastRotate = fileInfo.ModTime()
|
||||
p.mu.Unlock()
|
||||
lastRotate = fileInfo.ModTime()
|
||||
}
|
||||
|
||||
switch p.interval {
|
||||
case Daily:
|
||||
// 检查是否跨天
|
||||
return !sameDay(lastRotate, entryTime)
|
||||
case Hourly:
|
||||
// 检查是否跨小时
|
||||
return !sameHour(lastRotate, entryTime)
|
||||
default:
|
||||
// 默认按天
|
||||
return !sameDay(lastRotate, entryTime)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRotatedFileName 获取分片后的文件名
|
||||
func (p *TimeRotationPolicy) GetRotatedFileName(baseName string, timestamp time.Time) string {
|
||||
ext := filepath.Ext(baseName)
|
||||
nameWithoutExt := strings.TrimSuffix(baseName, ext)
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
|
||||
switch p.interval {
|
||||
case Hourly:
|
||||
// 格式: app.2024-01-15-14.log
|
||||
return fmt.Sprintf("%s.%s%s", nameWithoutExt, timestamp.Format("2006-01-02-15"), ext)
|
||||
default:
|
||||
// 格式: app.2024-01-15.log
|
||||
return fmt.Sprintf("%s.%s%s", nameWithoutExt, timestamp.Format("2006-01-02"), ext)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLastRotate 更新最后分片时间
|
||||
func (p *TimeRotationPolicy) UpdateLastRotate(t time.Time) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.lastRotate = t
|
||||
}
|
||||
|
||||
// sameDay 判断两个时间是否在同一天
|
||||
func sameDay(t1, t2 time.Time) bool {
|
||||
y1, m1, d1 := t1.Date()
|
||||
y2, m2, d2 := t2.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
}
|
||||
|
||||
// sameHour 判断两个时间是否在同一小时
|
||||
func sameHour(t1, t2 time.Time) bool {
|
||||
return sameDay(t1, t2) && t1.Hour() == t2.Hour()
|
||||
}
|
||||
|
||||
// SizeRotationPolicy 按大小分片策略
|
||||
// 当文件大小超过指定阈值时触发分片
|
||||
type SizeRotationPolicy struct {
|
||||
maxSize int64 // 最大文件大小(字节)
|
||||
sequence int // 当前序号(同一天内多次分片)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSizeRotationPolicy 创建大小分片策略
|
||||
// maxSizeBytes: 最大文件大小(字节)
|
||||
func NewSizeRotationPolicy(maxSizeBytes int64) *SizeRotationPolicy {
|
||||
return &SizeRotationPolicy{
|
||||
maxSize: maxSizeBytes,
|
||||
sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSizeRotationPolicyMB 创建大小分片策略(MB为单位)
|
||||
// maxSizeMB: 最大文件大小(MB)
|
||||
func NewSizeRotationPolicyMB(maxSizeMB int) *SizeRotationPolicy {
|
||||
return NewSizeRotationPolicy(int64(maxSizeMB) * 1024 * 1024)
|
||||
}
|
||||
|
||||
// ShouldRotate 判断是否应该触发大小分片
|
||||
func (p *SizeRotationPolicy) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool {
|
||||
if fileInfo == nil {
|
||||
return false
|
||||
}
|
||||
return fileInfo.Size() >= p.maxSize
|
||||
}
|
||||
|
||||
// GetRotatedFileName 获取分片后的文件名
|
||||
func (p *SizeRotationPolicy) GetRotatedFileName(baseName string, timestamp time.Time) string {
|
||||
ext := filepath.Ext(baseName)
|
||||
nameWithoutExt := strings.TrimSuffix(baseName, ext)
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.sequence++
|
||||
seq := p.sequence
|
||||
p.mu.Unlock()
|
||||
|
||||
// 格式: app.2024-01-15.1.log
|
||||
return fmt.Sprintf("%s.%s.%d%s", nameWithoutExt, timestamp.Format("2006-01-02"), seq, ext)
|
||||
}
|
||||
|
||||
// ResetSequence 重置序号(通常在日期变化时调用)
|
||||
func (p *SizeRotationPolicy) ResetSequence() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.sequence = 0
|
||||
}
|
||||
|
||||
// GetMaxSize 获取最大文件大小
|
||||
func (p *SizeRotationPolicy) GetMaxSize() int64 {
|
||||
return p.maxSize
|
||||
}
|
||||
|
||||
// CompositeRotationPolicy 组合分片策略
|
||||
// 任一子策略满足条件即触发分片
|
||||
type CompositeRotationPolicy struct {
|
||||
policies []RotationPolicy
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCompositeRotationPolicy 创建组合分片策略
|
||||
func NewCompositeRotationPolicy(policies ...RotationPolicy) *CompositeRotationPolicy {
|
||||
return &CompositeRotationPolicy{
|
||||
policies: policies,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldRotate 判断是否应该触发分片
|
||||
// 任一子策略返回 true 即触发
|
||||
func (p *CompositeRotationPolicy) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
for _, policy := range p.policies {
|
||||
if policy.ShouldRotate(fileInfo, entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRotatedFileName 获取分片后的文件名
|
||||
// 使用第一个策略的命名规则
|
||||
func (p *CompositeRotationPolicy) GetRotatedFileName(baseName string, timestamp time.Time) string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if len(p.policies) > 0 {
|
||||
return p.policies[0].GetRotatedFileName(baseName, timestamp)
|
||||
}
|
||||
|
||||
// 默认命名
|
||||
ext := filepath.Ext(baseName)
|
||||
nameWithoutExt := strings.TrimSuffix(baseName, ext)
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
return fmt.Sprintf("%s.%s%s", nameWithoutExt, timestamp.Format("2006-01-02"), ext)
|
||||
}
|
||||
|
||||
// AddPolicy 添加子策略
|
||||
func (p *CompositeRotationPolicy) AddPolicy(policy RotationPolicy) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.policies = append(p.policies, policy)
|
||||
}
|
||||
|
||||
// GetPolicies 获取所有子策略
|
||||
func (p *CompositeRotationPolicy) GetPolicies() []RotationPolicy {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
result := make([]RotationPolicy, len(p.policies))
|
||||
copy(result, p.policies)
|
||||
return result
|
||||
}
|
||||
|
||||
// RotationManagerConfig 分片管理器配置
|
||||
type RotationManagerConfig struct {
|
||||
BasePath string // 基础日志文件路径
|
||||
MaxBackups int // 最大保留文件数
|
||||
MaxAge int // 最大保留天数
|
||||
Policy RotationPolicy // 分片策略
|
||||
}
|
||||
|
||||
// RotationManager 日志分片管理器
|
||||
// 负责执行分片操作和清理历史文件
|
||||
type RotationManager struct {
|
||||
config RotationManagerConfig
|
||||
mu sync.Mutex
|
||||
currentSeq int // 当前序号
|
||||
}
|
||||
|
||||
// NewRotationManager 创建分片管理器
|
||||
func NewRotationManager(config RotationManagerConfig) *RotationManager {
|
||||
if config.MaxBackups <= 0 {
|
||||
config.MaxBackups = 5
|
||||
}
|
||||
return &RotationManager{
|
||||
config: config,
|
||||
currentSeq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldRotate 检查是否需要分片
|
||||
func (m *RotationManager) ShouldRotate(fileInfo os.FileInfo, entry *LogEntry) bool {
|
||||
if m.config.Policy == nil {
|
||||
return false
|
||||
}
|
||||
return m.config.Policy.ShouldRotate(fileInfo, entry)
|
||||
}
|
||||
|
||||
// Rotate 执行分片操作
|
||||
// 返回新的日志文件路径
|
||||
func (m *RotationManager) Rotate(currentFile *os.File) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if currentFile == nil {
|
||||
return "", fmt.Errorf("current file is nil")
|
||||
}
|
||||
|
||||
// 获取当前文件信息
|
||||
basePath := m.config.BasePath
|
||||
timestamp := time.Now()
|
||||
|
||||
// 生成分片文件名
|
||||
rotatedName := m.generateRotatedFileName(basePath, timestamp)
|
||||
|
||||
// 关闭当前文件
|
||||
if err := currentFile.Close(); err != nil {
|
||||
return "", fmt.Errorf("failed to close current file: %w", err)
|
||||
}
|
||||
|
||||
// 重命名当前文件为分片文件
|
||||
if err := os.Rename(basePath, rotatedName); err != nil {
|
||||
return "", fmt.Errorf("failed to rename file: %w", err)
|
||||
}
|
||||
|
||||
// 清理历史文件
|
||||
if err := m.cleanupOldFiles(); err != nil {
|
||||
// 清理失败不影响主流程,只记录错误
|
||||
fmt.Fprintf(os.Stderr, "failed to cleanup old files: %v\n", err)
|
||||
}
|
||||
|
||||
return rotatedName, nil
|
||||
}
|
||||
|
||||
// generateRotatedFileName 生成分片文件名
|
||||
// 格式: {basename}.{timestamp}[.{sequence}].log
|
||||
func (m *RotationManager) generateRotatedFileName(basePath string, timestamp time.Time) string {
|
||||
ext := filepath.Ext(basePath)
|
||||
nameWithoutExt := strings.TrimSuffix(basePath, ext)
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
|
||||
dateStr := timestamp.Format("2006-01-02")
|
||||
|
||||
// 检查是否已存在同日期的文件,确定序号
|
||||
seq := m.findNextSequence(nameWithoutExt, dateStr, ext)
|
||||
|
||||
if seq > 0 {
|
||||
// 格式: app.2024-01-15.1.log
|
||||
return fmt.Sprintf("%s.%s.%d%s", nameWithoutExt, dateStr, seq, ext)
|
||||
}
|
||||
// 格式: app.2024-01-15.log
|
||||
return fmt.Sprintf("%s.%s%s", nameWithoutExt, dateStr, ext)
|
||||
}
|
||||
|
||||
// findNextSequence 查找下一个可用序号
|
||||
func (m *RotationManager) findNextSequence(nameWithoutExt, dateStr, ext string) int {
|
||||
dir := filepath.Dir(nameWithoutExt)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
baseName := filepath.Base(nameWithoutExt)
|
||||
|
||||
// 查找已存在的同日期文件
|
||||
pattern := fmt.Sprintf("%s.%s*%s", baseName, dateStr, ext)
|
||||
matches, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 找到最大序号
|
||||
maxSeq := 0
|
||||
seqPattern := regexp.MustCompile(fmt.Sprintf(`%s\.%s(?:\.(\d+))?%s$`,
|
||||
regexp.QuoteMeta(baseName),
|
||||
regexp.QuoteMeta(dateStr),
|
||||
regexp.QuoteMeta(ext)))
|
||||
|
||||
for _, match := range matches {
|
||||
fileName := filepath.Base(match)
|
||||
if submatches := seqPattern.FindStringSubmatch(fileName); submatches != nil {
|
||||
if len(submatches) > 1 && submatches[1] != "" {
|
||||
var seq int
|
||||
fmt.Sscanf(submatches[1], "%d", &seq)
|
||||
if seq > maxSeq {
|
||||
maxSeq = seq
|
||||
}
|
||||
} else {
|
||||
// 无序号的文件存在,下一个从1开始
|
||||
if maxSeq == 0 {
|
||||
maxSeq = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxSeq + 1
|
||||
}
|
||||
|
||||
// cleanupOldFiles 清理历史文件
|
||||
func (m *RotationManager) cleanupOldFiles() error {
|
||||
files, err := m.listRotatedFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime.After(files[j].ModTime)
|
||||
})
|
||||
|
||||
// 删除超出数量限制的文件
|
||||
if len(files) > m.config.MaxBackups {
|
||||
for _, f := range files[m.config.MaxBackups:] {
|
||||
if err := os.Remove(f.Path); err != nil {
|
||||
return fmt.Errorf("failed to remove old file %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除超出时间限制的文件
|
||||
if m.config.MaxAge > 0 {
|
||||
cutoff := time.Now().AddDate(0, 0, -m.config.MaxAge)
|
||||
for _, f := range files {
|
||||
if f.ModTime.Before(cutoff) {
|
||||
if err := os.Remove(f.Path); err != nil {
|
||||
return fmt.Errorf("failed to remove old file %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotatedFileInfo 分片文件信息
|
||||
type rotatedFileInfo struct {
|
||||
Path string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// listRotatedFiles 列出所有分片文件
|
||||
func (m *RotationManager) listRotatedFiles() ([]rotatedFileInfo, error) {
|
||||
basePath := m.config.BasePath
|
||||
dir := filepath.Dir(basePath)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
|
||||
ext := filepath.Ext(basePath)
|
||||
nameWithoutExt := filepath.Base(strings.TrimSuffix(basePath, ext))
|
||||
if ext == "" {
|
||||
ext = ".log"
|
||||
}
|
||||
|
||||
// 匹配模式: app.YYYY-MM-DD*.log
|
||||
pattern := fmt.Sprintf("%s.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*%s", nameWithoutExt, ext)
|
||||
matches, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to glob files: %w", err)
|
||||
}
|
||||
|
||||
var files []rotatedFileInfo
|
||||
for _, match := range matches {
|
||||
info, err := os.Stat(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, rotatedFileInfo{
|
||||
Path: match,
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetRotatedFileCount 获取当前分片文件数量
|
||||
func (m *RotationManager) GetRotatedFileCount() (int, error) {
|
||||
files, err := m.listRotatedFiles()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(files), nil
|
||||
}
|
||||
|
||||
// GetConfig 获取配置
|
||||
func (m *RotationManager) GetConfig() RotationManagerConfig {
|
||||
return m.config
|
||||
}
|
||||
|
||||
// ValidateRotatedFileName 验证文件名是否符合分片命名格式
|
||||
// 格式: {basename}.{timestamp}[.{sequence}].log
|
||||
func ValidateRotatedFileName(fileName string) bool {
|
||||
// 匹配模式: name.YYYY-MM-DD.log 或 name.YYYY-MM-DD.N.log 或 name.YYYY-MM-DD-HH.log
|
||||
patterns := []string{
|
||||
`^.+\.\d{4}-\d{2}-\d{2}\.log$`, // app.2024-01-15.log
|
||||
`^.+\.\d{4}-\d{2}-\d{2}\.\d+\.log$`, // app.2024-01-15.1.log
|
||||
`^.+\.\d{4}-\d{2}-\d{2}-\d{2}\.log$`, // app.2024-01-15-14.log (hourly)
|
||||
`^.+\.\d{4}-\d{2}-\d{2}-\d{2}\.\d+\.log$`, // app.2024-01-15-14.1.log
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
matched, _ := regexp.MatchString(p, fileName)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseRotatedFileName 解析分片文件名
|
||||
// 返回基础名、时间戳、序号
|
||||
func ParseRotatedFileName(fileName string) (baseName string, timestamp time.Time, sequence int, err error) {
|
||||
ext := filepath.Ext(fileName)
|
||||
nameWithoutExt := strings.TrimSuffix(fileName, ext)
|
||||
|
||||
// 尝试匹配带序号的格式: app.2024-01-15.1
|
||||
seqPattern := regexp.MustCompile(`^(.+)\.(\d{4}-\d{2}-\d{2}(?:-\d{2})?)\.(\d+)$`)
|
||||
if matches := seqPattern.FindStringSubmatch(nameWithoutExt); matches != nil {
|
||||
baseName = matches[1]
|
||||
timestamp, err = parseTimestamp(matches[2])
|
||||
if err != nil {
|
||||
return "", time.Time{}, 0, err
|
||||
}
|
||||
fmt.Sscanf(matches[3], "%d", &sequence)
|
||||
return baseName, timestamp, sequence, nil
|
||||
}
|
||||
|
||||
// 尝试匹配不带序号的格式: app.2024-01-15
|
||||
noSeqPattern := regexp.MustCompile(`^(.+)\.(\d{4}-\d{2}-\d{2}(?:-\d{2})?)$`)
|
||||
if matches := noSeqPattern.FindStringSubmatch(nameWithoutExt); matches != nil {
|
||||
baseName = matches[1]
|
||||
timestamp, err = parseTimestamp(matches[2])
|
||||
if err != nil {
|
||||
return "", time.Time{}, 0, err
|
||||
}
|
||||
return baseName, timestamp, 0, nil
|
||||
}
|
||||
|
||||
return "", time.Time{}, 0, fmt.Errorf("invalid rotated file name format: %s", fileName)
|
||||
}
|
||||
|
||||
// parseTimestamp 解析时间戳字符串
|
||||
func parseTimestamp(s string) (time.Time, error) {
|
||||
// 尝试小时格式
|
||||
if t, err := time.Parse("2006-01-02-15", s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
// 尝试日期格式
|
||||
return time.Parse("2006-01-02", s)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// mockFileInfo 模拟文件信息用于测试
|
||||
type mockFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (m *mockFileInfo) Name() string { return m.name }
|
||||
func (m *mockFileInfo) Size() int64 { return m.size }
|
||||
func (m *mockFileInfo) Mode() os.FileMode { return m.mode }
|
||||
func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
|
||||
func (m *mockFileInfo) IsDir() bool { return m.isDir }
|
||||
func (m *mockFileInfo) Sys() interface{} { return nil }
|
||||
|
||||
// TestProperty6_SizeBasedRotationTrigger 属性测试:大小分片触发
|
||||
// **Property 6: Size-Based Rotation Trigger**
|
||||
// **Validates: Requirements 4.2**
|
||||
// *For any* configured max file size S, when the current log file size exceeds S,
|
||||
// a new log file SHALL be created before writing the next entry.
|
||||
func TestProperty6_SizeBasedRotationTrigger(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
// 生成随机的最大文件大小 (1KB - 100MB)
|
||||
maxSizeGen := gen.Int64Range(1024, 100*1024*1024)
|
||||
// 生成随机的当前文件大小 (0 - 200MB)
|
||||
currentSizeGen := gen.Int64Range(0, 200*1024*1024)
|
||||
|
||||
properties.Property("size rotation triggers when file size >= maxSize", prop.ForAll(
|
||||
func(maxSize, currentSize int64) bool {
|
||||
policy := NewSizeRotationPolicy(maxSize)
|
||||
|
||||
fileInfo := &mockFileInfo{
|
||||
name: "test.log",
|
||||
size: currentSize,
|
||||
modTime: time.Now(),
|
||||
}
|
||||
|
||||
entry := NewLogEntry(INFO, "test", "test message")
|
||||
shouldRotate := policy.ShouldRotate(fileInfo, entry)
|
||||
|
||||
// 当文件大小 >= 最大大小时,应该触发分片
|
||||
expected := currentSize >= maxSize
|
||||
return shouldRotate == expected
|
||||
},
|
||||
maxSizeGen,
|
||||
currentSizeGen,
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty8_HistoryFileLimit 属性测试:历史文件数量限制
|
||||
// **Property 8: History File Limit**
|
||||
// **Validates: Requirements 4.5**
|
||||
// *For any* configured max backup count N, the number of rotated log files
|
||||
// SHALL never exceed N, with oldest files deleted first.
|
||||
func TestProperty8_HistoryFileLimit(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
// 生成随机的最大备份数 (1-20)
|
||||
maxBackupsGen := gen.IntRange(1, 20)
|
||||
// 生成随机的初始文件数 (0-30)
|
||||
initialFilesGen := gen.IntRange(0, 30)
|
||||
|
||||
properties.Property("history files never exceed maxBackups after cleanup", prop.ForAll(
|
||||
func(maxBackups, initialFiles int) bool {
|
||||
// 创建临时目录
|
||||
tempDir, err := os.MkdirTemp("", "rotation_test_*")
|
||||
if err != nil {
|
||||
t.Logf("Failed to create temp dir: %v", err)
|
||||
return false
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
basePath := tempDir + "/app.log"
|
||||
|
||||
// 创建初始的分片文件
|
||||
baseTime := time.Now().AddDate(0, 0, -initialFiles)
|
||||
for i := 0; i < initialFiles; i++ {
|
||||
fileTime := baseTime.AddDate(0, 0, i)
|
||||
fileName := tempDir + "/app." + fileTime.Format("2006-01-02") + ".log"
|
||||
f, err := os.Create(fileName)
|
||||
if err != nil {
|
||||
t.Logf("Failed to create file: %v", err)
|
||||
return false
|
||||
}
|
||||
f.Close()
|
||||
// 设置文件修改时间以便排序
|
||||
os.Chtimes(fileName, fileTime, fileTime)
|
||||
}
|
||||
|
||||
// 创建 RotationManager 并执行清理
|
||||
manager := NewRotationManager(RotationManagerConfig{
|
||||
BasePath: basePath,
|
||||
MaxBackups: maxBackups,
|
||||
})
|
||||
|
||||
// 执行清理
|
||||
err = manager.cleanupOldFiles()
|
||||
if err != nil {
|
||||
t.Logf("Cleanup failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查剩余文件数
|
||||
count, err := manager.GetRotatedFileCount()
|
||||
if err != nil {
|
||||
t.Logf("Failed to get file count: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 文件数应该不超过 maxBackups
|
||||
return count <= maxBackups
|
||||
},
|
||||
maxBackupsGen,
|
||||
initialFilesGen,
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty9_RotatedFileNamingFormat 属性测试:分片文件命名格式
|
||||
// **Property 9: Rotated File Naming Format**
|
||||
// **Validates: Requirements 4.6**
|
||||
// *For any* rotated log file, the filename SHALL match the pattern
|
||||
// `{basename}.{timestamp}[.{sequence}].log` where timestamp is in ISO date format.
|
||||
func TestProperty9_RotatedFileNamingFormat(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
// 生成随机的基础文件名(使用字母数字字符)
|
||||
baseNameGen := gen.AlphaString().Map(func(s string) string {
|
||||
if s == "" || len(s) == 0 {
|
||||
return "app"
|
||||
}
|
||||
if len(s) > 20 {
|
||||
return s[:20]
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
// 生成随机时间戳 (过去一年内)
|
||||
timestampGen := gen.Int64Range(0, 365*24).Map(func(hours int64) time.Time {
|
||||
return time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
})
|
||||
|
||||
// 生成随机的时间间隔类型
|
||||
intervalGen := gen.OneConstOf(Daily, Hourly)
|
||||
|
||||
properties.Property("time rotation generates valid file names", prop.ForAll(
|
||||
func(baseName string, timestamp time.Time, interval TimeInterval) bool {
|
||||
policy := NewTimeRotationPolicy(interval)
|
||||
fileName := policy.GetRotatedFileName(baseName+".log", timestamp)
|
||||
|
||||
// 验证文件名格式
|
||||
return ValidateRotatedFileName(fileName)
|
||||
},
|
||||
baseNameGen,
|
||||
timestampGen,
|
||||
intervalGen,
|
||||
))
|
||||
|
||||
// 测试大小分片的文件命名
|
||||
properties.Property("size rotation generates valid file names", prop.ForAll(
|
||||
func(baseName string, timestamp time.Time, maxSize int64) bool {
|
||||
policy := NewSizeRotationPolicy(maxSize)
|
||||
fileName := policy.GetRotatedFileName(baseName+".log", timestamp)
|
||||
|
||||
// 验证文件名格式
|
||||
return ValidateRotatedFileName(fileName)
|
||||
},
|
||||
baseNameGen,
|
||||
timestampGen,
|
||||
gen.Int64Range(1024, 100*1024*1024),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConsoleWriter 控制台写入器
|
||||
// 将日志输出到标准输出
|
||||
type ConsoleWriter struct {
|
||||
formatter Formatter
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewConsoleWriter 创建新的控制台写入器
|
||||
func NewConsoleWriter(formatter Formatter) *ConsoleWriter {
|
||||
if formatter == nil {
|
||||
formatter = NewTextFormatter()
|
||||
}
|
||||
return &ConsoleWriter{
|
||||
formatter: formatter,
|
||||
}
|
||||
}
|
||||
|
||||
// Write 写入日志条目到控制台
|
||||
func (w *ConsoleWriter) Write(entry *LogEntry) error {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := w.formatter.Format(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format log entry: %w", err)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
_, err = os.Stdout.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to console: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭控制台写入器(无操作)
|
||||
func (w *ConsoleWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileWriterConfig 文件写入器配置
|
||||
type FileWriterConfig struct {
|
||||
FilePath string // 日志文件路径
|
||||
BufferSize int // 缓冲区大小(字节),默认 4KB
|
||||
FlushInterval time.Duration // 刷新间隔,默认 1s
|
||||
AsyncQueueSize int // 异步队列大小,默认 1000
|
||||
}
|
||||
|
||||
// DefaultFileWriterConfig 返回默认的文件写入器配置
|
||||
func DefaultFileWriterConfig(filePath string) FileWriterConfig {
|
||||
return FileWriterConfig{
|
||||
FilePath: filePath,
|
||||
BufferSize: 4 * 1024, // 4KB
|
||||
FlushInterval: time.Second,
|
||||
AsyncQueueSize: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// FileWriter 文件写入器
|
||||
// 支持缓冲写入和异步写入
|
||||
type FileWriter struct {
|
||||
config FileWriterConfig
|
||||
formatter Formatter
|
||||
file *os.File
|
||||
buffer *bufio.Writer
|
||||
mu sync.Mutex
|
||||
|
||||
// 异步写入相关
|
||||
asyncChan chan *LogEntry
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
asyncMode bool
|
||||
flushTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewFileWriter 创建新的文件写入器(同步模式)
|
||||
func NewFileWriter(config FileWriterConfig, formatter Formatter) (*FileWriter, error) {
|
||||
if formatter == nil {
|
||||
formatter = NewTextFormatter()
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(config.FilePath)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开文件(追加模式)
|
||||
file, err := os.OpenFile(config.FilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
// 设置默认缓冲区大小
|
||||
bufferSize := config.BufferSize
|
||||
if bufferSize <= 0 {
|
||||
bufferSize = 4 * 1024 // 4KB
|
||||
}
|
||||
|
||||
w := &FileWriter{
|
||||
config: config,
|
||||
formatter: formatter,
|
||||
file: file,
|
||||
buffer: bufio.NewWriterSize(file, bufferSize),
|
||||
asyncMode: false,
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Write 写入日志条目到文件(同步模式)
|
||||
func (w *FileWriter) Write(entry *LogEntry) error {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 如果是异步模式,发送到队列
|
||||
if w.asyncMode {
|
||||
return w.writeAsync(entry)
|
||||
}
|
||||
|
||||
return w.writeSync(entry)
|
||||
}
|
||||
|
||||
// writeSync 同步写入
|
||||
func (w *FileWriter) writeSync(entry *LogEntry) error {
|
||||
data, err := w.formatter.Format(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format log entry: %w", err)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
_, err = w.buffer.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to buffer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush 刷新缓冲区到文件
|
||||
func (w *FileWriter) Flush() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.buffer != nil {
|
||||
return w.buffer.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭文件写入器
|
||||
func (w *FileWriter) Close() error {
|
||||
// 如果是异步模式,先停止异步写入
|
||||
if w.asyncMode {
|
||||
w.stopAsync()
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
|
||||
// 刷新缓冲区
|
||||
if w.buffer != nil {
|
||||
if err := w.buffer.Flush(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to flush buffer: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭文件
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close file: %w", err))
|
||||
}
|
||||
w.file = nil
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilePath 获取当前日志文件路径
|
||||
func (w *FileWriter) GetFilePath() string {
|
||||
return w.config.FilePath
|
||||
}
|
||||
|
||||
// NewAsyncFileWriter 创建新的异步文件写入器
|
||||
func NewAsyncFileWriter(config FileWriterConfig, formatter Formatter) (*FileWriter, error) {
|
||||
w, err := NewFileWriter(config, formatter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 启用异步模式
|
||||
w.enableAsync()
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// enableAsync 启用异步写入模式
|
||||
func (w *FileWriter) enableAsync() {
|
||||
if w.asyncMode {
|
||||
return
|
||||
}
|
||||
|
||||
queueSize := w.config.AsyncQueueSize
|
||||
if queueSize <= 0 {
|
||||
queueSize = 1000
|
||||
}
|
||||
|
||||
flushInterval := w.config.FlushInterval
|
||||
if flushInterval <= 0 {
|
||||
flushInterval = time.Second
|
||||
}
|
||||
|
||||
w.asyncChan = make(chan *LogEntry, queueSize)
|
||||
w.done = make(chan struct{})
|
||||
w.flushTicker = time.NewTicker(flushInterval)
|
||||
w.asyncMode = true
|
||||
|
||||
// 启动后台写入 goroutine
|
||||
w.wg.Add(1)
|
||||
go w.asyncWriteLoop()
|
||||
}
|
||||
|
||||
// asyncWriteLoop 异步写入循环
|
||||
func (w *FileWriter) asyncWriteLoop() {
|
||||
defer w.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-w.asyncChan:
|
||||
if !ok {
|
||||
// 通道已关闭,处理剩余日志
|
||||
return
|
||||
}
|
||||
// 写入日志(忽略错误,避免阻塞)
|
||||
_ = w.writeSync(entry)
|
||||
|
||||
case <-w.flushTicker.C:
|
||||
// 定期刷新缓冲区
|
||||
_ = w.Flush()
|
||||
|
||||
case <-w.done:
|
||||
// 收到停止信号,处理剩余日志
|
||||
w.drainQueue()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainQueue 清空队列中的剩余日志
|
||||
func (w *FileWriter) drainQueue() {
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-w.asyncChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = w.writeSync(entry)
|
||||
default:
|
||||
// 队列已空
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeAsync 异步写入(非阻塞)
|
||||
func (w *FileWriter) writeAsync(entry *LogEntry) error {
|
||||
select {
|
||||
case w.asyncChan <- entry:
|
||||
return nil
|
||||
default:
|
||||
// 队列满,丢弃日志(非阻塞)
|
||||
return fmt.Errorf("async queue full, log entry dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// stopAsync 停止异步写入
|
||||
func (w *FileWriter) stopAsync() {
|
||||
if !w.asyncMode {
|
||||
return
|
||||
}
|
||||
|
||||
// 停止定时器
|
||||
if w.flushTicker != nil {
|
||||
w.flushTicker.Stop()
|
||||
}
|
||||
|
||||
// 发送停止信号
|
||||
close(w.done)
|
||||
|
||||
// 等待后台 goroutine 完成
|
||||
w.wg.Wait()
|
||||
|
||||
// 关闭通道
|
||||
close(w.asyncChan)
|
||||
|
||||
w.asyncMode = false
|
||||
}
|
||||
|
||||
// IsAsync 返回是否为异步模式
|
||||
func (w *FileWriter) IsAsync() bool {
|
||||
return w.asyncMode
|
||||
}
|
||||
|
||||
// QueueLength 返回当前异步队列长度(用于监控)
|
||||
func (w *FileWriter) QueueLength() int {
|
||||
if !w.asyncMode {
|
||||
return 0
|
||||
}
|
||||
return len(w.asyncChan)
|
||||
}
|
||||
|
||||
// MultiWriter 多写入器
|
||||
// 同时写入多个目标
|
||||
type MultiWriter struct {
|
||||
writers []Writer
|
||||
}
|
||||
|
||||
// NewMultiWriter 创建多写入器
|
||||
func NewMultiWriter(writers ...Writer) *MultiWriter {
|
||||
return &MultiWriter{
|
||||
writers: writers,
|
||||
}
|
||||
}
|
||||
|
||||
// Write 写入日志到所有写入器
|
||||
func (w *MultiWriter) Write(entry *LogEntry) error {
|
||||
var lastErr error
|
||||
for _, writer := range w.writers {
|
||||
if err := writer.Write(entry); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// Close 关闭所有写入器
|
||||
func (w *MultiWriter) Close() error {
|
||||
var lastErr error
|
||||
for _, writer := range w.writers {
|
||||
if err := writer.Close(); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// AddWriter 添加写入器
|
||||
func (w *MultiWriter) AddWriter(writer Writer) {
|
||||
w.writers = append(w.writers, writer)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// TestConsoleWriter_Write tests basic console writer functionality
|
||||
func TestConsoleWriter_Write(t *testing.T) {
|
||||
formatter := NewTextFormatter()
|
||||
writer := NewConsoleWriter(formatter)
|
||||
defer writer.Close()
|
||||
|
||||
entry := NewLogEntry(INFO, "test", "test message")
|
||||
err := writer.Write(entry)
|
||||
if err != nil {
|
||||
t.Errorf("ConsoleWriter.Write() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileWriter_Write tests basic file writer functionality
|
||||
func TestFileWriter_Write(t *testing.T) {
|
||||
// Create temp directory
|
||||
tmpDir := t.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "test.log")
|
||||
|
||||
config := DefaultFileWriterConfig(logPath)
|
||||
formatter := NewTextFormatter()
|
||||
|
||||
writer, err := NewFileWriter(config, formatter)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileWriter() error = %v", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
entry := NewLogEntry(INFO, "test", "test message")
|
||||
err = writer.Write(entry)
|
||||
if err != nil {
|
||||
t.Errorf("FileWriter.Write() error = %v", err)
|
||||
}
|
||||
|
||||
// Flush and verify file exists
|
||||
writer.Flush()
|
||||
|
||||
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||
t.Error("Log file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileWriter_CreateDirectory tests automatic directory creation
|
||||
func TestFileWriter_CreateDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "subdir", "nested", "test.log")
|
||||
|
||||
config := DefaultFileWriterConfig(logPath)
|
||||
formatter := NewTextFormatter()
|
||||
|
||||
writer, err := NewFileWriter(config, formatter)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileWriter() error = %v", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
// Verify directory was created
|
||||
dir := filepath.Dir(logPath)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
t.Error("Directory was not created automatically")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAsyncFileWriter_NonBlocking tests that async writes are non-blocking
|
||||
func TestAsyncFileWriter_NonBlocking(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "async_test.log")
|
||||
|
||||
config := FileWriterConfig{
|
||||
FilePath: logPath,
|
||||
BufferSize: 4 * 1024,
|
||||
FlushInterval: 100 * time.Millisecond,
|
||||
AsyncQueueSize: 100,
|
||||
}
|
||||
formatter := NewTextFormatter()
|
||||
|
||||
writer, err := NewAsyncFileWriter(config, formatter)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAsyncFileWriter() error = %v", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
// Write should return quickly
|
||||
entry := NewLogEntry(INFO, "test", "test message")
|
||||
start := time.Now()
|
||||
err = writer.Write(entry)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("AsyncFileWriter.Write() error = %v", err)
|
||||
}
|
||||
|
||||
// Should complete in less than 1ms (non-blocking)
|
||||
if elapsed > time.Millisecond {
|
||||
t.Errorf("Async write took too long: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Property 10: Async Write Non-Blocking
|
||||
// *For any* log write operation, the call SHALL return within a bounded time (< 1ms typical)
|
||||
// regardless of file I/O latency.
|
||||
// **Validates: Requirements 5.2**
|
||||
func TestProperty10_AsyncWriteNonBlocking(t *testing.T) {
|
||||
parameters := gopter.DefaultTestParameters()
|
||||
parameters.MinSuccessfulTests = 100
|
||||
parameters.MaxSize = 50
|
||||
|
||||
properties := gopter.NewProperties(parameters)
|
||||
|
||||
properties.Property("async write returns within bounded time", prop.ForAll(
|
||||
func(level int, component string, message string) bool {
|
||||
// Create temp file for each test
|
||||
tmpDir := os.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "pbt_async_test.log")
|
||||
defer os.Remove(logPath)
|
||||
|
||||
config := FileWriterConfig{
|
||||
FilePath: logPath,
|
||||
BufferSize: 4 * 1024,
|
||||
FlushInterval: time.Second,
|
||||
AsyncQueueSize: 1000,
|
||||
}
|
||||
formatter := NewTextFormatter()
|
||||
|
||||
writer, err := NewAsyncFileWriter(config, formatter)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
// Create log entry from generated data
|
||||
logLevel := Level(level % 4) // Ensure valid level 0-3
|
||||
entry := NewLogEntry(logLevel, component, message)
|
||||
|
||||
// Measure write time
|
||||
start := time.Now()
|
||||
_ = writer.Write(entry)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Property: write should complete within 1ms (non-blocking)
|
||||
// Using 5ms as upper bound to account for system variance
|
||||
return elapsed < 5*time.Millisecond
|
||||
},
|
||||
gen.IntRange(0, 3),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestMultiWriter tests writing to multiple destinations
|
||||
func TestMultiWriter(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "multi_test.log")
|
||||
|
||||
// Create console and file writers
|
||||
consoleWriter := NewConsoleWriter(NewTextFormatter())
|
||||
fileConfig := DefaultFileWriterConfig(logPath)
|
||||
fileWriter, err := NewFileWriter(fileConfig, NewTextFormatter())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileWriter() error = %v", err)
|
||||
}
|
||||
|
||||
multiWriter := NewMultiWriter(consoleWriter, fileWriter)
|
||||
defer multiWriter.Close()
|
||||
|
||||
entry := NewLogEntry(INFO, "test", "multi writer test")
|
||||
err = multiWriter.Write(entry)
|
||||
if err != nil {
|
||||
t.Errorf("MultiWriter.Write() error = %v", err)
|
||||
}
|
||||
|
||||
// Flush file writer
|
||||
fileWriter.Flush()
|
||||
|
||||
// Verify file was written
|
||||
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||
t.Error("Log file was not created by MultiWriter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClashManager Clash 进程管理器
|
||||
type ClashManager struct {
|
||||
Config *config.Config
|
||||
AppRoot string // 应用根目录,所有相对路径基于此解析
|
||||
Processes map[string]*exec.Cmd
|
||||
}
|
||||
|
||||
// NewClashManager 创建 Clash 管理器
|
||||
func NewClashManager(cfg *config.Config, appRoot string) *ClashManager {
|
||||
return &ClashManager{
|
||||
Config: cfg,
|
||||
AppRoot: appRoot,
|
||||
Processes: make(map[string]*exec.Cmd),
|
||||
}
|
||||
}
|
||||
|
||||
// ClashProfile Clash 配置接口
|
||||
type ClashProfile interface {
|
||||
GetProfileId() string
|
||||
GetClashEnabled() bool
|
||||
GetClashRunning() bool
|
||||
GetClashConfigPath() string
|
||||
GetClashProxyPort() int
|
||||
SetClashRunning(bool)
|
||||
SetClashPid(int)
|
||||
SetClashProxyPort(int)
|
||||
SetClashLastError(string)
|
||||
}
|
||||
|
||||
// StartForProfile 为配置启动 Clash 进程
|
||||
func (m *ClashManager) StartForProfile(profile ClashProfile, userDataDir string) error {
|
||||
log := logger.New("Clash")
|
||||
if !profile.GetClashEnabled() {
|
||||
return nil
|
||||
}
|
||||
if profile.GetClashRunning() {
|
||||
return nil
|
||||
}
|
||||
clashBinaryPath := strings.TrimSpace(m.Config.Browser.ClashBinaryPath)
|
||||
if clashBinaryPath == "" {
|
||||
err := fmt.Errorf("clash binary path not configured")
|
||||
profile.SetClashLastError(err.Error())
|
||||
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(clashBinaryPath); err != nil {
|
||||
profile.SetClashLastError(err.Error())
|
||||
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
templatePath := strings.TrimSpace(profile.GetClashConfigPath())
|
||||
if templatePath == "" {
|
||||
err := fmt.Errorf("clash config path not configured")
|
||||
profile.SetClashLastError(err.Error())
|
||||
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(templatePath); err != nil {
|
||||
profile.SetClashLastError(err.Error())
|
||||
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
port := profile.GetClashProxyPort()
|
||||
if port == 0 {
|
||||
p, err := nextAvailablePort()
|
||||
if err != nil {
|
||||
profile.SetClashLastError(err.Error())
|
||||
log.Error("Clash 端口分配失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
port = p
|
||||
profile.SetClashProxyPort(port)
|
||||
}
|
||||
args := []string{
|
||||
"-f", templatePath,
|
||||
"-d", userDataDir,
|
||||
}
|
||||
cmd := exec.Command(clashBinaryPath, args...)
|
||||
hideWindow(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
profile.SetClashLastError(err.Error())
|
||||
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
return err
|
||||
}
|
||||
m.Processes[profile.GetProfileId()] = cmd
|
||||
profile.SetClashRunning(true)
|
||||
profile.SetClashPid(cmd.Process.Pid)
|
||||
profile.SetClashLastError("")
|
||||
log.Info("Clash 启动成功", logger.F("profile_id", profile.GetProfileId()), logger.F("pid", cmd.Process.Pid), logger.F("port", port))
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopForProfile 停止配置的 Clash 进程
|
||||
func (m *ClashManager) StopForProfile(profile ClashProfile) {
|
||||
log := logger.New("Clash")
|
||||
cmd := m.Processes[profile.GetProfileId()]
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
log.Error("Clash 停止失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
|
||||
}
|
||||
}
|
||||
delete(m.Processes, profile.GetProfileId())
|
||||
profile.SetClashRunning(false)
|
||||
profile.SetClashPid(0)
|
||||
log.Info("Clash 已停止", logger.F("profile_id", profile.GetProfileId()))
|
||||
}
|
||||
|
||||
// StopAll 停止所有 Clash 进程
|
||||
func (m *ClashManager) StopAll() {
|
||||
for profileID, cmd := range m.Processes {
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
delete(m.Processes, profileID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// buildProxyHTTPClient 根据代理配置构建 HTTP 客户端,统一用于测速/健康检测场景。
|
||||
func buildProxyHTTPClient(
|
||||
src string,
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
timeout time.Duration,
|
||||
) (*http.Client, error) {
|
||||
l := strings.ToLower(strings.TrimSpace(src))
|
||||
if l == "" || l == "direct://" {
|
||||
return &http.Client{Timeout: timeout}, nil
|
||||
}
|
||||
|
||||
if IsSingBoxProtocol(src) {
|
||||
if singboxMgr == nil {
|
||||
return nil, fmt.Errorf("sing-box 管理器未初始化")
|
||||
}
|
||||
socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sing-box 桥接启动失败: %w", err)
|
||||
}
|
||||
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
|
||||
}
|
||||
|
||||
if RequiresBridge(src, proxies, proxyId) {
|
||||
if xrayMgr == nil {
|
||||
return nil, fmt.Errorf("xray 管理器未初始化")
|
||||
}
|
||||
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xray 桥接启动失败: %w", err)
|
||||
}
|
||||
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(l, "socks5://") {
|
||||
u, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 地址解析失败: %w", err)
|
||||
}
|
||||
var auth *xproxy.Auth
|
||||
if u.User != nil {
|
||||
pass, _ := u.User.Password()
|
||||
auth = &xproxy.Auth{
|
||||
User: u.User.Username(),
|
||||
Password: pass,
|
||||
}
|
||||
}
|
||||
dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
|
||||
}
|
||||
contextDialer, ok := dialer.(xproxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
|
||||
}
|
||||
transport := &http.Transport{DialContext: contextDialer.DialContext}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}, nil
|
||||
}
|
||||
|
||||
proxyURL, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("代理地址解析失败: %w", err)
|
||||
}
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}, nil
|
||||
}
|
||||
|
||||
func buildSocks5HTTPClient(socks5Host string, timeout time.Duration) (*http.Client, error) {
|
||||
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
|
||||
}
|
||||
contextDialer, ok := dialer.(xproxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
|
||||
}
|
||||
transport := &http.Transport{DialContext: contextDialer.DialContext}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
)
|
||||
|
||||
const defaultIPPureInfoURL = "https://my.ippure.com/v1/info"
|
||||
|
||||
// FetchIPPureInfo 通过指定代理链路查询 IPPure 的出口 IP 健康信息。
|
||||
// 返回值为第三方接口原始 JSON(map 形式),不做本地评分计算。
|
||||
func FetchIPPureInfo(
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
) (map[string]interface{}, error) {
|
||||
src := ""
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return nil, fmt.Errorf("未找到代理配置")
|
||||
}
|
||||
|
||||
client, err := buildIPPureHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, 20*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, defaultIPPureInfoURL, nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "AntChrome/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("调用 IPPure 接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 IPPure 响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("IPPure HTTP %d: %s", resp.StatusCode, bodySnippet(body, 180))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("IPPure JSON 解析失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildIPPureHTTPClient(
|
||||
src string,
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
timeout time.Duration,
|
||||
) (*http.Client, error) {
|
||||
return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
|
||||
}
|
||||
|
||||
func bodySnippet(body []byte, max int) string {
|
||||
s := strings.TrimSpace(string(body))
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ParseProxyNode 解析代理节点
|
||||
func ParseProxyNode(node string) (string, map[string]interface{}, error) {
|
||||
src := strings.TrimSpace(node)
|
||||
if src == "" {
|
||||
return "", nil, fmt.Errorf("代理节点为空")
|
||||
}
|
||||
l := strings.ToLower(src)
|
||||
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
|
||||
return src, nil, nil
|
||||
}
|
||||
if strings.HasPrefix(l, "clash://") || strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
|
||||
outbound, standard, err := parseClashNode(src)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if standard != "" {
|
||||
return standard, nil, nil
|
||||
}
|
||||
if outbound != nil {
|
||||
return "", outbound, nil
|
||||
}
|
||||
}
|
||||
outbound, err := buildXrayOutbound(src)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "", outbound, nil
|
||||
}
|
||||
|
||||
func parseClashNode(src string) (map[string]interface{}, string, error) {
|
||||
data := strings.TrimSpace(src)
|
||||
if strings.HasPrefix(strings.ToLower(data), "clash://") {
|
||||
raw := strings.TrimPrefix(data, "clash://")
|
||||
raw, _ = url.QueryUnescape(raw)
|
||||
decoded, err := decodeBase64String(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data = string(decoded)
|
||||
}
|
||||
var payload interface{}
|
||||
if err := yaml.Unmarshal([]byte(data), &payload); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
nodeMap := pickClashNode(payload)
|
||||
if nodeMap == nil {
|
||||
return nil, "", fmt.Errorf("clash 节点解析失败")
|
||||
}
|
||||
nodeType := strings.ToLower(getMapString(nodeMap, "type"))
|
||||
switch nodeType {
|
||||
case "socks5", "http", "https":
|
||||
return nil, buildStandardProxyFromClash(nodeMap, nodeType), nil
|
||||
case "vmess":
|
||||
return buildOutboundFromClashVmess(nodeMap)
|
||||
case "vless":
|
||||
return buildOutboundFromClashVless(nodeMap)
|
||||
case "trojan":
|
||||
return buildOutboundFromClashTrojan(nodeMap)
|
||||
case "ss", "shadowsocks":
|
||||
return buildOutboundFromClashSS(nodeMap)
|
||||
case "ssr":
|
||||
return nil, "", fmt.Errorf("不支持 ShadowsocksR 协议,Xray 不支持 SSR,请使用 SS/vmess/vless/trojan")
|
||||
case "hysteria2", "hysteria":
|
||||
return buildOutboundFromClashHysteria2(nodeMap)
|
||||
}
|
||||
return nil, "", fmt.Errorf("不支持的节点类型")
|
||||
}
|
||||
|
||||
func pickClashNode(payload interface{}) map[string]interface{} {
|
||||
if m := toStringMap(payload); m != nil {
|
||||
if proxies, ok := m["proxies"]; ok {
|
||||
if arr, ok := proxies.([]interface{}); ok && len(arr) > 0 {
|
||||
return toStringMap(arr[0])
|
||||
}
|
||||
}
|
||||
if proxyItem, ok := m["proxy"]; ok {
|
||||
if node := toStringMap(proxyItem); node != nil {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
if arr, ok := payload.([]interface{}); ok && len(arr) > 0 {
|
||||
return toStringMap(arr[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildStandardProxyFromClash(node map[string]interface{}, scheme string) string {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
username := getMapString(node, "username")
|
||||
password := getMapString(node, "password")
|
||||
if host == "" || port == 0 {
|
||||
return ""
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", host, port)
|
||||
if username != "" {
|
||||
user := url.UserPassword(username, password)
|
||||
return fmt.Sprintf("%s://%s@%s", scheme, user.String(), address)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, address)
|
||||
}
|
||||
|
||||
func buildOutboundFromClashVless(node map[string]interface{}) (map[string]interface{}, string, error) {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
id := getMapString(node, "uuid")
|
||||
flow := getMapString(node, "flow")
|
||||
// sni 和 servername 都要读
|
||||
sni := getMapString(node, "sni")
|
||||
if sni == "" {
|
||||
sni = getMapString(node, "servername")
|
||||
}
|
||||
network := getMapString(node, "network")
|
||||
out := map[string]interface{}{
|
||||
"protocol": "vless",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"vnext": []interface{}{
|
||||
map[string]interface{}{
|
||||
"address": host,
|
||||
"port": port,
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": id,
|
||||
"flow": flow,
|
||||
"encryption": "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
stream := map[string]interface{}{}
|
||||
tlsVal := strings.ToLower(getMapString(node, "tls"))
|
||||
_, hasRealityOpts := node["reality-opts"]
|
||||
|
||||
if hasRealityOpts {
|
||||
// Reality 模式:network 必须显式为 tcp,否则 xray 校验失败
|
||||
stream["network"] = "tcp"
|
||||
realityOpts := map[string]interface{}{
|
||||
"spiderX": "",
|
||||
}
|
||||
if sni != "" {
|
||||
realityOpts["serverName"] = sni
|
||||
}
|
||||
fingerprint := getMapString(node, "client-fingerprint")
|
||||
if fingerprint == "" {
|
||||
fingerprint = "chrome"
|
||||
}
|
||||
realityOpts["fingerprint"] = fingerprint
|
||||
if rm := toStringMap(node["reality-opts"]); rm != nil {
|
||||
if pbk := getMapString(rm, "public-key"); pbk != "" {
|
||||
realityOpts["publicKey"] = pbk
|
||||
}
|
||||
if sid := getMapString(rm, "short-id"); sid != "" {
|
||||
realityOpts["shortId"] = sid
|
||||
}
|
||||
}
|
||||
stream["security"] = "reality"
|
||||
stream["realitySettings"] = realityOpts
|
||||
} else if getMapBool(node, "tls") || tlsVal == "true" || tlsVal == "tls" {
|
||||
// 普通 TLS 模式
|
||||
tlsSettings := map[string]interface{}{}
|
||||
if sni != "" {
|
||||
tlsSettings["serverName"] = sni
|
||||
}
|
||||
tlsSettings["allowInsecure"] = getMapBool(node, "skip-cert-verify")
|
||||
stream["security"] = "tls"
|
||||
stream["tlsSettings"] = tlsSettings
|
||||
}
|
||||
if network == "ws" {
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]interface{}{}
|
||||
if wsOpts, ok := node["ws-opts"]; ok {
|
||||
if wsMap := toStringMap(wsOpts); wsMap != nil {
|
||||
path := getMapString(wsMap, "path")
|
||||
// path 为 "/" 也要设置
|
||||
if path != "" {
|
||||
ws["path"] = path
|
||||
}
|
||||
if headers, ok := wsMap["headers"]; ok {
|
||||
if headerMap := toStringMap(headers); headerMap != nil {
|
||||
if hostH := getMapString(headerMap, "Host"); hostH != "" {
|
||||
ws["headers"] = map[string]interface{}{"Host": hostH}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stream["wsSettings"] = ws
|
||||
}
|
||||
if network == "grpc" {
|
||||
stream["network"] = "grpc"
|
||||
if grpcOpts, ok := node["grpc-opts"]; ok {
|
||||
if grpcMap := toStringMap(grpcOpts); grpcMap != nil {
|
||||
serviceName := getMapString(grpcMap, "grpc-service-name")
|
||||
if serviceName != "" {
|
||||
stream["grpcSettings"] = map[string]interface{}{"serviceName": serviceName}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(stream) > 0 {
|
||||
out["streamSettings"] = stream
|
||||
}
|
||||
return out, "", nil
|
||||
}
|
||||
|
||||
func buildOutboundFromClashVmess(node map[string]interface{}) (map[string]interface{}, string, error) {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
id := getMapString(node, "uuid")
|
||||
cipher := getMapString(node, "cipher")
|
||||
if cipher == "" {
|
||||
cipher = "auto"
|
||||
}
|
||||
network := getMapString(node, "network")
|
||||
// sni 和 servername 都要读
|
||||
sni := getMapString(node, "sni")
|
||||
if sni == "" {
|
||||
sni = getMapString(node, "servername")
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"protocol": "vmess",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"vnext": []interface{}{
|
||||
map[string]interface{}{
|
||||
"address": host,
|
||||
"port": port,
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": id,
|
||||
"security": cipher,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
stream := map[string]interface{}{}
|
||||
if getMapBool(node, "tls") || strings.ToLower(getMapString(node, "tls")) == "true" {
|
||||
tlsSettings := map[string]interface{}{}
|
||||
if sni != "" {
|
||||
tlsSettings["serverName"] = sni
|
||||
}
|
||||
skipVerify := getMapBool(node, "skip-cert-verify")
|
||||
tlsSettings["allowInsecure"] = skipVerify
|
||||
stream["security"] = "tls"
|
||||
stream["tlsSettings"] = tlsSettings
|
||||
}
|
||||
if network == "ws" {
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]interface{}{}
|
||||
if wsOpts, ok := node["ws-opts"]; ok {
|
||||
if wsMap := toStringMap(wsOpts); wsMap != nil {
|
||||
path := getMapString(wsMap, "path")
|
||||
// path 为 "/" 也要设置
|
||||
if path != "" {
|
||||
ws["path"] = path
|
||||
}
|
||||
if headers, ok := wsMap["headers"]; ok {
|
||||
if headerMap := toStringMap(headers); headerMap != nil {
|
||||
if hostH := getMapString(headerMap, "Host"); hostH != "" {
|
||||
ws["headers"] = map[string]interface{}{"Host": hostH}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stream["wsSettings"] = ws
|
||||
}
|
||||
if network == "grpc" {
|
||||
stream["network"] = "grpc"
|
||||
if grpcOpts, ok := node["grpc-opts"]; ok {
|
||||
if grpcMap := toStringMap(grpcOpts); grpcMap != nil {
|
||||
serviceName := getMapString(grpcMap, "grpc-service-name")
|
||||
if serviceName != "" {
|
||||
stream["grpcSettings"] = map[string]interface{}{"serviceName": serviceName}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(stream) > 0 {
|
||||
out["streamSettings"] = stream
|
||||
}
|
||||
return out, "", nil
|
||||
}
|
||||
|
||||
func buildOutboundFromClashTrojan(node map[string]interface{}) (map[string]interface{}, string, error) {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
password := getMapString(node, "password")
|
||||
sni := getMapString(node, "sni")
|
||||
if sni == "" {
|
||||
sni = getMapString(node, "servername")
|
||||
}
|
||||
network := getMapString(node, "network")
|
||||
skipVerify := getMapBool(node, "skip-cert-verify")
|
||||
|
||||
out := map[string]interface{}{
|
||||
"protocol": "trojan",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"address": host,
|
||||
"port": port,
|
||||
"password": password,
|
||||
},
|
||||
}
|
||||
stream := map[string]interface{}{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]interface{}{
|
||||
"serverName": sni,
|
||||
"allowInsecure": skipVerify,
|
||||
},
|
||||
}
|
||||
if network == "ws" {
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]interface{}{}
|
||||
if wsOpts, ok := node["ws-opts"]; ok {
|
||||
if wsMap := toStringMap(wsOpts); wsMap != nil {
|
||||
if path := getMapString(wsMap, "path"); path != "" {
|
||||
ws["path"] = path
|
||||
}
|
||||
if headers := toStringMap(wsMap["headers"]); headers != nil {
|
||||
if h := getMapString(headers, "Host"); h != "" {
|
||||
ws["headers"] = map[string]interface{}{"Host": h}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stream["wsSettings"] = ws
|
||||
} else if network == "grpc" {
|
||||
stream["network"] = "grpc"
|
||||
if grpcOpts, ok := node["grpc-opts"]; ok {
|
||||
if grpcMap := toStringMap(grpcOpts); grpcMap != nil {
|
||||
if svcName := getMapString(grpcMap, "grpc-service-name"); svcName != "" {
|
||||
stream["grpcSettings"] = map[string]interface{}{"serviceName": svcName}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out["streamSettings"] = stream
|
||||
return out, "", nil
|
||||
}
|
||||
|
||||
func buildOutboundFromClashHysteria2(node map[string]interface{}) (map[string]interface{}, string, error) {
|
||||
// 支持的协议: vless, vmess, trojan, shadowsocks, socks, http, wireguard
|
||||
// hysteria2 需要使用 Hysteria 客户端或 sing-box
|
||||
return nil, "", fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理")
|
||||
}
|
||||
|
||||
func buildXrayOutbound(node string) (map[string]interface{}, error) {
|
||||
l := strings.ToLower(node)
|
||||
if strings.HasPrefix(l, "vmess://") {
|
||||
return buildOutboundVmess(node)
|
||||
}
|
||||
if strings.HasPrefix(l, "vless://") {
|
||||
return buildOutboundVless(node)
|
||||
}
|
||||
if strings.HasPrefix(l, "trojan://") {
|
||||
return buildOutboundTrojan(node)
|
||||
}
|
||||
if strings.HasPrefix(l, "ss://") {
|
||||
return buildOutboundSS(node)
|
||||
}
|
||||
if strings.HasPrefix(l, "ssr://") {
|
||||
return nil, fmt.Errorf("不支持 ShadowsocksR 协议,Xray 不支持 SSR,请使用 SS/vmess/vless/trojan")
|
||||
}
|
||||
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
|
||||
return buildOutboundHysteria2(node)
|
||||
}
|
||||
return nil, fmt.Errorf("不支持的节点协议")
|
||||
}
|
||||
|
||||
func buildOutboundVmess(node string) (map[string]interface{}, error) {
|
||||
raw := strings.TrimPrefix(node, "vmess://")
|
||||
decoded, err := decodeBase64String(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess 解析失败: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Add string `json:"add"`
|
||||
Port string `json:"port"`
|
||||
ID string `json:"id"`
|
||||
Net string `json:"net"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
TLS string `json:"tls"`
|
||||
Sni string `json:"sni"`
|
||||
Alpn string `json:"alpn"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &v); err != nil {
|
||||
return nil, fmt.Errorf("vmess 配置解析失败: %v", err)
|
||||
}
|
||||
p, _ := strconv.Atoi(v.Port)
|
||||
out := map[string]interface{}{
|
||||
"protocol": "vmess",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"vnext": []interface{}{
|
||||
map[string]interface{}{
|
||||
"address": v.Add,
|
||||
"port": p,
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": v.ID,
|
||||
"security": "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
stream := map[string]interface{}{}
|
||||
if v.TLS == "tls" {
|
||||
stream["security"] = "tls"
|
||||
if v.Sni != "" {
|
||||
stream["tlsSettings"] = map[string]interface{}{"serverName": v.Sni}
|
||||
}
|
||||
}
|
||||
if v.Net == "ws" {
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]interface{}{}
|
||||
if v.Path != "" {
|
||||
ws["path"] = v.Path
|
||||
}
|
||||
if v.Host != "" {
|
||||
ws["headers"] = map[string]interface{}{"Host": v.Host}
|
||||
}
|
||||
if len(ws) > 0 {
|
||||
stream["wsSettings"] = ws
|
||||
}
|
||||
}
|
||||
if len(stream) > 0 {
|
||||
out["streamSettings"] = stream
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildOutboundVless(node string) (map[string]interface{}, error) {
|
||||
u, err := url.Parse(node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vless 解析失败: %v", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
portStr := u.Port()
|
||||
p, _ := strconv.Atoi(portStr)
|
||||
id := u.User.Username()
|
||||
q := u.Query()
|
||||
flow := q.Get("flow")
|
||||
sec := strings.ToLower(q.Get("security"))
|
||||
sni := q.Get("sni")
|
||||
out := map[string]interface{}{
|
||||
"protocol": "vless",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"vnext": []interface{}{
|
||||
map[string]interface{}{
|
||||
"address": host,
|
||||
"port": p,
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": id,
|
||||
"flow": flow,
|
||||
"encryption": "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
stream := map[string]interface{}{}
|
||||
if sec == "tls" || sec == "reality" {
|
||||
stream["security"] = "tls"
|
||||
if sni != "" {
|
||||
stream["tlsSettings"] = map[string]interface{}{"serverName": sni}
|
||||
}
|
||||
}
|
||||
network := q.Get("type")
|
||||
if network == "" {
|
||||
network = q.Get("network")
|
||||
}
|
||||
if network == "ws" {
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]interface{}{}
|
||||
if pth := q.Get("path"); pth != "" {
|
||||
ws["path"] = pth
|
||||
}
|
||||
hostH := q.Get("host")
|
||||
if hostH == "" {
|
||||
hostH = u.Hostname()
|
||||
}
|
||||
if hostH != "" {
|
||||
ws["headers"] = map[string]interface{}{"Host": hostH}
|
||||
}
|
||||
stream["wsSettings"] = ws
|
||||
}
|
||||
if len(stream) > 0 {
|
||||
out["streamSettings"] = stream
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildOutboundHysteria2(node string) (map[string]interface{}, error) {
|
||||
// Xray 不支持 hysteria2 作为 outbound 协议
|
||||
// 支持的协议: vless, vmess, trojan, shadowsocks, socks, http, wireguard
|
||||
// hysteria2 需要使用 Hysteria 客户端或 sing-box
|
||||
return nil, fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理")
|
||||
}
|
||||
|
||||
// buildOutboundTrojan 解析 trojan:// URI 格式
|
||||
func buildOutboundTrojan(node string) (map[string]interface{}, error) {
|
||||
u, err := url.Parse(node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trojan 解析失败: %v", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
portStr := u.Port()
|
||||
p, _ := strconv.Atoi(portStr)
|
||||
password := u.User.Username()
|
||||
q := u.Query()
|
||||
sni := q.Get("sni")
|
||||
if sni == "" {
|
||||
sni = q.Get("peer")
|
||||
}
|
||||
skipVerify := q.Get("allowInsecure") == "1" || strings.ToLower(q.Get("allowInsecure")) == "true"
|
||||
network := q.Get("type")
|
||||
|
||||
out := map[string]interface{}{
|
||||
"protocol": "trojan",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"address": host,
|
||||
"port": p,
|
||||
"password": password,
|
||||
},
|
||||
}
|
||||
stream := map[string]interface{}{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]interface{}{
|
||||
"serverName": sni,
|
||||
"allowInsecure": skipVerify,
|
||||
},
|
||||
}
|
||||
if network == "ws" {
|
||||
stream["network"] = "ws"
|
||||
ws := map[string]interface{}{}
|
||||
if pth := q.Get("path"); pth != "" {
|
||||
ws["path"] = pth
|
||||
}
|
||||
if h := q.Get("host"); h != "" {
|
||||
ws["headers"] = map[string]interface{}{"Host": h}
|
||||
}
|
||||
stream["wsSettings"] = ws
|
||||
}
|
||||
out["streamSettings"] = stream
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildOutboundFromClashSS 从 Clash YAML 格式解析 Shadowsocks outbound
|
||||
func buildOutboundFromClashSS(node map[string]interface{}) (map[string]interface{}, string, error) {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
password := getMapString(node, "password")
|
||||
cipher := getMapString(node, "cipher")
|
||||
if cipher == "" {
|
||||
cipher = getMapString(node, "method")
|
||||
}
|
||||
if cipher == "" {
|
||||
cipher = "aes-256-gcm"
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"protocol": "shadowsocks",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"address": host,
|
||||
"port": port,
|
||||
"method": cipher,
|
||||
"password": password,
|
||||
},
|
||||
}
|
||||
// plugin 支持(obfs/v2ray-plugin)
|
||||
if plugin := getMapString(node, "plugin"); plugin != "" {
|
||||
pluginOpts := getMapString(node, "plugin-opts")
|
||||
_ = pluginOpts // xray 原生不支持 plugin,忽略
|
||||
}
|
||||
return out, "", nil
|
||||
}
|
||||
|
||||
// buildOutboundSS 解析 ss:// URI 格式
|
||||
// 支持两种格式:
|
||||
// 1. ss://BASE64(method:password)@host:port
|
||||
// 2. ss://BASE64(method:password@host:port)
|
||||
func buildOutboundSS(node string) (map[string]interface{}, error) {
|
||||
raw := strings.TrimPrefix(node, "ss://")
|
||||
// 去掉 fragment(#备注)
|
||||
if idx := strings.Index(raw, "#"); idx >= 0 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var host, method, password string
|
||||
var port int
|
||||
|
||||
// 格式1:method:password@host:port(SIP002)
|
||||
if strings.Contains(raw, "@") {
|
||||
u, err := url.Parse("ss://" + raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ss 解析失败: %v", err)
|
||||
}
|
||||
host = u.Hostname()
|
||||
port, _ = strconv.Atoi(u.Port())
|
||||
userInfo := u.User.String()
|
||||
// userInfo 可能是 base64 编码的 method:password
|
||||
if decoded, err := decodeBase64String(userInfo); err == nil {
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
password = parts[1]
|
||||
}
|
||||
} else {
|
||||
// 明文 method:password
|
||||
parts := strings.SplitN(userInfo, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
password = parts[1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 格式2:整体 base64
|
||||
decoded, err := decodeBase64String(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ss base64 解析失败: %v", err)
|
||||
}
|
||||
// method:password@host:port
|
||||
s := string(decoded)
|
||||
atIdx := strings.LastIndex(s, "@")
|
||||
if atIdx < 0 {
|
||||
return nil, fmt.Errorf("ss 格式错误")
|
||||
}
|
||||
userPart := s[:atIdx]
|
||||
hostPart := s[atIdx+1:]
|
||||
parts := strings.SplitN(userPart, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
password = parts[1]
|
||||
}
|
||||
hostPort := strings.Split(hostPart, ":")
|
||||
if len(hostPort) == 2 {
|
||||
host = hostPort[0]
|
||||
port, _ = strconv.Atoi(hostPort[1])
|
||||
}
|
||||
}
|
||||
|
||||
if host == "" || port == 0 || method == "" {
|
||||
return nil, fmt.Errorf("ss 节点信息不完整")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"protocol": "shadowsocks",
|
||||
"tag": "proxy-out",
|
||||
"settings": map[string]interface{}{
|
||||
"address": host,
|
||||
"port": port,
|
||||
"method": method,
|
||||
"password": password,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SingBoxBridge sing-box 桥接进程
|
||||
type SingBoxBridge struct {
|
||||
NodeKey string
|
||||
Port int
|
||||
Cmd *exec.Cmd
|
||||
Pid int
|
||||
Running bool
|
||||
LastError string
|
||||
}
|
||||
|
||||
// SingBoxManager sing-box 桥接管理器
|
||||
type SingBoxManager struct {
|
||||
Config *config.Config
|
||||
AppRoot string // 应用根目录,所有相对路径基于此解析
|
||||
Bridges map[string]*SingBoxBridge
|
||||
OnBridgeDied func(key string, err error)
|
||||
}
|
||||
|
||||
// NewSingBoxManager 创建 sing-box 管理器
|
||||
func NewSingBoxManager(cfg *config.Config, appRoot string) *SingBoxManager {
|
||||
return &SingBoxManager{
|
||||
Config: cfg,
|
||||
AppRoot: appRoot,
|
||||
Bridges: make(map[string]*SingBoxBridge),
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBridge 确保 sing-box 桥接进程运行,返回 socks5://127.0.0.1:port
|
||||
func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
|
||||
log := logger.New("SingBox")
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return "", fmt.Errorf("未找到代理节点")
|
||||
}
|
||||
|
||||
src = normalizeNodeScheme(src)
|
||||
outbound, err := BuildSingBoxOutbound(src)
|
||||
if err != nil {
|
||||
log.Error("节点解析失败", logger.F("error", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := computeNodeKey(src)
|
||||
|
||||
// 复用已有桥接
|
||||
if bridge, ok := m.Bridges[key]; ok && bridge != nil && bridge.Running {
|
||||
alive := bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil
|
||||
if alive {
|
||||
if err := waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond); err == nil {
|
||||
log.Info("复用 sing-box 桥接", logger.F("key", key[:8]), logger.F("port", bridge.Port))
|
||||
return fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port), nil
|
||||
}
|
||||
}
|
||||
log.Info("sing-box 桥接已失效,重新启动", logger.F("key", key[:8]))
|
||||
if bridge.Cmd != nil && bridge.Cmd.Process != nil {
|
||||
_ = bridge.Cmd.Process.Kill()
|
||||
}
|
||||
bridge.Running = false
|
||||
delete(m.Bridges, key)
|
||||
}
|
||||
|
||||
binaryPath, err := m.resolveBinary()
|
||||
if err != nil {
|
||||
log.Error("sing-box 不可用", logger.F("error", err), logger.F("appRoot", m.AppRoot))
|
||||
return "", err
|
||||
}
|
||||
log.Debug("sing-box binary", logger.F("path", binaryPath))
|
||||
|
||||
const maxRetries = 3
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
port, err := nextAvailablePort()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
cfgPath, err := m.buildConfig(key, outbound, port)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sing-box 配置生成失败: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
|
||||
hideWindow(cmd)
|
||||
cmd.Dir = filepath.Dir(cfgPath)
|
||||
stderrPath := filepath.Join(filepath.Dir(cfgPath), "singbox-stderr.log")
|
||||
stderrFile, _ := os.Create(stderrPath)
|
||||
if stderrFile != nil {
|
||||
cmd.Stderr = stderrFile
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
log.Error("sing-box 启动失败", logger.F("error", err), logger.F("attempt", attempt))
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
bridge := &SingBoxBridge{
|
||||
NodeKey: key,
|
||||
Port: port,
|
||||
Cmd: cmd,
|
||||
Pid: cmd.Process.Pid,
|
||||
Running: true,
|
||||
}
|
||||
m.Bridges[key] = bridge
|
||||
log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
|
||||
|
||||
if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil {
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
if content, readErr := os.ReadFile(stderrPath); readErr == nil && len(content) > 0 {
|
||||
log.Error("sing-box stderr", logger.F("output", string(content)))
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
bridge.Running = false
|
||||
bridge.LastError = err.Error()
|
||||
delete(m.Bridges, key)
|
||||
log.Error("sing-box 端口不可用,重试", logger.F("error", err), logger.F("attempt", attempt))
|
||||
lastErr = err
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
|
||||
go func(b *SingBoxBridge, nodeKey string) {
|
||||
_ = b.Cmd.Wait()
|
||||
b.Running = false
|
||||
if m.OnBridgeDied != nil {
|
||||
m.OnBridgeDied(nodeKey, fmt.Errorf("sing-box 桥接进程意外退出"))
|
||||
}
|
||||
}(bridge, key)
|
||||
|
||||
return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("sing-box 启动失败(已重试 %d 次): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// StopAll 关闭所有 sing-box 桥接进程
|
||||
func (m *SingBoxManager) StopAll() {
|
||||
for key, bridge := range m.Bridges {
|
||||
if bridge != nil && bridge.Cmd != nil && bridge.Cmd.Process != nil {
|
||||
_ = bridge.Cmd.Process.Kill()
|
||||
}
|
||||
delete(m.Bridges, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SingBoxManager) resolveBinary() (string, error) {
|
||||
configPath := strings.TrimSpace(m.Config.Browser.SingBoxBinaryPath)
|
||||
if configPath != "" {
|
||||
resolved := resolveEnvPath(configPath, m.AppRoot)
|
||||
if resolved != "" {
|
||||
if _, err := os.Stat(resolved); err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if env := strings.TrimSpace(os.Getenv("SINGBOX_BINARY_PATH")); env != "" {
|
||||
if _, err := os.Stat(env); err == nil {
|
||||
return env, nil
|
||||
}
|
||||
}
|
||||
// 优先基于 appRoot 查找 bin/sing-box.exe
|
||||
if m.AppRoot != "" {
|
||||
candidate := filepath.Join(m.AppRoot, "bin", "sing-box.exe")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
// 兜底:exe 目录
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(exePath), "bin", "sing-box.exe")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("sing-box"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if goruntime.GOOS == "windows" {
|
||||
if path, err := exec.LookPath("sing-box.exe"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到 sing-box.exe。请将 sing-box.exe 放到 bin/ 目录,或在配置中设置 SingBoxBinaryPath")
|
||||
}
|
||||
|
||||
func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}, port int) (string, error) {
|
||||
baseDir := m.resolveWorkdir(key)
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cfg := map[string]interface{}{
|
||||
"log": map[string]interface{}{
|
||||
"level": "info",
|
||||
"output": filepath.Join(baseDir, "singbox.log"),
|
||||
"timestamp": true,
|
||||
},
|
||||
"inbounds": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "socks",
|
||||
"tag": "socks-in",
|
||||
"listen": "127.0.0.1",
|
||||
"listen_port": port,
|
||||
},
|
||||
},
|
||||
"outbounds": []interface{}{
|
||||
outbound,
|
||||
map[string]interface{}{
|
||||
"type": "direct",
|
||||
"tag": "direct",
|
||||
},
|
||||
},
|
||||
"route": map[string]interface{}{
|
||||
"rules": []interface{}{
|
||||
map[string]interface{}{
|
||||
"inbound": []string{"socks-in"},
|
||||
"outbound": "proxy-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cfgPath := filepath.Join(baseDir, "singbox-config.json")
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfgPath, nil
|
||||
}
|
||||
|
||||
func (m *SingBoxManager) resolveWorkdir(key string) string {
|
||||
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
|
||||
if root == "" {
|
||||
root = "data"
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
if m.AppRoot != "" {
|
||||
root = filepath.Join(m.AppRoot, root)
|
||||
} else if exePath, err := os.Executable(); err == nil {
|
||||
root = filepath.Join(filepath.Dir(exePath), root)
|
||||
}
|
||||
}
|
||||
return filepath.Join(root, "_singbox", key)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// IsSingBoxProtocol 判断是否为 sing-box 支持的协议(hysteria2/tuic)
|
||||
func IsSingBoxProtocol(proxyConfig string) bool {
|
||||
l := strings.ToLower(strings.TrimSpace(proxyConfig))
|
||||
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
|
||||
return true
|
||||
}
|
||||
// Clash YAML 格式
|
||||
if strings.Contains(l, "type: hysteria2") || strings.Contains(l, "type:hysteria2") ||
|
||||
strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") ||
|
||||
strings.Contains(l, "type: tuic") || strings.Contains(l, "type:tuic") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildSingBoxOutbound 解析节点配置,返回 sing-box outbound map
|
||||
func BuildSingBoxOutbound(node string) (map[string]interface{}, error) {
|
||||
src := strings.TrimSpace(node)
|
||||
l := strings.ToLower(src)
|
||||
|
||||
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
|
||||
return parseHysteria2URI(src)
|
||||
}
|
||||
|
||||
// Clash YAML 格式
|
||||
if strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
|
||||
return parseClashSingBoxNode(src)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("不支持的 sing-box 节点格式")
|
||||
}
|
||||
|
||||
// parseHysteria2URI 解析 hysteria2:// URI
|
||||
// 格式: hysteria2://password@host:port?sni=xxx&insecure=1
|
||||
func parseHysteria2URI(node string) (map[string]interface{}, error) {
|
||||
// 统一为 hysteria2://
|
||||
if strings.HasPrefix(strings.ToLower(node), "hysteria://") {
|
||||
node = "hysteria2://" + node[len("hysteria://"):]
|
||||
}
|
||||
|
||||
u, err := url.Parse(node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hysteria2 URI 解析失败: %v", err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
portStr := u.Port()
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
password := u.User.Username()
|
||||
if password == "" {
|
||||
// 有些格式把密码放在 userinfo 里不带 @
|
||||
password = strings.TrimPrefix(u.Host, "@")
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
sni := q.Get("sni")
|
||||
if sni == "" {
|
||||
sni = q.Get("peer")
|
||||
}
|
||||
insecure := q.Get("insecure") == "1" || strings.ToLower(q.Get("insecure")) == "true"
|
||||
obfsPassword := q.Get("obfs-password")
|
||||
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("hysteria2 节点信息不完整: host=%s port=%d", host, port)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"type": "hysteria2",
|
||||
"tag": "proxy-out",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"password": password,
|
||||
"tls": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"insecure": insecure,
|
||||
},
|
||||
}
|
||||
|
||||
if sni != "" {
|
||||
out["tls"].(map[string]interface{})["server_name"] = sni
|
||||
}
|
||||
|
||||
if obfsPassword != "" {
|
||||
out["obfs"] = map[string]interface{}{
|
||||
"type": "salamander",
|
||||
"password": obfsPassword,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseClashSingBoxNode 解析 Clash YAML 格式的 sing-box 节点
|
||||
func parseClashSingBoxNode(src string) (map[string]interface{}, error) {
|
||||
// 复用已有的 YAML 解析基础设施
|
||||
var payload interface{}
|
||||
if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
|
||||
return nil, fmt.Errorf("YAML 解析失败: %v", err)
|
||||
}
|
||||
|
||||
nodeMap := pickClashNode(payload)
|
||||
if nodeMap == nil {
|
||||
return nil, fmt.Errorf("节点解析失败")
|
||||
}
|
||||
|
||||
nodeType := strings.ToLower(getMapString(nodeMap, "type"))
|
||||
switch nodeType {
|
||||
case "hysteria2", "hysteria":
|
||||
return buildSingBoxHysteria2FromClash(nodeMap)
|
||||
case "tuic":
|
||||
return buildSingBoxTUICFromClash(nodeMap)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 sing-box 节点类型: %s", nodeType)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSingBoxHysteria2FromClash(node map[string]interface{}) (map[string]interface{}, error) {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
password := getMapString(node, "password")
|
||||
sni := getMapString(node, "sni")
|
||||
if sni == "" {
|
||||
sni = getMapString(node, "servername")
|
||||
}
|
||||
skipVerify := getMapBool(node, "skip-cert-verify")
|
||||
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("hysteria2 节点信息不完整")
|
||||
}
|
||||
|
||||
tls := map[string]interface{}{
|
||||
"enabled": true,
|
||||
"insecure": skipVerify,
|
||||
}
|
||||
if sni != "" {
|
||||
tls["server_name"] = sni
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"type": "hysteria2",
|
||||
"tag": "proxy-out",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"password": password,
|
||||
"tls": tls,
|
||||
}
|
||||
|
||||
// 带宽限制(可选)
|
||||
if up := getMapString(node, "up"); up != "" {
|
||||
out["up_mbps"] = parseBandwidthMbps(up)
|
||||
}
|
||||
if down := getMapString(node, "down"); down != "" {
|
||||
out["down_mbps"] = parseBandwidthMbps(down)
|
||||
}
|
||||
|
||||
// obfs
|
||||
if obfsPassword := getMapString(node, "obfs-password"); obfsPassword != "" {
|
||||
out["obfs"] = map[string]interface{}{
|
||||
"type": "salamander",
|
||||
"password": obfsPassword,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildSingBoxTUICFromClash(node map[string]interface{}) (map[string]interface{}, error) {
|
||||
host := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
uuid := getMapString(node, "uuid")
|
||||
password := getMapString(node, "password")
|
||||
sni := getMapString(node, "sni")
|
||||
skipVerify := getMapBool(node, "skip-cert-verify")
|
||||
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("tuic 节点信息不完整")
|
||||
}
|
||||
|
||||
tls := map[string]interface{}{
|
||||
"enabled": true,
|
||||
"insecure": skipVerify,
|
||||
}
|
||||
if sni != "" {
|
||||
tls["server_name"] = sni
|
||||
}
|
||||
|
||||
// alpn
|
||||
if alpnRaw, ok := node["alpn"]; ok {
|
||||
if alpnList := toStringSlice(alpnRaw); len(alpnList) > 0 {
|
||||
tls["alpn"] = alpnList
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "tuic",
|
||||
"tag": "proxy-out",
|
||||
"server": host,
|
||||
"server_port": port,
|
||||
"uuid": uuid,
|
||||
"password": password,
|
||||
"congestion_control": "bbr",
|
||||
"tls": tls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseBandwidthMbps 解析带宽字符串,返回 Mbps 整数
|
||||
// 支持: "100 Mbps", "100", "100M"
|
||||
func parseBandwidthMbps(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.ToUpper(s)
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.TrimSuffix(s, "BPS")
|
||||
s = strings.TrimSuffix(s, "B")
|
||||
s = strings.TrimSuffix(s, "M")
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
|
||||
// toStringSlice 将 interface{} 转为 []string
|
||||
func toStringSlice(v interface{}) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if arr, ok := v.([]interface{}); ok {
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return []string{s}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
C "github.com/metacubex/mihomo/constant"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
)
|
||||
|
||||
// ─── Clash 标准测速 URL ───
|
||||
// 使用 HTTP 与 Clash 客户端保持一致
|
||||
|
||||
const defaultTestURL = "http://www.gstatic.com/generate_204"
|
||||
|
||||
// SpeedTestConfig 测速参数
|
||||
type SpeedTestConfig struct {
|
||||
Timeout time.Duration
|
||||
TCPTimeout time.Duration
|
||||
URLs []string
|
||||
}
|
||||
|
||||
var DefaultSpeedTestConfig = SpeedTestConfig{
|
||||
Timeout: 10 * time.Second,
|
||||
TCPTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// ─── 对外入口 ───
|
||||
|
||||
// SpeedTest 使用 mihomo 代理适配器进行测速。
|
||||
// 采用 unified-delay 策略:先建立连接(预热),再单独计时 HTTP 往返,
|
||||
// 与 Clash 客户端 unified-delay: true 的延迟结果一致。
|
||||
func SpeedTest(
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
cfg *SpeedTestConfig,
|
||||
) TestResult {
|
||||
log := logger.New("SpeedTest")
|
||||
|
||||
if cfg == nil {
|
||||
c := DefaultSpeedTestConfig
|
||||
cfg = &c
|
||||
}
|
||||
|
||||
// 查找代理配置
|
||||
src := ""
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
|
||||
}
|
||||
|
||||
if strings.ToLower(src) == "direct://" {
|
||||
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0}
|
||||
}
|
||||
|
||||
testURL := defaultTestURL
|
||||
if len(cfg.URLs) > 0 {
|
||||
testURL = cfg.URLs[0]
|
||||
}
|
||||
|
||||
// 将代理配置转换为 mihomo mapping
|
||||
mapping, err := proxyConfigToMapping(src)
|
||||
if err != nil {
|
||||
log.Warn("代理配置解析失败,降级到 TCP ping",
|
||||
logger.F("proxy_id", proxyId),
|
||||
logger.F("error", err.Error()),
|
||||
)
|
||||
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
|
||||
}
|
||||
|
||||
// 使用 mihomo adapter.ParseProxy 创建代理实例
|
||||
proxyInstance, err := adapter.ParseProxy(mapping)
|
||||
if err != nil {
|
||||
log.Warn("mihomo 代理创建失败,降级到 TCP ping",
|
||||
logger.F("proxy_id", proxyId),
|
||||
logger.F("error", err.Error()),
|
||||
logger.F("type", mapping["type"]),
|
||||
)
|
||||
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
|
||||
}
|
||||
|
||||
// unified-delay 测速:分离连接建立和 HTTP 往返计时
|
||||
return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout)
|
||||
}
|
||||
|
||||
// unifiedDelayTest 模拟 Clash unified-delay 模式:
|
||||
// 1. 通过代理建立到目标的 TCP 连接(预热,不计入延迟)
|
||||
// 2. 发送第一次 HTTP 请求预热连接(不计入延迟)
|
||||
// 3. 在已建立的连接上发送第二次 HTTP 请求,只计这次的 RTT
|
||||
// 这样测出的延迟 = 纯 HTTP 往返时间,和 Clash unified-delay: true 一致。
|
||||
func unifiedDelayTest(proxyId string, px C.Proxy, testURL string, timeout time.Duration) TestResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// 解析目标地址
|
||||
addr, err := urlToMeta(testURL)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("URL 解析失败: %v", err)}
|
||||
}
|
||||
|
||||
// 步骤 1:通过代理 DialContext 建立连接(预热)
|
||||
conn, err := px.DialContext(ctx, &addr)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理连接失败: %v", err)}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 构造复用此连接的 HTTP client
|
||||
transport := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
DisableKeepAlives: false,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// 步骤 2:第一次请求预热(不计时)
|
||||
req1, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
|
||||
resp1, err := client.Do(req1)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()}
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
// 步骤 3:第二次请求计时(纯 HTTP RTT)
|
||||
start := time.Now()
|
||||
req2, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
|
||||
resp2, err := client.Do(req2)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusNoContent {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency,
|
||||
Error: fmt.Sprintf("HTTP %d", resp2.StatusCode)}
|
||||
}
|
||||
|
||||
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
|
||||
}
|
||||
|
||||
// urlToMeta 将 URL 转换为 mihomo Metadata
|
||||
func urlToMeta(rawURL string) (C.Metadata, error) {
|
||||
var host string
|
||||
var portNum uint16
|
||||
if strings.HasPrefix(rawURL, "https://") {
|
||||
host = rawURL[len("https://"):]
|
||||
portNum = 443
|
||||
} else if strings.HasPrefix(rawURL, "http://") {
|
||||
host = rawURL[len("http://"):]
|
||||
portNum = 80
|
||||
} else {
|
||||
return C.Metadata{}, fmt.Errorf("不支持的 URL scheme")
|
||||
}
|
||||
// 去掉 path
|
||||
if idx := strings.Index(host, "/"); idx >= 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
// 检查是否有自定义端口
|
||||
if h, p, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
fmt.Sscanf(p, "%d", &portNum)
|
||||
}
|
||||
|
||||
meta := C.Metadata{
|
||||
Host: host,
|
||||
DstPort: portNum,
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
meta.DstIP = addr
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ─── 代理配置转换为 mihomo mapping ───
|
||||
|
||||
func proxyConfigToMapping(src string) (map[string]any, error) {
|
||||
src = strings.TrimSpace(src)
|
||||
l := strings.ToLower(src)
|
||||
|
||||
// http/https 直连代理
|
||||
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
|
||||
return parseStandardProxy(src, "http")
|
||||
}
|
||||
// socks5 直连代理
|
||||
if strings.HasPrefix(l, "socks5://") {
|
||||
return parseStandardProxy(src, "socks5")
|
||||
}
|
||||
|
||||
// URI 格式(vmess:// vless:// 等)暂不支持直接转 mapping,降级
|
||||
if strings.Contains(l, "://") && !strings.Contains(l, "type:") {
|
||||
return nil, fmt.Errorf("URI 格式暂不支持: %s", l[:min(30, len(l))])
|
||||
}
|
||||
|
||||
// Clash YAML 格式 → 直接解析
|
||||
return parseClashYAMLToMapping(src)
|
||||
}
|
||||
|
||||
func parseStandardProxy(src string, proxyType string) (map[string]any, error) {
|
||||
rest := src[strings.Index(src, "://")+3:]
|
||||
|
||||
var username, password, hostport string
|
||||
if atIdx := strings.LastIndex(rest, "@"); atIdx >= 0 {
|
||||
userInfo := rest[:atIdx]
|
||||
hostport = rest[atIdx+1:]
|
||||
parts := strings.SplitN(userInfo, ":", 2)
|
||||
username = parts[0]
|
||||
if len(parts) > 1 {
|
||||
password = parts[1]
|
||||
}
|
||||
} else {
|
||||
hostport = rest
|
||||
}
|
||||
hostport = strings.SplitN(hostport, "/", 2)[0]
|
||||
|
||||
host, port := splitHostPort(hostport)
|
||||
if host == "" || port == 0 {
|
||||
return nil, fmt.Errorf("无法解析地址: %s", src)
|
||||
}
|
||||
|
||||
mapping := map[string]any{
|
||||
"name": "speedtest-proxy",
|
||||
"type": proxyType,
|
||||
"server": host,
|
||||
"port": port,
|
||||
}
|
||||
if username != "" {
|
||||
mapping["username"] = username
|
||||
mapping["password"] = password
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func parseClashYAMLToMapping(src string) (map[string]any, error) {
|
||||
var payload interface{}
|
||||
if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
|
||||
return nil, fmt.Errorf("YAML 解析失败: %v", err)
|
||||
}
|
||||
|
||||
node := pickClashNode(payload)
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("无法提取 Clash 节点")
|
||||
}
|
||||
|
||||
if _, ok := node["name"]; !ok {
|
||||
node["name"] = "speedtest-proxy"
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func splitHostPort(hostport string) (string, int) {
|
||||
if strings.HasPrefix(hostport, "[") {
|
||||
if idx := strings.LastIndex(hostport, "]:"); idx >= 0 {
|
||||
host := hostport[1:idx]
|
||||
port := 0
|
||||
fmt.Sscanf(hostport[idx+2:], "%d", &port)
|
||||
return host, port
|
||||
}
|
||||
return strings.Trim(hostport, "[]"), 0
|
||||
}
|
||||
idx := strings.LastIndex(hostport, ":")
|
||||
if idx < 0 {
|
||||
return hostport, 0
|
||||
}
|
||||
host := hostport[:idx]
|
||||
port := 0
|
||||
fmt.Sscanf(hostport[idx+1:], "%d", &port)
|
||||
return host, port
|
||||
}
|
||||
|
||||
// ─── TCP Ping 降级 ───
|
||||
|
||||
func tcpPingFallback(proxyId, src string, timeout time.Duration, log *logger.Logger) TestResult {
|
||||
endpoint, err := proxyEndpoint(src)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("无法解析代理地址: %v", err)}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", endpoint, timeout)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("TCP 连接失败: %v", err)}
|
||||
}
|
||||
conn.Close()
|
||||
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package proxy
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
// do nothing on non-windows platforms
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// XrayBridge Xray 桥接进程
|
||||
type XrayBridge struct {
|
||||
NodeKey string
|
||||
Port int
|
||||
Cmd *exec.Cmd
|
||||
Pid int
|
||||
Running bool
|
||||
LastError string
|
||||
RefCount int
|
||||
LastUsedAt time.Time
|
||||
Stopping bool
|
||||
}
|
||||
|
||||
// ProxyResult 代理解析结果
|
||||
type ProxyResult struct {
|
||||
StandardProxy string // 标准代理 URL (http/socks5)
|
||||
Outbound map[string]interface{} // Xray outbound 配置
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestResult 代理测试结果
|
||||
type TestResult struct {
|
||||
ProxyId string
|
||||
Ok bool
|
||||
LatencyMs int64
|
||||
Error string
|
||||
}
|
||||
|
||||
// proxyEndpoint 从代理配置中提取 server:port,用于 TCP ping
|
||||
func proxyEndpoint(src string) (string, error) {
|
||||
src = strings.TrimSpace(src)
|
||||
l := strings.ToLower(src)
|
||||
|
||||
// 标准 URL 格式: socks5://host:port, http://host:port
|
||||
if strings.HasPrefix(l, "socks5://") || strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
|
||||
hostport := src[strings.Index(src, "//")+2:]
|
||||
hostport = strings.SplitN(hostport, "/", 2)[0]
|
||||
return hostport, nil
|
||||
}
|
||||
|
||||
// vmess:// URL (base64 encoded JSON)
|
||||
if strings.HasPrefix(l, "vmess://") {
|
||||
raw := strings.TrimPrefix(src, "vmess://")
|
||||
decoded, err := decodeBase64String(strings.TrimSpace(raw))
|
||||
if err == nil {
|
||||
var v struct {
|
||||
Add string `json:"add"`
|
||||
Port interface{} `json:"port"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(decoded, &v); jsonErr == nil && v.Add != "" {
|
||||
return fmt.Sprintf("%s:%v", v.Add, v.Port), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vless:// URL: vless://uuid@host:port?...
|
||||
if strings.HasPrefix(l, "vless://") {
|
||||
rest := src[len("vless://"):]
|
||||
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
||||
hostport := strings.SplitN(rest[at+1:], "?", 2)[0]
|
||||
hostport = strings.SplitN(hostport, "#", 2)[0]
|
||||
return hostport, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Clash YAML 格式
|
||||
var payload interface{}
|
||||
if err := yaml.Unmarshal([]byte(src), &payload); err == nil {
|
||||
node := pickClashNode(payload)
|
||||
if node != nil {
|
||||
server := getMapString(node, "server")
|
||||
port := getMapInt(node, "port")
|
||||
if server != "" && port > 0 {
|
||||
return fmt.Sprintf("%s:%d", server, port), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("无法解析代理地址")
|
||||
}
|
||||
|
||||
// TestConnectivity 通过 TCP 握手测试代理服务器的可达性和延迟
|
||||
// 直接对 server:port 建立 TCP 连接测量 RTT,无需启动外部进程
|
||||
func TestConnectivity(proxyId string, proxyConfig string, proxies []config.BrowserProxy, _ interface{}) TestResult {
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
|
||||
}
|
||||
|
||||
endpoint, err := proxyEndpoint(src)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("地址解析失败: %v", err)}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", endpoint, 10*time.Second)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
|
||||
}
|
||||
conn.Close()
|
||||
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
|
||||
}
|
||||
|
||||
func toStringMap(input interface{}) map[string]interface{} {
|
||||
switch v := input.(type) {
|
||||
case map[string]interface{}:
|
||||
return v
|
||||
case map[interface{}]interface{}:
|
||||
out := map[string]interface{}{}
|
||||
for k, val := range v {
|
||||
out[fmt.Sprint(k)] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMapString(m map[string]interface{}, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(s)
|
||||
case int:
|
||||
return strconv.Itoa(s)
|
||||
case int64:
|
||||
return strconv.FormatInt(s, 10)
|
||||
case float64:
|
||||
return strconv.Itoa(int(s))
|
||||
case bool:
|
||||
if s {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
|
||||
func getMapInt(m map[string]interface{}, key string) int {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case int:
|
||||
return s
|
||||
case int64:
|
||||
return int(s)
|
||||
case float64:
|
||||
return int(s)
|
||||
case string:
|
||||
value, _ := strconv.Atoi(s)
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getMapBool(m map[string]interface{}, key string) bool {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case bool:
|
||||
return s
|
||||
case string:
|
||||
return strings.ToLower(s) == "true"
|
||||
case int:
|
||||
return s != 0
|
||||
case float64:
|
||||
return int(s) != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeBase64String(raw string) ([]byte, error) {
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("base64 内容为空")
|
||||
}
|
||||
if data, err := base64.StdEncoding.DecodeString(raw); err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if data, err := base64.RawStdEncoding.DecodeString(raw); err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if data, err := base64.URLEncoding.DecodeString(raw); err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if data, err := base64.RawURLEncoding.DecodeString(raw); err == nil {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("base64 解析失败")
|
||||
}
|
||||
|
||||
// isUnsupportedProtocol 判断是否为不支持的协议(hysteria/hysteria2)
|
||||
func isUnsupportedProtocol(src string) bool {
|
||||
l := strings.ToLower(strings.TrimSpace(src))
|
||||
return strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://")
|
||||
}
|
||||
|
||||
// TestRealConnectivity 通过代理链路发起真实 HTTP 请求测量端到端延迟。
|
||||
// - DirectProxy (http/https/socks5):直接通过该代理发送请求
|
||||
// - BridgeProxy (vmess/vless/Clash):调用 EnsureBridge 获取 socks5 地址后发送请求
|
||||
// - SingBoxProxy (hysteria2/tuic):调用 SingBoxManager.EnsureBridge 后发送请求
|
||||
func TestRealConnectivity(
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
) TestResult {
|
||||
return TestRealConnectivityWithSingBox(proxyId, proxies, xrayMgr, nil)
|
||||
}
|
||||
|
||||
// TestRealConnectivityWithSingBox 支持 sing-box 的真实连通性测试
|
||||
func TestRealConnectivityWithSingBox(
|
||||
proxyId string,
|
||||
proxies []config.BrowserProxy,
|
||||
xrayMgr *XrayManager,
|
||||
singboxMgr *SingBoxManager,
|
||||
) TestResult {
|
||||
src := ""
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
|
||||
}
|
||||
|
||||
const targetURL = "http://www.gstatic.com/generate_204"
|
||||
const timeout = 15 * time.Second
|
||||
|
||||
var client *http.Client
|
||||
|
||||
if IsSingBoxProtocol(src) {
|
||||
// hysteria2/tuic → sing-box 桥接
|
||||
if singboxMgr == nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"}
|
||||
}
|
||||
socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("sing-box 桥接启动失败: %v", err)}
|
||||
}
|
||||
socks5Host := strings.TrimPrefix(socks5Addr, "socks5://")
|
||||
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)}
|
||||
}
|
||||
contextDialer, ok := dialer.(xproxy.ContextDialer)
|
||||
if !ok {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"}
|
||||
}
|
||||
transport := &http.Transport{DialContext: contextDialer.DialContext}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
} else if RequiresBridge(src, proxies, proxyId) {
|
||||
// BridgeProxy:通过 xray socks5 桥接
|
||||
if xrayMgr == nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "xray 管理器未初始化"}
|
||||
}
|
||||
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("桥接启动失败: %v", err)}
|
||||
}
|
||||
// 解析 socks5://127.0.0.1:port
|
||||
socks5Host := strings.TrimPrefix(socks5Addr, "socks5://")
|
||||
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)}
|
||||
}
|
||||
contextDialer, ok := dialer.(xproxy.ContextDialer)
|
||||
if !ok {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"}
|
||||
}
|
||||
transport := &http.Transport{DialContext: contextDialer.DialContext}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
} else {
|
||||
// DirectProxy:http/https/socks5 直接代理
|
||||
proxyURL, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理地址解析失败: %v", err)}
|
||||
}
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
client = &http.Client{Transport: transport, Timeout: timeout}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Get(targetURL)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}
|
||||
}
|
||||
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
xrayBridgeIdleTTL = 45 * time.Second
|
||||
xrayBridgeCleanupInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// XrayManager Xray 桥接管理器
|
||||
type XrayManager struct {
|
||||
Config *config.Config
|
||||
AppRoot string // 应用根目录,所有相对路径基于此解析
|
||||
Bridges map[string]*XrayBridge
|
||||
OnBridgeDied func(key string, err error) // 桥接进程意外退出回调
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewXrayManager 创建 Xray 管理器
|
||||
func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager {
|
||||
manager := &XrayManager{
|
||||
Config: cfg,
|
||||
AppRoot: appRoot,
|
||||
Bridges: make(map[string]*XrayBridge),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go manager.cleanupLoop()
|
||||
return manager
|
||||
}
|
||||
|
||||
// ValidateProxyConfig 验证代理配置是否支持
|
||||
// 返回: supported bool, errorMsg string
|
||||
func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (bool, string) {
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
found := false
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false, fmt.Sprintf("代理链路不可用:代理池节点已不存在(proxyId=%s)。可能因订阅刷新后节点下线或被删除,请重新选择代理后再启动。", proxyId)
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return true, "" // 无代理配置,允许启动
|
||||
}
|
||||
if strings.EqualFold(src, "direct://") {
|
||||
return true, ""
|
||||
}
|
||||
l := strings.ToLower(src)
|
||||
// 标准代理格式,支持
|
||||
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
|
||||
return true, ""
|
||||
}
|
||||
// hysteria2/tuic 通过 sing-box 支持,先做可解析性校验
|
||||
if IsSingBoxProtocol(src) {
|
||||
if _, err := BuildSingBoxOutbound(src); err != nil {
|
||||
return false, fmt.Sprintf("代理配置解析失败: %v", err)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// 其余协议交给统一解析器校验,防止无效字符串被当成代理参数透传给 Chrome
|
||||
standardProxy, outbound, err := ParseProxyNode(src)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("代理配置解析失败: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(standardProxy) == "" && outbound == nil {
|
||||
return false, "代理配置无效"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// RequiresBridge 判断是否需要 Xray 桥接
|
||||
// 注意: Xray 仅支持 vless/vmess/trojan/shadowsocks 等协议
|
||||
// hysteria2 不支持,需要使用 Hysteria 客户端或 sing-box
|
||||
func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) bool {
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return false
|
||||
}
|
||||
l := strings.ToLower(src)
|
||||
// 标准代理格式,不需要桥接
|
||||
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
|
||||
return false
|
||||
}
|
||||
// hysteria2 Xray 不支持,不触发桥接
|
||||
if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") {
|
||||
return false
|
||||
}
|
||||
// Xray 支持的协议
|
||||
if strings.HasPrefix(l, "vmess://") || strings.HasPrefix(l, "vless://") || strings.HasPrefix(l, "trojan://") || strings.HasPrefix(l, "ss://") {
|
||||
return true
|
||||
}
|
||||
// Clash 格式需要进一步检查类型
|
||||
if strings.HasPrefix(l, "clash://") || strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
|
||||
// 排除 hysteria 类型
|
||||
if strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnsureBridge 确保 Xray 桥接进程运行,用于临时请求场景。
|
||||
func (m *XrayManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
|
||||
socksURL, _, err := m.ensureBridge(proxyConfig, proxies, proxyId, false)
|
||||
return socksURL, err
|
||||
}
|
||||
|
||||
// AcquireBridge 获取一个带引用计数的 Xray 桥接,用于浏览器实例等长生命周期场景。
|
||||
func (m *XrayManager) AcquireBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, string, error) {
|
||||
return m.ensureBridge(proxyConfig, proxies, proxyId, true)
|
||||
}
|
||||
|
||||
// ReleaseBridge 释放一个已占用的桥接引用;空闲桥接会由后台回收协程延迟清理。
|
||||
func (m *XrayManager) ReleaseBridge(key string) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
bridge, ok := m.Bridges[key]
|
||||
if !ok || bridge == nil {
|
||||
return
|
||||
}
|
||||
if bridge.RefCount > 0 {
|
||||
bridge.RefCount--
|
||||
}
|
||||
bridge.LastUsedAt = time.Now()
|
||||
}
|
||||
|
||||
// StopAll 关闭所有 xray 桥接进程。
|
||||
func (m *XrayManager) StopAll() {
|
||||
m.stopOnce.Do(func() {
|
||||
close(m.stopCh)
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
bridges := make([]*XrayBridge, 0, len(m.Bridges))
|
||||
for key, bridge := range m.Bridges {
|
||||
if bridge != nil {
|
||||
bridge.Stopping = true
|
||||
bridges = append(bridges, bridge)
|
||||
}
|
||||
delete(m.Bridges, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, bridge := range bridges {
|
||||
m.stopBridgeProcess(bridge)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) {
|
||||
log := logger.New("Xray")
|
||||
src := strings.TrimSpace(proxyConfig)
|
||||
dnsServers := ""
|
||||
if proxyId != "" {
|
||||
for _, item := range proxies {
|
||||
if strings.EqualFold(item.ProxyId, proxyId) {
|
||||
src = strings.TrimSpace(item.ProxyConfig)
|
||||
dnsServers = item.DnsServers
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if src == "" {
|
||||
return "", "", fmt.Errorf("未找到代理节点")
|
||||
}
|
||||
src = normalizeNodeScheme(src)
|
||||
standardProxy, outbound, err := ParseProxyNode(src)
|
||||
if err != nil {
|
||||
log.Error("节点解析失败", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
if standardProxy != "" {
|
||||
return standardProxy, "", nil
|
||||
}
|
||||
if outbound == nil {
|
||||
return "", "", fmt.Errorf("节点解析失败")
|
||||
}
|
||||
key := computeNodeKey(src + "\x00" + dnsServers)
|
||||
|
||||
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
|
||||
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
|
||||
return socksURL, key, nil
|
||||
}
|
||||
|
||||
binaryPath, err := m.resolveBinary()
|
||||
if err != nil {
|
||||
log.Error("xray 不可用", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
// 最多重试 3 次,解决端口分配后被抢占的 TOCTOU 竞争问题
|
||||
const maxLaunchRetries = 3
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxLaunchRetries; attempt++ {
|
||||
port, err := nextAvailablePort()
|
||||
if err != nil {
|
||||
log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt))
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers)
|
||||
if err != nil {
|
||||
log.Error("xray 配置生成失败", logger.F("error", err))
|
||||
return "", "", err
|
||||
}
|
||||
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
|
||||
hideWindow(cmd)
|
||||
cmd.Dir = filepath.Dir(cfgPath)
|
||||
stderrPath := filepath.Join(filepath.Dir(cfgPath), "xray-stderr.log")
|
||||
stderrFile, _ := os.Create(stderrPath)
|
||||
if stderrFile != nil {
|
||||
cmd.Stderr = stderrFile
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
log.Error("xray 启动失败", logger.F("error", err), logger.F("attempt", attempt))
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
bridge := &XrayBridge{
|
||||
NodeKey: key,
|
||||
Port: port,
|
||||
Cmd: cmd,
|
||||
Pid: cmd.Process.Pid,
|
||||
Running: true,
|
||||
RefCount: 0,
|
||||
LastUsedAt: time.Now(),
|
||||
}
|
||||
log.Info("xray 启动", logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt))
|
||||
if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil {
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
// 优先读 stderr,再读 xray-error.log
|
||||
if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 {
|
||||
log.Error("xray stderr", logger.F("output", string(stderrContent)))
|
||||
} else {
|
||||
errLogPath := filepath.Join(filepath.Dir(cfgPath), "xray-error.log")
|
||||
if errContent, readErr := os.ReadFile(errLogPath); readErr == nil && len(errContent) > 0 {
|
||||
log.Error("xray error.log", logger.F("output", string(errContent)))
|
||||
}
|
||||
}
|
||||
bridge.Stopping = true
|
||||
m.stopBridgeProcess(bridge)
|
||||
bridge.Running = false
|
||||
bridge.Pid = 0
|
||||
bridge.LastError = err.Error()
|
||||
log.Error("xray 端口不可用,重试", logger.F("key", key), logger.F("error", err), logger.F("port", port), logger.F("attempt", attempt))
|
||||
lastErr = err
|
||||
// 等待一下再重试,给 OS 时间回收端口
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if stderrFile != nil {
|
||||
stderrFile.Close()
|
||||
}
|
||||
|
||||
if socksURL, reused := m.registerBridge(key, bridge, pin); reused {
|
||||
log.Info("复用已就绪桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
|
||||
bridge.Stopping = true
|
||||
m.stopBridgeProcess(bridge)
|
||||
return socksURL, key, nil
|
||||
}
|
||||
|
||||
go m.watchBridge(bridge, key)
|
||||
return fmt.Sprintf("socks5://127.0.0.1:%d", port), key, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr)
|
||||
}
|
||||
|
||||
func (m *XrayManager) tryReuseBridge(key string, pin bool) (string, bool) {
|
||||
var stale *XrayBridge
|
||||
|
||||
m.mu.Lock()
|
||||
if bridge, ok := m.Bridges[key]; ok && bridge != nil {
|
||||
alive := bridge.Running && bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil
|
||||
if alive && waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
|
||||
if pin {
|
||||
bridge.RefCount++
|
||||
}
|
||||
bridge.LastUsedAt = time.Now()
|
||||
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port)
|
||||
m.mu.Unlock()
|
||||
return socksURL, true
|
||||
}
|
||||
|
||||
bridge.Stopping = true
|
||||
stale = bridge
|
||||
delete(m.Bridges, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if stale != nil {
|
||||
m.stopBridgeProcess(stale)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) (string, bool) {
|
||||
var duplicate *XrayBridge
|
||||
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.Bridges[key]; ok && existing != nil {
|
||||
alive := existing.Running && existing.Cmd != nil && existing.Cmd.Process != nil && existing.Cmd.ProcessState == nil
|
||||
if alive && waitPortReady("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
|
||||
if pin {
|
||||
existing.RefCount++
|
||||
}
|
||||
existing.LastUsedAt = time.Now()
|
||||
duplicate = bridge
|
||||
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port)
|
||||
m.mu.Unlock()
|
||||
if duplicate != nil {
|
||||
duplicate.Stopping = true
|
||||
m.stopBridgeProcess(duplicate)
|
||||
}
|
||||
return socksURL, true
|
||||
}
|
||||
|
||||
existing.Stopping = true
|
||||
delete(m.Bridges, key)
|
||||
duplicate = existing
|
||||
}
|
||||
|
||||
if pin {
|
||||
bridge.RefCount = 1
|
||||
}
|
||||
bridge.LastUsedAt = time.Now()
|
||||
m.Bridges[key] = bridge
|
||||
m.mu.Unlock()
|
||||
|
||||
if duplicate != nil {
|
||||
m.stopBridgeProcess(duplicate)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (m *XrayManager) watchBridge(bridge *XrayBridge, key string) {
|
||||
if bridge == nil || bridge.Cmd == nil {
|
||||
return
|
||||
}
|
||||
_ = bridge.Cmd.Wait()
|
||||
|
||||
m.mu.Lock()
|
||||
if current, ok := m.Bridges[key]; ok && current == bridge {
|
||||
delete(m.Bridges, key)
|
||||
}
|
||||
bridge.Running = false
|
||||
stopping := bridge.Stopping
|
||||
m.mu.Unlock()
|
||||
|
||||
if !stopping && m.OnBridgeDied != nil {
|
||||
m.OnBridgeDied(key, fmt.Errorf("xray 桥接进程意外退出"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *XrayManager) cleanupLoop() {
|
||||
ticker := time.NewTicker(xrayBridgeCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.recycleIdleBridges()
|
||||
case <-m.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *XrayManager) recycleIdleBridges() {
|
||||
now := time.Now()
|
||||
var stale []*XrayBridge
|
||||
|
||||
m.mu.Lock()
|
||||
for key, bridge := range m.Bridges {
|
||||
if bridge == nil {
|
||||
delete(m.Bridges, key)
|
||||
continue
|
||||
}
|
||||
if bridge.RefCount > 0 {
|
||||
continue
|
||||
}
|
||||
if now.Sub(bridge.LastUsedAt) < xrayBridgeIdleTTL {
|
||||
continue
|
||||
}
|
||||
|
||||
bridge.Stopping = true
|
||||
stale = append(stale, bridge)
|
||||
delete(m.Bridges, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(stale) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log := logger.New("Xray")
|
||||
for _, bridge := range stale {
|
||||
log.Info("回收空闲桥接进程", logger.F("key", bridge.NodeKey), logger.F("pid", bridge.Pid))
|
||||
m.stopBridgeProcess(bridge)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *XrayManager) stopBridgeProcess(bridge *XrayBridge) {
|
||||
if bridge == nil || bridge.Cmd == nil || bridge.Cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = bridge.Cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func (m *XrayManager) resolveBinary() (string, error) {
|
||||
configPath := strings.TrimSpace(m.Config.Browser.XrayBinaryPath)
|
||||
if configPath != "" {
|
||||
resolved := resolveEnvPath(configPath, m.AppRoot)
|
||||
if resolved != "" {
|
||||
if _, err := os.Stat(resolved); err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH"))
|
||||
if env != "" {
|
||||
if _, err := os.Stat(env); err == nil {
|
||||
return env, nil
|
||||
}
|
||||
}
|
||||
// 优先基于 appRoot 查找 bin/xray.exe
|
||||
if m.AppRoot != "" {
|
||||
candidate := filepath.Join(m.AppRoot, "bin", "xray.exe")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
// 兜底:exe 目录
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(exePath), "bin", "xray.exe")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("xray"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if goruntime.GOOS == "windows" {
|
||||
if path, err := exec.LookPath("xray.exe"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到 xray.exe。请将 xray.exe 放到 bin/ 目录,或在配置中设置 XrayBinaryPath")
|
||||
}
|
||||
|
||||
// parseDnsConfig 解析 DNS 配置,支持两种格式:
|
||||
// 1. Clash dns: YAML 块(含 nameserver/fallback 等字段)
|
||||
// 2. 逗号分隔的 IP 列表(兼容旧格式)
|
||||
// 返回 xray dns 配置 map,若无有效配置则返回 nil
|
||||
//
|
||||
// 注意:xray dns.servers 只支持纯 IP 或 DoH(https://)地址,
|
||||
// 不支持 Clash 的 tls:// 格式(DoT),会被自动过滤。
|
||||
func parseDnsConfig(raw string) map[string]interface{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 尝试解析 Clash dns: YAML 块
|
||||
type clashDns struct {
|
||||
Enable bool `yaml:"enable"`
|
||||
Nameserver []string `yaml:"nameserver"`
|
||||
Fallback []string `yaml:"fallback"`
|
||||
}
|
||||
type clashDnsWrapper struct {
|
||||
Dns clashDns `yaml:"dns"`
|
||||
}
|
||||
|
||||
var wrapper clashDnsWrapper
|
||||
if err := yaml.Unmarshal([]byte(raw), &wrapper); err == nil && len(wrapper.Dns.Nameserver) > 0 {
|
||||
servers := make([]interface{}, 0)
|
||||
for _, s := range wrapper.Dns.Nameserver {
|
||||
if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) {
|
||||
servers = append(servers, s)
|
||||
}
|
||||
}
|
||||
for _, s := range wrapper.Dns.Fallback {
|
||||
if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) {
|
||||
servers = append(servers, s)
|
||||
}
|
||||
}
|
||||
if len(servers) > 0 {
|
||||
return map[string]interface{}{"servers": servers}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧格式:逗号分隔的 IP 列表
|
||||
var result []string
|
||||
for _, s := range strings.Split(raw, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
if len(result) > 0 {
|
||||
servers := make([]interface{}, len(result))
|
||||
for i, s := range result {
|
||||
servers[i] = s
|
||||
}
|
||||
return map[string]interface{}{"servers": servers}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isXrayDnsAddr 判断 DNS 地址是否为 xray 支持的格式。
|
||||
// xray 支持:纯 IP(如 8.8.8.8)、IP:port(如 8.8.8.8:53)、
|
||||
// DoH(https://...)、localhost。
|
||||
// 不支持:Clash 的 tls:// 格式(DoT)。
|
||||
func isXrayDnsAddr(s string) bool {
|
||||
l := strings.ToLower(s)
|
||||
if strings.HasPrefix(l, "tls://") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) {
|
||||
baseDir := m.resolveWorkdir(key)
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfgPath := filepath.Join(baseDir, "xray-config.json")
|
||||
cfg := map[string]interface{}{
|
||||
"log": map[string]interface{}{
|
||||
"loglevel": "info",
|
||||
"error": filepath.Join(baseDir, "xray-error.log"),
|
||||
},
|
||||
"inbounds": []interface{}{
|
||||
map[string]interface{}{
|
||||
"tag": "socks-in",
|
||||
"port": port,
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "socks",
|
||||
"settings": map[string]interface{}{
|
||||
"udp": true,
|
||||
},
|
||||
"sniffing": map[string]interface{}{
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
"outbounds": []interface{}{
|
||||
outbound,
|
||||
map[string]interface{}{
|
||||
"protocol": "direct",
|
||||
"tag": "direct",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"protocol": "blackhole",
|
||||
"tag": "block",
|
||||
},
|
||||
},
|
||||
"routing": map[string]interface{}{
|
||||
"rules": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"socks-in"},
|
||||
"outboundTag": "proxy-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil {
|
||||
cfg["dns"] = dnsCfg
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfgPath, nil
|
||||
}
|
||||
|
||||
func (m *XrayManager) resolveWorkdir(key string) string {
|
||||
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
|
||||
if root == "" {
|
||||
root = "data"
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
if m.AppRoot != "" {
|
||||
root = filepath.Join(m.AppRoot, root)
|
||||
} else if exePath, err := os.Executable(); err == nil {
|
||||
root = filepath.Join(filepath.Dir(exePath), root)
|
||||
}
|
||||
}
|
||||
return filepath.Join(root, "_xray", key)
|
||||
}
|
||||
|
||||
func computeNodeKey(src string) string {
|
||||
h := sha256.Sum256([]byte(strings.TrimSpace(src)))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func normalizeNodeScheme(src string) string {
|
||||
s := strings.TrimSpace(src)
|
||||
if strings.HasPrefix(strings.ToLower(s), "hysteria://") {
|
||||
return "hysteria2://" + strings.TrimPrefix(s, "hysteria://")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func resolveEnvPath(path string, appRoot string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
// 优先基于 appRoot 解析
|
||||
if appRoot != "" {
|
||||
candidate := filepath.Join(appRoot, path)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// 兜底:exe 目录
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(exePath), path)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// 兜底:CWD
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidate := filepath.Join(cwd, path)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func waitPortReady(host string, port int, timeout time.Duration) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("端口 %d 不可用", port)
|
||||
}
|
||||
|
||||
// nextAvailablePort 分配一个可用端口。
|
||||
// 采用二次验证策略:分配后立即再次绑定确认未被其他进程抢占,
|
||||
// 并在 EnsureBridge 层面加重试,彻底消除 TOCTOU 竞争窗口。
|
||||
func nextAvailablePort() (int, error) {
|
||||
return nextAvailablePortWithRetry(10)
|
||||
}
|
||||
|
||||
func nextAvailablePortWithRetry(maxRetries int) (int, error) {
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
listener.Close()
|
||||
// 短暂等待确保 OS 释放端口
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// 二次验证端口确实可用(没有被其他进程抢占)
|
||||
verifyListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
// 端口被抢占,重试
|
||||
continue
|
||||
}
|
||||
verifyListener.Close()
|
||||
return port, nil
|
||||
}
|
||||
return 0, fmt.Errorf("无法分配可用端口,已重试 %d 次", maxRetries)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"ant-chrome/backend/internal/config"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateProxyConfigInvalidRawString(t *testing.T) {
|
||||
ok, msg := ValidateProxyConfig("not-a-proxy-config", nil, "")
|
||||
if ok {
|
||||
t.Fatalf("expected invalid raw string to fail validation")
|
||||
}
|
||||
if !strings.Contains(msg, "解析失败") {
|
||||
t.Fatalf("unexpected message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyConfigMissingProxyId(t *testing.T) {
|
||||
ok, msg := ValidateProxyConfig("", []config.BrowserProxy{
|
||||
{ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}, "missing-proxy")
|
||||
if ok {
|
||||
t.Fatalf("expected missing proxyId to fail validation")
|
||||
}
|
||||
if !strings.Contains(msg, "不存在") {
|
||||
t.Fatalf("unexpected message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyConfigStandardProxy(t *testing.T) {
|
||||
ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", nil, "")
|
||||
if !ok {
|
||||
t.Fatalf("expected standard proxy to pass: %s", msg)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
|
||||
"github.com/energye/systray"
|
||||
)
|
||||
|
||||
//go:embed icon.ico
|
||||
var iconData []byte
|
||||
|
||||
// Callbacks 托盘回调
|
||||
type Callbacks struct {
|
||||
OnShow func()
|
||||
OnQuit func()
|
||||
}
|
||||
|
||||
// Run 启动系统托盘(阻塞,需在独立 goroutine 中调用)
|
||||
func Run(cb Callbacks) {
|
||||
systray.Run(func() {
|
||||
systray.SetIcon(iconData)
|
||||
systray.SetTitle("Ant Chrome")
|
||||
systray.SetTooltip("Ant Chrome")
|
||||
|
||||
mShow := systray.AddMenuItem("显示窗口", "显示主窗口")
|
||||
systray.AddSeparator()
|
||||
mQuit := systray.AddMenuItem("退出", "退出应用")
|
||||
|
||||
systray.SetOnClick(func(menu systray.IMenu) {
|
||||
if cb.OnShow != nil {
|
||||
cb.OnShow()
|
||||
}
|
||||
})
|
||||
|
||||
systray.SetOnDClick(func(menu systray.IMenu) {
|
||||
if cb.OnShow != nil {
|
||||
cb.OnShow()
|
||||
}
|
||||
})
|
||||
|
||||
mShow.Click(func() {
|
||||
if cb.OnShow != nil {
|
||||
cb.OnShow()
|
||||
}
|
||||
})
|
||||
|
||||
mQuit.Click(func() {
|
||||
systray.Quit()
|
||||
if cb.OnQuit != nil {
|
||||
cb.OnQuit()
|
||||
}
|
||||
})
|
||||
}, func() {
|
||||
// onExit: 托盘退出时什么都不做,由 OnQuit 回调处理
|
||||
})
|
||||
}
|
||||
|
||||
// Quit 主动退出托盘循环
|
||||
func Quit() {
|
||||
systray.Quit()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const localLicenseStateFilename = ".ant-license.json"
|
||||
|
||||
type localLicenseState struct {
|
||||
MaxProfileLimit int `json:"maxProfileLimit"`
|
||||
UsedCDKeys []string `json:"usedCdKeys,omitempty"`
|
||||
}
|
||||
|
||||
func localLicenseStatePath(configPath string) string {
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
if configPath == "" {
|
||||
return localLicenseStateFilename
|
||||
}
|
||||
dir := filepath.Dir(configPath)
|
||||
if dir == "." || dir == "" {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
dir = cwd
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, localLicenseStateFilename)
|
||||
}
|
||||
|
||||
func loadLocalLicenseState(configPath string) (*localLicenseState, bool, error) {
|
||||
statePath := localLicenseStatePath(configPath)
|
||||
data, err := os.ReadFile(statePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &localLicenseState{}, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("读取本机额度状态失败: %w", err)
|
||||
}
|
||||
|
||||
var state localLicenseState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
// 状态文件损坏时回退到当前配置并在后续自动重建,避免阻断启动。
|
||||
return &localLicenseState{}, false, nil
|
||||
}
|
||||
normalizeLocalLicenseState(&state)
|
||||
return &state, true, nil
|
||||
}
|
||||
|
||||
func saveLocalLicenseState(configPath string, state *localLicenseState) error {
|
||||
if state == nil {
|
||||
state = &localLicenseState{}
|
||||
}
|
||||
cloned := *state
|
||||
normalizeLocalLicenseState(&cloned)
|
||||
|
||||
data, err := json.MarshalIndent(cloned, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化本机额度状态失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(localLicenseStatePath(configPath), data, 0644); err != nil {
|
||||
return fmt.Errorf("写入本机额度状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reconcileConfigWithLocalLicense(configPath string, cfg *Config) (bool, bool, error) {
|
||||
if cfg == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
state, stateExists, err := loadLocalLicenseState(configPath)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
originalKeys := normalizeUsedCDKeys(cfg.App.UsedCDKeys)
|
||||
originalMax := cfg.App.MaxProfileLimit
|
||||
|
||||
mergedKeys := unionUsedCDKeys(originalKeys, state.UsedCDKeys)
|
||||
effectiveMax := maxInt(originalMax, state.MaxProfileLimit)
|
||||
minLimit := minimumProfileLimitForKeys(mergedKeys)
|
||||
if effectiveMax < minLimit {
|
||||
effectiveMax = minLimit
|
||||
}
|
||||
|
||||
cfg.App.UsedCDKeys = mergedKeys
|
||||
cfg.App.MaxProfileLimit = effectiveMax
|
||||
|
||||
configChanged := originalMax != effectiveMax || !sameStringSlice(originalKeys, mergedKeys)
|
||||
|
||||
desiredState := &localLicenseState{
|
||||
MaxProfileLimit: effectiveMax,
|
||||
UsedCDKeys: mergedKeys,
|
||||
}
|
||||
normalizeLocalLicenseState(desiredState)
|
||||
|
||||
stateChanged := state.MaxProfileLimit != desiredState.MaxProfileLimit || !sameStringSlice(state.UsedCDKeys, desiredState.UsedCDKeys)
|
||||
shouldPersist := stateExists || desiredState.MaxProfileLimit > DefaultConfig().App.MaxProfileLimit || len(desiredState.UsedCDKeys) > 0
|
||||
if shouldPersist && stateChanged {
|
||||
if err := saveLocalLicenseState(configPath, desiredState); err != nil {
|
||||
return configChanged, false, err
|
||||
}
|
||||
return configChanged, true, nil
|
||||
}
|
||||
|
||||
return configChanged, false, nil
|
||||
}
|
||||
|
||||
func normalizeLocalLicenseState(state *localLicenseState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.UsedCDKeys = normalizeUsedCDKeys(state.UsedCDKeys)
|
||||
minLimit := minimumProfileLimitForKeys(state.UsedCDKeys)
|
||||
if state.MaxProfileLimit < minLimit {
|
||||
state.MaxProfileLimit = minLimit
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUsedCDKeys(keys []string) []string {
|
||||
result := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(key))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func unionUsedCDKeys(primary, secondary []string) []string {
|
||||
result := make([]string, 0, len(primary)+len(secondary))
|
||||
seen := make(map[string]struct{}, len(primary)+len(secondary))
|
||||
appendKeys := func(list []string) {
|
||||
for _, key := range normalizeUsedCDKeys(list) {
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, key)
|
||||
}
|
||||
}
|
||||
appendKeys(primary)
|
||||
appendKeys(secondary)
|
||||
return result
|
||||
}
|
||||
|
||||
func minimumProfileLimitForKeys(keys []string) int {
|
||||
baseLimit := DefaultConfig().App.MaxProfileLimit
|
||||
return baseLimit + len(normalizeUsedCDKeys(keys))*3
|
||||
}
|
||||
|
||||
func sameStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
appconfig "ant-chrome/backend/internal/config"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigRestoresLocalLicenseState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
|
||||
cfg := appconfig.DefaultConfig()
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
if err := saveLocalLicenseState(configPath, &localLicenseState{
|
||||
MaxProfileLimit: 12,
|
||||
UsedCDKeys: []string{"GITHUB_STAR_REWARD", "ANT-AAAA-BBBB-CCCC-DDDD-EEEEEEEE"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入本机额度状态失败: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig 失败: %v", err)
|
||||
}
|
||||
|
||||
if loaded.App.MaxProfileLimit != 12 {
|
||||
t.Fatalf("本机额度状态未恢复: got=%d", loaded.App.MaxProfileLimit)
|
||||
}
|
||||
if len(loaded.App.UsedCDKeys) != 2 {
|
||||
t.Fatalf("兑换记录未恢复: %+v", loaded.App.UsedCDKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigSeedsLocalLicenseStateFromConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
|
||||
cfg := appconfig.DefaultConfig()
|
||||
cfg.App.MaxProfileLimit = 15
|
||||
cfg.App.UsedCDKeys = []string{"GITHUB_STAR_REWARD"}
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig 失败: %v", err)
|
||||
}
|
||||
if loaded.App.MaxProfileLimit != 15 {
|
||||
t.Fatalf("LoadConfig 读取额度失败: got=%d", loaded.App.MaxProfileLimit)
|
||||
}
|
||||
|
||||
state, exists, err := loadLocalLicenseState(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取本机额度状态失败: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("应当从现有配置补建本机额度状态")
|
||||
}
|
||||
if state.MaxProfileLimit != 15 {
|
||||
t.Fatalf("本机额度状态未补建: got=%d", state.MaxProfileLimit)
|
||||
}
|
||||
if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" {
|
||||
t.Fatalf("本机兑换记录未补建: %+v", state.UsedCDKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemGithubStarPersistsLocalLicenseState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
|
||||
cfg := appconfig.DefaultConfig()
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
t.Fatalf("写入测试配置失败: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp(root)
|
||||
app.config = cfg
|
||||
|
||||
if err := app.RedeemGithubStar(); err != nil {
|
||||
t.Fatalf("RedeemGithubStar 失败: %v", err)
|
||||
}
|
||||
|
||||
state, exists, err := loadLocalLicenseState(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取本机额度状态失败: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("兑换后应写入本机额度状态")
|
||||
}
|
||||
if state.MaxProfileLimit != 6 {
|
||||
t.Fatalf("兑换后本机额度状态错误: got=%d", state.MaxProfileLimit)
|
||||
}
|
||||
if len(state.UsedCDKeys) != 1 || state.UsedCDKeys[0] != "GITHUB_STAR_REWARD" {
|
||||
t.Fatalf("兑换后本机兑换记录错误: %+v", state.UsedCDKeys)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package backend
|
||||
|
||||
func killResidualRuntimeProcesses(appRoot string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func killResidualRuntimeProcesses(appRoot string) error {
|
||||
appRoot = strings.TrimSpace(appRoot)
|
||||
if appRoot == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(appRoot); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
psScript := `param([string]$Root, [string]$ExcludePath)
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
if ([string]::IsNullOrWhiteSpace($Root) -or -not (Test-Path -LiteralPath $Root)) { exit 0 }
|
||||
$root = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + '\'
|
||||
$exclude = ''
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExcludePath)) {
|
||||
$exclude = [System.IO.Path]::GetFullPath($ExcludePath)
|
||||
}
|
||||
function Get-AntChromeResidualProcesses {
|
||||
@(
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.ExecutablePath -and (
|
||||
$_.ExecutablePath.StartsWith(($root + 'bin\'), [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$_.ExecutablePath.StartsWith(($root + 'chrome\'), [System.StringComparison]::OrdinalIgnoreCase)
|
||||
) -and (
|
||||
$exclude -eq '' -or
|
||||
-not $_.ExecutablePath.Equals($exclude, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
$targets = @(Get-AntChromeResidualProcesses | Sort-Object ProcessId -Descending)
|
||||
foreach ($p in $targets) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop } catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 400
|
||||
$left = @(Get-AntChromeResidualProcesses)
|
||||
if ($left.Count -gt 0) {
|
||||
$names = ($left | ForEach-Object { $_.Name + '#' + $_.ProcessId }) -join ', '
|
||||
Write-Host ('still running: ' + $names)
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
`
|
||||
|
||||
tempFile, err := os.CreateTemp("", "ant-chrome-cleanup-*.ps1")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建清理脚本失败: %w", err)
|
||||
}
|
||||
scriptPath := tempFile.Name()
|
||||
if _, err := tempFile.WriteString(psScript); err != nil {
|
||||
tempFile.Close()
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("写入清理脚本失败: %w", err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("关闭清理脚本失败: %w", err)
|
||||
}
|
||||
defer os.Remove(scriptPath)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
powershellPath := `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||||
if _, err := os.Stat(powershellPath); err != nil {
|
||||
if fallbackPath, lookErr := exec.LookPath("powershell.exe"); lookErr == nil {
|
||||
powershellPath = fallbackPath
|
||||
} else {
|
||||
return fmt.Errorf("未找到 powershell.exe")
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, powershellPath, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", filepath.Clean(scriptPath), "-Root", appRoot, "-ExcludePath", exePath)
|
||||
hideWindow(cmd)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("清理残留进程超时")
|
||||
}
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return fmt.Errorf("清理残留进程失败: %s", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package backend
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
// do nothing on non-windows platforms
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package launchcode_test
|
||||
|
||||
// Feature: instance-launch-code, Property 2: persistence round-trip
|
||||
// Validates: Requirements 1.3, 5.1, 5.3
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// newTestDB 创建内存 SQLite 数据库并执行建表迁移
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建索引失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// newFileTestDB 创建基于文件的 SQLite 数据库(用于需要独立隔离的测试)
|
||||
func newFileTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "launchcode_test_*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时数据库文件失败: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
dbPath := f.Name()
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS launch_codes (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_codes_code ON launch_codes(code)`)
|
||||
if err != nil {
|
||||
t.Fatalf("建索引失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
os.Remove(dbPath)
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// TestProperty2_PersistenceRoundTrip
|
||||
// Property 2: 持久化 Round-Trip
|
||||
// 对于任意 ProfileId 和 LaunchCode,Upsert 后:
|
||||
// - FindProfileId(code) 返回相同的 profileId
|
||||
// - FindCode(profileId) 返回相同的 code
|
||||
func TestProperty2_PersistenceRoundTrip(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("Upsert 后 FindProfileId 返回正确 profileId", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindProfileId(code)
|
||||
return err == nil && got == profileId
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.Property("Upsert 后 FindCode 返回正确 code", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindCode(profileId)
|
||||
return err == nil && got == code
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.Property("Upsert 幂等:相同 profileId 更新 code 后查询返回新 code", prop.ForAll(
|
||||
func(profileId, code1, code2 string) bool {
|
||||
if code1 == code2 {
|
||||
return true // 跳过相同 code 的情况
|
||||
}
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code1); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := dao.Upsert(profileId, code2); err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := dao.FindCode(profileId)
|
||||
return err == nil && got == code2
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty2_DeleteRemovesMapping
|
||||
// Property 2 补充:Delete 后查询应返回 not found
|
||||
func TestProperty2_DeleteRemovesMapping(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("Delete 后 FindCode 返回错误", prop.ForAll(
|
||||
func(profileId, code string) bool {
|
||||
db := newFileTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := dao.Delete(profileId); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := dao.FindCode(profileId)
|
||||
return err != nil
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) <= 64 }),
|
||||
gen.RegexMatch(`[A-Z0-9]{6}`),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestProperty2_LoadAllRoundTrip
|
||||
// Property 2 补充:LoadAll 返回所有已写入的映射
|
||||
func TestProperty2_LoadAllRoundTrip(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
dao := launchcode.NewSQLiteLaunchCodeDAO(db)
|
||||
|
||||
// 写入一批映射
|
||||
entries := map[string]string{}
|
||||
for i := 0; i < 10; i++ {
|
||||
profileId := fmt.Sprintf("profile-%02d", i)
|
||||
code := fmt.Sprintf("CODE%02d", i)
|
||||
entries[profileId] = code
|
||||
if err := dao.Upsert(profileId, code); err != nil {
|
||||
t.Fatalf("Upsert 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := dao.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAll 失败: %v", err)
|
||||
}
|
||||
|
||||
for profileId, code := range entries {
|
||||
got, ok := loaded[profileId]
|
||||
if !ok {
|
||||
t.Errorf("LoadAll 缺少 profileId=%s", profileId)
|
||||
continue
|
||||
}
|
||||
if got != code {
|
||||
t.Errorf("LoadAll profileId=%s: 期望 code=%s,实际=%s", profileId, code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
type mockStarterWithParams struct {
|
||||
profiles map[string]*browser.Profile
|
||||
lastProfile string
|
||||
lastParams launchcode.LaunchRequestParams
|
||||
}
|
||||
|
||||
func newMockStarterWithParams() *mockStarterWithParams {
|
||||
return &mockStarterWithParams{profiles: make(map[string]*browser.Profile)}
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) addProfile(p *browser.Profile) {
|
||||
m.profiles[p.ProfileId] = p
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
m.lastProfile = profileId
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *mockStarterWithParams) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
|
||||
m.lastProfile = profileId
|
||||
m.lastParams = params
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func TestLaunchWithParams(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-automation",
|
||||
ProfileName: "automation",
|
||||
Pid: 321,
|
||||
DebugPort: 9555,
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-automation")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
body := map[string]interface{}{
|
||||
"code": code,
|
||||
"launchArgs": []string{"--window-size=1280,800", "--lang=en-US"},
|
||||
"startUrls": []string{"https://example.com"},
|
||||
"skipDefaultStartUrls": true,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if starter.lastProfile != "profile-automation" {
|
||||
t.Fatalf("profileId 传递错误: %s", starter.lastProfile)
|
||||
}
|
||||
if len(starter.lastParams.LaunchArgs) != 2 {
|
||||
t.Fatalf("launchArgs 传递错误: %+v", starter.lastParams.LaunchArgs)
|
||||
}
|
||||
if len(starter.lastParams.StartURLs) != 1 || starter.lastParams.StartURLs[0] != "https://example.com" {
|
||||
t.Fatalf("startUrls 传递错误: %+v", starter.lastParams.StartURLs)
|
||||
}
|
||||
if !starter.lastParams.SkipDefaultStartURLs {
|
||||
t.Fatal("skipDefaultStartUrls 传递错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithParamsBadRequest(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
t.Run("invalid-json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString("{bad json}"))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing-code", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewBufferString(`{"launchArgs":["--incognito"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLaunchLogsEndpoint(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarterWithParams()
|
||||
starter.addProfile(&browser.Profile{
|
||||
ProfileId: "profile-log-test",
|
||||
ProfileName: "log-test",
|
||||
Pid: 456,
|
||||
DebugPort: 9666,
|
||||
})
|
||||
|
||||
code, err := svc.EnsureCode("profile-log-test")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCode 失败: %v", err)
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
payload := bytes.NewBufferString(`{"code":"` + code + `","launchArgs":["--incognito"]}`)
|
||||
|
||||
reqLaunch := httptest.NewRequest(http.MethodPost, "/api/launch", payload)
|
||||
reqLaunch.Header.Set("Content-Type", "application/json")
|
||||
wLaunch := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLaunch, reqLaunch)
|
||||
if wLaunch.Code != http.StatusOK {
|
||||
t.Fatalf("调用 launch 失败: %d", wLaunch.Code)
|
||||
}
|
||||
|
||||
reqLogs := httptest.NewRequest(http.MethodGet, "/api/launch/logs?limit=10", nil)
|
||||
wLogs := httptest.NewRecorder()
|
||||
handler.ServeHTTP(wLogs, reqLogs)
|
||||
if wLogs.Code != http.StatusOK {
|
||||
t.Fatalf("查询 logs 失败: %d", wLogs.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Items []launchcode.LaunchCallRecord `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(wLogs.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析 logs 响应失败: %v", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
t.Fatal("logs 响应 ok=false")
|
||||
}
|
||||
if len(resp.Items) == 0 {
|
||||
t.Fatal("logs 为空,期望至少一条记录")
|
||||
}
|
||||
if resp.Items[0].Path != "/api/launch" {
|
||||
t.Fatalf("最新记录 path 不正确: %s", resp.Items[0].Path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package launchcode_test
|
||||
|
||||
// Feature: instance-launch-code, Property 6: valid code response structure
|
||||
// Feature: instance-launch-code, Property 7: invalid code returns 404
|
||||
// Feature: instance-launch-code, Property 8: idempotent launch
|
||||
// Validates: Requirements 3.2, 3.3, 3.4, 3.5, 4.1, 4.2, 4.4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// --- 测试辅助类型 ---
|
||||
|
||||
// mockStarter 模拟 BrowserStarter,记录调用次数
|
||||
type mockStarter struct {
|
||||
profiles map[string]*browser.Profile
|
||||
callCounts map[string]int
|
||||
}
|
||||
|
||||
func newMockStarter() *mockStarter {
|
||||
return &mockStarter{
|
||||
profiles: make(map[string]*browser.Profile),
|
||||
callCounts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockStarter) addProfile(p *browser.Profile) {
|
||||
m.profiles[p.ProfileId] = p
|
||||
}
|
||||
|
||||
func (m *mockStarter) StartInstance(profileId string) (*browser.Profile, error) {
|
||||
m.callCounts[profileId]++
|
||||
p, ok := m.profiles[profileId]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// buildTestHandler 构建一个可直接用于 httptest 的 handler(绕过 localhost 中间件)
|
||||
// 通过直接调用 server 内部 handler 的方式,使用 httptest.NewRecorder 测试路由逻辑
|
||||
func buildTestHandler(svc *launchcode.LaunchCodeService, starter launchcode.BrowserStarter) http.Handler {
|
||||
srv := launchcode.NewLaunchServer(svc, starter, nil, 0)
|
||||
return launchcode.NewTestHandler(srv)
|
||||
}
|
||||
|
||||
// newInMemoryService 创建一个使用内存 DAO 的 LaunchCodeService
|
||||
func newInMemoryService() *launchcode.LaunchCodeService {
|
||||
dao := launchcode.NewMemoryLaunchCodeDAO()
|
||||
return launchcode.NewLaunchCodeService(dao)
|
||||
}
|
||||
|
||||
// --- Property 6: 有效 Code 返回正确响应结构 ---
|
||||
|
||||
// genNonEmptyAlpha 生成长度 1-32 的字母字符串(不使用 SuchThat 过滤)
|
||||
func genNonEmptyAlpha() gopter.Gen {
|
||||
return gen.SliceOfN(8, gen.RuneRange('a', 'z')).Map(func(runes []rune) string {
|
||||
return string(runes)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty6_ValidCodeResponseStructure
|
||||
// 对于任意存在的 LaunchCode,GET /api/launch/{code} 应返回:
|
||||
// - HTTP 200
|
||||
// - Content-Type: application/json
|
||||
// - 响应体含 ok:true, profileId, profileName, pid, debugPort
|
||||
func TestProperty6_ValidCodeResponseStructure(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("有效 code 返回 200 及正确响应结构", prop.ForAll(
|
||||
func(profileId, profileName string, pid, debugPort int) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: profileName,
|
||||
Pid: pid,
|
||||
DebugPort: debugPort,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profileId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, _ := resp["ok"].(bool)
|
||||
gotProfileId, _ := resp["profileId"].(string)
|
||||
gotProfileName, _ := resp["profileName"].(string)
|
||||
_, hasPid := resp["pid"]
|
||||
_, hasDebugPort := resp["debugPort"]
|
||||
|
||||
return ok &&
|
||||
gotProfileId == profileId &&
|
||||
gotProfileName == profileName &&
|
||||
hasPid && hasDebugPort
|
||||
},
|
||||
genNonEmptyAlpha(),
|
||||
genNonEmptyAlpha(),
|
||||
gen.IntRange(1000, 99999),
|
||||
gen.IntRange(9000, 9999),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- Property 7: 无效 Code 返回 404 ---
|
||||
|
||||
// genInvalidCode 生成一定不存在于空 service 中的 code(小写字母,不符合 A-Z0-9 格式)
|
||||
func genInvalidCode() gopter.Gen {
|
||||
// 生成 4 位小写字母字符串,永远不会匹配 [A-Z0-9]{6} 格式的有效 code
|
||||
return gen.SliceOfN(4, gen.RuneRange('a', 'z')).Map(func(runes []rune) string {
|
||||
return string(runes)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperty7_InvalidCodeReturns404
|
||||
// 对于任意不存在的 code,GET /api/launch/{code} 应返回:
|
||||
// - HTTP 404
|
||||
// - Content-Type: application/json
|
||||
// - 响应体含 ok:false 和 error 字段
|
||||
func TestProperty7_InvalidCodeReturns404(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("不存在的 code 返回 404", prop.ForAll(
|
||||
func(code string) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Type"), "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok, _ := resp["ok"].(bool)
|
||||
_, hasError := resp["error"]
|
||||
return !ok && hasError
|
||||
},
|
||||
genInvalidCode(),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- Property 8: 重复唤起的幂等性 ---
|
||||
|
||||
// TestProperty8_IdempotentLaunch
|
||||
// 对于已运行的实例,连续两次 GET /api/launch/{code}:
|
||||
// - 两次均返回 HTTP 200
|
||||
// - 两次返回的 pid 相同(不重新启动)
|
||||
func TestProperty8_IdempotentLaunch(t *testing.T) {
|
||||
properties := gopter.NewProperties(gopter.DefaultTestParameters())
|
||||
|
||||
properties.Property("重复唤起返回相同 pid,不重新启动", prop.ForAll(
|
||||
func(profileId string, pid int) bool {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
|
||||
profile := &browser.Profile{
|
||||
ProfileId: profileId,
|
||||
ProfileName: "test-profile",
|
||||
Pid: pid,
|
||||
DebugPort: 9222,
|
||||
Running: true,
|
||||
}
|
||||
starter.addProfile(profile)
|
||||
|
||||
code, err := svc.EnsureCode(profileId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
// 第一次请求
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w1, req1)
|
||||
|
||||
// 第二次请求
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/launch/"+code, nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
if w1.Code != http.StatusOK || w2.Code != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
var resp1, resp2 map[string]interface{}
|
||||
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pid1, _ := resp1["pid"].(float64)
|
||||
pid2, _ := resp2["pid"].(float64)
|
||||
|
||||
// 两次 pid 相同,且 StartInstance 被调用了 2 次(幂等由 starter 保证返回同一 profile)
|
||||
return pid1 == pid2 && pid1 == float64(pid)
|
||||
},
|
||||
genNonEmptyAlpha(),
|
||||
gen.IntRange(1000, 99999),
|
||||
))
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// --- 健康检查单元测试 ---
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
svc := newInMemoryService()
|
||||
starter := newMockStarter()
|
||||
handler := buildTestHandler(svc, starter)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
ok, _ := resp["ok"].(bool)
|
||||
if !ok {
|
||||
t.Error("期望 ok=true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
func TestSetCodeAndResolveCaseInsensitive(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
code, err := svc.SetCode("p1", "demo_code")
|
||||
if err != nil {
|
||||
t.Fatalf("SetCode 失败: %v", err)
|
||||
}
|
||||
if code != "DEMO_CODE" {
|
||||
t.Fatalf("期望 DEMO_CODE,实际 %s", code)
|
||||
}
|
||||
|
||||
profileID, err := svc.Resolve("demo_code")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve 失败: %v", err)
|
||||
}
|
||||
if profileID != "p1" {
|
||||
t.Fatalf("期望 p1,实际 %s", profileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCodeConflict(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
if _, err := svc.SetCode("p1", "AAA111"); err != nil {
|
||||
t.Fatalf("SetCode p1 失败: %v", err)
|
||||
}
|
||||
if _, err := svc.SetCode("p2", "AAA111"); err == nil {
|
||||
t.Fatal("期望 code 冲突时报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCodeValidation(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
cases := []string{"", "a", "ab", "中文123", "abc!123", strings.Repeat("A", 40)}
|
||||
for _, c := range cases {
|
||||
if _, err := svc.SetCode("p1", c); err == nil {
|
||||
t.Fatalf("期望非法 code 报错: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/config"
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
)
|
||||
|
||||
// 模拟数据库中实际存储的 Clash YAML 格式代理配置
|
||||
var testTrojanConfig = `- name: HK01|香港|x1.0
|
||||
type: trojan
|
||||
server: trojan.example.com
|
||||
port: 443
|
||||
password: example-password
|
||||
udp: true
|
||||
skip-cert-verify: true
|
||||
network: tcp`
|
||||
|
||||
var testVmessConfig = `- name: DE-Vmess(NL1) 1x
|
||||
type: vmess
|
||||
server: 203.0.113.55
|
||||
port: 443
|
||||
uuid: 11111111-1111-4111-8111-111111111111
|
||||
alterId: 0
|
||||
cipher: auto
|
||||
udp: true
|
||||
tls: true
|
||||
skip-cert-verify: false
|
||||
servername: vmess.example.com
|
||||
network: ws
|
||||
ws-opts:
|
||||
path: /
|
||||
headers:
|
||||
Host: vmess.example.com`
|
||||
|
||||
var testHysteria2Config = `- name: Hysteria Japan Pluse | 0.1x
|
||||
server: hy2.example.com
|
||||
port: 443
|
||||
sni: hy2.example.com
|
||||
up: 102400
|
||||
down: 102400
|
||||
skip-cert-verify: true
|
||||
ports: 10800-10888
|
||||
type: hysteria2
|
||||
password: example-password`
|
||||
|
||||
func TestProtocolDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
}{
|
||||
{"trojan-clash", testTrojanConfig},
|
||||
{"vmess-clash", testVmessConfig},
|
||||
{"hysteria2-clash", testHysteria2Config},
|
||||
{"socks5-direct", "socks5://127.0.0.1:1080"},
|
||||
{"http-direct", "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
src := strings.TrimSpace(tt.config)
|
||||
l := strings.ToLower(src)
|
||||
|
||||
isSingBox := proxy.IsSingBoxProtocol(src)
|
||||
requiresBridge := proxy.RequiresBridge(src, nil, "")
|
||||
isDirectHTTP := strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://")
|
||||
isDirectSocks := strings.HasPrefix(l, "socks5://")
|
||||
|
||||
fmt.Printf("\n=== %s ===\n", tt.name)
|
||||
fmt.Printf(" config前30字符: %q\n", src[:minInt(30, len(src))])
|
||||
fmt.Printf(" IsSingBoxProtocol: %v\n", isSingBox)
|
||||
fmt.Printf(" RequiresBridge: %v\n", requiresBridge)
|
||||
fmt.Printf(" isDirectHTTP: %v\n", isDirectHTTP)
|
||||
fmt.Printf(" isDirectSocks: %v\n", isDirectSocks)
|
||||
|
||||
// 测试 ParseProxyNode
|
||||
standardProxy, outbound, err := proxy.ParseProxyNode(src)
|
||||
fmt.Printf(" ParseProxyNode:\n")
|
||||
fmt.Printf(" standardProxy: %q\n", standardProxy)
|
||||
fmt.Printf(" outbound nil?: %v\n", outbound == nil)
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
|
||||
if !isSingBox && !requiresBridge && !isDirectHTTP && !isDirectSocks {
|
||||
t.Errorf("代理配置未被任何分支识别!会走到兜底逻辑")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTestWithMockProxies(t *testing.T) {
|
||||
// 模拟 a.config.Browser.Proxies 的内容
|
||||
proxies := []config.BrowserProxy{
|
||||
{ProxyId: "test-trojan", ProxyName: "测试trojan", ProxyConfig: testTrojanConfig},
|
||||
{ProxyId: "test-vmess", ProxyName: "测试vmess", ProxyConfig: testVmessConfig},
|
||||
{ProxyId: "test-hysteria2", ProxyName: "测试hysteria2", ProxyConfig: testHysteria2Config},
|
||||
{ProxyId: "test-http", ProxyName: "测试http", ProxyConfig: "http://127.0.0.1:7890"},
|
||||
}
|
||||
|
||||
for _, p := range proxies {
|
||||
t.Run(p.ProxyName, func(t *testing.T) {
|
||||
// 不传 xrayMgr/singboxMgr,看会走到哪个分支
|
||||
result := proxy.SpeedTest(p.ProxyId, proxies, nil, nil, nil)
|
||||
fmt.Printf("\n=== SpeedTest %s ===\n", p.ProxyName)
|
||||
fmt.Printf(" Ok: %v\n", result.Ok)
|
||||
fmt.Printf(" LatencyMs: %d\n", result.LatencyMs)
|
||||
fmt.Printf(" Error: %q\n", result.Error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/proxy"
|
||||
)
|
||||
|
||||
func TestTrojanClashYAML(t *testing.T) {
|
||||
node := `- name: JP02|日本|x1.0
|
||||
type: trojan
|
||||
server: trojan.example.com
|
||||
port: 443
|
||||
password: example-password
|
||||
udp: true
|
||||
skip-cert-verify: true
|
||||
network: tcp`
|
||||
|
||||
standard, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if standard != "" {
|
||||
t.Fatalf("期望 outbound,得到 standard: %s", standard)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("trojan clash outbound:\n%s", string(data))
|
||||
|
||||
if outbound["protocol"] != "trojan" {
|
||||
t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"])
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["address"] != "trojan.example.com" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
if settings["password"] != "example-password" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
stream := outbound["streamSettings"].(map[string]interface{})
|
||||
if stream["security"] != "tls" {
|
||||
t.Errorf("security 期望 tls,得到 %v", stream["security"])
|
||||
}
|
||||
tls := stream["tlsSettings"].(map[string]interface{})
|
||||
if tls["allowInsecure"] != true {
|
||||
t.Errorf("allowInsecure 期望 true,得到 %v", tls["allowInsecure"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrojanURI(t *testing.T) {
|
||||
node := "trojan://mypassword@example.com:443?sni=example.com&allowInsecure=1"
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if outbound["protocol"] != "trojan" {
|
||||
t.Errorf("protocol 期望 trojan,得到 %v", outbound["protocol"])
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["address"] != "example.com" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
if settings["password"] != "mypassword" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSClashYAML(t *testing.T) {
|
||||
node := `- name: SS节点
|
||||
type: ss
|
||||
server: 1.2.3.4
|
||||
port: 8388
|
||||
cipher: aes-256-gcm
|
||||
password: testpassword`
|
||||
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("SS clash outbound:\n%s", string(data))
|
||||
|
||||
if outbound["protocol"] != "shadowsocks" {
|
||||
t.Errorf("protocol 期望 shadowsocks,得到 %v", outbound["protocol"])
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["address"] != "1.2.3.4" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
if settings["method"] != "aes-256-gcm" {
|
||||
t.Errorf("method 不匹配: %v", settings["method"])
|
||||
}
|
||||
if settings["password"] != "testpassword" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSURI_SIP002(t *testing.T) {
|
||||
// SIP002: ss://BASE64(method:password)@host:port#name
|
||||
userInfo := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:mypassword"))
|
||||
node := fmt.Sprintf("ss://%s@1.2.3.4:8388#测试节点", userInfo)
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("SS SIP002 outbound:\n%s", string(data))
|
||||
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["method"] != "aes-256-gcm" {
|
||||
t.Errorf("method 不匹配: %v", settings["method"])
|
||||
}
|
||||
if settings["password"] != "mypassword" {
|
||||
t.Errorf("password 不匹配: %v", settings["password"])
|
||||
}
|
||||
if settings["address"] != "1.2.3.4" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSURI_Legacy(t *testing.T) {
|
||||
// 旧格式: ss://BASE64(method:password@host:port)
|
||||
raw := base64.StdEncoding.EncodeToString([]byte("chacha20-ietf-poly1305:pass123@2.3.4.5:443"))
|
||||
node := "ss://" + raw
|
||||
_, outbound, err := proxy.ParseProxyNode(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
settings := outbound["settings"].(map[string]interface{})
|
||||
if settings["method"] != "chacha20-ietf-poly1305" {
|
||||
t.Errorf("method 不匹配: %v", settings["method"])
|
||||
}
|
||||
if settings["address"] != "2.3.4.5" {
|
||||
t.Errorf("address 不匹配: %v", settings["address"])
|
||||
}
|
||||
t.Logf("SS legacy outbound OK: %v", settings)
|
||||
}
|
||||
|
||||
func TestSSR_Unsupported(t *testing.T) {
|
||||
node := "ssr://somebase64data"
|
||||
_, _, err := proxy.ParseProxyNode(node)
|
||||
if err == nil {
|
||||
t.Fatal("期望 SSR 返回错误,但没有")
|
||||
}
|
||||
t.Logf("SSR 正确返回错误: %v", err)
|
||||
}
|
||||
|
||||
func TestHysteria2URI(t *testing.T) {
|
||||
node := "hysteria2://mypassword@example.com:443?sni=example.com&insecure=1"
|
||||
outbound, err := proxy.BuildSingBoxOutbound(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("hysteria2 URI outbound:\n%s", string(data))
|
||||
|
||||
if outbound["type"] != "hysteria2" {
|
||||
t.Errorf("type 期望 hysteria2,得到 %v", outbound["type"])
|
||||
}
|
||||
if outbound["server"] != "example.com" {
|
||||
t.Errorf("server 不匹配: %v", outbound["server"])
|
||||
}
|
||||
if outbound["password"] != "mypassword" {
|
||||
t.Errorf("password 不匹配: %v", outbound["password"])
|
||||
}
|
||||
tls := outbound["tls"].(map[string]interface{})
|
||||
if tls["insecure"] != true {
|
||||
t.Errorf("insecure 期望 true,得到 %v", tls["insecure"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteria2ClashYAML(t *testing.T) {
|
||||
node := `- name: HY2节点
|
||||
type: hysteria2
|
||||
server: example.com
|
||||
port: 443
|
||||
password: testpass
|
||||
sni: example.com
|
||||
skip-cert-verify: false`
|
||||
|
||||
outbound, err := proxy.BuildSingBoxOutbound(node)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(outbound, "", " ")
|
||||
t.Logf("hysteria2 clash outbound:\n%s", string(data))
|
||||
|
||||
if outbound["type"] != "hysteria2" {
|
||||
t.Errorf("type 期望 hysteria2,得到 %v", outbound["type"])
|
||||
}
|
||||
if outbound["server"] != "example.com" {
|
||||
t.Errorf("server 不匹配: %v", outbound["server"])
|
||||
}
|
||||
tls := outbound["tls"].(map[string]interface{})
|
||||
if tls["server_name"] != "example.com" {
|
||||
t.Errorf("server_name 不匹配: %v", tls["server_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingBoxProtocol(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"hysteria2://pass@host:443", true},
|
||||
{"hysteria://pass@host:443", true},
|
||||
{"- name: n\n type: hysteria2\n server: h\n port: 443", true},
|
||||
{"- name: n\n type: tuic\n server: h\n port: 443", true},
|
||||
{"vmess://xxx", false},
|
||||
{"trojan://pass@host:443", false},
|
||||
{"socks5://127.0.0.1:1080", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := proxy.IsSingBoxProtocol(c.input)
|
||||
if got != c.expected {
|
||||
t.Errorf("IsSingBoxProtocol(%q) = %v, 期望 %v", c.input[:min(30, len(c.input))], got, c.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM 切换到项目根目录(脚本所在目录的上一级)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ========================================
|
||||
echo 综合资讯平台 - Wails 构建脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 当前工作目录: %CD%
|
||||
echo.
|
||||
|
||||
REM ======== 代理配置 ========
|
||||
REM 本地代理地址(例如 Clash、V2Ray 等)
|
||||
set PROXY_HOST=127.0.0.1
|
||||
set PROXY_PORT=7890
|
||||
set USE_PROXY=1
|
||||
|
||||
REM 如果不需要使用代理,将 USE_PROXY 设置为 0
|
||||
REM set USE_PROXY=0
|
||||
|
||||
REM 设置代理环境变量
|
||||
if "%USE_PROXY%"=="1" (
|
||||
echo [0/6] 正在配置代理...
|
||||
set HTTP_PROXY=http://%PROXY_HOST%:%PROXY_PORT%
|
||||
set HTTPS_PROXY=http://%PROXY_HOST%:%PROXY_PORT%
|
||||
set http_proxy=http://%PROXY_HOST%:%PROXY_PORT%
|
||||
set https_proxy=http://%PROXY_HOST%:%PROXY_PORT%
|
||||
|
||||
REM 配置 npm 代理
|
||||
call npm config set proxy http://%PROXY_HOST%:%PROXY_PORT% 2>nul
|
||||
call npm config set https-proxy http://%PROXY_HOST%:%PROXY_PORT% 2>nul
|
||||
|
||||
REM 配置 Go 代理环境变量
|
||||
set GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
echo ✓ 代理已配置: %PROXY_HOST%:%PROXY_PORT%
|
||||
echo.
|
||||
)
|
||||
|
||||
REM 定义清理函数(用于恢复代理设置)
|
||||
goto :skip_cleanup_function
|
||||
:cleanup
|
||||
if "%USE_PROXY%"=="1" (
|
||||
echo.
|
||||
echo [清理代理配置...]
|
||||
call npm config delete proxy 2>nul
|
||||
call npm config delete https-proxy 2>nul
|
||||
echo ✓ 代理配置已清理
|
||||
)
|
||||
exit /b
|
||||
:skip_cleanup_function
|
||||
|
||||
|
||||
echo [1/6] 安装前端依赖...
|
||||
cd frontend
|
||||
call npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ 安装前端依赖失败
|
||||
cd ..
|
||||
call :cleanup
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd ..
|
||||
|
||||
echo.
|
||||
echo [2/6] 安装 Go 依赖...
|
||||
go mod download
|
||||
go mod tidy
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ 安装 Go 依赖失败
|
||||
call :cleanup
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [3/6] 创建临时 dist 目录...
|
||||
if not exist "frontend\dist" (
|
||||
mkdir "frontend\dist"
|
||||
echo. > "frontend\dist\index.html"
|
||||
echo ✓ 临时 dist 目录已创建
|
||||
) else (
|
||||
echo ✓ dist 目录已存在
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [4/6] 修复 wailsjs 绑定文件...
|
||||
call bat\fix-bindings.bat
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ 修复绑定文件失败
|
||||
call :cleanup
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [5/6] 构建前端项目...
|
||||
REM 清理临时 dist 目录
|
||||
if exist "frontend\dist" (
|
||||
rmdir /S /Q "frontend\dist" 2>nul
|
||||
echo ✓ 临时 dist 目录已清理
|
||||
)
|
||||
cd frontend
|
||||
call npm run build
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ 构建前端失败
|
||||
cd ..
|
||||
call :cleanup
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd ..
|
||||
|
||||
echo.
|
||||
echo [6/6] 构建应用...
|
||||
wails build
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ 构建失败
|
||||
call :cleanup
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [7/7] 复制运行时依赖...
|
||||
if exist "bin" (
|
||||
xcopy /E /I /Y bin build\bin\bin >nul
|
||||
echo ✓ bin 目录已复制到 build\bin\bin\
|
||||
) else (
|
||||
echo [Warn] bin 目录不存在,跳过复制
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo ✓ 构建成功!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 可执行文件位置: build\bin\news-platform.exe
|
||||
echo.
|
||||
|
||||
REM 清理代理配置
|
||||
call :cleanup
|
||||
|
||||
pause
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ========================================
|
||||
echo Ant Chrome - Dev Launcher
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Current workdir: %CD%
|
||||
echo.
|
||||
|
||||
call :cleanup_dev_logs
|
||||
|
||||
echo Cleaning stale processes...
|
||||
taskkill /F /IM ant-chrome-dev.exe >nul 2>&1
|
||||
taskkill /F /IM ant-chrome.exe >nul 2>&1
|
||||
echo.
|
||||
|
||||
set FRONTEND_PORT=5218
|
||||
set PORT_ERROR=0
|
||||
set TEMP_DEV_DIST_CREATED=0
|
||||
set TEMP_DEV_PLACEHOLDER_CREATED=0
|
||||
|
||||
call :cleanup_local_vite_port %FRONTEND_PORT%
|
||||
|
||||
echo Checking port status...
|
||||
call :check_port %FRONTEND_PORT%
|
||||
|
||||
if "!PORT_ERROR!"=="1" (
|
||||
echo.
|
||||
echo Please close the process using the occupied port and retry.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
set GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
echo Checking dependencies...
|
||||
if not exist "go.sum" (
|
||||
echo Installing Go dependencies...
|
||||
go mod download
|
||||
go mod tidy
|
||||
)
|
||||
|
||||
if not exist "frontend\node_modules" (
|
||||
echo Installing frontend dependencies...
|
||||
pushd frontend
|
||||
call npm install
|
||||
popd
|
||||
)
|
||||
echo.
|
||||
|
||||
echo Regenerating Wails bindings...
|
||||
if not exist "frontend\dist" (
|
||||
mkdir "frontend\dist"
|
||||
set TEMP_DEV_DIST_CREATED=1
|
||||
)
|
||||
if not exist "frontend\dist\__wails_placeholder__.txt" (
|
||||
echo placeholder> "frontend\dist\__wails_placeholder__.txt"
|
||||
set TEMP_DEV_PLACEHOLDER_CREATED=1
|
||||
)
|
||||
wails generate module
|
||||
if errorlevel 1 (
|
||||
call :cleanup_temp_dist
|
||||
echo [ERROR] Failed to generate Wails bindings.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if exist "frontend\wailsjs" (
|
||||
xcopy /E /I /Y "frontend\wailsjs" "frontend\src\wailsjs" >nul
|
||||
)
|
||||
if not exist "frontend\src\wailsjs" (
|
||||
call :cleanup_temp_dist
|
||||
echo [ERROR] Wails bindings output folder not found.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
call :cleanup_temp_dist
|
||||
echo.
|
||||
|
||||
echo Starting dev server...
|
||||
echo Frontend URL: http://127.0.0.1:%FRONTEND_PORT%
|
||||
echo Wails dev endpoint: auto-select
|
||||
echo.
|
||||
|
||||
wails dev -viteservertimeout 60
|
||||
set EXIT_CODE=%errorlevel%
|
||||
|
||||
if not "%EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo [ERROR] wails dev exited with code %EXIT_CODE%.
|
||||
)
|
||||
|
||||
pause
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:cleanup_temp_dist
|
||||
if "%TEMP_DEV_PLACEHOLDER_CREATED%"=="1" (
|
||||
del /F /Q "frontend\dist\__wails_placeholder__.txt" >nul 2>&1
|
||||
)
|
||||
if "%TEMP_DEV_DIST_CREATED%"=="1" (
|
||||
rmdir /S /Q "frontend\dist" >nul 2>&1
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:cleanup_dev_logs
|
||||
for %%f in (
|
||||
"tmp-npm-dev.err.log"
|
||||
"tmp-npm-dev.log"
|
||||
"tmp-wails-err.log"
|
||||
"tmp-wails-out.log"
|
||||
"tmp-wails2-err.log"
|
||||
"tmp-wails2-out.log"
|
||||
"tmp-wails3-err.log"
|
||||
"tmp-wails3-out.log"
|
||||
"tmp-wails.err"
|
||||
"wails-dev-capture.log"
|
||||
"wails-dev-run.log"
|
||||
"wails-dev-stderr.log"
|
||||
"wails-dev-stdout.log"
|
||||
) do (
|
||||
if exist %%~f del /F /Q %%~f >nul 2>&1
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:cleanup_local_vite_port
|
||||
set "CHECK_PORT=%~1"
|
||||
set "CHECK_PID="
|
||||
set "CHECK_CMDLINE="
|
||||
for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$port=%CHECK_PORT%; $procId=(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess); if($procId){Write-Output $procId}"`) do (
|
||||
set "CHECK_PID=%%a"
|
||||
)
|
||||
if not defined CHECK_PID exit /b 0
|
||||
|
||||
for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$line=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq %CHECK_PID% } | Select-Object -First 1 -ExpandProperty CommandLine; if($line){Write-Output $line}"`) do (
|
||||
set "CHECK_CMDLINE=%%a"
|
||||
)
|
||||
|
||||
echo !CHECK_CMDLINE! | findstr /I /C:"%CD%\frontend" >nul
|
||||
set "MATCH_PROJECT=!errorlevel!"
|
||||
echo !CHECK_CMDLINE! | findstr /I /C:"vite" >nul
|
||||
set "MATCH_VITE=!errorlevel!"
|
||||
|
||||
if "!MATCH_PROJECT!"=="0" if "!MATCH_VITE!"=="0" (
|
||||
echo Cleaning stale local Vite process on port %CHECK_PORT% ^(PID !CHECK_PID!^)...
|
||||
taskkill /F /PID !CHECK_PID! /T >nul 2>&1
|
||||
timeout /t 1 /nobreak >nul
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:check_port
|
||||
set "CHECK_PORT=%~1"
|
||||
set "CHECK_PID="
|
||||
for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "$port=%CHECK_PORT%; $procId=(Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess); if($procId){Write-Output $procId}"`) do (
|
||||
set "CHECK_PID=%%a"
|
||||
)
|
||||
|
||||
if defined CHECK_PID (
|
||||
set PORT_ERROR=1
|
||||
echo [ERROR] Port %CHECK_PORT% is occupied. PID: !CHECK_PID!
|
||||
) else (
|
||||
echo [OK] Port %CHECK_PORT% is available.
|
||||
)
|
||||
exit /b 0
|
||||
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo 修复 wailsjs 绑定文件...
|
||||
|
||||
REM 备份原文件
|
||||
copy /Y "frontend\src\wailsjs\go\models.ts" "frontend\src\wailsjs\go\models.ts.bak" >nul 2>&1
|
||||
copy /Y "frontend\src\wailsjs\go\main\App.js" "frontend\src\wailsjs\go\main\App.js.bak" >nul 2>&1
|
||||
copy /Y "frontend\src\wailsjs\go\main\App.d.ts" "frontend\src\wailsjs\go\main\App.d.ts.bak" >nul 2>&1
|
||||
|
||||
REM 使用 PowerShell 修复文件
|
||||
powershell -ExecutionPolicy Bypass -File "bat\fix-bindings.ps1"
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo ✓ 绑定文件修复成功
|
||||
) else (
|
||||
echo ✗ 绑定文件修复失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
# 修复 wailsjs 绑定文件的 PowerShell 脚本
|
||||
|
||||
$modelsFile = "frontend/src/wailsjs/go/models.ts"
|
||||
$appJsFile = "frontend/src/wailsjs/go/main/App.js"
|
||||
$appDtsFile = "frontend/src/wailsjs/go/main/App.d.ts"
|
||||
|
||||
Write-Host "正在修复 models.ts..."
|
||||
|
||||
# 读取 models.ts
|
||||
$modelsContent = Get-Content $modelsFile -Raw
|
||||
|
||||
# 检查是否已包含 PersonalNote
|
||||
if ($modelsContent -notmatch "export class PersonalNote") {
|
||||
Write-Host "添加 PersonalNote 类型..."
|
||||
|
||||
# 在 TaskWithChannel 之前插入 PersonalNote 和 AIChatMessage
|
||||
$insertContent = @"
|
||||
|
||||
export class PersonalNote {
|
||||
id: string;
|
||||
news_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
created_at: any;
|
||||
updated_at: any;
|
||||
news_title?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new PersonalNote(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.news_id = source["news_id"];
|
||||
this.title = source["title"];
|
||||
this.content = source["content"];
|
||||
this.tags = source["tags"];
|
||||
this.created_at = source["created_at"];
|
||||
this.updated_at = source["updated_at"];
|
||||
this.news_title = source["news_title"];
|
||||
}
|
||||
}
|
||||
|
||||
export class AIChatMessage {
|
||||
id: string;
|
||||
news_id: string;
|
||||
session_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AIChatMessage(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.news_id = source["news_id"];
|
||||
this.session_id = source["session_id"];
|
||||
this.role = source["role"];
|
||||
this.content = source["content"];
|
||||
this.created_at = source["created_at"];
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
$modelsContent = $modelsContent -replace "(export class TaskWithChannel \{)", "$insertContent`r`n`$1"
|
||||
Set-Content -Path $modelsFile -Value $modelsContent -Encoding UTF8
|
||||
}
|
||||
|
||||
# 修复 TaskLog 添加 query 字段
|
||||
if ($modelsContent -match "export class TaskLog" -and $modelsContent -notmatch "query: string;") {
|
||||
Write-Host "为 TaskLog 添加 query 字段..."
|
||||
$modelsContent = Get-Content $modelsFile -Raw
|
||||
$modelsContent = $modelsContent -replace "(message: string;)", "`$1`r`n`t query: string;"
|
||||
$modelsContent = $modelsContent -replace '(this\.message = source\["message"\];)', "`$1`r`n`t this.query = source[""query""];"
|
||||
Set-Content -Path $modelsFile -Value $modelsContent -Encoding UTF8
|
||||
}
|
||||
|
||||
Write-Host "正在修复 App.js..."
|
||||
|
||||
# 读取 App.js
|
||||
$appJsContent = Get-Content $appJsFile -Raw
|
||||
|
||||
# 修复 FormatNewsContent 参数
|
||||
if ($appJsContent -match "export function FormatNewsContent\(arg1\) \{") {
|
||||
Write-Host "修复 FormatNewsContent 参数..."
|
||||
$appJsContent = $appJsContent -replace "export function FormatNewsContent\(arg1\) \{", "export function FormatNewsContent(arg1, arg2) {"
|
||||
$appJsContent = $appJsContent -replace "window\['go'\]\['main'\]\['App'\]\['FormatNewsContent'\]\(arg1\);", "window['go']['main']['App']['FormatNewsContent'](arg1, arg2);"
|
||||
}
|
||||
|
||||
# 修复 RunTaskManually 参数
|
||||
if ($appJsContent -match "export function RunTaskManually\(arg1\) \{") {
|
||||
Write-Host "修复 RunTaskManually 参数..."
|
||||
$appJsContent = $appJsContent -replace "export function RunTaskManually\(arg1\) \{", "export function RunTaskManually(arg1, arg2, arg3) {"
|
||||
$appJsContent = $appJsContent -replace "window\['go'\]\['main'\]\['App'\]\['RunTaskManually'\]\(arg1\);", "window['go']['main']['App']['RunTaskManually'](arg1, arg2, arg3);"
|
||||
}
|
||||
|
||||
# 添加缺失的 AI 函数
|
||||
if ($appJsContent -notmatch "export function TestAIConnection") {
|
||||
Write-Host "添加缺失的 AI 函数..."
|
||||
$aiFunction = @"
|
||||
|
||||
|
||||
export function TestAIConnection(arg1, arg2) {
|
||||
return window['go']['main']['App']['TestAIConnection'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestAIConfigConnection(arg1) {
|
||||
return window['go']['main']['App']['TestAIConfigConnection'](arg1);
|
||||
}
|
||||
|
||||
export function GetAIModels(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetAIModels'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetAIConfigModels(arg1) {
|
||||
return window['go']['main']['App']['GetAIConfigModels'](arg1);
|
||||
}
|
||||
"@
|
||||
$appJsContent = $appJsContent + $aiFunction
|
||||
}
|
||||
|
||||
Set-Content -Path $appJsFile -Value $appJsContent -Encoding UTF8
|
||||
|
||||
Write-Host "正在修复 App.d.ts..."
|
||||
|
||||
# 读取 App.d.ts
|
||||
$appDtsContent = Get-Content $appDtsFile -Raw
|
||||
|
||||
# 修复 FormatNewsContent 参数
|
||||
if ($appDtsContent -match "export function FormatNewsContent\(arg1:string\):Promise<model\.News>;") {
|
||||
Write-Host "修复 FormatNewsContent 类型..."
|
||||
$appDtsContent = $appDtsContent -replace "export function FormatNewsContent\(arg1:string\):Promise<model\.News>;", "export function FormatNewsContent(arg1:string,arg2?:string):Promise<model.News>;"
|
||||
}
|
||||
|
||||
# 修复 RunTaskManually 参数
|
||||
if ($appDtsContent -match "export function RunTaskManually\(arg1:string\):Promise<void>;") {
|
||||
Write-Host "修复 RunTaskManually 类型..."
|
||||
$appDtsContent = $appDtsContent -replace "export function RunTaskManually\(arg1:string\):Promise<void>;", "export function RunTaskManually(arg1:string,arg2?:boolean,arg3?:string):Promise<void>;"
|
||||
}
|
||||
|
||||
# 添加缺失的 AI 函数类型
|
||||
if ($appDtsContent -notmatch "export function TestAIConnection") {
|
||||
Write-Host "添加缺失的 AI 函数类型..."
|
||||
$aiFunctionTypes = @"
|
||||
|
||||
|
||||
export function TestAIConnection(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function TestAIConfigConnection(arg1:string):Promise<void>;
|
||||
|
||||
export function GetAIModels(arg1:string,arg2:string):Promise<Array<string>>;
|
||||
|
||||
export function GetAIConfigModels(arg1:string):Promise<Array<string>>;
|
||||
"@
|
||||
$appDtsContent = $appDtsContent + $aiFunctionTypes
|
||||
}
|
||||
|
||||
Set-Content -Path $appDtsFile -Value $appDtsContent -Encoding UTF8
|
||||
|
||||
Write-Host "✓ 所有绑定文件已修复"
|
||||
@@ -0,0 +1,77 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Change to repository root (parent directory of this script).
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set "TEMP_DIST_CREATED=0"
|
||||
set "TEMP_PLACEHOLDER_CREATED=0"
|
||||
|
||||
echo ========================================
|
||||
echo Generate Wails Bindings
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Working directory: %CD%
|
||||
echo.
|
||||
|
||||
echo [1/3] Ensure frontend\dist exists...
|
||||
if not exist "frontend\dist" (
|
||||
mkdir "frontend\dist"
|
||||
set "TEMP_DIST_CREATED=1"
|
||||
echo Created temporary dist directory.
|
||||
) else (
|
||||
echo Dist directory already exists.
|
||||
)
|
||||
|
||||
if not exist "frontend\dist\__wails_placeholder__.txt" (
|
||||
echo placeholder> "frontend\dist\__wails_placeholder__.txt"
|
||||
set "TEMP_PLACEHOLDER_CREATED=1"
|
||||
echo Created temporary placeholder file.
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/3] Regenerating Wails bindings...
|
||||
wails generate module
|
||||
if errorlevel 1 (
|
||||
echo Failed to regenerate Wails bindings.
|
||||
goto :cleanup_fail
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [3/3] Verify bindings output...
|
||||
if exist "frontend\wailsjs" (
|
||||
xcopy /E /I /Y "frontend\wailsjs" "frontend\src\wailsjs" >nul
|
||||
echo Bindings copied from frontend\wailsjs to frontend\src\wailsjs.
|
||||
) else if exist "frontend\src\wailsjs" (
|
||||
echo Bindings already generated in frontend\src\wailsjs.
|
||||
) else (
|
||||
echo Cannot find generated bindings in frontend\wailsjs or frontend\src\wailsjs.
|
||||
goto :cleanup_fail
|
||||
)
|
||||
|
||||
call :cleanup
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Done
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:cleanup
|
||||
if "!TEMP_PLACEHOLDER_CREATED!"=="1" (
|
||||
del /Q "frontend\dist\__wails_placeholder__.txt" >nul 2>&1
|
||||
)
|
||||
if "!TEMP_DIST_CREATED!"=="1" (
|
||||
rmdir /S /Q "frontend\dist" >nul 2>&1
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:cleanup_fail
|
||||
call :cleanup
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%publish-public.ps1" %*
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
|
||||
endlocal & exit /b %EXIT_CODE%
|
||||
@@ -0,0 +1,458 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Publish a sanitized snapshot to public release/dev branches.
|
||||
|
||||
.DESCRIPTION
|
||||
- Default mode: publish only release/<version> from master using a safe bot identity.
|
||||
- Optional dev mode: append one snapshot commit per publish to the public dev branch.
|
||||
- Public release/<version> branch: always a single-commit branch (orphan).
|
||||
- Private development history is never pushed directly.
|
||||
|
||||
.EXAMPLE
|
||||
.\bat\publish-public.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\bat\publish-public.ps1 -Version 1.0.0 -PublicRemote github
|
||||
|
||||
.EXAMPLE
|
||||
.\bat\publish-public.ps1 -Version 1.0.1 -PublicRemote https://github.com/org/repo.git -PublishDev -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
.\bat\publish-public.ps1 -Version 1.0.0 -PublicRemote github -ForceOverwriteRelease
|
||||
#>
|
||||
param(
|
||||
[string]$Version,
|
||||
[string]$PublicRemote,
|
||||
[string]$SourceRef = "master",
|
||||
[string]$DevBranch = "dev",
|
||||
[string]$ReleasePrefix = "release/",
|
||||
|
||||
[switch]$PublishDev,
|
||||
[switch]$ForceOverwriteRelease,
|
||||
[switch]$AllowDirtyWorkingTree,
|
||||
[switch]$SkipRuntimeCheck,
|
||||
[switch]$DryRun,
|
||||
[switch]$KeepTempDir,
|
||||
[string]$CommitterName,
|
||||
[string]$CommitterEmail,
|
||||
[switch]$IncludeSourceCommit
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) {
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
}
|
||||
|
||||
$defaultCommitterName = "Ant Browser Release Bot"
|
||||
$defaultCommitterEmail = "release-bot@ant-browser.local"
|
||||
|
||||
function Get-TrimmedText {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return ""
|
||||
}
|
||||
return $Value.Trim()
|
||||
}
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Invoke-Git {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Args,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
|
||||
$tempToken = [guid]::NewGuid().ToString("N")
|
||||
$stdoutPath = Join-Path $env:TEMP "git-stdout-$tempToken.log"
|
||||
$stderrPath = Join-Path $env:TEMP "git-stderr-$tempToken.log"
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
|
||||
try {
|
||||
$ErrorActionPreference = "Continue"
|
||||
& git @Args 1> $stdoutPath 2> $stderrPath
|
||||
$code = $LASTEXITCODE
|
||||
$output = @()
|
||||
if (Test-Path -LiteralPath $stdoutPath) {
|
||||
$output += Get-Content -LiteralPath $stdoutPath
|
||||
}
|
||||
if (Test-Path -LiteralPath $stderrPath) {
|
||||
$output += Get-Content -LiteralPath $stderrPath
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
Remove-Item -LiteralPath $stdoutPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($code -ne 0 -and -not $AllowFailure) {
|
||||
$argText = $Args -join " "
|
||||
$outText = $output -join [Environment]::NewLine
|
||||
throw "git $argText failed with exit code $code.`n$outText"
|
||||
}
|
||||
return @{
|
||||
Code = $code
|
||||
Output = $output
|
||||
}
|
||||
}
|
||||
|
||||
function Get-FirstOutputLine {
|
||||
param([string[]]$Lines)
|
||||
if (($Lines | Measure-Object).Count -eq 0) {
|
||||
return ""
|
||||
}
|
||||
return $Lines[0].Trim()
|
||||
}
|
||||
|
||||
function Resolve-PublicRemoteUrl {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RemoteOrUrl
|
||||
)
|
||||
|
||||
$knownRemotes = (Invoke-Git -Args @("remote")).Output
|
||||
foreach ($item in $knownRemotes) {
|
||||
if ($item.Trim() -eq $RemoteOrUrl) {
|
||||
return Get-FirstOutputLine -Lines (Invoke-Git -Args @("remote", "get-url", $RemoteOrUrl)).Output
|
||||
}
|
||||
}
|
||||
return $RemoteOrUrl
|
||||
}
|
||||
|
||||
function Resolve-DefaultPublicRemote {
|
||||
$knownRemotes = @((Invoke-Git -Args @("remote")).Output | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" })
|
||||
foreach ($candidate in @("github", "public")) {
|
||||
if ($knownRemotes -contains $candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
throw "PublicRemote was not provided and no default public remote (github/public) was found. Pass -PublicRemote explicitly."
|
||||
}
|
||||
|
||||
function Resolve-VersionValue {
|
||||
param([string]$ExplicitVersion)
|
||||
|
||||
$explicit = Get-TrimmedText $ExplicitVersion
|
||||
if ($explicit -ne "") {
|
||||
return $explicit
|
||||
}
|
||||
|
||||
$wailsConfigPath = Join-Path $repoRoot "wails.json"
|
||||
if (-not (Test-Path -LiteralPath $wailsConfigPath)) {
|
||||
throw "wails.json was not found and -Version was not provided."
|
||||
}
|
||||
|
||||
$wailsConfig = Get-Content -LiteralPath $wailsConfigPath -Raw | ConvertFrom-Json
|
||||
$resolvedVersion = Get-TrimmedText ([string]$wailsConfig.info.productVersion)
|
||||
if ($resolvedVersion -eq "") {
|
||||
throw "Could not resolve productVersion from wails.json. Pass -Version explicitly."
|
||||
}
|
||||
return $resolvedVersion
|
||||
}
|
||||
|
||||
function Resolve-CommitterIdentity {
|
||||
param(
|
||||
[string]$ExplicitName,
|
||||
[string]$ExplicitEmail
|
||||
)
|
||||
|
||||
$resolvedName = Get-TrimmedText $ExplicitName
|
||||
if ($resolvedName -eq "") {
|
||||
$resolvedName = Get-TrimmedText ([string]$env:PUBLISH_COMMITTER_NAME)
|
||||
}
|
||||
if ($resolvedName -eq "") {
|
||||
$resolvedName = $defaultCommitterName
|
||||
}
|
||||
|
||||
$resolvedEmail = Get-TrimmedText $ExplicitEmail
|
||||
if ($resolvedEmail -eq "") {
|
||||
$resolvedEmail = Get-TrimmedText ([string]$env:PUBLISH_COMMITTER_EMAIL)
|
||||
}
|
||||
if ($resolvedEmail -eq "") {
|
||||
$resolvedEmail = $defaultCommitterEmail
|
||||
}
|
||||
if ($resolvedEmail -notmatch "^[^@\s]+@[^@\s]+$") {
|
||||
throw "Invalid publish committer email: $resolvedEmail"
|
||||
}
|
||||
|
||||
return @{
|
||||
Name = $resolvedName
|
||||
Email = $resolvedEmail
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-CleanTrackedWorkingTree {
|
||||
$unstaged = Invoke-Git -Args @("diff", "--quiet", "--ignore-submodules", "--") -AllowFailure
|
||||
if ($unstaged.Code -gt 1) {
|
||||
throw "Unable to inspect unstaged tracked-file changes."
|
||||
}
|
||||
if ($unstaged.Code -eq 1) {
|
||||
throw "Tracked files have unstaged changes. Commit or stash them first, or use -AllowDirtyWorkingTree."
|
||||
}
|
||||
|
||||
$staged = Invoke-Git -Args @("diff", "--cached", "--quiet", "--ignore-submodules", "--") -AllowFailure
|
||||
if ($staged.Code -gt 1) {
|
||||
throw "Unable to inspect staged tracked-file changes."
|
||||
}
|
||||
if ($staged.Code -eq 1) {
|
||||
throw "Tracked files have staged but uncommitted changes. Commit them first, or use -AllowDirtyWorkingTree."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RemoteBranchExists {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RemoteUrl,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BranchName
|
||||
)
|
||||
|
||||
$result = Invoke-Git -Args @("ls-remote", "--heads", $RemoteUrl, "refs/heads/$BranchName")
|
||||
if ($result.Code -ne 0) {
|
||||
throw "Unable to query remote branch refs/heads/$BranchName from $RemoteUrl."
|
||||
}
|
||||
return ($result.Output | Measure-Object).Count -gt 0
|
||||
}
|
||||
|
||||
function Sync-SnapshotToRepo {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SnapshotDir,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepoDir
|
||||
)
|
||||
|
||||
Get-ChildItem -LiteralPath $RepoDir -Force |
|
||||
Where-Object { $_.Name -ne ".git" } |
|
||||
Remove-Item -Recurse -Force
|
||||
|
||||
Get-ChildItem -LiteralPath $SnapshotDir -Force | ForEach-Object {
|
||||
$target = Join-Path $RepoDir $_.Name
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $target -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
function Build-CommitMessage {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Title,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Channel,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$VersionValue,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SourceRefValue,
|
||||
[string]$SourceCommit,
|
||||
[switch]$AppendSourceCommit
|
||||
)
|
||||
|
||||
$publishedAtUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
$lines = @(
|
||||
$Title,
|
||||
"",
|
||||
"channel: $Channel",
|
||||
"version: $VersionValue",
|
||||
"source-ref: $SourceRefValue",
|
||||
"published-at-utc: $publishedAtUtc"
|
||||
)
|
||||
if ($AppendSourceCommit -and ((Get-TrimmedText $SourceCommit) -ne "")) {
|
||||
$lines += "source-commit: $SourceCommit"
|
||||
}
|
||||
return $lines -join "`n"
|
||||
}
|
||||
|
||||
function Invoke-GitCommit {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Message,
|
||||
[switch]$AllowEmpty
|
||||
)
|
||||
|
||||
$messagePath = Join-Path $env:TEMP ("ant-chrome-commit-message-" + [guid]::NewGuid().ToString("N") + ".txt")
|
||||
try {
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($messagePath, $Message, $utf8NoBom)
|
||||
|
||||
$commitArgs = @("commit")
|
||||
if ($AllowEmpty) {
|
||||
$commitArgs += "--allow-empty"
|
||||
}
|
||||
$commitArgs += @("-F", $messagePath)
|
||||
|
||||
Invoke-Git -Args $commitArgs | Out-Null
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $messagePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
Set-Location $repoRoot
|
||||
|
||||
Write-Step "Validating repository state"
|
||||
Invoke-Git -Args @("rev-parse", "--is-inside-work-tree") | Out-Null
|
||||
|
||||
if (-not $AllowDirtyWorkingTree) {
|
||||
Assert-CleanTrackedWorkingTree
|
||||
}
|
||||
|
||||
$Version = Resolve-VersionValue -ExplicitVersion $Version
|
||||
if ((Get-TrimmedText $PublicRemote) -eq "") {
|
||||
$PublicRemote = Resolve-DefaultPublicRemote
|
||||
}
|
||||
$committer = Resolve-CommitterIdentity -ExplicitName $CommitterName -ExplicitEmail $CommitterEmail
|
||||
|
||||
$sourceCommit = Get-FirstOutputLine -Lines (Invoke-Git -Args @("rev-parse", "--verify", "$SourceRef`^{commit}")).Output
|
||||
$sourceShort = Get-FirstOutputLine -Lines (Invoke-Git -Args @("rev-parse", "--short", $sourceCommit)).Output
|
||||
$releaseBranch = "$ReleasePrefix$Version"
|
||||
$publicUrl = Resolve-PublicRemoteUrl -RemoteOrUrl $PublicRemote
|
||||
|
||||
Write-Host "Source ref: $SourceRef -> $sourceCommit"
|
||||
Write-Host "Public remote: $publicUrl"
|
||||
if ($PublishDev) {
|
||||
Write-Host "Dev branch: $DevBranch"
|
||||
} else {
|
||||
Write-Host "Dev branch: skipped"
|
||||
}
|
||||
Write-Host "Release branch: $releaseBranch"
|
||||
Write-Host "Committer: $($committer.Name) <$($committer.Email)>"
|
||||
if ($DryRun) {
|
||||
Write-Host "Dry-run: enabled (no push will be performed)"
|
||||
}
|
||||
|
||||
Write-Step "Checking remote branch existence"
|
||||
$devExists = $false
|
||||
if ($PublishDev) {
|
||||
$devExists = Test-RemoteBranchExists -RemoteUrl $publicUrl -BranchName $DevBranch
|
||||
}
|
||||
$releaseExists = Test-RemoteBranchExists -RemoteUrl $publicUrl -BranchName $releaseBranch
|
||||
|
||||
if ($releaseExists -and -not $ForceOverwriteRelease) {
|
||||
throw "Remote release branch $releaseBranch already exists. Use -ForceOverwriteRelease to replace it."
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$tempRoot = Join-Path $env:TEMP "ant-chrome-public-$Version-$timestamp"
|
||||
$snapshotDir = Join-Path $tempRoot "snapshot"
|
||||
$workRepoDir = Join-Path $tempRoot "workrepo"
|
||||
$archivePath = Join-Path $tempRoot "snapshot.zip"
|
||||
|
||||
New-Item -ItemType Directory -Path $snapshotDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $workRepoDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
Write-Step "Exporting source snapshot"
|
||||
Invoke-Git -Args @("archive", "--format=zip", "-o", $archivePath, $sourceCommit) | Out-Null
|
||||
Expand-Archive -LiteralPath $archivePath -DestinationPath $snapshotDir -Force
|
||||
|
||||
if (-not $SkipRuntimeCheck) {
|
||||
Write-Step "Checking required runtime files in snapshot"
|
||||
$requiredFiles = @(
|
||||
"bin/xray.exe",
|
||||
"bin/sing-box.exe"
|
||||
)
|
||||
foreach ($file in $requiredFiles) {
|
||||
$path = Join-Path $snapshotDir $file
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
throw "Required runtime file missing in source snapshot: $file. Add it before publishing, or use -SkipRuntimeCheck."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Preparing temporary publish repository"
|
||||
Push-Location $workRepoDir
|
||||
try {
|
||||
Invoke-Git -Args @("init") | Out-Null
|
||||
Invoke-Git -Args @("remote", "add", "public", $publicUrl) | Out-Null
|
||||
Invoke-Git -Args @("config", "user.name", $committer.Name) | Out-Null
|
||||
Invoke-Git -Args @("config", "user.email", $committer.Email) | Out-Null
|
||||
|
||||
if ($PublishDev) {
|
||||
if ($devExists) {
|
||||
Invoke-Git -Args @("fetch", "--no-tags", "public", "refs/heads/${DevBranch}:refs/remotes/public/$DevBranch") | Out-Null
|
||||
Invoke-Git -Args @("checkout", "-B", $DevBranch, "refs/remotes/public/$DevBranch") | Out-Null
|
||||
} else {
|
||||
Invoke-Git -Args @("checkout", "--orphan", $DevBranch) | Out-Null
|
||||
}
|
||||
}
|
||||
if ($releaseExists) {
|
||||
Invoke-Git -Args @("fetch", "--no-tags", "public", "refs/heads/${releaseBranch}:refs/remotes/public/$releaseBranch") | Out-Null
|
||||
}
|
||||
|
||||
if ($PublishDev) {
|
||||
Write-Step "Publishing snapshot to $DevBranch"
|
||||
Sync-SnapshotToRepo -SnapshotDir $snapshotDir -RepoDir $workRepoDir
|
||||
Invoke-Git -Args @("add", "-A") | Out-Null
|
||||
$devMessage = Build-CommitMessage `
|
||||
-Title "publish: $Version snapshot ($sourceShort)" `
|
||||
-Channel "dev" `
|
||||
-VersionValue $Version `
|
||||
-SourceRefValue $SourceRef `
|
||||
-SourceCommit $sourceCommit `
|
||||
-AppendSourceCommit:$IncludeSourceCommit
|
||||
Invoke-GitCommit -Message $devMessage -AllowEmpty
|
||||
if ($DryRun) {
|
||||
Write-Host "DRY-RUN: skip push -> public $DevBranch"
|
||||
} else {
|
||||
Invoke-Git -Args @("push", "public", $DevBranch) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Publishing snapshot to $releaseBranch (single-commit branch)"
|
||||
Invoke-Git -Args @("checkout", "--orphan", $releaseBranch) | Out-Null
|
||||
Sync-SnapshotToRepo -SnapshotDir $snapshotDir -RepoDir $workRepoDir
|
||||
Invoke-Git -Args @("add", "-A") | Out-Null
|
||||
$releaseMessage = Build-CommitMessage `
|
||||
-Title "release: $Version snapshot ($sourceShort)" `
|
||||
-Channel "release" `
|
||||
-VersionValue $Version `
|
||||
-SourceRefValue $SourceRef `
|
||||
-SourceCommit $sourceCommit `
|
||||
-AppendSourceCommit:$IncludeSourceCommit
|
||||
Invoke-GitCommit -Message $releaseMessage
|
||||
|
||||
$releaseCount = Get-FirstOutputLine -Lines (Invoke-Git -Args @("rev-list", "--count", $releaseBranch)).Output
|
||||
if ($releaseCount -ne "1") {
|
||||
throw "Local release branch $releaseBranch is expected to have exactly 1 commit, got $releaseCount."
|
||||
}
|
||||
|
||||
if ($releaseExists) {
|
||||
if ($DryRun) {
|
||||
Write-Host "DRY-RUN: skip push -> public $releaseBranch --force-with-lease"
|
||||
} else {
|
||||
Invoke-Git -Args @("push", "--force-with-lease", "public", $releaseBranch) | Out-Null
|
||||
}
|
||||
} else {
|
||||
if ($DryRun) {
|
||||
Write-Host "DRY-RUN: skip push -> public $releaseBranch"
|
||||
} else {
|
||||
Invoke-Git -Args @("push", "public", $releaseBranch) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Step "Publish completed"
|
||||
Write-Host "Published source commit: $sourceCommit"
|
||||
if ($PublishDev) {
|
||||
Write-Host "Updated dev branch: $DevBranch (appends one snapshot commit per publish)"
|
||||
}
|
||||
Write-Host "Updated release branch: $releaseBranch (always single commit)"
|
||||
}
|
||||
finally {
|
||||
if ($KeepTempDir) {
|
||||
Write-Host ""
|
||||
Write-Host "Temporary directory kept: $tempRoot"
|
||||
} else {
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM 切换到项目根目录(脚本所在目录的上一级)
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ========================================
|
||||
echo Ant Browser - 发布打包脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 当前工作目录: %CD%
|
||||
echo.
|
||||
|
||||
REM ======== [1/6] 检测 NSIS ========
|
||||
echo [1/6] 检测 NSIS 安装...
|
||||
echo 支持环境变量:MAKENSIS_PATH / NSIS_PATH / NSIS_HOME
|
||||
echo.
|
||||
|
||||
set "MAKENSIS="
|
||||
|
||||
REM 优先级 1:MAKENSIS_PATH 直接指向 makensis.exe
|
||||
if defined MAKENSIS_PATH (
|
||||
if exist "!MAKENSIS_PATH!" (
|
||||
set "MAKENSIS=!MAKENSIS_PATH!"
|
||||
goto :nsis_found
|
||||
)
|
||||
)
|
||||
|
||||
REM 优先级 2:NSIS_PATH 可以是 makensis.exe 或 NSIS 目录
|
||||
if defined NSIS_PATH (
|
||||
if exist "!NSIS_PATH!\makensis.exe" (
|
||||
set "MAKENSIS=!NSIS_PATH!\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
if exist "!NSIS_PATH!" (
|
||||
set "MAKENSIS=!NSIS_PATH!"
|
||||
goto :nsis_found
|
||||
)
|
||||
)
|
||||
|
||||
REM 优先级 3:NSIS_HOME 为 NSIS 安装根目录
|
||||
if defined NSIS_HOME (
|
||||
if exist "!NSIS_HOME!\makensis.exe" (
|
||||
set "MAKENSIS=!NSIS_HOME!\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
)
|
||||
|
||||
REM 优先级 4:系统 PATH
|
||||
for /f "delims=" %%i in ('where makensis.exe 2^>nul') do (
|
||||
set "MAKENSIS=%%i"
|
||||
goto :nsis_found
|
||||
)
|
||||
|
||||
REM 优先级 5:常见安装目录
|
||||
if exist "C:\Program Files (x86)\NSIS\makensis.exe" (
|
||||
set "MAKENSIS=C:\Program Files (x86)\NSIS\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
if exist "C:\Program Files\NSIS\makensis.exe" (
|
||||
set "MAKENSIS=C:\Program Files\NSIS\makensis.exe"
|
||||
goto :nsis_found
|
||||
)
|
||||
|
||||
echo ✗ 未找到 NSIS(makensis.exe)
|
||||
echo.
|
||||
echo 请安装 NSIS 后,通过以下任一方式配置(PowerShell):
|
||||
echo setx MAKENSIS_PATH "D:\tools\NSIS\makensis.exe"
|
||||
echo setx NSIS_PATH "D:\tools\NSIS"
|
||||
echo setx NSIS_HOME "D:\tools\NSIS"
|
||||
echo.
|
||||
echo 或下载安装:https://nsis.sourceforge.io/Download
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:nsis_found
|
||||
echo ✓ NSIS 已就绪: !MAKENSIS!
|
||||
echo.
|
||||
|
||||
REM ======== [2/6] 读取版本号 ========
|
||||
echo [2/6] 读取版本号...
|
||||
|
||||
set "VERSION="
|
||||
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content wails.json | ConvertFrom-Json).info.productVersion"`) do (
|
||||
set "VERSION=%%v"
|
||||
)
|
||||
|
||||
if "!VERSION!"=="" (
|
||||
echo ✗ 无法从 wails.json 读取版本号
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 版本号: !VERSION!
|
||||
echo.
|
||||
|
||||
REM ======== [3/6] Wails 构建 ========
|
||||
echo [3/6] 执行 Wails 构建...
|
||||
|
||||
set GOPROXY=https://goproxy.cn,direct
|
||||
wails build
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ Wails 构建失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "build\bin\ant-chrome.exe" (
|
||||
echo ✗ 构建产物不存在: build\bin\ant-chrome.exe
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 构建成功: build\bin\ant-chrome.exe
|
||||
echo.
|
||||
|
||||
REM ======== [4/6] 组装 staging 目录 ========
|
||||
echo [4/6] 组装 staging 目录...
|
||||
|
||||
set "STAGING=publish\staging"
|
||||
set "RELEASE_CONFIG=publish\config.init.yaml"
|
||||
|
||||
if exist "!STAGING!" rmdir /S /Q "!STAGING!"
|
||||
mkdir "!STAGING!"
|
||||
|
||||
copy /Y "build\bin\ant-chrome.exe" "!STAGING!\ant-chrome.exe" >nul
|
||||
if errorlevel 1 (
|
||||
echo ✗ 复制 ant-chrome.exe 失败
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "!STAGING!\ant-chrome.exe" (
|
||||
echo ✗ staging 中缺少 ant-chrome.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 复制 ant-chrome.exe
|
||||
|
||||
if not exist "!RELEASE_CONFIG!" (
|
||||
echo ✗ 未找到发布配置模板: !RELEASE_CONFIG!
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
copy /Y "!RELEASE_CONFIG!" "!STAGING!\config.yaml" >nul
|
||||
if errorlevel 1 (
|
||||
echo ✗ 复制发布配置模板失败: !RELEASE_CONFIG!
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "!STAGING!\config.yaml" (
|
||||
echo ✗ staging 中缺少 config.yaml(来源: !RELEASE_CONFIG!)
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 复制发布配置模板 !RELEASE_CONFIG! -> config.yaml
|
||||
|
||||
if not exist "bin" (
|
||||
echo ✗ bin\ 目录不存在,缺少代理运行时文件
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "bin\xray.exe" (
|
||||
echo ✗ 缺少运行时文件: bin\xray.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "bin\sing-box.exe" (
|
||||
echo ✗ 缺少运行时文件: bin\sing-box.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
xcopy /E /I /Y bin "!STAGING!\bin" >nul
|
||||
if not exist "!STAGING!\bin\xray.exe" (
|
||||
echo ✗ 复制后仍缺少 !STAGING!\bin\xray.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "!STAGING!\bin\sing-box.exe" (
|
||||
echo ✗ 复制后仍缺少 !STAGING!\bin\sing-box.exe
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 复制 bin\(xray.exe, sing-box.exe)
|
||||
|
||||
if not exist "chrome" (
|
||||
echo ✗ chrome\ 目录不存在,Chrome 内核为必需文件
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
xcopy /E /I /Y chrome "!STAGING!\chrome" >nul
|
||||
echo ✓ 复制 chrome\
|
||||
|
||||
mkdir "!STAGING!\data"
|
||||
echo ✓ 创建空 data 目录(不打包 app.db,首次启动自动初始化)
|
||||
|
||||
echo.
|
||||
echo ✓ staging 目录组装完成
|
||||
echo.
|
||||
|
||||
REM ======== [5/6] NSIS 打包 ========
|
||||
echo [5/6] 调用 NSIS 打包...
|
||||
|
||||
if not exist "publish\output" mkdir "publish\output"
|
||||
|
||||
REM 确保 installer.nsi 是 UTF-8 with BOM(NSIS Unicode True 要求)
|
||||
powershell -NoProfile -Command "$f=(Resolve-Path 'publish\installer.nsi').Path; $c=[System.IO.File]::ReadAllText($f,[System.Text.Encoding]::UTF8); [System.IO.File]::WriteAllText($f,$c,[System.Text.UTF8Encoding]::new($true))" >nul
|
||||
|
||||
for /f "usebackq delims=" %%p in (`powershell -NoProfile -Command "(Resolve-Path '!STAGING!').Path"`) do (
|
||||
set "STAGING_ABS=%%p"
|
||||
)
|
||||
|
||||
"!MAKENSIS!" /DVERSION=!VERSION! "/DSTAGINGDIR=!STAGING_ABS!" publish\installer.nsi
|
||||
if %errorlevel% neq 0 (
|
||||
echo ✗ NSIS 打包失败
|
||||
rmdir /S /Q "!STAGING!"
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✓ 安装包生成成功
|
||||
echo.
|
||||
|
||||
REM ======== [6/6] 清理 staging ========
|
||||
echo [6/6] 清理临时文件...
|
||||
rmdir /S /Q "!STAGING!"
|
||||
echo ✓ staging 目录已清理
|
||||
echo.
|
||||
|
||||
echo ========================================
|
||||
echo ✓ 发布完成!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 安装包位置: publish\output\AntBrowser-Setup-!VERSION!.exe
|
||||
echo.
|
||||
echo 提示:用户安装后可将旧的 data\ 目录粘贴到安装目录覆盖初始数据
|
||||
echo.
|
||||
pause
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 930 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user