publish: 1.0.0 snapshot (bad2ec1)

channel: master
version: 1.0.0
source-ref: master
published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
Ant Browser Release Bot
2026-03-13 23:19:29 +08:00
commit 6f58a6c19a
230 changed files with 44624 additions and 0 deletions
+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()
}