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:
Ant Browser Release Bot
2026-03-13 21:17:53 +08:00
commit f2cc3654a5
247 changed files with 46404 additions and 0 deletions
+66
View File
@@ -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
+42
View File
@@ -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` 目录)。
- **路径映射**: 如果你想使用非标准路径,依然可以在主程序的“系统设置”中手动修改。
+209
View File
@@ -0,0 +1,209 @@
# Ant Browser
> 面向多账号隔离、代理绑定和本地环境管理的 Windows 桌面浏览器工具。
[![Release](https://img.shields.io/github/v/release/black-ant/Ant-Browser?sort=semver)](https://github.com/black-ant/Ant-Browser/releases)
[![Platform](https://img.shields.io/badge/Platform-Windows%2010%2F11-blue)](https://github.com/black-ant/Ant-Browser/releases)
[![Issues](https://img.shields.io/github/issues/black-ant/Ant-Browser)](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 1164 位)
- 建议内存: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
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
+128
View File
@@ -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))
}
}
+64
View File
@@ -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...))
}
+184
View File
@@ -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
}
+115
View File
@@ -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
}
+571
View File
@@ -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
}
+57
View File
@@ -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)
}
+48
View File
@@ -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)
}
}
+83
View File
@@ -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 获取实例的 LaunchCodeWails 绑定)
func (a *App) BrowserProfileGetCode(profileId string) (string, error) {
if a.launchCodeSvc == nil {
return "", nil
}
return a.launchCodeSvc.EnsureCode(profileId)
}
// BrowserProfileRegenerateCode 重新生成实例的 LaunchCodeWails 绑定)
func (a *App) BrowserProfileRegenerateCode(profileId string) (string, error) {
if a.launchCodeSvc == nil {
return "", nil
}
return a.launchCodeSvc.RegenerateCode(profileId)
}
// BrowserProfileSetCode 自定义设置实例 LaunchCodeWails 绑定)
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)
+162
View File
@@ -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
}
+34
View File
@@ -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")
}
+93
View File
@@ -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()
}
+248
View File
@@ -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)
}
}
+60
View File
@@ -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)
}
}
+151
View File
@@ -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
}
+293
View File
@@ -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)
}
+237
View File
@@ -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 启动时加载代理数据。
// 优先从 ProxyDAOSQLite)读取;若 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)))
}
+69
View File
@@ -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()
}
+409
View File
@@ -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
}
+124
View File
@@ -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)
}
}
+69
View File
@@ -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()
}
+223
View File
@@ -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,
)
}
+6
View File
@@ -0,0 +1,6 @@
package browser
// BuildLaunchArgs 构建启动参数
func BuildLaunchArgs(args []string, profile *Profile) []string {
return args
}
+344
View File
@@ -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
}
+96
View File
@@ -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()
}
+460
View File
@@ -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
}
+83
View File
@@ -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)
}
}
+223
View File
@@ -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
}
+471
View File
@@ -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")
// 优先从 DAOSQLite)加载
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
}
+225
View File
@@ -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
}
+185
View File
@@ -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()
}
+110
View File
@@ -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()
}
+174
View File
@@ -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
}
+430
View File
@@ -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
}
+179
View File
@@ -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)
}
}
+257
View File
@@ -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(&currentVersion); 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] + "..."
}
+112
View File
@@ -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
}
+73
View File
@@ -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
}
+461
View File
@@ -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)
}
}
+208
View File
@@ -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
}
+192
View File
@@ -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
}
+280
View File
@@ -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")
}
}
+488
View File
@@ -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()
}
+466
View File
@@ -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)
}
+134
View File
@@ -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
}
+163
View File
@@ -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"])
}
}
+546
View File
@@ -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
}
+439
View File
@@ -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()
}
+76
View File
@@ -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]
}
+524
View File
@@ -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)
}
+197
View File
@@ -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)
}
+375
View File
@@ -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)
}
+194
View File
@@ -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")
}
}
+127
View File
@@ -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)
}
}
+95
View File
@@ -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
}
+82
View File
@@ -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] + "..."
}
+685
View File
@@ -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
// 格式1method:password@host:portSIP002
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
}
+281
View File
@@ -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)
}
+248
View File
@@ -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
}
+321
View File
@@ -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
}
+10
View File
@@ -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
}
+13
View File
@@ -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}
}
+25
View File
@@ -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 配置
}
+309
View File
@@ -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 {
// DirectProxyhttp/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}
}
+725
View File
@@ -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 或 DoHhttps://)地址,
// 不支持 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)、
// DoHhttps://...)、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

+63
View File
@@ -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()
}
+178
View File
@@ -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
}
+100
View File
@@ -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)
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
// +build !windows
package backend
func killResidualRuntimeProcesses(appRoot string) error {
return nil
}
+104
View File
@@ -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
}
+10
View File
@@ -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
}
+13
View File
@@ -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}
}
+199
View File
@@ -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 和 LaunchCodeUpsert 后:
// - 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,实际 %dbody=%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)
}
}
+274
View File
@@ -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
// 对于任意存在的 LaunchCodeGET /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
// 对于任意不存在的 codeGET /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)
}
}
}
+119
View File
@@ -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
}
+228
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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
)
+165
View File
@@ -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 "✓ 所有绑定文件已修复"
+77
View File
@@ -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
+8
View File
@@ -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%
+458
View File
@@ -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
View File
@@ -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 优先级 1MAKENSIS_PATH 直接指向 makensis.exe
if defined MAKENSIS_PATH (
if exist "!MAKENSIS_PATH!" (
set "MAKENSIS=!MAKENSIS_PATH!"
goto :nsis_found
)
)
REM 优先级 2NSIS_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 优先级 3NSIS_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 ✗ 未找到 NSISmakensis.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 BOMNSIS 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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

+15
View File
@@ -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