From cc45cac6225277f6f3ce3528b4f902522e5d8d06 Mon Sep 17 00:00:00 2001 From: wood chen Date: Sat, 22 Mar 2025 18:17:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=9B=B4=E6=96=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86=E5=92=8C=E6=89=A9=E5=B1=95=E5=90=8D?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在配置中添加新的扩展名规则支持,允许用户定义文件扩展名与目标URL的映射 - 优化配置加载逻辑,确保路径配置的扩展名规则在初始化时得到处理 - 更新前端配置页面,支持添加、编辑和删除扩展名规则 - 增强错误处理和用户提示,确保用户体验流畅 --- .gitignore | 11 +- go.mod | 1 + go.sum | 2 + internal/config/config.go | 52 +- internal/config/init.go | 16 + internal/config/types.go | 115 +-- internal/initapp/config_migration_20250322.go | 137 +++ internal/initapp/init.go | 19 + internal/metrics/init.go | 42 +- internal/metrics/metricsstorage.go | 44 + internal/utils/utils.go | 205 +++-- main.go | 35 +- web/app/dashboard/config/page.tsx | 804 +++++++++--------- 13 files changed, 892 insertions(+), 591 deletions(-) create mode 100644 internal/config/init.go create mode 100644 internal/initapp/config_migration_20250322.go create mode 100644 internal/initapp/init.go create mode 100644 internal/metrics/metricsstorage.go diff --git a/.gitignore b/.gitignore index b20f803..f07ff2e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,15 @@ vendor/ web/node_modules/ web/dist/ data/config.json -data/config.json kaifa.md .cursor +data/cache/config.json +data/mirror_cache/config.json +data/metrics/latency_distribution.json +data/metrics/metrics.json +data/metrics/path_stats.json +data/metrics/referer_stats.json +data/metrics/status_codes.json +data/config.example.json +data/config.json +.env diff --git a/go.mod b/go.mod index 2b9d1d4..84fd138 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/andybalholm/brotli v1.1.1 + github.com/joho/godotenv v1.5.1 golang.org/x/net v0.37.0 ) diff --git a/go.sum b/go.sum index 88390df..02db7db 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= diff --git a/internal/config/config.go b/internal/config/config.go index 029d750..ac0fecf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,7 +7,6 @@ import ( "strings" "sync" "sync/atomic" - "time" ) // Config 配置结构体 @@ -30,18 +29,26 @@ type ConfigManager struct { configPath string } -func NewConfigManager(path string) *ConfigManager { - cm := &ConfigManager{configPath: path} - cm.loadConfig() - go cm.watchConfig() - return cm -} - -func (cm *ConfigManager) watchConfig() { - ticker := time.NewTicker(30 * time.Second) - for range ticker.C { - cm.loadConfig() +func NewConfigManager(configPath string) (*ConfigManager, error) { + cm := &ConfigManager{ + configPath: configPath, } + + // 加载配置 + config, err := Load(configPath) + if err != nil { + return nil, err + } + + // 确保所有路径配置的扩展名规则都已更新 + for _, pathConfig := range config.MAP { + pathConfig.ProcessExtensionMap() + } + + cm.config.Store(config) + log.Printf("[ConfigManager] 配置已加载: %d 个路径映射", len(config.MAP)) + + return cm, nil } // Load 加载配置 @@ -75,6 +82,21 @@ func createDefaultConfig(path string) error { MAP: map[string]PathConfig{ "/": { DefaultTarget: "http://localhost:8080", + // 添加新式扩展名规则映射示例 + ExtensionMap: []ExtRuleConfig{ + { + Extensions: "jpg,png,webp", + Target: "https://img1.example.com", + SizeThreshold: 500 * 1024, // 500KB + MaxSize: 2 * 1024 * 1024, // 2MB + }, + { + Extensions: "jpg,png,webp", + Target: "https://img2.example.com", + SizeThreshold: 2 * 1024 * 1024, // 2MB + MaxSize: 5 * 1024 * 1024, // 5MB + }, + }, }, }, Compression: CompressionConfig{ @@ -108,7 +130,7 @@ func RegisterUpdateCallback(callback func(*Config)) { // TriggerCallbacks 触发所有回调 func TriggerCallbacks(cfg *Config) { - // 确保所有路径配置的processedExtMap都已更新 + // 确保所有路径配置的扩展名规则都已更新 for _, pathConfig := range cfg.MAP { pathConfig.ProcessExtensionMap() } @@ -128,7 +150,7 @@ func (c *configImpl) Update(newConfig *Config) { c.Lock() defer c.Unlock() - // 确保所有路径配置的processedExtMap都已更新 + // 确保所有路径配置的扩展名规则都已更新 for _, pathConfig := range newConfig.MAP { pathConfig.ProcessExtensionMap() } @@ -168,7 +190,7 @@ func (cm *ConfigManager) loadConfig() error { return err } - // 确保所有路径配置的processedExtMap都已更新 + // 确保所有路径配置的扩展名规则都已更新 for _, pathConfig := range config.MAP { pathConfig.ProcessExtensionMap() } diff --git a/internal/config/init.go b/internal/config/init.go new file mode 100644 index 0000000..ffc52cb --- /dev/null +++ b/internal/config/init.go @@ -0,0 +1,16 @@ +package config + +import "log" + +func Init(configPath string) (*ConfigManager, error) { + log.Printf("[Config] 初始化配置管理器...") + + configManager, err := NewConfigManager(configPath) + if err != nil { + log.Printf("[Config] 初始化配置管理器失败: %v", err) + return nil, err + } + + log.Printf("[Config] 配置管理器初始化成功") + return configManager, nil +} diff --git a/internal/config/types.go b/internal/config/types.go index 90bdf00..3962573 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,22 +1,26 @@ package config import ( - "encoding/json" "strings" ) type Config struct { - MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig + MAP map[string]PathConfig `json:"MAP"` // 路径映射配置 Compression CompressionConfig `json:"Compression"` } type PathConfig struct { - Path string `json:"Path"` - DefaultTarget string `json:"DefaultTarget"` - ExtensionMap map[string]string `json:"ExtensionMap"` - SizeThreshold int64 `json:"SizeThreshold"` // 最小文件大小阈值 - MaxSize int64 `json:"MaxSize"` // 最大文件大小阈值 - processedExtMap map[string]string // 内部使用,存储拆分后的映射 + DefaultTarget string `json:"DefaultTarget"` // 默认目标URL + ExtensionMap []ExtRuleConfig `json:"ExtensionMap"` // 扩展名映射规则 + ExtRules []ExtensionRule `json:"-"` // 内部使用,存储处理后的扩展名规则 +} + +// ExtensionRule 表示一个扩展名映射规则(内部使用) +type ExtensionRule struct { + Extensions []string // 支持的扩展名列表 + Target string // 目标服务器 + SizeThreshold int64 // 最小文件大小阈值 + MaxSize int64 // 最大文件大小阈值 } type CompressionConfig struct { @@ -29,87 +33,40 @@ type CompressorConfig struct { Level int `json:"Level"` } -// 添加一个辅助方法来处理字符串到 PathConfig 的转换 -func (c *Config) UnmarshalJSON(data []byte) error { - // 创建一个临时结构来解析原始JSON - type TempConfig struct { - MAP map[string]json.RawMessage `json:"MAP"` - Compression CompressionConfig `json:"Compression"` - } - - var temp TempConfig - if err := json.Unmarshal(data, &temp); err != nil { - return err - } - - // 初始化 MAP - c.MAP = make(map[string]PathConfig) - - // 处理每个路径配置 - for key, raw := range temp.MAP { - // 尝试作为字符串解析 - var strValue string - if err := json.Unmarshal(raw, &strValue); err == nil { - pathConfig := PathConfig{ - DefaultTarget: strValue, - } - pathConfig.ProcessExtensionMap() // 处理扩展名映射 - c.MAP[key] = pathConfig - continue - } - - // 如果不是字符串,尝试作为PathConfig解析 - var pathConfig PathConfig - if err := json.Unmarshal(raw, &pathConfig); err != nil { - return err - } - pathConfig.ProcessExtensionMap() // 处理扩展名映射 - c.MAP[key] = pathConfig - } - - // 复制其他字段 - c.Compression = temp.Compression - - return nil +// 扩展名映射配置结构 +type ExtRuleConfig struct { + Extensions string `json:"Extensions"` // 逗号分隔的扩展名 + Target string `json:"Target"` // 目标服务器 + SizeThreshold int64 `json:"SizeThreshold"` // 最小文件大小阈值 + MaxSize int64 `json:"MaxSize"` // 最大文件大小阈值 } -// 添加处理扩展名映射的方法 +// 处理扩展名映射的方法 func (p *PathConfig) ProcessExtensionMap() { + p.ExtRules = nil + if p.ExtensionMap == nil { - p.processedExtMap = nil return } - // 重新创建processedExtMap,确保它是最新的 - p.processedExtMap = make(map[string]string) + // 处理扩展名规则 + for _, rule := range p.ExtensionMap { + extRule := ExtensionRule{ + Target: rule.Target, + SizeThreshold: rule.SizeThreshold, + MaxSize: rule.MaxSize, + } - for exts, target := range p.ExtensionMap { - // 分割扩展名 - for _, ext := range strings.Split(exts, ",") { - ext = strings.TrimSpace(ext) // 移除可能的空格 + // 处理扩展名列表 + for _, ext := range strings.Split(rule.Extensions, ",") { + ext = strings.TrimSpace(ext) if ext != "" { - p.processedExtMap[ext] = target + extRule.Extensions = append(extRule.Extensions, ext) } } + + if len(extRule.Extensions) > 0 { + p.ExtRules = append(p.ExtRules, extRule) + } } } - -// 添加获取目标URL的方法 -func (p *PathConfig) GetTargetForExt(ext string) string { - if p.processedExtMap == nil { - p.ProcessExtensionMap() - } - if target, exists := p.processedExtMap[ext]; exists { - return target - } - return p.DefaultTarget -} - -// 添加检查扩展名是否存在的方法 -func (p *PathConfig) GetExtensionTarget(ext string) (string, bool) { - if p.processedExtMap == nil { - p.ProcessExtensionMap() - } - target, exists := p.processedExtMap[ext] - return target, exists -} diff --git a/internal/initapp/config_migration_20250322.go b/internal/initapp/config_migration_20250322.go new file mode 100644 index 0000000..d9a92c5 --- /dev/null +++ b/internal/initapp/config_migration_20250322.go @@ -0,0 +1,137 @@ +package initapp + +import ( + "encoding/json" + "log" + "os" +) + +// 旧配置结构 +type OldPathConfig struct { + DefaultTarget string `json:"DefaultTarget"` + ExtensionMap interface{} `json:"ExtensionMap"` + SizeThreshold int64 `json:"SizeThreshold,omitempty"` + MaxSize int64 `json:"MaxSize,omitempty"` + Path string `json:"Path,omitempty"` +} + +// 新配置结构 +type NewPathConfig struct { + DefaultTarget string `json:"DefaultTarget"` + ExtensionMap []ExtRuleConfig `json:"ExtensionMap"` +} + +type ExtRuleConfig struct { + Extensions string `json:"Extensions"` + Target string `json:"Target"` + SizeThreshold int64 `json:"SizeThreshold"` + MaxSize int64 `json:"MaxSize"` +} + +type CompressionConfig struct { + Gzip CompressorConfig `json:"Gzip"` + Brotli CompressorConfig `json:"Brotli"` +} + +type CompressorConfig struct { + Enabled bool `json:"Enabled"` + Level int `json:"Level"` +} + +type Config struct { + MAP map[string]interface{} `json:"MAP"` + Compression CompressionConfig `json:"Compression"` +} + +// MigrateConfig 检查并迁移配置文件,确保使用新格式 +func MigrateConfig(configPath string) error { + // 读取配置文件 + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + // 解析为通用配置结构 + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return err + } + + configChanged := false + + // 遍历所有路径配置 + for path, rawPathConfig := range config.MAP { + // 将接口转换为JSON + rawData, err := json.Marshal(rawPathConfig) + if err != nil { + log.Printf("[Init] 无法序列化路径 %s 的配置: %v", path, err) + continue + } + + // 尝试解析为旧格式 + var oldPathConfig OldPathConfig + if err := json.Unmarshal(rawData, &oldPathConfig); err != nil { + log.Printf("[Init] 无法解析路径 %s 的配置: %v", path, err) + continue + } + + // 创建新格式配置 + newPathConfig := NewPathConfig{ + DefaultTarget: oldPathConfig.DefaultTarget, + ExtensionMap: []ExtRuleConfig{}, + } + + // 检查ExtensionMap类型 + if oldPathConfig.ExtensionMap != nil { + // 尝试将ExtensionMap解析为旧格式的map + oldFormatMap := make(map[string]string) + if rawExtMap, err := json.Marshal(oldPathConfig.ExtensionMap); err == nil { + if json.Unmarshal(rawExtMap, &oldFormatMap) == nil && len(oldFormatMap) > 0 { + // 是旧格式的map,转换为数组 + for exts, target := range oldFormatMap { + rule := ExtRuleConfig{ + Extensions: exts, + Target: target, + SizeThreshold: oldPathConfig.SizeThreshold, + MaxSize: oldPathConfig.MaxSize, + } + newPathConfig.ExtensionMap = append(newPathConfig.ExtensionMap, rule) + } + configChanged = true + log.Printf("[Init] 路径 %s 的配置已从旧版格式迁移到新版格式", path) + } + } + + // 尝试将ExtensionMap解析为新格式的数组 + if len(newPathConfig.ExtensionMap) == 0 { + var newFormatArray []ExtRuleConfig + if rawExtMap, err := json.Marshal(oldPathConfig.ExtensionMap); err == nil { + if json.Unmarshal(rawExtMap, &newFormatArray) == nil { + newPathConfig.ExtensionMap = newFormatArray + } + } + } + } + + // 更新配置 + config.MAP[path] = newPathConfig + } + + // 如果有配置变更,保存回文件 + newData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(configPath, newData, 0644); err != nil { + return err + } + + if configChanged { + log.Printf("[Init] 配置文件已成功迁移到新格式并保存: %s", configPath) + } else { + log.Printf("[Init] 配置文件格式已规范化: %s", configPath) + } + + return nil +} diff --git a/internal/initapp/init.go b/internal/initapp/init.go new file mode 100644 index 0000000..8066d65 --- /dev/null +++ b/internal/initapp/init.go @@ -0,0 +1,19 @@ +package initapp + +import ( + "log" +) + +func Init(configPath string) error { + + log.Printf("[Init] 开始初始化应用程序...") + + // 迁移配置文件 + if err := MigrateConfig(configPath); err != nil { + log.Printf("[Init] 配置迁移失败: %v", err) + return err + } + + log.Printf("[Init] 应用程序初始化完成") + return nil +} diff --git a/internal/metrics/init.go b/internal/metrics/init.go index ea37858..56d4ddc 100644 --- a/internal/metrics/init.go +++ b/internal/metrics/init.go @@ -2,47 +2,25 @@ package metrics import ( "log" - "path/filepath" "proxy-go/internal/config" - "time" ) -var ( - metricsStorage *MetricsStorage -) - -// InitMetricsStorage 初始化指标存储服务 -func InitMetricsStorage(cfg *config.Config) error { - // 确保收集器已初始化 +func Init(cfg *config.Config) error { + // 初始化收集器 if err := InitCollector(cfg); err != nil { + log.Printf("[Metrics] 初始化收集器失败: %v", err) + //继续运行 return err } - // 创建指标存储服务 - dataDir := filepath.Join("data", "metrics") - saveInterval := 30 * time.Minute // 默认30分钟保存一次,减少IO操作 - - metricsStorage = NewMetricsStorage(GetCollector(), dataDir, saveInterval) - - // 启动指标存储服务 - if err := metricsStorage.Start(); err != nil { - log.Printf("[Metrics] 启动指标存储服务失败: %v", err) + // 初始化指标存储服务 + if err := InitMetricsStorage(cfg); err != nil { + log.Printf("[Metrics] 初始化指标存储服务失败: %v", err) + //继续运行 return err } - log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval) + log.Printf("[Metrics] 初始化完成") + return nil } - -// StopMetricsStorage 停止指标存储服务 -func StopMetricsStorage() { - if metricsStorage != nil { - metricsStorage.Stop() - log.Printf("[Metrics] 指标存储服务已停止") - } -} - -// GetMetricsStorage 获取指标存储服务实例 -func GetMetricsStorage() *MetricsStorage { - return metricsStorage -} diff --git a/internal/metrics/metricsstorage.go b/internal/metrics/metricsstorage.go new file mode 100644 index 0000000..c3a7e44 --- /dev/null +++ b/internal/metrics/metricsstorage.go @@ -0,0 +1,44 @@ +package metrics + +import ( + "log" + "path/filepath" + "proxy-go/internal/config" + "time" +) + +var ( + metricsStorage *MetricsStorage +) + +// InitMetricsStorage 初始化指标存储服务 +func InitMetricsStorage(cfg *config.Config) error { + + // 创建指标存储服务 + dataDir := filepath.Join("data", "metrics") + saveInterval := 30 * time.Minute // 默认30分钟保存一次,减少IO操作 + + metricsStorage = NewMetricsStorage(GetCollector(), dataDir, saveInterval) + + // 启动指标存储服务 + if err := metricsStorage.Start(); err != nil { + log.Printf("[Metrics] 启动指标存储服务失败: %v", err) + return err + } + + log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval) + return nil +} + +// StopMetricsStorage 停止指标存储服务 +func StopMetricsStorage() { + if metricsStorage != nil { + metricsStorage.Stop() + log.Printf("[Metrics] 指标存储服务已停止") + } +} + +// GetMetricsStorage 获取指标存储服务实例 +func GetMetricsStorage() *MetricsStorage { + return metricsStorage +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index c4569cb..e7c9e64 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -10,6 +10,7 @@ import ( "net/http" "path/filepath" "proxy-go/internal/config" + "slices" "sort" "strings" "sync" @@ -188,86 +189,148 @@ func GetTargetURL(client *http.Client, r *http.Request, pathConfig config.PathCo targetBase := pathConfig.DefaultTarget usedAltTarget := false - // 如果配置了扩展名映射 - if pathConfig.ExtensionMap != nil { - ext := strings.ToLower(filepath.Ext(path)) - if ext != "" { - ext = ext[1:] // 移除开头的点 - // 检查是否在扩展名映射中 - if altTarget, exists := pathConfig.GetExtensionTarget(ext); exists { - // 检查文件大小 - contentLength, err := GetFileSize(client, targetBase+path) - if err != nil { - log.Printf("[Route] %s -> %s (error getting size: %v)", path, targetBase, err) - return targetBase, false - } + // 获取文件扩展名 + ext := strings.ToLower(filepath.Ext(path)) + if ext != "" { + ext = ext[1:] // 移除开头的点 + } else { + log.Printf("[Route] %s -> %s (无扩展名)", path, targetBase) + // 即使没有扩展名,也要尝试匹配 * 通配符规则 + } - // 如果没有设置最小阈值,使用默认值 500KB - minThreshold := pathConfig.SizeThreshold - if minThreshold <= 0 { - minThreshold = 500 * 1024 - } + // 获取文件大小 + contentLength, err := GetFileSize(client, targetBase+path) + if err != nil { + log.Printf("[Route] %s -> %s (获取文件大小出错: %v)", path, targetBase, err) + return targetBase, false + } - // 如果没有设置最大阈值,使用默认值 10MB - maxThreshold := pathConfig.MaxSize - if maxThreshold <= 0 { - maxThreshold = 10 * 1024 * 1024 - } + // 获取匹配的扩展名规则 + matchingRules := []config.ExtensionRule{} + wildcardRules := []config.ExtensionRule{} // 存储通配符规则 - if contentLength > minThreshold && contentLength <= maxThreshold { - // 创建一个带超时的 context - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + // 处理扩展名,找出所有匹配的规则 + if pathConfig.ExtRules == nil { + pathConfig.ProcessExtensionMap() + } - // 使用 channel 来接收备用源检查结果 - altChan := make(chan struct { - accessible bool - err error - }, 1) + // 找出所有匹配当前扩展名的规则 + ext = strings.ToLower(ext) + for _, rule := range pathConfig.ExtRules { + // 处理阈值默认值 + if rule.SizeThreshold <= 0 { + rule.SizeThreshold = 500 * 1024 // 默认最小阈值 500KB + } + if rule.MaxSize <= 0 { + rule.MaxSize = 10 * 1024 * 1024 // 默认最大阈值 10MB + } - // 在 goroutine 中检查备用源可访问性 - go func() { - accessible := isTargetAccessible(client, altTarget+path) - select { - case altChan <- struct { - accessible bool - err error - }{accessible: accessible}: - case <-ctx.Done(): - // context 已取消,不需要发送结果 - } - }() + // 检查是否包含通配符 + if slices.Contains(rule.Extensions, "*") { + wildcardRules = append(wildcardRules, rule) + continue + } - // 等待结果或超时 - select { - case result := <-altChan: - if result.accessible { - log.Printf("[Route] %s -> %s (size: %s > %s and <= %s)", - path, altTarget, FormatBytes(contentLength), - FormatBytes(minThreshold), FormatBytes(maxThreshold)) - return altTarget, true - } - log.Printf("[Route] %s -> %s (fallback: alternative target not accessible)", - path, targetBase) - case <-ctx.Done(): - log.Printf("[Route] %s -> %s (fallback: alternative target check timeout)", - path, targetBase) - } - } else if contentLength <= minThreshold { - log.Printf("[Route] %s -> %s (size: %s <= %s)", - path, targetBase, FormatBytes(contentLength), FormatBytes(minThreshold)) - } else { - log.Printf("[Route] %s -> %s (size: %s > %s)", - path, targetBase, FormatBytes(contentLength), FormatBytes(maxThreshold)) - } - } else { - log.Printf("[Route] %s -> %s (no extension mapping)", path, targetBase) - } + // 检查具体扩展名匹配 + if slices.Contains(rule.Extensions, ext) { + matchingRules = append(matchingRules, rule) + } + } + + // 如果没有找到匹配的具体扩展名规则,使用通配符规则 + if len(matchingRules) == 0 { + if len(wildcardRules) > 0 { + log.Printf("[Route] %s -> 使用通配符规则 (扩展名: %s)", path, ext) + matchingRules = wildcardRules } else { - log.Printf("[Route] %s -> %s (no extension)", path, targetBase) + log.Printf("[Route] %s -> %s (没有找到扩展名 %s 的规则)", path, targetBase, ext) + return targetBase, false + } + } + + // 按阈值排序规则,优先使用阈值范围更精确的规则 + // 先按最小阈值升序排序,再按最大阈值降序排序(在最小阈值相同的情况下) + sort.Slice(matchingRules, func(i, j int) bool { + if matchingRules[i].SizeThreshold == matchingRules[j].SizeThreshold { + return matchingRules[i].MaxSize > matchingRules[j].MaxSize + } + return matchingRules[i].SizeThreshold < matchingRules[j].SizeThreshold + }) + + // 根据文件大小找出最匹配的规则 + var bestRule *config.ExtensionRule + + for i := range matchingRules { + rule := &matchingRules[i] + + // 检查文件大小是否在阈值范围内 + if contentLength > rule.SizeThreshold && contentLength <= rule.MaxSize { + // 找到匹配的规则 + bestRule = rule + break + } + } + + // 如果找到匹配的规则 + if bestRule != nil { + // 创建一个带超时的 context + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 使用 channel 来接收备用源检查结果 + altChan := make(chan struct { + accessible bool + err error + }, 1) + + // 在 goroutine 中检查备用源可访问性 + go func() { + accessible := isTargetAccessible(client, bestRule.Target+path) + select { + case altChan <- struct { + accessible bool + err error + }{accessible: accessible}: + case <-ctx.Done(): + // context 已取消,不需要发送结果 + } + }() + + // 等待结果或超时 + select { + case result := <-altChan: + if result.accessible { + log.Printf("[Route] %s -> %s (文件大小: %s, 在区间 %s 到 %s 之间)", + path, bestRule.Target, FormatBytes(contentLength), + FormatBytes(bestRule.SizeThreshold), FormatBytes(bestRule.MaxSize)) + return bestRule.Target, true + } + // 如果是通配符规则但不可访问,记录日志 + if slices.Contains(bestRule.Extensions, "*") { + log.Printf("[Route] %s -> %s (回退: 通配符规则目标不可访问)", + path, targetBase) + } else { + log.Printf("[Route] %s -> %s (回退: 备用目标不可访问)", + path, targetBase) + } + case <-ctx.Done(): + log.Printf("[Route] %s -> %s (回退: 备用目标检查超时)", + path, targetBase) } } else { - log.Printf("[Route] %s -> %s (no extension map)", path, targetBase) + // 记录日志,为什么没有匹配的规则 + allThresholds := "" + for i, rule := range matchingRules { + if i > 0 { + allThresholds += ", " + } + allThresholds += fmt.Sprintf("[%s-%s]", + FormatBytes(rule.SizeThreshold), + FormatBytes(rule.MaxSize)) + } + + log.Printf("[Route] %s -> %s (文件大小: %s 不在任何阈值范围内: %s)", + path, targetBase, FormatBytes(contentLength), allThresholds) } return targetBase, usedAltTarget diff --git a/main.go b/main.go index a6260c9..c22f2aa 100644 --- a/main.go +++ b/main.go @@ -10,10 +10,13 @@ import ( "proxy-go/internal/config" "proxy-go/internal/constants" "proxy-go/internal/handler" + "proxy-go/internal/initapp" "proxy-go/internal/metrics" "proxy-go/internal/middleware" "strings" "syscall" + + "github.com/joho/godotenv" ) // Route 定义路由结构 @@ -25,25 +28,29 @@ type Route struct { } func main() { - // 加载配置 - cfg, err := config.Load("data/config.json") - if err != nil { - log.Fatal("Error loading config:", err) + // 加载.env文件 + if err := godotenv.Load(); err != nil { + log.Printf("警告: 无法加载.env文件: %v", err) } + // 初始化应用程序(包括配置迁移) + configPath := "data/config.json" + initapp.Init(configPath) + + // 初始化配置管理器 + configManager, err := config.Init(configPath) + if err != nil { + log.Fatal("Error initializing config manager:", err) + } + + // 获取配置 + cfg := configManager.GetConfig() + // 更新常量配置 constants.UpdateFromConfig(cfg) - // 初始化指标收集器 - if err := metrics.InitCollector(cfg); err != nil { - log.Fatal("Error initializing metrics collector:", err) - } - - // 初始化指标存储服务 - if err := metrics.InitMetricsStorage(cfg); err != nil { - log.Printf("Warning: Failed to initialize metrics storage: %v", err) - // 不致命,继续运行 - } + // 初始化统计服务 + metrics.Init(cfg) // 创建压缩管理器 compManager := compression.NewManager(compression.Config{ diff --git a/web/app/dashboard/config/page.tsx b/web/app/dashboard/config/page.tsx index fbe4a70..442fe13 100644 --- a/web/app/dashboard/config/page.tsx +++ b/web/app/dashboard/config/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState, useCallback, useRef } from "react" +import React, { useEffect, useState, useCallback, useRef } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useToast } from "@/components/ui/use-toast" @@ -8,14 +8,6 @@ import { useRouter } from "next/navigation" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" import { Dialog, DialogContent, @@ -37,11 +29,18 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" +interface ExtRuleConfig { + Extensions: string; // 逗号分隔的扩展名 + Target: string; // 目标服务器 + SizeThreshold: number; // 最小文件大小阈值(字节) + MaxSize: number; // 最大文件大小阈值(字节) +} + interface PathMapping { DefaultTarget: string - ExtensionMap?: Record - SizeThreshold?: number // 最小文件大小阈值 - MaxSize?: number // 最大文件大小阈值 + ExtensionMap?: ExtRuleConfig[] // 只支持新格式 + SizeThreshold?: number // 保留全局阈值字段(向后兼容) + MaxSize?: number // 保留全局阈值字段(向后兼容) } interface CompressionConfig { @@ -85,10 +84,7 @@ export default function ConfigPage() { maxSizeUnit: 'MB' as 'B' | 'KB' | 'MB' | 'GB', }) - const [extensionMapDialogOpen, setExtensionMapDialogOpen] = useState(false) const [editingPath, setEditingPath] = useState(null) - const [editingExtension, setEditingExtension] = useState<{ext: string, target: string} | null>(null) - const [newExtension, setNewExtension] = useState({ ext: "", target: "" }) const [editingPathData, setEditingPathData] = useState<{ path: string; @@ -100,7 +96,36 @@ export default function ConfigPage() { } | null>(null); const [deletingPath, setDeletingPath] = useState(null) - const [deletingExtension, setDeletingExtension] = useState<{path: string, ext: string} | null>(null) + + // 添加扩展名规则状态 + const [newExtensionRule, setNewExtensionRule] = useState<{ + extensions: string; + target: string; + sizeThreshold: number; + maxSize: number; + sizeThresholdUnit: 'B' | 'KB' | 'MB' | 'GB'; + maxSizeUnit: 'B' | 'KB' | 'MB' | 'GB'; + }>({ + extensions: "", + target: "", + sizeThreshold: 0, + maxSize: 0, + sizeThresholdUnit: 'MB', + maxSizeUnit: 'MB', + }); + + const [editingExtensionRule, setEditingExtensionRule] = useState<{ + index: number, + extensions: string; + target: string; + sizeThreshold: number; + maxSize: number; + sizeThresholdUnit: 'B' | 'KB' | 'MB' | 'GB'; + maxSizeUnit: 'B' | 'KB' | 'MB' | 'GB'; + } | null>(null); + + // 添加扩展名规则对话框状态 + const [extensionRuleDialogOpen, setExtensionRuleDialogOpen] = useState(false); const fetchConfig = useCallback(async () => { try { @@ -266,22 +291,11 @@ export default function ConfigPage() { }) }, [handleDialogOpenChange]) - const handleExtensionMapDialogOpenChange = useCallback((open: boolean) => { - handleDialogOpenChange(open, (isOpen) => { - setExtensionMapDialogOpen(isOpen) - if (!isOpen) { - setEditingPath(null) - setEditingExtension(null) - setNewExtension({ ext: "", target: "" }) - } - }) - }, [handleDialogOpenChange]) - const addOrUpdatePath = () => { if (!config) return const data = editingPathData || newPathData - const { path, defaultTarget, sizeThreshold, maxSize, sizeThresholdUnit, maxSizeUnit } = data + const { path, defaultTarget } = data if (!path || !defaultTarget) { toast({ @@ -292,26 +306,10 @@ export default function ConfigPage() { return } - // 转换大小为字节 - const sizeThresholdBytes = convertToBytes(sizeThreshold, sizeThresholdUnit) - const maxSizeBytes = convertToBytes(maxSize, maxSizeUnit) - - // 验证阈值 - if (maxSizeBytes > 0 && sizeThresholdBytes >= maxSizeBytes) { - toast({ - title: "错误", - description: "最大文件大小阈值必须大于最小文件大小阈值", - variant: "destructive", - }) - return - } - const newConfig = { ...config } const pathConfig: PathMapping = { DefaultTarget: defaultTarget, - ExtensionMap: {}, - SizeThreshold: sizeThresholdBytes, - MaxSize: maxSizeBytes + ExtensionMap: [] } // 如果是编辑现有路径,保留原有的扩展名映射 @@ -363,98 +361,14 @@ export default function ConfigPage() { updateConfig(newConfig) } - const handleExtensionMapEdit = (path: string, ext?: string, target?: string) => { - setEditingPath(path) - if (ext && target) { - setEditingExtension({ ext, target }) - setNewExtension({ ext, target }) - } else { - setEditingExtension(null) - setNewExtension({ ext: "", target: "" }) - } - setExtensionMapDialogOpen(true) - } + const handleExtensionMapEdit = (path: string) => { + // 将添加规则的操作重定向到handleExtensionRuleEdit + handleExtensionRuleEdit(path); + }; - const addOrUpdateExtensionMap = () => { - if (!config || !editingPath) return - const { ext, target } = newExtension - - // 验证输入 - if (!ext.trim() || !target.trim()) { - toast({ - title: "错误", - description: "扩展名和目标不能为空", - variant: "destructive", - }) - return - } - - // 验证扩展名格式 - const extensions = ext.split(',').map(e => e.trim()) - if (extensions.some(e => !e || e.includes('.'))) { - toast({ - title: "错误", - description: "扩展名格式不正确,不需要包含点号", - variant: "destructive", - }) - return - } - - // 验证URL格式 - try { - new URL(target) - } catch { - toast({ - title: "错误", - description: "目标URL格式不正确", - variant: "destructive", - }) - return - } - - const newConfig = { ...config } - const mapping = newConfig.MAP[editingPath] - if (typeof mapping === "string") { - newConfig.MAP[editingPath] = { - DefaultTarget: mapping, - ExtensionMap: { [ext]: target } - } - } else { - // 如果是编辑现有的扩展名映射,先删除旧的 - if (editingExtension) { - const newExtMap = { ...mapping.ExtensionMap } - delete newExtMap[editingExtension.ext] - mapping.ExtensionMap = newExtMap - } - // 添加新的映射 - mapping.ExtensionMap = { - ...mapping.ExtensionMap, - [ext]: target - } - } - - updateConfig(newConfig) - setExtensionMapDialogOpen(false) - setEditingExtension(null) - setNewExtension({ ext: "", target: "" }) - } - - const deleteExtensionMap = (path: string, ext: string) => { - setDeletingExtension({ path, ext }) - } - - const confirmDeleteExtensionMap = () => { - if (!config || !deletingExtension) return - const newConfig = { ...config } - const mapping = newConfig.MAP[deletingExtension.path] - if (typeof mapping !== "string" && mapping.ExtensionMap) { - const newExtensionMap = { ...mapping.ExtensionMap } - delete newExtensionMap[deletingExtension.ext] - mapping.ExtensionMap = newExtensionMap - } - updateConfig(newConfig) - setDeletingExtension(null) - } + const deleteExtensionRule = (path: string, index: number) => { + setDeletingExtensionRule({ path, index }); + }; const openAddPathDialog = () => { setEditingPathData(null) @@ -587,6 +501,196 @@ export default function ConfigPage() { } }, []) + // 为扩展名规则对话框添加处理函数 + const handleExtensionRuleDialogOpenChange = useCallback((open: boolean) => { + handleDialogOpenChange(open, (isOpen) => { + setExtensionRuleDialogOpen(isOpen); + if (!isOpen) { + setEditingExtensionRule(null); + setNewExtensionRule({ + extensions: "", + target: "", + sizeThreshold: 0, + maxSize: 0, + sizeThresholdUnit: 'MB', + maxSizeUnit: 'MB', + }); + } + }); + }, [handleDialogOpenChange]); + + // 处理扩展名规则的编辑 + const handleExtensionRuleEdit = (path: string, index?: number, rule?: { Extensions: string; Target: string; SizeThreshold?: number; MaxSize?: number }) => { + setEditingPath(path); + + if (index !== undefined && rule) { + // 转换规则的阈值到合适的单位显示 + const { value: thresholdValue, unit: thresholdUnit } = convertBytesToUnit(rule.SizeThreshold || 0); + const { value: maxValue, unit: maxUnit } = convertBytesToUnit(rule.MaxSize || 0); + + setEditingExtensionRule({ + index, + extensions: rule.Extensions, + target: rule.Target, + sizeThreshold: thresholdValue, + maxSize: maxValue, + sizeThresholdUnit: thresholdUnit, + maxSizeUnit: maxUnit, + }); + + // 同时更新表单显示数据 + setNewExtensionRule({ + extensions: rule.Extensions, + target: rule.Target, + sizeThreshold: thresholdValue, + maxSize: maxValue, + sizeThresholdUnit: thresholdUnit, + maxSizeUnit: maxUnit, + }); + } else { + setEditingExtensionRule(null); + // 重置表单 + setNewExtensionRule({ + extensions: "", + target: "", + sizeThreshold: 0, + maxSize: 0, + sizeThresholdUnit: 'MB', + maxSizeUnit: 'MB', + }); + } + + setExtensionRuleDialogOpen(true); + }; + + // 添加或更新扩展名规则 + const addOrUpdateExtensionRule = () => { + if (!config || !editingPath) return; + + const { extensions, target, sizeThreshold, maxSize, sizeThresholdUnit, maxSizeUnit } = newExtensionRule; + + // 验证输入 + if (!extensions.trim() || !target.trim()) { + toast({ + title: "错误", + description: "扩展名和目标不能为空", + variant: "destructive", + }); + return; + } + + // 验证扩展名格式 + const extensionList = extensions.split(',').map(e => e.trim()); + if (extensionList.some(e => !e || (e !== "*" && e.includes('.')))) { + toast({ + title: "错误", + description: "扩展名格式不正确,不需要包含点号", + variant: "destructive", + }); + return; + } + + // 验证URL格式 + try { + new URL(target); + } catch { + toast({ + title: "错误", + description: "目标URL格式不正确", + variant: "destructive", + }); + return; + } + + // 转换大小为字节 + const sizeThresholdBytes = convertToBytes(sizeThreshold, sizeThresholdUnit); + const maxSizeBytes = convertToBytes(maxSize, maxSizeUnit); + + // 验证阈值 + if (maxSizeBytes > 0 && sizeThresholdBytes >= maxSizeBytes) { + toast({ + title: "错误", + description: "最大文件大小阈值必须大于最小文件大小阈值", + variant: "destructive", + }); + return; + } + + const newConfig = { ...config }; + const mapping = newConfig.MAP[editingPath]; + + if (typeof mapping === "string") { + // 如果映射是字符串,创建新的PathConfig对象 + newConfig.MAP[editingPath] = { + DefaultTarget: mapping, + ExtensionMap: [{ + Extensions: extensions, + Target: target, + SizeThreshold: sizeThresholdBytes, + MaxSize: maxSizeBytes + }] + }; + } else { + // 确保ExtensionMap是数组 + if (!Array.isArray(mapping.ExtensionMap)) { + mapping.ExtensionMap = []; + } + + if (editingExtensionRule) { + // 更新现有规则 + const rules = mapping.ExtensionMap as ExtRuleConfig[]; + rules[editingExtensionRule.index] = { + Extensions: extensions, + Target: target, + SizeThreshold: sizeThresholdBytes, + MaxSize: maxSizeBytes + }; + } else { + // 添加新规则 + mapping.ExtensionMap.push({ + Extensions: extensions, + Target: target, + SizeThreshold: sizeThresholdBytes, + MaxSize: maxSizeBytes + }); + } + } + + updateConfig(newConfig); + setExtensionRuleDialogOpen(false); + setEditingExtensionRule(null); + setNewExtensionRule({ + extensions: "", + target: "", + sizeThreshold: 0, + maxSize: 0, + sizeThresholdUnit: 'MB', + maxSizeUnit: 'MB', + }); + }; + + // 删除扩展名规则 + const [deletingExtensionRule, setDeletingExtensionRule] = useState<{path: string, index: number} | null>(null); + + const confirmDeleteExtensionRule = () => { + if (!config || !deletingExtensionRule) return; + + const newConfig = { ...config }; + const mapping = newConfig.MAP[deletingExtensionRule.path]; + + if (typeof mapping !== "string" && Array.isArray(mapping.ExtensionMap)) { + // 移除指定索引的规则 + const rules = mapping.ExtensionMap as ExtRuleConfig[]; + mapping.ExtensionMap = [ + ...rules.slice(0, deletingExtensionRule.index), + ...rules.slice(deletingExtensionRule.index + 1) + ]; + } + + updateConfig(newConfig); + setDeletingExtensionRule(null); + }; + if (loading) { return (
@@ -677,100 +781,6 @@ export default function ConfigPage() { 默认的回源地址,所有请求都会转发到这个地址

-
-
- -
- { - if (editingPathData) { - setEditingPathData({ - ...editingPathData, - sizeThreshold: Number(e.target.value), - }) - } else { - setNewPathData({ - ...newPathData, - sizeThreshold: Number(e.target.value), - }) - } - }} - /> - -
-
-
- -
- { - if (editingPathData) { - setEditingPathData({ - ...editingPathData, - maxSize: Number(e.target.value), - }) - } else { - setNewPathData({ - ...newPathData, - maxSize: Number(e.target.value), - }) - } - }} - /> - -
-
-
@@ -779,119 +789,94 @@ export default function ConfigPage() { - - - - 路径 - 默认目标 - 最小阈值 - 最大阈值 - 扩展名映射 - 操作 - - - - {config && Object.entries(config.MAP).map(([path, target]) => ( - <> - - {path} - - {typeof target === 'string' ? target : target.DefaultTarget} - - - {typeof target === 'object' && target.SizeThreshold ? ( - - {formatBytes(target.SizeThreshold)} - - ) : '-'} - - - {typeof target === 'object' && target.MaxSize ? ( - - {formatBytes(target.MaxSize)} - - ) : '-'} - - +
+ {config && Object.entries(config.MAP).map(([path, target]) => ( + + + + {path} +
- - -
- - + +
+ + + +
+ 默认目标: + {typeof target === 'string' ? target : target.DefaultTarget} +
+ + + + {typeof target === 'object' && target.ExtensionMap && Array.isArray(target.ExtensionMap) && target.ExtensionMap.length > 0 && ( +
+
扩展名映射规则
+
+ {target.ExtensionMap.map((rule, index) => ( +
+
+ {rule.Extensions} +
+ + +
+
+
+ 目标: {truncateUrl(rule.Target)} +
+
+
阈值: {formatBytes(rule.SizeThreshold || 0)}
+
最大: {formatBytes(rule.MaxSize || 0)}
+
+
+ ))}
- - - {typeof target === 'object' && target.ExtensionMap && Object.keys(target.ExtensionMap).length > 0 && ( - - -
-
- - - 扩展名 - 目标地址 - 操作 - - - - {Object.entries(target.ExtensionMap).map(([ext, url]) => ( - - {ext} - - {truncateUrl(url)} - - -
- - -
-
-
- ))} -
-
- - - + )} - - ))} - - + + + ))} + @@ -949,43 +934,6 @@ export default function ConfigPage() { - - - - - {editingExtension ? "编辑扩展名映射" : "添加扩展名映射"} - - -
-
- - setNewExtension({ ...newExtension, ext: e.target.value })} - placeholder="jpg,png,webp" - /> -

- 多个扩展名用逗号分隔,不需要包含点号 -

-
-
- - setNewExtension({ ...newExtension, target: e.target.value })} - placeholder="https://example.com" - /> -

- 当文件大小超过阈值且扩展名匹配时,将使用此地址 -

-
- -
-
-
- handleDeleteDialogOpenChange(open, setDeletingPath)} @@ -1004,20 +952,118 @@ export default function ConfigPage() { + + + + + {editingExtensionRule ? "编辑扩展名规则" : "添加扩展名规则"} + + +
+
+ + setNewExtensionRule({ ...newExtensionRule, extensions: e.target.value })} + placeholder="jpg,png,webp" + /> +

+ 多个扩展名用逗号分隔,不需要包含点号。使用星号 * 表示匹配所有未指定的扩展名。 +

+
+
+ + setNewExtensionRule({ ...newExtensionRule, target: e.target.value })} + placeholder="https://example.com" + /> +
+
+
+ +
+ { + setNewExtensionRule({ + ...newExtensionRule, + sizeThreshold: Number(e.target.value), + }); + }} + /> + +
+
+
+ +
+ { + setNewExtensionRule({ + ...newExtensionRule, + maxSize: Number(e.target.value), + }); + }} + /> + +
+
+
+ +
+
+
+ handleDeleteDialogOpenChange(open, setDeletingExtension)} + open={!!deletingExtensionRule} + onOpenChange={(open) => handleDeleteDialogOpenChange(open, () => setDeletingExtensionRule(null))} > 确认删除 - 确定要删除扩展名 “{deletingExtension?.ext}” 的映射吗?此操作无法撤销。 + 确定要删除这个扩展名规则吗?此操作无法撤销。 取消 - 删除 + 删除