Compare commits

..

No commits in common. "main" and "v1.0.4" have entirely different histories.
main ... v1.0.4

44 changed files with 2055 additions and 5760 deletions

12
.gitignore vendored
View File

@ -24,15 +24,5 @@ vendor/
web/node_modules/
web/dist/
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.json
.env
data/cache
kaifa.md

View File

@ -1,70 +0,0 @@
{
"MAP": {
"/path1": {
"DefaultTarget": "https://path1.com/path/path/path",
"ExtensionMap": [
{
"Extensions": "jpg,png,avif",
"Target": "https://path1-img.com/path/path/path",
"SizeThreshold": 204800,
"MaxSize": 5242880
},
{
"Extensions": "mp4,webm",
"Target": "https://path1-video.com/path/path/path",
"SizeThreshold": 204800,
"MaxSize": 5242880
},
{
"Extensions": "*",
"Target": "https://path1-wildcard.com/path/path/path",
"SizeThreshold": 204800,
"MaxSize": 5242880
}
]
},
"/path2": "https://path2.com",
"/path3": {
"DefaultTarget": "https://path3.com",
"ExtensionMap": [
{
"Extensions": "*",
"Target": "https://path3-wildcard.com",
"SizeThreshold": 512000,
"MaxSize": 10485760
}
],
"SizeThreshold": 512000
},
"/wildcard-no-limits": {
"DefaultTarget": "https://default.example.com",
"ExtensionMap": [
{
"Extensions": "*",
"Target": "https://unlimited.example.com",
"SizeThreshold": 0,
"MaxSize": 0
}
]
}
},
"Compression": {
"Gzip": {
"Enabled": false,
"Level": 6
},
"Brotli": {
"Enabled": false,
"Level": 4
}
},
"Security": {
"IPBan": {
"Enabled": true,
"ErrorThreshold": 10,
"WindowMinutes": 5,
"BanDurationMinutes": 5,
"CleanupIntervalMinutes": 1
}
}
}

39
data/config.json Normal file
View File

@ -0,0 +1,39 @@
{
"MAP": {
"/path1": {
"DefaultTarget": "https://path1.com/path/path/path",
"ExtensionMap": {
"jpg,png,avif": "https://path1-img.com/path/path/path",
"mp4,webm": "https://path1-video.com/path/path/path"
},
"SizeThreshold": 204800
},
"/path2": "https://path2.com",
"/path3": {
"DefaultTarget": "https://path3.com",
"SizeThreshold": 512000
}
},
"Compression": {
"Gzip": {
"Enabled": false,
"Level": 6
},
"Brotli": {
"Enabled": false,
"Level": 4
}
},
"FixedPaths": [
{
"Path": "/cdnjs",
"TargetHost": "cdnjs.cloudflare.com",
"TargetURL": "https://cdnjs.cloudflare.com"
},
{
"Path": "/jsdelivr",
"TargetHost": "cdn.jsdelivr.net",
"TargetURL": "https://cdn.jsdelivr.net"
}
]
}

View File

@ -6,7 +6,6 @@ services:
- "3336:3336"
volumes:
- ./data:/app/data
- ./favicon:/app/favicon
environment:
- TZ=Asia/Shanghai
- OAUTH_CLIENT_ID=your_client_id

View File

@ -1,2 +0,0 @@
# 这个文件确保 favicon 目录被 git 跟踪
# 用户可以在这个目录中放置自定义的 favicon.ico 文件

View File

@ -1,32 +0,0 @@
# Favicon 自定义设置
## 使用方法
1. 将你的 favicon 文件重命名为 `favicon.ico`
2. 放置在这个 `favicon` 目录中
3. 重启 proxy-go 服务
## 支持的文件格式
- `.ico` 文件(推荐)
- `.png` 文件(需要重命名为 favicon.ico
- `.jpg/.jpeg` 文件(需要重命名为 favicon.ico
- `.svg` 文件(需要重命名为 favicon.ico
## 注意事项
- 文件必须命名为 `favicon.ico`
- 推荐尺寸16x16, 32x32, 48x48 像素
- 如果没有放置文件,将返回 404浏览器会使用默认图标
## 示例
```bash
# 将你的 favicon 文件复制到这个目录
cp your-favicon.ico ./favicon/favicon.ico
# 重启服务
docker-compose restart
```
现在访问 `http://your-domain.com/favicon.ico` 就会显示你的自定义 favicon 了!

9
go.mod
View File

@ -1,13 +1,10 @@
module proxy-go
go 1.23.0
toolchain go1.23.1
go 1.23.1
require (
github.com/andybalholm/brotli v1.1.1
github.com/woodchen-ink/go-web-utils v1.0.0
golang.org/x/net v0.40.0
golang.org/x/net v0.37.0
)
require golang.org/x/text v0.25.0 // indirect
require golang.org/x/text v0.23.0 // indirect

10
go.sum
View File

@ -1,10 +1,8 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/woodchen-ink/go-web-utils v1.0.0 h1:Kybe0ZPhRI4w5FJ4bZdPcepNEKTmbw3to3xLR31e+ws=
github.com/woodchen-ink/go-web-utils v1.0.0/go.mod h1:hpiT30rd5Egj2LqRwYBqbEtUXjhjh/Qary0S14KCZgw=
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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=

View File

@ -1,216 +0,0 @@
package cache
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"proxy-go/internal/config"
"proxy-go/internal/utils"
"sync"
"time"
)
// ExtensionMatcherCacheItem 扩展名匹配器缓存项
type ExtensionMatcherCacheItem struct {
Matcher *utils.ExtensionMatcher
Hash string // 配置的哈希值,用于检测配置变化
CreatedAt time.Time
LastUsed time.Time
UseCount int64
}
// ExtensionMatcherCache 扩展名匹配器缓存管理器
type ExtensionMatcherCache struct {
cache sync.Map
maxAge time.Duration
cleanupTick time.Duration
stopCleanup chan struct{}
mu sync.RWMutex
}
// NewExtensionMatcherCache 创建新的扩展名匹配器缓存
func NewExtensionMatcherCache() *ExtensionMatcherCache {
emc := &ExtensionMatcherCache{
maxAge: 10 * time.Minute, // 缓存10分钟
cleanupTick: 2 * time.Minute, // 每2分钟清理一次
stopCleanup: make(chan struct{}),
}
// 启动清理协程
go emc.startCleanup()
return emc
}
// generateConfigHash 生成配置的哈希值
func (emc *ExtensionMatcherCache) generateConfigHash(rules []config.ExtensionRule) string {
// 将规则序列化为JSON
data, err := json.Marshal(rules)
if err != nil {
// 如果序列化失败,使用时间戳作为哈希
return hex.EncodeToString([]byte(time.Now().String()))
}
// 计算SHA256哈希
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// GetOrCreate 获取或创建扩展名匹配器
func (emc *ExtensionMatcherCache) GetOrCreate(pathKey string, rules []config.ExtensionRule) *utils.ExtensionMatcher {
// 如果没有规则,直接创建新的匹配器
if len(rules) == 0 {
return utils.NewExtensionMatcher(rules)
}
// 生成配置哈希
configHash := emc.generateConfigHash(rules)
// 尝试从缓存获取
if value, ok := emc.cache.Load(pathKey); ok {
item := value.(*ExtensionMatcherCacheItem)
// 检查配置是否变化
if item.Hash == configHash {
// 配置未变化,更新使用信息
emc.mu.Lock()
item.LastUsed = time.Now()
item.UseCount++
emc.mu.Unlock()
log.Printf("[ExtensionMatcherCache] HIT %s (使用次数: %d)", pathKey, item.UseCount)
return item.Matcher
} else {
// 配置已变化,删除旧缓存
emc.cache.Delete(pathKey)
log.Printf("[ExtensionMatcherCache] CONFIG_CHANGED %s", pathKey)
}
}
// 创建新的匹配器
matcher := utils.NewExtensionMatcher(rules)
// 创建缓存项
item := &ExtensionMatcherCacheItem{
Matcher: matcher,
Hash: configHash,
CreatedAt: time.Now(),
LastUsed: time.Now(),
UseCount: 1,
}
// 存储到缓存
emc.cache.Store(pathKey, item)
log.Printf("[ExtensionMatcherCache] NEW %s (规则数量: %d)", pathKey, len(rules))
return matcher
}
// InvalidatePath 使指定路径的缓存失效
func (emc *ExtensionMatcherCache) InvalidatePath(pathKey string) {
if _, ok := emc.cache.LoadAndDelete(pathKey); ok {
log.Printf("[ExtensionMatcherCache] INVALIDATED %s", pathKey)
}
}
// InvalidateAll 清空所有缓存
func (emc *ExtensionMatcherCache) InvalidateAll() {
count := 0
emc.cache.Range(func(key, value interface{}) bool {
emc.cache.Delete(key)
count++
return true
})
log.Printf("[ExtensionMatcherCache] INVALIDATED_ALL (清理了 %d 个缓存项)", count)
}
// GetStats 获取缓存统计信息
func (emc *ExtensionMatcherCache) GetStats() ExtensionMatcherCacheStats {
stats := ExtensionMatcherCacheStats{
MaxAge: int64(emc.maxAge.Minutes()),
CleanupTick: int64(emc.cleanupTick.Minutes()),
}
emc.cache.Range(func(key, value interface{}) bool {
item := value.(*ExtensionMatcherCacheItem)
stats.TotalItems++
stats.TotalUseCount += item.UseCount
// 计算平均年龄
age := time.Since(item.CreatedAt)
stats.AverageAge += int64(age.Minutes())
return true
})
if stats.TotalItems > 0 {
stats.AverageAge /= int64(stats.TotalItems)
}
return stats
}
// ExtensionMatcherCacheStats 扩展名匹配器缓存统计信息
type ExtensionMatcherCacheStats struct {
TotalItems int `json:"total_items"` // 缓存项数量
TotalUseCount int64 `json:"total_use_count"` // 总使用次数
AverageAge int64 `json:"average_age"` // 平均年龄(分钟)
MaxAge int64 `json:"max_age"` // 最大缓存时间(分钟)
CleanupTick int64 `json:"cleanup_tick"` // 清理间隔(分钟)
}
// startCleanup 启动清理协程
func (emc *ExtensionMatcherCache) startCleanup() {
ticker := time.NewTicker(emc.cleanupTick)
defer ticker.Stop()
for {
select {
case <-ticker.C:
emc.cleanup()
case <-emc.stopCleanup:
return
}
}
}
// cleanup 清理过期的缓存项
func (emc *ExtensionMatcherCache) cleanup() {
now := time.Now()
expiredKeys := make([]interface{}, 0)
// 收集过期的键
emc.cache.Range(func(key, value interface{}) bool {
item := value.(*ExtensionMatcherCacheItem)
if now.Sub(item.LastUsed) > emc.maxAge {
expiredKeys = append(expiredKeys, key)
}
return true
})
// 删除过期的缓存项
for _, key := range expiredKeys {
emc.cache.Delete(key)
}
if len(expiredKeys) > 0 {
log.Printf("[ExtensionMatcherCache] CLEANUP 清理了 %d 个过期缓存项", len(expiredKeys))
}
}
// Stop 停止缓存管理器
func (emc *ExtensionMatcherCache) Stop() {
close(emc.stopCleanup)
}
// UpdateConfig 更新缓存配置
func (emc *ExtensionMatcherCache) UpdateConfig(maxAge, cleanupTick time.Duration) {
emc.mu.Lock()
defer emc.mu.Unlock()
emc.maxAge = maxAge
emc.cleanupTick = cleanupTick
log.Printf("[ExtensionMatcherCache] CONFIG_UPDATED maxAge=%v cleanupTick=%v", maxAge, cleanupTick)
}

View File

@ -10,7 +10,6 @@ import (
"net/http"
"os"
"path/filepath"
"proxy-go/internal/config"
"proxy-go/internal/utils"
"sort"
"strings"
@ -19,174 +18,6 @@ import (
"time"
)
// 内存池用于复用缓冲区
var (
bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 32*1024) // 32KB 缓冲区
},
}
// 大缓冲区池(用于大文件)
largeBufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024*1024) // 1MB 缓冲区
},
}
)
// GetBuffer 从池中获取缓冲区
func GetBuffer(size int) []byte {
if size <= 32*1024 {
buf := bufferPool.Get().([]byte)
if cap(buf) >= size {
return buf[:size]
}
bufferPool.Put(buf)
} else if size <= 1024*1024 {
buf := largeBufPool.Get().([]byte)
if cap(buf) >= size {
return buf[:size]
}
largeBufPool.Put(buf)
}
// 如果池中的缓冲区不够大,创建新的
return make([]byte, size)
}
// PutBuffer 将缓冲区放回池中
func PutBuffer(buf []byte) {
if cap(buf) == 32*1024 {
bufferPool.Put(buf)
} else if cap(buf) == 1024*1024 {
largeBufPool.Put(buf)
}
// 其他大小的缓冲区让GC处理
}
// LRU 缓存节点
type LRUNode struct {
key CacheKey
value *CacheItem
prev *LRUNode
next *LRUNode
}
// LRU 缓存实现
type LRUCache struct {
capacity int
size int
head *LRUNode
tail *LRUNode
cache map[CacheKey]*LRUNode
mu sync.RWMutex
}
// NewLRUCache 创建LRU缓存
func NewLRUCache(capacity int) *LRUCache {
lru := &LRUCache{
capacity: capacity,
cache: make(map[CacheKey]*LRUNode),
head: &LRUNode{},
tail: &LRUNode{},
}
lru.head.next = lru.tail
lru.tail.prev = lru.head
return lru
}
// Get 从LRU缓存中获取
func (lru *LRUCache) Get(key CacheKey) (*CacheItem, bool) {
lru.mu.Lock()
defer lru.mu.Unlock()
if node, exists := lru.cache[key]; exists {
lru.moveToHead(node)
return node.value, true
}
return nil, false
}
// Put 向LRU缓存中添加
func (lru *LRUCache) Put(key CacheKey, value *CacheItem) {
lru.mu.Lock()
defer lru.mu.Unlock()
if node, exists := lru.cache[key]; exists {
node.value = value
lru.moveToHead(node)
} else {
newNode := &LRUNode{key: key, value: value}
lru.cache[key] = newNode
lru.addToHead(newNode)
lru.size++
if lru.size > lru.capacity {
tail := lru.removeTail()
delete(lru.cache, tail.key)
lru.size--
}
}
}
// Delete 从LRU缓存中删除
func (lru *LRUCache) Delete(key CacheKey) {
lru.mu.Lock()
defer lru.mu.Unlock()
if node, exists := lru.cache[key]; exists {
lru.removeNode(node)
delete(lru.cache, key)
lru.size--
}
}
// moveToHead 将节点移到头部
func (lru *LRUCache) moveToHead(node *LRUNode) {
lru.removeNode(node)
lru.addToHead(node)
}
// addToHead 添加到头部
func (lru *LRUCache) addToHead(node *LRUNode) {
node.prev = lru.head
node.next = lru.head.next
lru.head.next.prev = node
lru.head.next = node
}
// removeNode 移除节点
func (lru *LRUCache) removeNode(node *LRUNode) {
node.prev.next = node.next
node.next.prev = node.prev
}
// removeTail 移除尾部节点
func (lru *LRUCache) removeTail() *LRUNode {
lastNode := lru.tail.prev
lru.removeNode(lastNode)
return lastNode
}
// Range 遍历所有缓存项
func (lru *LRUCache) Range(fn func(key CacheKey, value *CacheItem) bool) {
lru.mu.RLock()
defer lru.mu.RUnlock()
for key, node := range lru.cache {
if !fn(key, node.value) {
break
}
}
}
// Size 返回缓存大小
func (lru *LRUCache) Size() int {
lru.mu.RLock()
defer lru.mu.RUnlock()
return lru.size
}
// CacheKey 用于标识缓存项的唯一键
type CacheKey struct {
URL string
@ -223,28 +54,23 @@ type CacheItem struct {
Hash string
CreatedAt time.Time
AccessCount int64
Priority int // 缓存优先级
}
// CacheStats 缓存统计信息
type CacheStats struct {
TotalItems int `json:"total_items"` // 缓存项数量
TotalSize int64 `json:"total_size"` // 总大小
HitCount int64 `json:"hit_count"` // 命中次数
MissCount int64 `json:"miss_count"` // 未命中次数
HitRate float64 `json:"hit_rate"` // 命中率
BytesSaved int64 `json:"bytes_saved"` // 节省的带宽
Enabled bool `json:"enabled"` // 缓存开关状态
FormatFallbackHit int64 `json:"format_fallback_hit"` // 格式回退命中次数
ImageCacheHit int64 `json:"image_cache_hit"` // 图片缓存命中次数
RegularCacheHit int64 `json:"regular_cache_hit"` // 常规缓存命中次数
TotalItems int `json:"total_items"` // 缓存项数量
TotalSize int64 `json:"total_size"` // 总大小
HitCount int64 `json:"hit_count"` // 命中次数
MissCount int64 `json:"miss_count"` // 未命中次数
HitRate float64 `json:"hit_rate"` // 命中率
BytesSaved int64 `json:"bytes_saved"` // 节省的带宽
Enabled bool `json:"enabled"` // 缓存开关状态
}
// CacheManager 缓存管理器
type CacheManager struct {
cacheDir string
items sync.Map // 保持原有的 sync.Map 用于文件缓存
lruCache *LRUCache // 新增LRU缓存用于热点数据
items sync.Map
maxAge time.Duration
cleanupTick time.Duration
maxCacheSize int64
@ -254,14 +80,6 @@ type CacheManager struct {
bytesSaved atomic.Int64 // 节省的带宽
cleanupTimer *time.Ticker // 添加清理定时器
stopCleanup chan struct{} // 添加停止信号通道
// 新增:格式回退统计
formatFallbackHit atomic.Int64 // 格式回退命中次数
imageCacheHit atomic.Int64 // 图片缓存命中次数
regularCacheHit atomic.Int64 // 常规缓存命中次数
// ExtensionMatcher缓存
extensionMatcherCache *ExtensionMatcherCache
}
// NewCacheManager 创建新的缓存管理器
@ -272,14 +90,10 @@ func NewCacheManager(cacheDir string) (*CacheManager, error) {
cm := &CacheManager{
cacheDir: cacheDir,
lruCache: NewLRUCache(10000), // 10000个热点缓存项
maxAge: 30 * time.Minute,
cleanupTick: 5 * time.Minute,
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
stopCleanup: make(chan struct{}),
// 初始化ExtensionMatcher缓存
extensionMatcherCache: NewExtensionMatcherCache(),
}
cm.enabled.Store(true) // 默认启用缓存
@ -313,79 +127,10 @@ func (cm *CacheManager) GenerateCacheKey(r *http.Request) CacheKey {
}
sort.Strings(varyHeaders)
url := r.URL.String()
acceptHeaders := r.Header.Get("Accept")
userAgent := r.Header.Get("User-Agent")
// 🎯 针对图片请求进行智能缓存键优化
if utils.IsImageRequest(r.URL.Path) {
// 解析Accept头中的图片格式偏好
imageFormat := cm.parseImageFormatPreference(acceptHeaders)
// 为图片请求生成格式感知的缓存键
return CacheKey{
URL: url,
AcceptHeaders: imageFormat, // 使用标准化的图片格式
UserAgent: cm.normalizeUserAgent(userAgent), // 标准化UserAgent
}
}
return CacheKey{
URL: url,
AcceptHeaders: acceptHeaders,
UserAgent: userAgent,
}
}
// parseImageFormatPreference 解析图片格式偏好,返回标准化的格式标识
func (cm *CacheManager) parseImageFormatPreference(accept string) string {
if accept == "" {
return "image/jpeg" // 默认格式
}
accept = strings.ToLower(accept)
// 按优先级检查现代图片格式
switch {
case strings.Contains(accept, "image/avif"):
return "image/avif"
case strings.Contains(accept, "image/webp"):
return "image/webp"
case strings.Contains(accept, "image/jpeg") || strings.Contains(accept, "image/jpg"):
return "image/jpeg"
case strings.Contains(accept, "image/png"):
return "image/png"
case strings.Contains(accept, "image/gif"):
return "image/gif"
case strings.Contains(accept, "image/*"):
return "image/auto" // 自动格式
default:
return "image/jpeg" // 默认格式
}
}
// normalizeUserAgent 标准化UserAgent减少缓存键的变化
func (cm *CacheManager) normalizeUserAgent(ua string) string {
if ua == "" {
return "default"
}
ua = strings.ToLower(ua)
// 根据主要浏览器类型进行分类
switch {
case strings.Contains(ua, "chrome") && !strings.Contains(ua, "edge"):
return "chrome"
case strings.Contains(ua, "firefox"):
return "firefox"
case strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome"):
return "safari"
case strings.Contains(ua, "edge"):
return "edge"
case strings.Contains(ua, "bot") || strings.Contains(ua, "crawler"):
return "bot"
default:
return "other"
URL: r.URL.String(),
AcceptHeaders: r.Header.Get("Accept"),
UserAgent: r.Header.Get("User-Agent"),
}
}
@ -395,117 +140,7 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
return nil, false, false
}
// 🎯 针对图片请求实现智能格式回退
if utils.IsImageRequest(r.URL.Path) {
return cm.getImageWithFallback(key, r)
}
return cm.getRegularItem(key)
}
// getImageWithFallback 获取图片缓存项,支持格式回退
func (cm *CacheManager) getImageWithFallback(key CacheKey, r *http.Request) (*CacheItem, bool, bool) {
// 首先尝试精确匹配
if item, found, notModified := cm.getRegularItem(key); found {
cm.imageCacheHit.Add(1)
return item, found, notModified
}
// 如果精确匹配失败,尝试格式回退
if item, found, notModified := cm.tryFormatFallback(key, r); found {
cm.formatFallbackHit.Add(1)
return item, found, notModified
}
return nil, false, false
}
// tryFormatFallback 尝试格式回退
func (cm *CacheManager) tryFormatFallback(originalKey CacheKey, r *http.Request) (*CacheItem, bool, bool) {
requestedFormat := originalKey.AcceptHeaders
// 定义格式回退顺序
fallbackFormats := cm.getFormatFallbackOrder(requestedFormat)
for _, format := range fallbackFormats {
fallbackKey := CacheKey{
URL: originalKey.URL,
AcceptHeaders: format,
UserAgent: originalKey.UserAgent,
}
if item, found, notModified := cm.getRegularItem(fallbackKey); found {
// 找到了兼容格式,检查是否真的兼容
if cm.isFormatCompatible(requestedFormat, format, item.ContentType) {
log.Printf("[Cache] 格式回退: %s -> %s (%s)", requestedFormat, format, originalKey.URL)
return item, found, notModified
}
}
}
return nil, false, false
}
// getFormatFallbackOrder 获取格式回退顺序
func (cm *CacheManager) getFormatFallbackOrder(requestedFormat string) []string {
switch requestedFormat {
case "image/avif":
return []string{"image/webp", "image/jpeg", "image/png"}
case "image/webp":
return []string{"image/jpeg", "image/png", "image/avif"}
case "image/jpeg":
return []string{"image/webp", "image/png", "image/avif"}
case "image/png":
return []string{"image/webp", "image/jpeg", "image/avif"}
case "image/auto":
return []string{"image/webp", "image/avif", "image/jpeg", "image/png"}
default:
return []string{"image/jpeg", "image/webp", "image/png"}
}
}
// isFormatCompatible 检查格式是否兼容
func (cm *CacheManager) isFormatCompatible(requestedFormat, cachedFormat, actualContentType string) bool {
// 如果是自动格式,接受任何现代格式
if requestedFormat == "image/auto" {
return true
}
// 现代浏览器通常可以处理多种格式
modernFormats := map[string]bool{
"image/webp": true,
"image/avif": true,
"image/jpeg": true,
"image/png": true,
}
// 检查实际内容类型是否为现代格式
if actualContentType != "" {
return modernFormats[strings.ToLower(actualContentType)]
}
return modernFormats[cachedFormat]
}
// getRegularItem 获取常规缓存项(原有逻辑)
func (cm *CacheManager) getRegularItem(key CacheKey) (*CacheItem, bool, bool) {
// 检查LRU缓存
if item, found := cm.lruCache.Get(key); found {
// 检查LRU缓存项是否过期
if time.Since(item.LastAccess) > cm.maxAge {
cm.lruCache.Delete(key)
cm.missCount.Add(1)
return nil, false, false
}
// 更新访问时间
item.LastAccess = time.Now()
atomic.AddInt64(&item.AccessCount, 1)
cm.hitCount.Add(1)
cm.regularCacheHit.Add(1)
return item, true, false
}
// 检查文件缓存
// 检查缓存项是否存在
value, ok := cm.items.Load(key)
if !ok {
cm.missCount.Add(1)
@ -533,12 +168,8 @@ func (cm *CacheManager) getRegularItem(key CacheKey) (*CacheItem, bool, bool) {
item.LastAccess = time.Now()
atomic.AddInt64(&item.AccessCount, 1)
cm.hitCount.Add(1)
cm.regularCacheHit.Add(1)
cm.bytesSaved.Add(item.Size)
// 将缓存项添加到LRU缓存
cm.lruCache.Put(key, item)
return item, true, false
}
@ -592,11 +223,7 @@ func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*Ca
}
cm.items.Store(key, item)
method := "GET"
if resp.Request != nil {
method = resp.Request.Method
}
log.Printf("[Cache] NEW %s %s (%s) from %s", method, key.URL, formatBytes(item.Size), utils.GetRequestSource(resp.Request))
log.Printf("[Cache] NEW %s %s (%s) from %s", resp.Request.Method, key.URL, formatBytes(item.Size), utils.GetRequestSource(resp.Request))
return item, nil
}
@ -698,16 +325,13 @@ func (cm *CacheManager) GetStats() CacheStats {
}
return CacheStats{
TotalItems: totalItems,
TotalSize: totalSize,
HitCount: hitCount,
MissCount: missCount,
HitRate: hitRate,
BytesSaved: cm.bytesSaved.Load(),
Enabled: cm.enabled.Load(),
FormatFallbackHit: cm.formatFallbackHit.Load(),
ImageCacheHit: cm.imageCacheHit.Load(),
RegularCacheHit: cm.regularCacheHit.Load(),
TotalItems: totalItems,
TotalSize: totalSize,
HitCount: hitCount,
MissCount: missCount,
HitRate: hitRate,
BytesSaved: cm.bytesSaved.Load(),
Enabled: cm.enabled.Load(),
}
}
@ -750,9 +374,6 @@ func (cm *CacheManager) ClearCache() error {
cm.hitCount.Store(0)
cm.missCount.Store(0)
cm.bytesSaved.Store(0)
cm.formatFallbackHit.Store(0)
cm.imageCacheHit.Store(0)
cm.regularCacheHit.Store(0)
return nil
}
@ -823,41 +444,15 @@ func (cm *CacheManager) Commit(key CacheKey, tempPath string, resp *http.Respons
return fmt.Errorf("cache is disabled")
}
// 读取临时文件内容以计算哈希
tempData, err := os.ReadFile(tempPath)
if err != nil {
os.Remove(tempPath)
return fmt.Errorf("failed to read temp file: %v", err)
// 生成最终的缓存文件名
h := sha256.New()
h.Write([]byte(key.String()))
hashStr := hex.EncodeToString(h.Sum(nil))
ext := filepath.Ext(key.URL)
if ext == "" {
ext = ".bin"
}
// 计算内容哈希与Put方法保持一致
contentHash := sha256.Sum256(tempData)
hashStr := hex.EncodeToString(contentHash[:])
// 检查是否存在相同哈希的缓存项
var existingItem *CacheItem
cm.items.Range(func(k, v interface{}) bool {
if item := v.(*CacheItem); item.Hash == hashStr {
if _, err := os.Stat(item.FilePath); err == nil {
existingItem = item
return false
}
cm.items.Delete(k)
}
return true
})
if existingItem != nil {
// 删除临时文件,使用现有缓存
os.Remove(tempPath)
cm.items.Store(key, existingItem)
log.Printf("[Cache] HIT %s %s (%s) from %s", resp.Request.Method, key.URL, formatBytes(existingItem.Size), utils.GetRequestSource(resp.Request))
return nil
}
// 生成最终的缓存文件名(使用内容哈希)
fileName := hashStr
filePath := filepath.Join(cm.cacheDir, fileName)
filePath := filepath.Join(cm.cacheDir, hashStr+ext)
// 重命名临时文件
if err := os.Rename(tempPath, filePath); err != nil {
@ -996,54 +591,3 @@ func (cm *CacheManager) loadConfig() error {
return nil
}
// GetExtensionMatcher 获取缓存的ExtensionMatcher
func (cm *CacheManager) GetExtensionMatcher(pathKey string, rules []config.ExtensionRule) *utils.ExtensionMatcher {
if cm.extensionMatcherCache == nil {
return utils.NewExtensionMatcher(rules)
}
return cm.extensionMatcherCache.GetOrCreate(pathKey, rules)
}
// InvalidateExtensionMatcherPath 使指定路径的ExtensionMatcher缓存失效
func (cm *CacheManager) InvalidateExtensionMatcherPath(pathKey string) {
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.InvalidatePath(pathKey)
}
}
// InvalidateAllExtensionMatchers 清空所有ExtensionMatcher缓存
func (cm *CacheManager) InvalidateAllExtensionMatchers() {
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.InvalidateAll()
}
}
// GetExtensionMatcherStats 获取ExtensionMatcher缓存统计信息
func (cm *CacheManager) GetExtensionMatcherStats() ExtensionMatcherCacheStats {
if cm.extensionMatcherCache != nil {
return cm.extensionMatcherCache.GetStats()
}
return ExtensionMatcherCacheStats{}
}
// UpdateExtensionMatcherConfig 更新ExtensionMatcher缓存配置
func (cm *CacheManager) UpdateExtensionMatcherConfig(maxAge, cleanupTick time.Duration) {
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.UpdateConfig(maxAge, cleanupTick)
}
}
// Stop 停止缓存管理器包括ExtensionMatcher缓存
func (cm *CacheManager) Stop() {
// 停止主缓存清理
if cm.cleanupTimer != nil {
cm.cleanupTimer.Stop()
}
close(cm.stopCleanup)
// 停止ExtensionMatcher缓存
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.Stop()
}
}

64
internal/cache/manager_test.go vendored Normal file
View File

@ -0,0 +1,64 @@
package cache
import (
"net/http"
"os"
"testing"
"time"
)
func TestCacheExpiry(t *testing.T) {
// 创建临时目录用于测试
tempDir, err := os.MkdirTemp("", "cache-test-*")
if err != nil {
t.Fatal("Failed to create temp dir:", err)
}
defer os.RemoveAll(tempDir)
// 创建缓存管理器设置较短的过期时间5秒用于测试
cm, err := NewCacheManager(tempDir)
if err != nil {
t.Fatal("Failed to create cache manager:", err)
}
cm.maxAge = 5 * time.Second
// 创建测试请求和响应
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
}
testData := []byte("test data")
// 生成缓存键
key := cm.GenerateCacheKey(req)
// 1. 首先放入缓存
_, err = cm.Put(key, resp, testData)
if err != nil {
t.Fatal("Failed to put item in cache:", err)
}
// 2. 立即获取,应该能命中
if _, hit, _ := cm.Get(key, req); !hit {
t.Error("Cache should hit immediately after putting")
}
// 3. 等待3秒未过期再次访问
time.Sleep(3 * time.Second)
if _, hit, _ := cm.Get(key, req); !hit {
t.Error("Cache should hit after 3 seconds")
}
// 4. 再等待3秒总共6秒但因为上次访问重置了时间所以应该还在有效期内
time.Sleep(3 * time.Second)
if _, hit, _ := cm.Get(key, req); !hit {
t.Error("Cache should hit after 6 seconds because last access reset the timer")
}
// 5. 等待6秒超过过期时间且无访问这次应该过期
time.Sleep(6 * time.Second)
if _, hit, _ := cm.Get(key, req); hit {
t.Error("Cache should expire after 6 seconds of no access")
}
}

View File

@ -2,14 +2,23 @@ package config
import (
"encoding/json"
"log"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
// Config 配置结构体
type configImpl struct {
sync.RWMutex
Config
// 配置更新回调函数
onConfigUpdate []func(*Config)
}
var (
instance *configImpl
once sync.Once
configCallbacks []func(*Config)
callbackMutex sync.RWMutex
)
@ -17,185 +26,30 @@ var (
type ConfigManager struct {
config atomic.Value
configPath string
mu sync.RWMutex
}
func NewConfigManager(configPath string) (*ConfigManager, error) {
cm := &ConfigManager{
configPath: configPath,
}
// 加载配置
config, err := cm.loadConfigFromFile()
if err != nil {
return nil, err
}
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range config.MAP {
pc.ProcessExtensionMap()
config.MAP[path] = pc // 更新回原始map
}
cm.config.Store(config)
log.Printf("[ConfigManager] 配置已加载: %d 个路径映射", len(config.MAP))
return cm, nil
func NewConfigManager(path string) *ConfigManager {
cm := &ConfigManager{configPath: path}
cm.loadConfig()
go cm.watchConfig()
return cm
}
// loadConfigFromFile 从文件加载配置
func (cm *ConfigManager) loadConfigFromFile() (*Config, error) {
data, err := os.ReadFile(cm.configPath)
if err != nil {
// 如果文件不存在,创建默认配置
if os.IsNotExist(err) {
if createErr := cm.createDefaultConfig(); createErr == nil {
return cm.loadConfigFromFile() // 重新加载
} else {
return nil, createErr
}
}
return nil, err
func (cm *ConfigManager) watchConfig() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
cm.loadConfig()
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// createDefaultConfig 创建默认配置文件
func (cm *ConfigManager) createDefaultConfig() error {
// 创建目录(如果不存在)
dir := cm.configPath[:strings.LastIndex(cm.configPath, "/")]
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// 创建默认配置
defaultConfig := Config{
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
Domains: "a.com,b.com", // 只对a.com和b.com域名生效
},
{
Extensions: "jpg,png,webp",
Target: "https://img2.example.com",
SizeThreshold: 2 * 1024 * 1024, // 2MB
MaxSize: 5 * 1024 * 1024, // 5MB
Domains: "b.com", // 只对b.com域名生效
},
{
Extensions: "mp4,avi",
Target: "https://video.example.com",
SizeThreshold: 1024 * 1024, // 1MB
MaxSize: 50 * 1024 * 1024, // 50MB
// 不指定Domains对所有域名生效
},
},
},
},
Compression: CompressionConfig{
Gzip: CompressorConfig{
Enabled: true,
Level: 6,
},
Brotli: CompressorConfig{
Enabled: true,
Level: 6,
},
},
}
// 序列化为JSON
data, err := json.MarshalIndent(defaultConfig, "", " ")
if err != nil {
return err
}
// 写入文件
return os.WriteFile(cm.configPath, data, 0644)
}
// GetConfig 获取当前配置
func (cm *ConfigManager) GetConfig() *Config {
return cm.config.Load().(*Config)
}
// UpdateConfig 更新配置
func (cm *ConfigManager) UpdateConfig(newConfig *Config) error {
cm.mu.Lock()
defer cm.mu.Unlock()
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range newConfig.MAP {
pc.ProcessExtensionMap()
newConfig.MAP[path] = pc // 更新回原始map
}
// 保存到文件
if err := cm.saveConfigToFile(newConfig); err != nil {
return err
}
// 更新内存中的配置
cm.config.Store(newConfig)
// 触发回调
TriggerCallbacks(newConfig)
log.Printf("[ConfigManager] 配置已更新: %d 个路径映射", len(newConfig.MAP))
return nil
}
// saveConfigToFile 保存配置到文件
func (cm *ConfigManager) saveConfigToFile(config *Config) error {
// 将新配置格式化为JSON
configData, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
// 保存到临时文件
tempFile := cm.configPath + ".tmp"
if err := os.WriteFile(tempFile, configData, 0644); err != nil {
return err
}
// 重命名临时文件为正式文件
return os.Rename(tempFile, cm.configPath)
}
// ReloadConfig 重新加载配置文件
func (cm *ConfigManager) ReloadConfig() error {
config, err := cm.loadConfigFromFile()
if err != nil {
return err
}
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range config.MAP {
pc.ProcessExtensionMap()
config.MAP[path] = pc // 更新回原始map
}
cm.config.Store(config)
// 触发回调
TriggerCallbacks(config)
log.Printf("[ConfigManager] 配置已重新加载: %d 个路径映射", len(config.MAP))
return nil
// Load 加载配置
func Load(path string) (*Config, error) {
var err error
once.Do(func() {
instance = &configImpl{}
err = instance.reload(path)
})
return &instance.Config, err
}
// RegisterUpdateCallback 注册配置更新回调函数
@ -207,33 +61,53 @@ func RegisterUpdateCallback(callback func(*Config)) {
// TriggerCallbacks 触发所有回调
func TriggerCallbacks(cfg *Config) {
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range cfg.MAP {
pc.ProcessExtensionMap()
cfg.MAP[path] = pc // 更新回原始map
}
callbackMutex.RLock()
defer callbackMutex.RUnlock()
for _, callback := range configCallbacks {
callback(cfg)
}
// 添加日志
log.Printf("[Config] 触发了 %d 个配置更新回调", len(configCallbacks))
}
// 为了向后兼容保留Load函数但现在它使用ConfigManager
var globalConfigManager *ConfigManager
// Update 更新配置并触发回调
func (c *configImpl) Update(newConfig *Config) {
c.Lock()
defer c.Unlock()
// Load 加载配置(向后兼容)
func Load(path string) (*Config, error) {
if globalConfigManager == nil {
var err error
globalConfigManager, err = NewConfigManager(path)
if err != nil {
return nil, err
}
// 更新配置
c.MAP = newConfig.MAP
c.Compression = newConfig.Compression
// 触发回调
for _, callback := range c.onConfigUpdate {
callback(newConfig)
}
return globalConfigManager.GetConfig(), nil
}
// reload 重新加载配置文件
func (c *configImpl) reload(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
var newConfig Config
if err := json.Unmarshal(data, &newConfig); err != nil {
return err
}
c.Update(&newConfig)
return nil
}
func (cm *ConfigManager) loadConfig() error {
config, err := Load(cm.configPath)
if err != nil {
return err
}
cm.config.Store(config)
return nil
}
func (cm *ConfigManager) GetConfig() *Config {
return cm.config.Load().(*Config)
}

View File

@ -1,16 +0,0 @@
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
}

View File

@ -1,30 +1,22 @@
package config
import (
"encoding/json"
"strings"
)
type Config struct {
MAP map[string]PathConfig `json:"MAP"` // 路径映射配置
MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig
Compression CompressionConfig `json:"Compression"`
Security SecurityConfig `json:"Security"` // 安全配置
}
type PathConfig struct {
DefaultTarget string `json:"DefaultTarget"` // 默认目标URL
ExtensionMap []ExtRuleConfig `json:"ExtensionMap"` // 扩展名映射规则
ExtRules []ExtensionRule `json:"-"` // 内部使用,存储处理后的扩展名规则
RedirectMode bool `json:"RedirectMode"` // 是否使用302跳转模式
}
// ExtensionRule 表示一个扩展名映射规则(内部使用)
type ExtensionRule struct {
Extensions []string // 支持的扩展名列表
Target string // 目标服务器
SizeThreshold int64 // 最小阈值
MaxSize int64 // 最大阈值
RedirectMode bool // 是否使用302跳转模式
Domains []string // 支持的域名列表,为空表示匹配所有域名
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 // 内部使用,存储拆分后的映射
}
type CompressionConfig struct {
@ -37,99 +29,84 @@ type CompressorConfig struct {
Level int `json:"Level"`
}
type SecurityConfig struct {
IPBan IPBanConfig `json:"IPBan"` // IP封禁配置
// 添加一个辅助方法来处理字符串到 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 IPBanConfig struct {
Enabled bool `json:"Enabled"` // 是否启用IP封禁
ErrorThreshold int `json:"ErrorThreshold"` // 404错误阈值
WindowMinutes int `json:"WindowMinutes"` // 统计窗口时间(分钟)
BanDurationMinutes int `json:"BanDurationMinutes"` // 封禁时长(分钟)
CleanupIntervalMinutes int `json:"CleanupIntervalMinutes"` // 清理间隔(分钟)
}
// 扩展名映射配置结构
type ExtRuleConfig struct {
Extensions string `json:"Extensions"` // 逗号分隔的扩展名
Target string `json:"Target"` // 目标服务器
SizeThreshold int64 `json:"SizeThreshold"` // 最小阈值
MaxSize int64 `json:"MaxSize"` // 最大阈值
RedirectMode bool `json:"RedirectMode"` // 是否使用302跳转模式
Domains string `json:"Domains"` // 逗号分隔的域名列表,为空表示匹配所有域名
}
// 处理扩展名映射的方法
// 添加处理扩展名映射的方法
func (p *PathConfig) ProcessExtensionMap() {
p.ExtRules = nil
if p.ExtensionMap == nil {
return
}
// 处理扩展名规则
for _, rule := range p.ExtensionMap {
extRule := ExtensionRule{
Target: rule.Target,
SizeThreshold: rule.SizeThreshold,
MaxSize: rule.MaxSize,
RedirectMode: rule.RedirectMode,
}
// 处理扩展名列表
for _, ext := range strings.Split(rule.Extensions, ",") {
ext = strings.TrimSpace(ext)
p.processedExtMap = make(map[string]string)
for exts, target := range p.ExtensionMap {
// 分割扩展名
for _, ext := range strings.Split(exts, ",") {
ext = strings.TrimSpace(ext) // 移除可能的空格
if ext != "" {
extRule.Extensions = append(extRule.Extensions, ext)
p.processedExtMap[ext] = target
}
}
// 处理域名列表
if rule.Domains != "" {
for _, domain := range strings.Split(rule.Domains, ",") {
domain = strings.TrimSpace(domain)
if domain != "" {
extRule.Domains = append(extRule.Domains, domain)
}
}
}
if len(extRule.Extensions) > 0 {
p.ExtRules = append(p.ExtRules, extRule)
}
}
}
// GetProcessedExtTarget 快速获取扩展名对应的目标URL如果存在返回true
func (p *PathConfig) GetProcessedExtTarget(ext string) (string, bool) {
if p.ExtRules == nil {
return "", false
// 添加获取目标URL的方法
func (p *PathConfig) GetTargetForExt(ext string) string {
if p.processedExtMap == nil {
p.ProcessExtensionMap()
}
for _, rule := range p.ExtRules {
for _, e := range rule.Extensions {
if e == ext {
return rule.Target, true
}
}
if target, exists := p.processedExtMap[ext]; exists {
return target
}
return "", false
return p.DefaultTarget
}
// GetProcessedExtRule 获取扩展名对应的完整规则信息包括RedirectMode
func (p *PathConfig) GetProcessedExtRule(ext string) (*ExtensionRule, bool) {
if p.ExtRules == nil {
return nil, false
// 添加检查扩展名是否存在的方法
func (p *PathConfig) GetExtensionTarget(ext string) (string, bool) {
if p.processedExtMap == nil {
p.ProcessExtensionMap()
}
for _, rule := range p.ExtRules {
for _, e := range rule.Extensions {
if e == ext {
return &rule, true
}
}
}
return nil, false
target, exists := p.processedExtMap[ext]
return target, exists
}

22
internal/errors/errors.go Normal file
View File

@ -0,0 +1,22 @@
package errors
type ErrorCode int
const (
ErrInvalidConfig ErrorCode = iota + 1
ErrRateLimit
ErrMetricsCollection
)
type MetricsError struct {
Code ErrorCode
Message string
Err error
}
func (e *MetricsError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}

View File

@ -14,13 +14,10 @@ import (
"strings"
"sync"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
)
const (
tokenExpiry = 30 * 24 * time.Hour // Token 过期时间为 30 天
stateExpiry = 10 * time.Minute // State 过期时间为 10 分钟
)
type OAuthUserInfo struct {
@ -44,10 +41,9 @@ type OAuthUserInfo struct {
}
type OAuthToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type tokenInfo struct {
@ -56,11 +52,6 @@ type tokenInfo struct {
username string
}
type stateInfo struct {
createdAt time.Time
expiresAt time.Time
}
type authManager struct {
tokens sync.Map
states sync.Map
@ -69,7 +60,6 @@ type authManager struct {
func newAuthManager() *authManager {
am := &authManager{}
go am.cleanExpiredTokens()
go am.cleanExpiredStates()
return am
}
@ -79,27 +69,6 @@ func (am *authManager) generateToken() string {
return base64.URLEncoding.EncodeToString(b)
}
func (am *authManager) generateState() string {
state := am.generateToken()
am.states.Store(state, stateInfo{
createdAt: time.Now(),
expiresAt: time.Now().Add(stateExpiry),
})
return state
}
func (am *authManager) validateState(state string) bool {
if info, ok := am.states.Load(state); ok {
stateInfo := info.(stateInfo)
if time.Now().Before(stateInfo.expiresAt) {
am.states.Delete(state) // 使用后立即删除
return true
}
am.states.Delete(state) // 过期也删除
}
return false
}
func (am *authManager) addToken(token string, username string, expiry time.Duration) {
am.tokens.Store(token, tokenInfo{
createdAt: time.Now(),
@ -133,20 +102,6 @@ func (am *authManager) cleanExpiredTokens() {
}
}
func (am *authManager) cleanExpiredStates() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
am.states.Range(func(key, value interface{}) bool {
state := key.(string)
info := value.(stateInfo)
if time.Now().After(info.expiresAt) {
am.states.Delete(state)
}
return true
})
}
}
// CheckAuth 检查认证令牌是否有效
func (h *ProxyHandler) CheckAuth(token string) bool {
return h.auth.validateToken(token)
@ -156,7 +111,7 @@ func (h *ProxyHandler) CheckAuth(token string) bool {
func (h *ProxyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@ -164,7 +119,7 @@ func (h *ProxyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(auth, "Bearer ")
h.auth.tokens.Delete(token)
log.Printf("[Auth] %s %s -> 200 (%s) logout success from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] %s %s -> 200 (%s) logout success from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
@ -177,14 +132,14 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(auth, "Bearer ")
if !h.auth.validateToken(token) {
log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
@ -195,48 +150,30 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
// getCallbackURL 从请求中获取回调地址
func getCallbackURL(r *http.Request) string {
if redirectURI := os.Getenv("OAUTH_REDIRECT_URI"); redirectURI != "" {
// 验证URI格式
if _, err := url.Parse(redirectURI); err == nil {
log.Printf("[Auth] DEBUG Using configured OAUTH_REDIRECT_URI: %s", redirectURI)
return redirectURI
if os.Getenv("OAUTH_REDIRECT_URI") != "" {
return os.Getenv("OAUTH_REDIRECT_URI")
} else {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
log.Printf("[Auth] WARNING Invalid OAUTH_REDIRECT_URI format: %s", redirectURI)
return fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, r.Host)
}
// 更可靠地检测协议
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
// 考虑X-Forwarded-Host头
host := r.Host
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
callbackURL := fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, host)
log.Printf("[Auth] DEBUG Generated callback URL: %s", callbackURL)
return callbackURL
}
// LoginHandler 处理登录请求,重定向到 OAuth 授权页面
func (h *ProxyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
state := h.auth.generateState()
state := h.auth.generateToken()
h.auth.states.Store(state, time.Now())
clientID := os.Getenv("OAUTH_CLIENT_ID")
redirectURI := getCallbackURL(r)
// 记录生成的state和重定向URI
log.Printf("[Auth] DEBUG %s %s -> Generated state=%s, redirect_uri=%s",
r.Method, r.URL.Path, state, redirectURI)
authURL := fmt.Sprintf("https://connect.czl.net/oauth2/authorize?%s",
url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {redirectURI},
"scope": {"read write"}, // 添加scope参数
"state": {state},
}.Encode())
@ -248,21 +185,17 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
// 记录完整请求信息
log.Printf("[Auth] DEBUG %s %s -> Callback received with state=%s, code=%s, full URL: %s",
r.Method, r.URL.Path, state, code, r.URL.String())
// 验证 state
if !h.auth.validateState(state) {
log.Printf("[Auth] ERR %s %s -> 400 (%s) invalid state '%s' from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), state, utils.GetRequestSource(r))
if _, ok := h.auth.states.Load(state); !ok {
log.Printf("[Auth] ERR %s %s -> 400 (%s) invalid state from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
h.auth.states.Delete(state)
// 验证code参数
if code == "" {
log.Printf("[Auth] ERR %s %s -> 400 (%s) missing code parameter from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 400 (%s) missing code parameter from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Missing code parameter", http.StatusBadRequest)
return
}
@ -274,15 +207,11 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
// 验证OAuth配置
if clientID == "" || clientSecret == "" {
log.Printf("[Auth] ERR %s %s -> 500 (%s) missing OAuth credentials from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 500 (%s) missing OAuth credentials from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Server configuration error", http.StatusInternalServerError)
return
}
// 记录令牌交换请求信息
log.Printf("[Auth] DEBUG %s %s -> Exchanging code for token with redirect_uri=%s",
r.Method, r.URL.Path, redirectURI)
resp, err := http.PostForm("https://connect.czl.net/api/oauth2/token",
url.Values{
"grant_type": {"authorization_code"},
@ -292,7 +221,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
"client_secret": {clientSecret},
})
if err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get access token: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get access token: %v from %s", r.Method, r.URL.Path, utils.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
return
}
@ -300,24 +229,22 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
// 读取错误响应内容
bodyBytes, _ := io.ReadAll(resp.Body)
log.Printf("[Auth] ERR %s %s -> %d (%s) OAuth server returned error: %s, response: %s",
r.Method, r.URL.Path, resp.StatusCode, iputil.GetClientIP(r), resp.Status, string(bodyBytes))
log.Printf("[Auth] ERR %s %s -> %d (%s) OAuth server returned error status: %s from %s",
r.Method, r.URL.Path, resp.StatusCode, utils.GetClientIP(r), resp.Status, utils.GetRequestSource(r))
http.Error(w, "OAuth server error: "+resp.Status, http.StatusInternalServerError)
return
}
var token OAuthToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse token response: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse token response: %v from %s", r.Method, r.URL.Path, utils.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to parse token response", http.StatusInternalServerError)
return
}
// 验证访问令牌
if token.AccessToken == "" {
log.Printf("[Auth] ERR %s %s -> 500 (%s) received empty access token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 500 (%s) received empty access token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Received invalid token", http.StatusInternalServerError)
return
}
@ -328,7 +255,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
client := &http.Client{Timeout: 10 * time.Second}
userResp, err := client.Do(req)
if err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get user info: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get user info: %v from %s", r.Method, r.URL.Path, utils.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to get user info", http.StatusInternalServerError)
return
}
@ -337,7 +264,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
// 检查用户信息响应状态码
if userResp.StatusCode != http.StatusOK {
log.Printf("[Auth] ERR %s %s -> %d (%s) userinfo endpoint returned error status: %s from %s",
r.Method, r.URL.Path, userResp.StatusCode, iputil.GetClientIP(r), userResp.Status, utils.GetRequestSource(r))
r.Method, r.URL.Path, userResp.StatusCode, utils.GetClientIP(r), userResp.Status, utils.GetRequestSource(r))
http.Error(w, "Failed to get user info: "+userResp.Status, http.StatusInternalServerError)
return
}
@ -346,7 +273,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
bodyBytes, err := io.ReadAll(userResp.Body)
if err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to read user info response body: %v from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
r.Method, r.URL.Path, utils.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to read user info response", http.StatusInternalServerError)
return
}
@ -358,7 +285,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
var rawUserInfo map[string]interface{}
if err := json.Unmarshal(bodyBytes, &rawUserInfo); err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse raw user info: %v from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
r.Method, r.URL.Path, utils.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
return
}
@ -393,7 +320,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
// 验证用户信息
if userInfo.Username == "" {
log.Printf("[Auth] ERR %s %s -> 500 (%s) could not extract username from user info from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Invalid user information: missing username", http.StatusInternalServerError)
return
}
@ -402,7 +329,7 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
internalToken := h.auth.generateToken()
h.auth.addToken(internalToken, userInfo.Username, tokenExpiry)
log.Printf("[Auth] %s %s -> 200 (%s) login success for user %s from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), userInfo.Username, utils.GetRequestSource(r))
log.Printf("[Auth] %s %s -> 200 (%s) login success for user %s from %s", r.Method, r.URL.Path, utils.GetClientIP(r), userInfo.Username, utils.GetRequestSource(r))
// 返回登录成功页面
w.Header().Set("Content-Type", "text/html")

View File

@ -11,13 +11,13 @@ import (
// ConfigHandler 配置管理处理器
type ConfigHandler struct {
configManager *config.ConfigManager
config *config.Config
}
// NewConfigHandler 创建新的配置管理处理器
func NewConfigHandler(configManager *config.ConfigManager) *ConfigHandler {
func NewConfigHandler(cfg *config.Config) *ConfigHandler {
return &ConfigHandler{
configManager: configManager,
config: cfg,
}
}
@ -67,14 +67,29 @@ func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request)
return
}
// 使用ConfigManager更新配置
if err := h.configManager.UpdateConfig(&newConfig); err != nil {
http.Error(w, fmt.Sprintf("更新配置失败: %v", err), http.StatusInternalServerError)
// 将新配置格式化为JSON
configData, err := json.MarshalIndent(newConfig, "", " ")
if err != nil {
http.Error(w, fmt.Sprintf("格式化配置失败: %v", err), http.StatusInternalServerError)
return
}
// 添加日志
fmt.Printf("[Config] 配置已更新: %d 个路径映射\n", len(newConfig.MAP))
// 保存到临时文件
tempFile := "data/config.json.tmp"
if err := os.WriteFile(tempFile, configData, 0644); err != nil {
http.Error(w, fmt.Sprintf("保存配置失败: %v", err), http.StatusInternalServerError)
return
}
// 重命名临时文件为正式文件
if err := os.Rename(tempFile, "data/config.json"); err != nil {
http.Error(w, fmt.Sprintf("更新配置文件失败: %v", err), http.StatusInternalServerError)
return
}
// 更新运行时配置
*h.config = newConfig
config.TriggerCallbacks(h.config)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "配置已更新并生效"}`))

View File

@ -25,9 +25,8 @@ type Metrics struct {
MemoryUsage string `json:"memory_usage"`
// 性能指标
AverageResponseTime string `json:"avg_response_time"`
RequestsPerSecond float64 `json:"requests_per_second"`
CurrentSessionRequests int64 `json:"current_session_requests"`
AverageResponseTime string `json:"avg_response_time"`
RequestsPerSecond float64 `json:"requests_per_second"`
// 传输指标
TotalBytes int64 `json:"total_bytes"`
@ -36,6 +35,9 @@ type Metrics struct {
// 状态码统计
StatusCodeStats map[string]int64 `json:"status_code_stats"`
// 路径统计
TopPaths []models.PathMetricsJSON `json:"top_paths"`
// 最近请求
RecentRequests []models.RequestLog `json:"recent_requests"`
@ -49,6 +51,13 @@ type Metrics struct {
Distribution map[string]int64 `json:"distribution"`
} `json:"latency_stats"`
// 错误统计
ErrorStats struct {
ClientErrors int64 `json:"client_errors"`
ServerErrors int64 `json:"server_errors"`
Types map[string]int64 `json:"types"`
} `json:"error_stats"`
// 带宽统计
BandwidthHistory map[string]string `json:"bandwidth_history"`
CurrentBandwidth string `json:"current_bandwidth"`
@ -62,21 +71,21 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
if stats == nil {
stats = map[string]interface{}{
"uptime": metrics.FormatUptime(uptime),
"active_requests": int64(0),
"total_requests": int64(0),
"total_errors": int64(0),
"error_rate": float64(0),
"num_goroutine": runtime.NumGoroutine(),
"memory_usage": "0 B",
"avg_response_time": "0 ms",
"total_bytes": int64(0),
"bytes_per_second": float64(0),
"requests_per_second": float64(0),
"current_session_requests": int64(0),
"status_code_stats": make(map[string]int64),
"recent_requests": make([]models.RequestLog, 0),
"top_referers": make([]models.PathMetrics, 0),
"uptime": metrics.FormatUptime(uptime),
"active_requests": int64(0),
"total_requests": int64(0),
"total_errors": int64(0),
"error_rate": float64(0),
"num_goroutine": runtime.NumGoroutine(),
"memory_usage": "0 B",
"avg_response_time": "0 ms",
"total_bytes": int64(0),
"bytes_per_second": float64(0),
"requests_per_second": float64(0),
"status_code_stats": make(map[string]int64),
"top_paths": make([]models.PathMetrics, 0),
"recent_requests": make([]models.RequestLog, 0),
"top_referers": make([]models.PathMetrics, 0),
"latency_stats": map[string]interface{}{
"min": "0ms",
"max": "0ms",
@ -106,27 +115,45 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
}
}
// 处理状态码统计数据
// 计算客户端错误和服务器错误数量
var clientErrors, serverErrors int64
statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"])
for code, count := range statusCodeStats {
codeInt := utils.ParseInt(code, 0)
if codeInt >= 400 && codeInt < 500 {
clientErrors += count
} else if codeInt >= 500 {
serverErrors += count
}
}
// 创建错误类型统计
errorTypes := make(map[string]int64)
if clientErrors > 0 {
errorTypes["客户端错误"] = clientErrors
}
if serverErrors > 0 {
errorTypes["服务器错误"] = serverErrors
}
metrics := Metrics{
Uptime: metrics.FormatUptime(uptime),
ActiveRequests: utils.SafeInt64(stats["active_requests"]),
TotalRequests: totalRequests,
TotalErrors: totalErrors,
ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)),
NumGoroutine: utils.SafeInt(stats["num_goroutine"]),
MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"),
AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"),
TotalBytes: totalBytes,
BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1),
RequestsPerSecond: utils.SafeFloat64(stats["requests_per_second"]),
CurrentSessionRequests: utils.SafeInt64(stats["current_session_requests"]),
StatusCodeStats: statusCodeStats,
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
TopReferers: models.SafePathMetrics(stats["top_referers"]),
BandwidthHistory: bandwidthHistory,
CurrentBandwidth: utils.SafeString(stats["current_bandwidth"], "0 B/s"),
Uptime: metrics.FormatUptime(uptime),
ActiveRequests: utils.SafeInt64(stats["active_requests"]),
TotalRequests: totalRequests,
TotalErrors: totalErrors,
ErrorRate: float64(totalErrors) / float64(utils.Max(totalRequests, 1)),
NumGoroutine: utils.SafeInt(stats["num_goroutine"]),
MemoryUsage: utils.SafeString(stats["memory_usage"], "0 B"),
AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"),
TotalBytes: totalBytes,
BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1),
RequestsPerSecond: float64(totalRequests) / utils.MaxFloat64(uptimeSeconds, 1),
StatusCodeStats: statusCodeStats,
TopPaths: models.SafePathMetrics(stats["top_paths"]),
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
TopReferers: models.SafePathMetrics(stats["top_referers"]),
BandwidthHistory: bandwidthHistory,
CurrentBandwidth: utils.SafeString(stats["current_bandwidth"], "0 B/s"),
}
// 填充延迟统计数据
@ -170,6 +197,11 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
metrics.LatencyStats.Distribution["gt1s"] = 0
}
// 填充错误统计数据
metrics.ErrorStats.ClientErrors = clientErrors
metrics.ErrorStats.ServerErrors = serverErrors
metrics.ErrorStats.Types = errorTypes
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(metrics); err != nil {
log.Printf("Error encoding metrics: %v", err)

View File

@ -4,7 +4,6 @@ import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"proxy-go/internal/cache"
@ -12,17 +11,6 @@ import (
"proxy-go/internal/utils"
"strings"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
"golang.org/x/net/http2"
)
// 镜像代理专用配置常量
const (
mirrorMaxIdleConns = 2000 // 镜像代理全局最大空闲连接
mirrorMaxIdleConnsPerHost = 200 // 镜像代理每个主机最大空闲连接
mirrorMaxConnsPerHost = 500 // 镜像代理每个主机最大连接数
mirrorTimeout = 60 * time.Second // 镜像代理超时时间
)
type MirrorProxyHandler struct {
@ -31,38 +19,10 @@ type MirrorProxyHandler struct {
}
func NewMirrorProxyHandler() *MirrorProxyHandler {
// 创建优化的拨号器
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
// 创建优化的传输层
transport := &http.Transport{
DialContext: dialer.DialContext,
MaxIdleConns: mirrorMaxIdleConns,
MaxIdleConnsPerHost: mirrorMaxIdleConnsPerHost,
MaxConnsPerHost: mirrorMaxConnsPerHost,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
DisableCompression: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 128 * 1024,
ReadBufferSize: 128 * 1024,
ResponseHeaderTimeout: 30 * time.Second,
MaxResponseHeaderBytes: 64 * 1024,
}
// 配置 HTTP/2
http2Transport, err := http2.ConfigureTransports(transport)
if err == nil && http2Transport != nil {
http2Transport.ReadIdleTimeout = 30 * time.Second
http2Transport.PingTimeout = 10 * time.Second
http2Transport.AllowHTTP = false
http2Transport.MaxReadFrameSize = 32 * 1024
http2Transport.StrictMaxConcurrentStreams = true
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
// 初始化缓存管理器
@ -74,13 +34,7 @@ func NewMirrorProxyHandler() *MirrorProxyHandler {
return &MirrorProxyHandler{
client: &http.Client{
Transport: transport,
Timeout: mirrorTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
},
Timeout: 30 * time.Second,
},
Cache: cacheManager,
}
@ -102,7 +56,7 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | CORS Preflight",
r.Method, http.StatusOK, time.Since(startTime),
iputil.GetClientIP(r), "-", r.URL.Path)
utils.GetClientIP(r), "-", r.URL.Path)
return
}
@ -112,7 +66,7 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid URL", http.StatusBadRequest)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Invalid URL",
r.Method, http.StatusBadRequest, time.Since(startTime),
iputil.GetClientIP(r), "-", r.URL.Path)
utils.GetClientIP(r), "-", r.URL.Path)
return
}
@ -120,33 +74,13 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
actualURL += "?" + r.URL.RawQuery
}
// 早期缓存检查 - 只对GET请求进行缓存检查
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache-HIT", "1")
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, iputil.GetClientIP(r), r)
return
}
}
// 解析目标 URL 以获取 host
parsedURL, err := url.Parse(actualURL)
if err != nil {
http.Error(w, "Invalid URL", http.StatusBadRequest)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Parse URL error: %v",
r.Method, http.StatusBadRequest, time.Since(startTime),
iputil.GetClientIP(r), "-", actualURL, err)
utils.GetClientIP(r), "-", actualURL, err)
return
}
@ -164,7 +98,7 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Error creating request", http.StatusInternalServerError)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Error creating request: %v",
r.Method, http.StatusInternalServerError, time.Since(startTime),
iputil.GetClientIP(r), "-", actualURL, err)
utils.GetClientIP(r), "-", actualURL, err)
return
}
@ -182,20 +116,40 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
proxyReq.Header.Set("Host", parsedURL.Host)
proxyReq.Host = parsedURL.Host
// 检查是否可以使用缓存
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache", "HIT")
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, utils.GetClientIP(r), r)
return
}
}
// 发送请求
resp, err := h.client.Do(proxyReq)
if err != nil {
http.Error(w, "Error forwarding request", http.StatusBadGateway)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Error forwarding request: %v",
r.Method, http.StatusBadGateway, time.Since(startTime),
iputil.GetClientIP(r), "-", actualURL, err)
utils.GetClientIP(r), "-", actualURL, err)
return
}
defer resp.Body.Close()
// 复制响应头
copyHeader(w.Header(), resp.Header)
w.Header().Set("Proxy-Go-Cache-HIT", "0")
w.Header().Set("Proxy-Go-Cache", "MISS")
// 设置状态码
w.WriteHeader(resp.StatusCode)
@ -229,9 +183,9 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 记录访问日志
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | %s",
r.Method, resp.StatusCode, time.Since(startTime),
iputil.GetClientIP(r), utils.FormatBytes(written),
utils.GetClientIP(r), utils.FormatBytes(written),
utils.GetRequestSource(r), actualURL)
// 记录统计信息
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, iputil.GetClientIP(r), r)
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, utils.GetClientIP(r), r)
}

View File

@ -11,13 +11,10 @@ import (
"proxy-go/internal/cache"
"proxy-go/internal/config"
"proxy-go/internal/metrics"
"proxy-go/internal/service"
"proxy-go/internal/utils"
"sort"
"strings"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
"golang.org/x/net/http2"
)
@ -25,9 +22,9 @@ const (
// 超时时间常量
clientConnTimeout = 10 * time.Second
proxyRespTimeout = 60 * time.Second
backendServTimeout = 30 * time.Second
idleConnTimeout = 90 * time.Second
tlsHandshakeTimeout = 5 * time.Second
backendServTimeout = 40 * time.Second
idleConnTimeout = 120 * time.Second
tlsHandshakeTimeout = 10 * time.Second
)
// 添加 hop-by-hop 头部映射
@ -43,93 +40,17 @@ var hopHeadersBase = map[string]bool{
"Upgrade": true,
}
// 优化后的连接池配置常量
const (
// 连接池配置
maxIdleConns = 5000 // 全局最大空闲连接数(增加)
maxIdleConnsPerHost = 500 // 每个主机最大空闲连接数(增加)
maxConnsPerHost = 1000 // 每个主机最大连接数(增加)
// 缓冲区大小优化
writeBufferSize = 256 * 1024 // 写缓冲区(增加)
readBufferSize = 256 * 1024 // 读缓冲区(增加)
// HTTP/2 配置
maxReadFrameSize = 64 * 1024 // HTTP/2 最大读帧大小(增加)
)
// ErrorHandler 定义错误处理函数类型
type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)
type ProxyHandler struct {
pathMap map[string]config.PathConfig
prefixTree *prefixMatcher // 添加前缀匹配树
client *http.Client
startTime time.Time
config *config.Config
auth *authManager
errorHandler ErrorHandler
Cache *cache.CacheManager
redirectHandler *RedirectHandler // 添加302跳转处理器
ruleService *service.RuleService // 添加规则服务
}
// 前缀匹配器结构体
type prefixMatcher struct {
prefixes []string
configs map[string]config.PathConfig
}
// 创建新的前缀匹配器
func newPrefixMatcher(pathMap map[string]config.PathConfig) *prefixMatcher {
pm := &prefixMatcher{
prefixes: make([]string, 0, len(pathMap)),
configs: make(map[string]config.PathConfig, len(pathMap)),
}
// 按长度降序排列前缀,确保最长匹配优先
for prefix, cfg := range pathMap {
pm.prefixes = append(pm.prefixes, prefix)
pm.configs[prefix] = cfg
}
// 按长度降序排列
sort.Slice(pm.prefixes, func(i, j int) bool {
return len(pm.prefixes[i]) > len(pm.prefixes[j])
})
return pm
}
// 根据路径查找匹配的前缀和配置
func (pm *prefixMatcher) match(path string) (string, config.PathConfig, bool) {
// 按预排序的前缀列表查找最长匹配
for _, prefix := range pm.prefixes {
if strings.HasPrefix(path, prefix) {
// 确保匹配的是完整的路径段
restPath := path[len(prefix):]
if restPath == "" || restPath[0] == '/' {
return prefix, pm.configs[prefix], true
}
}
}
return "", config.PathConfig{}, false
}
// 更新前缀匹配器
func (pm *prefixMatcher) update(pathMap map[string]config.PathConfig) {
pm.prefixes = make([]string, 0, len(pathMap))
pm.configs = make(map[string]config.PathConfig, len(pathMap))
for prefix, cfg := range pathMap {
pm.prefixes = append(pm.prefixes, prefix)
pm.configs[prefix] = cfg
}
// 按长度降序排列
sort.Slice(pm.prefixes, func(i, j int) bool {
return len(pm.prefixes[i]) > len(pm.prefixes[j])
})
pathMap map[string]config.PathConfig
client *http.Client
startTime time.Time
config *config.Config
auth *authManager
errorHandler ErrorHandler
Cache *cache.CacheManager
}
// NewProxyHandler 创建新的代理处理器
@ -141,30 +62,29 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
transport := &http.Transport{
DialContext: dialer.DialContext,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: idleConnTimeout,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ExpectContinueTimeout: 1 * time.Second,
MaxConnsPerHost: maxConnsPerHost,
MaxConnsPerHost: 200,
DisableKeepAlives: false,
DisableCompression: false,
ForceAttemptHTTP2: true,
WriteBufferSize: writeBufferSize,
ReadBufferSize: readBufferSize,
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
ResponseHeaderTimeout: backendServTimeout,
MaxResponseHeaderBytes: 128 * 1024, // 增加响应头缓冲区
MaxResponseHeaderBytes: 64 * 1024,
}
// 设置HTTP/2传输配置
http2Transport, err := http2.ConfigureTransports(transport)
if err == nil && http2Transport != nil {
http2Transport.ReadIdleTimeout = 30 * time.Second // 增加读空闲超时
http2Transport.PingTimeout = 10 * time.Second // 增加ping超时
http2Transport.ReadIdleTimeout = 10 * time.Second
http2Transport.PingTimeout = 5 * time.Second
http2Transport.AllowHTTP = false
http2Transport.MaxReadFrameSize = maxReadFrameSize // 使用常量
http2Transport.MaxReadFrameSize = 32 * 1024
http2Transport.StrictMaxConcurrentStreams = true
}
// 初始化缓存管理器
@ -173,12 +93,8 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
log.Printf("[Cache] Failed to initialize cache manager: %v", err)
}
// 初始化规则服务
ruleService := service.NewRuleService(cacheManager)
handler := &ProxyHandler{
pathMap: cfg.MAP,
prefixTree: newPrefixMatcher(cfg.MAP), // 初始化前缀匹配树
pathMap: cfg.MAP,
client: &http.Client{
Transport: transport,
Timeout: proxyRespTimeout,
@ -189,12 +105,10 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
return nil
},
},
startTime: time.Now(),
config: cfg,
auth: newAuthManager(),
Cache: cacheManager,
ruleService: ruleService,
redirectHandler: NewRedirectHandler(ruleService), // 初始化302跳转处理器
startTime: time.Now(),
config: cfg,
auth: newAuthManager(),
Cache: cacheManager,
errorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[Error] %s %s -> %v from %s", r.Method, r.URL.Path, err, utils.GetRequestSource(r))
w.WriteHeader(http.StatusInternalServerError)
@ -204,22 +118,9 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
// 注册配置更新回调
config.RegisterUpdateCallback(func(newCfg *config.Config) {
// 注意config包已经在回调触发前处理了所有ExtRules这里无需再次处理
handler.pathMap = newCfg.MAP
handler.prefixTree.update(newCfg.MAP) // 更新前缀匹配树
handler.config = newCfg
// 清理ExtensionMatcher缓存确保使用新配置
if handler.Cache != nil {
handler.Cache.InvalidateAllExtensionMatchers()
log.Printf("[Config] ExtensionMatcher缓存已清理")
}
// 清理URL可访问性缓存和文件大小缓存
utils.ClearAccessibilityCache()
utils.ClearFileSizeCache()
log.Printf("[Config] 代理处理器配置已更新: %d 个路径映射", len(newCfg.MAP))
log.Printf("[Config] 配置已更新并生效")
})
return handler
@ -249,41 +150,35 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "Welcome to CZL proxy.")
log.Printf("[Proxy] %s %s -> %d (%s) from %s", r.Method, r.URL.Path, http.StatusOK, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Proxy] %s %s -> %d (%s) from %s", r.Method, r.URL.Path, http.StatusOK, utils.GetClientIP(r), utils.GetRequestSource(r))
return
}
// 使用前缀匹配树快速查找匹配的路径
matchedPrefix, pathConfig, matched := h.prefixTree.match(r.URL.Path)
// 查找匹配的代理路径
var matchedPrefix string
var pathConfig config.PathConfig
// 如果没有找到匹配返回404
if !matched {
// 首先尝试完全匹配路径段
for prefix, cfg := range h.pathMap {
// 检查是否是完整的路径段匹配
if strings.HasPrefix(r.URL.Path, prefix) {
// 确保匹配的是完整的路径段
restPath := r.URL.Path[len(prefix):]
if restPath == "" || restPath[0] == '/' {
matchedPrefix = prefix
pathConfig = cfg
break
}
}
}
// 如果没有找到完全匹配返回404
if matchedPrefix == "" {
// 返回 404
http.NotFound(w, r)
return
}
// 早期缓存检查 - 只对GET请求进行缓存检查
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache-HIT", "1")
w.Header().Set("Proxy-Go-AltTarget", "0") // 缓存命中时设为0
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, iputil.GetClientIP(r), r)
return
}
}
// 构建目标 URL
targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix)
@ -294,15 +189,8 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// 检查是否需要进行302跳转
if h.redirectHandler != nil && h.redirectHandler.HandleRedirect(w, r, pathConfig, decodedPath, h.client) {
// 如果进行了302跳转直接返回不继续处理
collector.RecordRequest(r.URL.Path, http.StatusFound, time.Since(start), 0, iputil.GetClientIP(r), r)
return
}
// 使用统一的路由选择逻辑
targetBase, usedAltTarget := h.ruleService.GetTargetURL(h.client, r, pathConfig, decodedPath)
targetBase := utils.GetTargetURL(h.client, r, pathConfig, decodedPath)
// 重新编码路径,保留 '/'
parts := strings.Split(decodedPath, "/")
@ -312,11 +200,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
encodedPath := strings.Join(parts, "/")
targetURL := targetBase + encodedPath
// 添加原始请求的查询参数
if r.URL.RawQuery != "" {
targetURL = targetURL + "?" + r.URL.RawQuery
}
// 解析目标 URL 以获取 host
parsedURL, err := url.Parse(targetURL)
if err != nil {
@ -331,61 +214,34 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// 复制并处理请求头 - 使用更高效的方式
// 复制并处理请求头
copyHeader(proxyReq.Header, r.Header)
// 添加常见浏览器User-Agent - 避免冗余字符串操作
if r.Header.Get("User-Agent") == "" {
proxyReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
}
// 使用预先构建的URL字符串
hostScheme := parsedURL.Scheme + "://" + parsedURL.Host
// 添加Origin
proxyReq.Header.Set("Origin", hostScheme)
// 设置Referer为源站的完整域名带上斜杠
proxyReq.Header.Set("Referer", hostScheme+"/")
// 设置Host头和proxyReq.Host
proxyReq.Header.Set("Host", parsedURL.Host)
proxyReq.Host = parsedURL.Host
// 确保设置适当的Accept头 - 避免冗余字符串操作
if r.Header.Get("Accept") == "" {
proxyReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
}
// 确保设置Accept-Encoding - 避免冗余字符串操作
if ae := r.Header.Get("Accept-Encoding"); ae != "" {
proxyReq.Header.Set("Accept-Encoding", ae)
} else {
proxyReq.Header.Del("Accept-Encoding")
}
// 特别处理图片请求
if utils.IsImageRequest(r.URL.Path) {
// 获取 Accept 头
accept := r.Header.Get("Accept")
// 使用switch语句优化条件分支
switch {
case strings.Contains(accept, "image/avif"):
// 根据 Accept 头设置合适的图片格式
if strings.Contains(accept, "image/avif") {
proxyReq.Header.Set("Accept", "image/avif")
case strings.Contains(accept, "image/webp"):
} else if strings.Contains(accept, "image/webp") {
proxyReq.Header.Set("Accept", "image/webp")
}
// 设置 Cloudflare 特定的头部
proxyReq.Header.Set("CF-Image-Format", "auto")
proxyReq.Header.Set("CF-Image-Format", "auto") // 让 Cloudflare 根据 Accept 头自动选择格式
}
// 设置最小必要的代理头部
clientIP := iputil.GetClientIP(r)
proxyReq.Header.Set("X-Real-IP", clientIP)
// 设置其他必要的头部
proxyReq.Host = parsedURL.Host
proxyReq.Header.Set("Host", parsedURL.Host)
proxyReq.Header.Set("X-Real-IP", utils.GetClientIP(r))
proxyReq.Header.Set("X-Forwarded-Host", r.Host)
proxyReq.Header.Set("X-Forwarded-Proto", r.URL.Scheme)
// 添加或更新 X-Forwarded-For - 减少重复获取客户端IP
if clientIP != "" {
// 添加或更新 X-Forwarded-For
if clientIP := utils.GetClientIP(r); clientIP != "" {
if prior := proxyReq.Header.Get("X-Forwarded-For"); prior != "" {
proxyReq.Header.Set("X-Forwarded-For", prior+", "+clientIP)
} else {
@ -406,15 +262,35 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// 检查是否可以使用缓存
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache", "HIT")
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, utils.GetClientIP(r), r)
return
}
}
// 发送代理请求
resp, err := h.client.Do(proxyReq)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
h.errorHandler(w, r, fmt.Errorf("request timeout after %v", proxyRespTimeout))
log.Printf("[Proxy] ERR %s %s -> 408 (%s) timeout from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Proxy] ERR %s %s -> 408 (%s) timeout from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
} else {
h.errorHandler(w, r, fmt.Errorf("proxy error: %v", err))
log.Printf("[Proxy] ERR %s %s -> 502 (%s) proxy error from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Proxy] ERR %s %s -> 502 (%s) proxy error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
}
return
}
@ -422,26 +298,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 复制响应头
copyHeader(w.Header(), resp.Header)
w.Header().Set("Proxy-Go-Cache-HIT", "0")
// 如果使用了扩展名映射的备用目标,添加标记响应头
if usedAltTarget {
w.Header().Set("Proxy-Go-AltTarget", "1")
} else {
w.Header().Set("Proxy-Go-AltTarget", "0")
}
// 对于图片请求,添加 Vary 头部以支持 CDN 基于 Accept 头部的缓存
if utils.IsImageRequest(r.URL.Path) {
// 添加 Vary: Accept 头部,让 CDN 知道响应会根据 Accept 头部变化
if existingVary := w.Header().Get("Vary"); existingVary != "" {
if !strings.Contains(existingVary, "Accept") {
w.Header().Set("Vary", existingVary+", Accept")
}
} else {
w.Header().Set("Vary", "Accept")
}
}
w.Header().Set("Proxy-Go-Cache", "MISS")
// 设置响应状态码
w.WriteHeader(resp.StatusCode)
@ -452,47 +309,28 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cacheKey := h.Cache.GenerateCacheKey(r)
if cacheFile, err := h.Cache.CreateTemp(cacheKey, resp); err == nil {
defer cacheFile.Close()
// 使用缓冲IO提高性能
bufSize := 32 * 1024 // 32KB 缓冲区
buf := make([]byte, bufSize)
teeReader := io.TeeReader(resp.Body, cacheFile)
written, err = io.CopyBuffer(w, teeReader, buf)
written, err = io.Copy(w, teeReader)
if err == nil {
// 异步提交缓存,不阻塞当前请求处理
fileName := cacheFile.Name()
respClone := *resp // 创建响应的浅拷贝
go func() {
h.Cache.Commit(cacheKey, fileName, &respClone, written)
}()
h.Cache.Commit(cacheKey, cacheFile.Name(), resp, written)
}
} else {
// 使用缓冲的复制提高性能
bufSize := 32 * 1024 // 32KB 缓冲区
buf := make([]byte, bufSize)
written, err = io.CopyBuffer(w, resp.Body, buf)
written, err = io.Copy(w, resp.Body)
if err != nil && !isConnectionClosed(err) {
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
return
}
}
} else {
// 使用缓冲的复制提高性能
bufSize := 32 * 1024 // 32KB 缓冲区
buf := make([]byte, bufSize)
written, err = io.CopyBuffer(w, resp.Body, buf)
written, err = io.Copy(w, resp.Body)
if err != nil && !isConnectionClosed(err) {
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
return
}
}
// 记录统计信息
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), written, iputil.GetClientIP(r), r)
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), written, utils.GetClientIP(r), r)
}
func copyHeader(dst, src http.Header) {
@ -509,17 +347,9 @@ func copyHeader(dst, src http.Header) {
}
}
// 添加需要过滤的安全头部
securityHeaders := map[string]bool{
"Content-Security-Policy": true,
"Content-Security-Policy-Report-Only": true,
"X-Content-Security-Policy": true,
"X-WebKit-CSP": true,
}
// 使用局部 map 快速查找,跳过 hop-by-hop 头部和安全头部
// 使用局部 map 快速查找,跳过 hop-by-hop 头部
for k, vv := range src {
if !hopHeaders[k] && !securityHeaders[k] {
if !hopHeaders[k] {
for _, v := range vv {
dst.Add(k, v)
}

View File

@ -1,124 +0,0 @@
package handler
import (
"log"
"net/http"
"net/url"
"proxy-go/internal/config"
"proxy-go/internal/service"
"proxy-go/internal/utils"
"strings"
"github.com/woodchen-ink/go-web-utils/iputil"
)
// RedirectHandler 处理302跳转逻辑
type RedirectHandler struct {
ruleService *service.RuleService
}
// NewRedirectHandler 创建新的跳转处理器
func NewRedirectHandler(ruleService *service.RuleService) *RedirectHandler {
return &RedirectHandler{
ruleService: ruleService,
}
}
// HandleRedirect 处理302跳转请求
func (rh *RedirectHandler) HandleRedirect(w http.ResponseWriter, r *http.Request, pathConfig config.PathConfig, targetPath string, client *http.Client) bool {
// 检查是否需要进行302跳转
shouldRedirect, targetURL := rh.shouldRedirect(r, pathConfig, targetPath, client)
if !shouldRedirect {
return false
}
// 执行302跳转
rh.performRedirect(w, r, targetURL)
return true
}
// shouldRedirect 判断是否应该进行302跳转并返回目标URL优化版本
func (rh *RedirectHandler) shouldRedirect(r *http.Request, pathConfig config.PathConfig, targetPath string, client *http.Client) (bool, string) {
// 使用service包的规则选择函数传递请求的域名
result := rh.ruleService.SelectRuleForRedirect(client, pathConfig, targetPath, r.Host)
if result.ShouldRedirect {
// 构建完整的目标URL
targetURL := rh.buildTargetURL(result.TargetURL, targetPath, r.URL.RawQuery)
if result.Rule != nil {
log.Printf("[Redirect] %s -> 使用选中规则进行302跳转 (域名: %s): %s", targetPath, r.Host, targetURL)
} else {
log.Printf("[Redirect] %s -> 使用默认目标进行302跳转 (域名: %s): %s", targetPath, r.Host, targetURL)
}
return true, targetURL
}
return false, ""
}
// buildTargetURL 构建完整的目标URL
func (rh *RedirectHandler) buildTargetURL(baseURL, targetPath, rawQuery string) string {
// URL 解码,然后重新编码,确保特殊字符被正确处理
decodedPath, err := url.QueryUnescape(targetPath)
if err != nil {
// 如果解码失败,使用原始路径
decodedPath = targetPath
}
// 重新编码路径,保留 '/'
parts := strings.Split(decodedPath, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
encodedPath := strings.Join(parts, "/")
// 构建完整URL
targetURL := baseURL + encodedPath
// 添加查询参数
if rawQuery != "" {
targetURL = targetURL + "?" + rawQuery
}
return targetURL
}
// performRedirect 执行302跳转
func (rh *RedirectHandler) performRedirect(w http.ResponseWriter, r *http.Request, targetURL string) {
// 设置302跳转响应头
w.Header().Set("Location", targetURL)
w.Header().Set("Proxy-Go-Redirect", "1")
// 添加缓存控制头,避免浏览器缓存跳转响应
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// 设置状态码为302
w.WriteHeader(http.StatusFound)
// 记录跳转日志
clientIP := iputil.GetClientIP(r)
log.Printf("[Redirect] %s %s -> 302 %s (%s) from %s",
r.Method, r.URL.Path, targetURL, clientIP, utils.GetRequestSource(r))
}
// IsRedirectEnabled 检查路径配置是否启用了任何形式的302跳转
func (rh *RedirectHandler) IsRedirectEnabled(pathConfig config.PathConfig) bool {
// 检查默认目标是否启用跳转
if pathConfig.RedirectMode {
return true
}
// 检查扩展名规则是否有启用跳转的
for _, rule := range pathConfig.ExtRules {
if rule.RedirectMode {
return true
}
}
return false
}

View File

@ -1,130 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"proxy-go/internal/security"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
)
// SecurityHandler 安全管理处理器
type SecurityHandler struct {
banManager *security.IPBanManager
}
// NewSecurityHandler 创建安全管理处理器
func NewSecurityHandler(banManager *security.IPBanManager) *SecurityHandler {
return &SecurityHandler{
banManager: banManager,
}
}
// GetBannedIPs 获取被封禁的IP列表
func (sh *SecurityHandler) GetBannedIPs(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
bannedIPs := sh.banManager.GetBannedIPs()
// 转换为前端友好的格式
result := make([]map[string]interface{}, 0, len(bannedIPs))
for ip, banEndTime := range bannedIPs {
result = append(result, map[string]interface{}{
"ip": ip,
"ban_end_time": banEndTime.Format("2006-01-02 15:04:05"),
"remaining_seconds": int64(time.Until(banEndTime).Seconds()),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"banned_ips": result,
"count": len(result),
})
}
// UnbanIP 手动解封IP
func (sh *SecurityHandler) UnbanIP(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.IP == "" {
http.Error(w, "IP address is required", http.StatusBadRequest)
return
}
success := sh.banManager.UnbanIP(req.IP)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": success,
"message": func() string {
if success {
return "IP解封成功"
}
return "IP未在封禁列表中"
}(),
})
}
// GetSecurityStats 获取安全统计信息
func (sh *SecurityHandler) GetSecurityStats(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
stats := sh.banManager.GetStats()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// CheckIPStatus 检查IP状态
func (sh *SecurityHandler) CheckIPStatus(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
ip := r.URL.Query().Get("ip")
if ip == "" {
// 如果没有指定IP使用请求的IP
ip = iputil.GetClientIP(r)
}
banned, banEndTime := sh.banManager.GetBanInfo(ip)
result := map[string]interface{}{
"ip": ip,
"banned": banned,
}
if banned {
result["ban_end_time"] = banEndTime.Format("2006-01-02 15:04:05")
result["remaining_seconds"] = int64(time.Until(banEndTime).Seconds())
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}

View File

@ -1,14 +0,0 @@
package initapp
import (
"log"
)
func Init(configPath string) error {
log.Printf("[Init] 开始初始化应用程序...")
// 迁移配置文件已移除,不再需要
log.Printf("[Init] 应用程序初始化完成")
return nil
}

View File

@ -10,163 +10,12 @@ import (
"proxy-go/internal/utils"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// 优化的状态码统计结构
type StatusCodeStats struct {
mu sync.RWMutex
stats map[int]*int64 // 预分配常见状态码
}
// 优化的延迟分布统计
type LatencyBuckets struct {
lt10ms int64
ms10_50 int64
ms50_200 int64
ms200_1000 int64
gt1s int64
}
// 优化的引用来源统计(使用分片减少锁竞争)
type RefererStats struct {
shards []*RefererShard
mask uint64
}
type RefererShard struct {
mu sync.RWMutex
data map[string]*models.PathMetrics
}
const (
refererShardCount = 32 // 分片数量必须是2的幂
)
func NewRefererStats() *RefererStats {
rs := &RefererStats{
shards: make([]*RefererShard, refererShardCount),
mask: refererShardCount - 1,
}
for i := 0; i < refererShardCount; i++ {
rs.shards[i] = &RefererShard{
data: make(map[string]*models.PathMetrics),
}
}
return rs
}
func (rs *RefererStats) hash(key string) uint64 {
// 简单的字符串哈希函数
var h uint64 = 14695981039346656037
for _, b := range []byte(key) {
h ^= uint64(b)
h *= 1099511628211
}
return h
}
func (rs *RefererStats) getShard(key string) *RefererShard {
return rs.shards[rs.hash(key)&rs.mask]
}
func (rs *RefererStats) Load(key string) (*models.PathMetrics, bool) {
shard := rs.getShard(key)
shard.mu.RLock()
defer shard.mu.RUnlock()
val, ok := shard.data[key]
return val, ok
}
func (rs *RefererStats) Store(key string, value *models.PathMetrics) {
shard := rs.getShard(key)
shard.mu.Lock()
defer shard.mu.Unlock()
shard.data[key] = value
}
func (rs *RefererStats) Delete(key string) {
shard := rs.getShard(key)
shard.mu.Lock()
defer shard.mu.Unlock()
delete(shard.data, key)
}
func (rs *RefererStats) Range(f func(key string, value *models.PathMetrics) bool) {
for _, shard := range rs.shards {
shard.mu.RLock()
for k, v := range shard.data {
if !f(k, v) {
shard.mu.RUnlock()
return
}
}
shard.mu.RUnlock()
}
}
func (rs *RefererStats) Cleanup(cutoff int64) int {
deleted := 0
for _, shard := range rs.shards {
shard.mu.Lock()
for k, v := range shard.data {
if v.LastAccessTime.Load() < cutoff {
delete(shard.data, k)
deleted++
}
}
shard.mu.Unlock()
}
return deleted
}
func NewStatusCodeStats() *StatusCodeStats {
s := &StatusCodeStats{
stats: make(map[int]*int64),
}
// 预分配常见状态码
commonCodes := []int{200, 201, 204, 301, 302, 304, 400, 401, 403, 404, 429, 500, 502, 503, 504}
for _, code := range commonCodes {
counter := new(int64)
s.stats[code] = counter
}
return s
}
func (s *StatusCodeStats) Increment(code int) {
s.mu.RLock()
if counter, exists := s.stats[code]; exists {
s.mu.RUnlock()
atomic.AddInt64(counter, 1)
return
}
s.mu.RUnlock()
// 需要创建新的计数器
s.mu.Lock()
defer s.mu.Unlock()
if counter, exists := s.stats[code]; exists {
atomic.AddInt64(counter, 1)
} else {
counter := new(int64)
*counter = 1
s.stats[code] = counter
}
}
func (s *StatusCodeStats) GetStats() map[string]int64 {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]int64)
for code, counter := range s.stats {
result[fmt.Sprintf("%d", code)] = atomic.LoadInt64(counter)
}
return result
}
// Collector 指标收集器
type Collector struct {
startTime time.Time
@ -175,9 +24,10 @@ type Collector struct {
latencySum int64
maxLatency int64 // 最大响应时间
minLatency int64 // 最小响应时间
statusCodeStats *StatusCodeStats
latencyBuckets *LatencyBuckets // 使用结构体替代 sync.Map
refererStats *RefererStats // 使用分片哈希表
pathStats sync.Map
statusCodeStats sync.Map
latencyBuckets sync.Map // 响应时间分布
refererStats sync.Map // 引用来源统计
bandwidthStats struct {
sync.RWMutex
window time.Duration
@ -186,33 +36,10 @@ type Collector struct {
history map[string]int64
}
recentRequests *models.RequestQueue
pathStatsMutex sync.RWMutex
config *config.Config
// 新增:当前会话统计
sessionRequests int64 // 当前会话的请求数(不包含历史数据)
// 新增:基于时间窗口的请求统计
requestsWindow struct {
sync.RWMutex
window time.Duration // 时间窗口大小5分钟
buckets []int64 // 时间桶每个桶统计10秒内的请求数
bucketSize time.Duration // 每个桶的时间长度10秒
lastUpdate time.Time // 最后更新时间
current int64 // 当前桶的请求数
}
}
type RequestMetric struct {
Path string
Status int
Latency time.Duration
Bytes int64
ClientIP string
Request *http.Request
}
var requestChan chan RequestMetric
var (
instance *Collector
once sync.Once
@ -222,13 +49,10 @@ var (
func InitCollector(cfg *config.Config) error {
once.Do(func() {
instance = &Collector{
startTime: time.Now(),
recentRequests: models.NewRequestQueue(100),
config: cfg,
minLatency: math.MaxInt64,
statusCodeStats: NewStatusCodeStats(),
latencyBuckets: &LatencyBuckets{},
refererStats: NewRefererStats(),
startTime: time.Now(),
recentRequests: models.NewRequestQueue(100),
config: cfg,
minLatency: math.MaxInt64,
}
// 初始化带宽统计
@ -236,37 +60,14 @@ func InitCollector(cfg *config.Config) error {
instance.bandwidthStats.lastUpdate = time.Now()
instance.bandwidthStats.history = make(map[string]int64)
// 初始化请求窗口统计5分钟窗口10秒一个桶共30个桶
instance.requestsWindow.window = 5 * time.Minute
instance.requestsWindow.bucketSize = 10 * time.Second
bucketCount := int(instance.requestsWindow.window / instance.requestsWindow.bucketSize)
instance.requestsWindow.buckets = make([]int64, bucketCount)
instance.requestsWindow.lastUpdate = time.Now()
// 初始化延迟分布桶
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"}
for _, bucket := range buckets {
counter := new(int64)
*counter = 0
// 根据 bucket 名称设置对应的桶计数器
switch bucket {
case "lt10ms":
instance.latencyBuckets.lt10ms = atomic.LoadInt64(counter)
case "10-50ms":
instance.latencyBuckets.ms10_50 = atomic.LoadInt64(counter)
case "50-200ms":
instance.latencyBuckets.ms50_200 = atomic.LoadInt64(counter)
case "200-1000ms":
instance.latencyBuckets.ms200_1000 = atomic.LoadInt64(counter)
case "gt1s":
instance.latencyBuckets.gt1s = atomic.LoadInt64(counter)
}
instance.latencyBuckets.Store(bucket, counter)
}
// 初始化异步指标收集通道
requestChan = make(chan RequestMetric, 10000)
instance.startAsyncMetricsUpdater()
// 启动数据一致性检查器
instance.startConsistencyChecker()
@ -291,22 +92,131 @@ func (c *Collector) EndRequest() {
atomic.AddInt64(&c.activeRequests, -1)
}
// RecordRequest 记录请求异步写入channel
// RecordRequest 记录请求
func (c *Collector) RecordRequest(path string, status int, latency time.Duration, bytes int64, clientIP string, r *http.Request) {
metric := RequestMetric{
Path: path,
Status: status,
Latency: latency,
Bytes: bytes,
ClientIP: clientIP,
Request: r,
// 更新状态码统计
statusKey := fmt.Sprintf("%d", status)
if counter, ok := c.statusCodeStats.Load(statusKey); ok {
atomic.AddInt64(counter.(*int64), 1)
} else {
counter := new(int64)
*counter = 1
c.statusCodeStats.Store(statusKey, counter)
}
select {
case requestChan <- metric:
// ok
// 更新总字节数和带宽统计
atomic.AddInt64(&c.totalBytes, bytes)
c.updateBandwidthStats(bytes)
// 更新延迟统计
atomic.AddInt64(&c.latencySum, int64(latency))
latencyNanos := int64(latency)
for {
oldMin := atomic.LoadInt64(&c.minLatency)
if oldMin <= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.minLatency, oldMin, latencyNanos) {
break
}
}
for {
oldMax := atomic.LoadInt64(&c.maxLatency)
if oldMax >= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.maxLatency, oldMax, latencyNanos) {
break
}
}
// 更新延迟分布
latencyMs := latency.Milliseconds()
var bucketKey string
switch {
case latencyMs < 10:
bucketKey = "lt10ms"
case latencyMs < 50:
bucketKey = "10-50ms"
case latencyMs < 200:
bucketKey = "50-200ms"
case latencyMs < 1000:
bucketKey = "200-1000ms"
default:
// channel 满了,丢弃或降级处理
bucketKey = "gt1s"
}
if counter, ok := c.latencyBuckets.Load(bucketKey); ok {
atomic.AddInt64(counter.(*int64), 1)
} else {
counter := new(int64)
*counter = 1
c.latencyBuckets.Store(bucketKey, counter)
}
// 更新路径统计
c.pathStatsMutex.Lock()
if value, ok := c.pathStats.Load(path); ok {
stat := value.(*models.PathMetrics)
stat.AddRequest()
if status >= 400 {
stat.AddError()
}
stat.AddLatency(int64(latency))
stat.AddBytes(bytes)
} else {
newStat := &models.PathMetrics{
Path: path,
}
newStat.RequestCount.Store(1)
if status >= 400 {
newStat.ErrorCount.Store(1)
}
newStat.TotalLatency.Store(int64(latency))
newStat.BytesTransferred.Store(bytes)
c.pathStats.Store(path, newStat)
}
c.pathStatsMutex.Unlock()
// 更新引用来源统计
if r != nil {
referer := r.Header.Get("Referer")
if referer != "" {
// 简化引用来源,只保留域名部分
referer = simplifyReferer(referer)
if value, ok := c.refererStats.Load(referer); ok {
stat := value.(*models.PathMetrics)
stat.AddRequest()
if status >= 400 {
stat.AddError()
}
stat.AddLatency(int64(latency))
stat.AddBytes(bytes)
} else {
newStat := &models.PathMetrics{
Path: referer,
}
newStat.RequestCount.Store(1)
if status >= 400 {
newStat.ErrorCount.Store(1)
}
newStat.TotalLatency.Store(int64(latency))
newStat.BytesTransferred.Store(bytes)
c.refererStats.Store(referer, newStat)
}
}
}
// 更新最近请求记录
c.recentRequests.Push(models.RequestLog{
Time: time.Now(),
Path: path,
Status: status,
Latency: int64(latency),
BytesSent: bytes,
ClientIP: clientIP,
})
}
// FormatUptime 格式化运行时间
@ -339,40 +249,78 @@ func (c *Collector) GetStats() map[string]interface{} {
// 计算总请求数和平均延迟
var totalRequests int64
var totalErrors int64
statusCodeStats := c.statusCodeStats.GetStats()
for statusCode, count := range statusCodeStats {
totalRequests += count
// 计算错误数4xx和5xx状态码
if code, err := strconv.Atoi(statusCode); err == nil && code >= 400 {
totalErrors += count
c.statusCodeStats.Range(func(key, value interface{}) bool {
if counter, ok := value.(*int64); ok {
totalRequests += atomic.LoadInt64(counter)
} else {
totalRequests += value.(int64)
}
}
return true
})
avgLatency := float64(0)
if totalRequests > 0 {
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalRequests)
}
// 计算错误率
errorRate := float64(0)
if totalRequests > 0 {
errorRate = float64(totalErrors) / float64(totalRequests)
// 计算总体平均每秒请求数
requestsPerSecond := float64(totalRequests) / totalRuntime.Seconds()
// 收集状态码统计
statusCodeStats := make(map[string]int64)
c.statusCodeStats.Range(func(key, value interface{}) bool {
if counter, ok := value.(*int64); ok {
statusCodeStats[key.(string)] = atomic.LoadInt64(counter)
} else {
statusCodeStats[key.(string)] = value.(int64)
}
return true
})
// 收集路径统计
var pathMetrics []*models.PathMetrics
pathCount := 0
c.pathStats.Range(func(key, value interface{}) bool {
stats := value.(*models.PathMetrics)
requestCount := stats.GetRequestCount()
if requestCount > 0 {
totalLatency := stats.GetTotalLatency()
avgLatencyMs := float64(totalLatency) / float64(requestCount) / float64(time.Millisecond)
stats.AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs)
pathMetrics = append(pathMetrics, stats)
}
// 限制遍历的数量,避免过多数据导致内存占用过高
pathCount++
return pathCount < 100 // 最多遍历100个路径
})
// 按请求数降序排序,请求数相同时按路径字典序排序
sort.Slice(pathMetrics, func(i, j int) bool {
countI := pathMetrics[i].GetRequestCount()
countJ := pathMetrics[j].GetRequestCount()
if countI != countJ {
return countI > countJ
}
return pathMetrics[i].Path < pathMetrics[j].Path
})
// 只保留前10个
if len(pathMetrics) > 10 {
pathMetrics = pathMetrics[:10]
}
// 计算当前会话的请求数(基于本次启动后的实际请求)
sessionRequests := atomic.LoadInt64(&c.sessionRequests)
// 计算最近5分钟的平均每秒请求数
requestsPerSecond := c.getRecentRequestsPerSecond()
// 收集状态码统计(已经在上面获取了)
// 转换为值切片
pathMetricsValues := make([]models.PathMetricsJSON, len(pathMetrics))
for i, metric := range pathMetrics {
pathMetricsValues[i] = metric.ToJSON()
}
// 收集引用来源统计
var refererMetrics []*models.PathMetrics
refererCount := 0
c.refererStats.Range(func(key string, value *models.PathMetrics) bool {
stats := value
c.refererStats.Range(func(key, value interface{}) bool {
stats := value.(*models.PathMetrics)
requestCount := stats.GetRequestCount()
if requestCount > 0 {
totalLatency := stats.GetTotalLatency()
@ -396,9 +344,9 @@ func (c *Collector) GetStats() map[string]interface{} {
return refererMetrics[i].Path < refererMetrics[j].Path
})
// 只保留前20个
if len(refererMetrics) > 20 {
refererMetrics = refererMetrics[:20]
// 只保留前10个
if len(refererMetrics) > 10 {
refererMetrics = refererMetrics[:10]
}
// 转换为值切片
@ -409,11 +357,21 @@ func (c *Collector) GetStats() map[string]interface{} {
// 收集延迟分布
latencyDistribution := make(map[string]int64)
latencyDistribution["lt10ms"] = atomic.LoadInt64(&c.latencyBuckets.lt10ms)
latencyDistribution["10-50ms"] = atomic.LoadInt64(&c.latencyBuckets.ms10_50)
latencyDistribution["50-200ms"] = atomic.LoadInt64(&c.latencyBuckets.ms50_200)
latencyDistribution["200-1000ms"] = atomic.LoadInt64(&c.latencyBuckets.ms200_1000)
latencyDistribution["gt1s"] = atomic.LoadInt64(&c.latencyBuckets.gt1s)
// 确保所有桶都存在即使计数为0
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"}
for _, bucket := range buckets {
if counter, ok := c.latencyBuckets.Load(bucket); ok {
if counter != nil {
value := atomic.LoadInt64(counter.(*int64))
latencyDistribution[bucket] = value
} else {
latencyDistribution[bucket] = 0
}
} else {
latencyDistribution[bucket] = 0
}
}
// 获取最近请求记录(使用读锁)
recentRequests := c.recentRequests.GetAll()
@ -431,9 +389,6 @@ func (c *Collector) GetStats() map[string]interface{} {
return map[string]interface{}{
"uptime": FormatUptime(totalRuntime),
"active_requests": atomic.LoadInt64(&c.activeRequests),
"total_requests": totalRequests,
"total_errors": totalErrors,
"error_rate": errorRate,
"total_bytes": atomic.LoadInt64(&c.totalBytes),
"num_goroutine": runtime.NumGoroutine(),
"memory_usage": utils.FormatBytes(int64(mem.Alloc)),
@ -441,6 +396,7 @@ func (c *Collector) GetStats() map[string]interface{} {
"requests_per_second": requestsPerSecond,
"bytes_per_second": float64(atomic.LoadInt64(&c.totalBytes)) / totalRuntime.Seconds(),
"status_code_stats": statusCodeStats,
"top_paths": pathMetricsValues,
"top_referers": refererMetricsValues,
"recent_requests": recentRequests,
"latency_stats": map[string]interface{}{
@ -448,9 +404,8 @@ func (c *Collector) GetStats() map[string]interface{} {
"max": fmt.Sprintf("%.2fms", float64(maxLatency)/float64(time.Millisecond)),
"distribution": latencyDistribution,
},
"bandwidth_history": bandwidthHistory,
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
"current_session_requests": sessionRequests,
"bandwidth_history": bandwidthHistory,
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
}
}
@ -488,12 +443,36 @@ func (c *Collector) validateLoadedData() error {
// 验证状态码统计
var statusCodeTotal int64
statusStats := c.statusCodeStats.GetStats()
for _, count := range statusStats {
c.statusCodeStats.Range(func(key, value interface{}) bool {
count := atomic.LoadInt64(value.(*int64))
if count < 0 {
return fmt.Errorf("invalid negative status code count")
return false
}
statusCodeTotal += count
return true
})
// 验证路径统计
var totalPathRequests int64
c.pathStats.Range(func(_, value interface{}) bool {
stats := value.(*models.PathMetrics)
requestCount := stats.GetRequestCount()
errorCount := stats.GetErrorCount()
if requestCount < 0 || errorCount < 0 {
return false
}
if errorCount > requestCount {
return false
}
totalPathRequests += requestCount
return true
})
// 由于我们限制了路径统计的收集数量,路径统计总数可能小于状态码统计总数
// 因此,我们只需要确保路径统计总数不超过状态码统计总数即可
if float64(totalPathRequests) > float64(statusCodeTotal)*1.1 { // 允许10%的误差
return fmt.Errorf("path stats total (%d) significantly exceeds status code total (%d)",
totalPathRequests, statusCodeTotal)
}
return nil
@ -576,76 +555,6 @@ func (c *Collector) getCurrentBandwidth() float64 {
return float64(c.bandwidthStats.current) / duration
}
// updateRequestsWindow 更新请求窗口统计
func (c *Collector) updateRequestsWindow(count int64) {
c.requestsWindow.Lock()
defer c.requestsWindow.Unlock()
now := time.Now()
// 如果是第一次调用,初始化时间
if c.requestsWindow.lastUpdate.IsZero() {
c.requestsWindow.lastUpdate = now
}
// 计算当前时间桶的索引
timeSinceLastUpdate := now.Sub(c.requestsWindow.lastUpdate)
// 如果时间跨度超过桶大小,需要移动到新桶
if timeSinceLastUpdate >= c.requestsWindow.bucketSize {
bucketsToMove := int(timeSinceLastUpdate / c.requestsWindow.bucketSize)
if bucketsToMove >= len(c.requestsWindow.buckets) {
// 如果移动的桶数超过总桶数,清空所有桶
for i := range c.requestsWindow.buckets {
c.requestsWindow.buckets[i] = 0
}
} else {
// 向右移动桶数据新数据在索引0
copy(c.requestsWindow.buckets[bucketsToMove:], c.requestsWindow.buckets[:len(c.requestsWindow.buckets)-bucketsToMove])
// 清空前面的桶
for i := 0; i < bucketsToMove; i++ {
c.requestsWindow.buckets[i] = 0
}
}
// 更新时间为当前桶的开始时间
c.requestsWindow.lastUpdate = now.Truncate(c.requestsWindow.bucketSize)
}
// 将请求数加到第一个桶(当前时间桶)
if len(c.requestsWindow.buckets) > 0 {
c.requestsWindow.buckets[0] += count
}
}
// getRecentRequestsPerSecond 获取最近5分钟的平均每秒请求数
func (c *Collector) getRecentRequestsPerSecond() float64 {
c.requestsWindow.RLock()
defer c.requestsWindow.RUnlock()
// 统计所有桶的总请求数
var totalRequests int64
for _, bucket := range c.requestsWindow.buckets {
totalRequests += bucket
}
// 计算实际的时间窗口可能不满5分钟
now := time.Now()
actualWindow := c.requestsWindow.window
// 如果程序运行时间不足5分钟使用实际运行时间
if runTime := now.Sub(c.startTime); runTime < c.requestsWindow.window {
actualWindow = runTime
}
if actualWindow.Seconds() == 0 {
return 0
}
return float64(totalRequests) / actualWindow.Seconds()
}
// getBandwidthHistory 获取带宽历史记录
func (c *Collector) getBandwidthHistory() map[string]string {
c.bandwidthStats.RLock()
@ -658,137 +567,177 @@ func (c *Collector) getBandwidthHistory() map[string]string {
return history
}
// simplifyReferer 简化引用来源URL只保留域名部分
func simplifyReferer(referer string) string {
// 移除协议部分
if idx := strings.Index(referer, "://"); idx != -1 {
referer = referer[idx+3:]
}
// 只保留域名部分
if idx := strings.Index(referer, "/"); idx != -1 {
referer = referer[:idx]
}
return referer
}
// startCleanupTask 启动定期清理任务
func (c *Collector) startCleanupTask() {
go func() {
ticker := time.NewTicker(1 * time.Hour)
// 先立即执行一次清理
c.cleanupOldData()
ticker := time.NewTicker(15 * time.Minute) // 每15分钟清理一次
defer ticker.Stop()
for {
<-ticker.C
oneDayAgo := time.Now().Add(-24 * time.Hour).Unix()
// 清理超过24小时的引用来源统计
deletedCount := c.refererStats.Cleanup(oneDayAgo)
if deletedCount > 0 {
log.Printf("[Collector] 已清理 %d 条过期的引用来源统计", deletedCount)
}
// 强制GC
runtime.GC()
for range ticker.C {
c.cleanupOldData()
}
}()
}
// 异步批量处理请求指标
func (c *Collector) startAsyncMetricsUpdater() {
go func() {
batch := make([]RequestMetric, 0, 1000)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case metric := <-requestChan:
batch = append(batch, metric)
if len(batch) >= 1000 {
c.updateMetricsBatch(batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
c.updateMetricsBatch(batch)
batch = batch[:0]
}
}
}
}()
}
// cleanupOldData 清理旧数据
func (c *Collector) cleanupOldData() {
log.Printf("[Metrics] 开始清理旧数据...")
// 批量更新指标
func (c *Collector) updateMetricsBatch(batch []RequestMetric) {
for _, m := range batch {
// 增加当前会话请求计数
atomic.AddInt64(&c.sessionRequests, 1)
// 清理路径统计 - 只保留有请求且请求数较多的路径
var pathsToRemove []string
var pathsCount int
var totalRequests int64
// 更新请求窗口统计
c.updateRequestsWindow(1)
// 更新状态码统计
c.statusCodeStats.Increment(m.Status)
// 更新总字节数和带宽统计
atomic.AddInt64(&c.totalBytes, m.Bytes)
c.updateBandwidthStats(m.Bytes)
// 更新延迟统计
atomic.AddInt64(&c.latencySum, int64(m.Latency))
latencyNanos := int64(m.Latency)
for {
oldMin := atomic.LoadInt64(&c.minLatency)
if oldMin <= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.minLatency, oldMin, latencyNanos) {
break
}
}
for {
oldMax := atomic.LoadInt64(&c.maxLatency)
if oldMax >= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.maxLatency, oldMax, latencyNanos) {
break
}
}
// 更新延迟分布
latencyMs := m.Latency.Milliseconds()
switch {
case latencyMs < 10:
atomic.AddInt64(&c.latencyBuckets.lt10ms, 1)
case latencyMs < 50:
atomic.AddInt64(&c.latencyBuckets.ms10_50, 1)
case latencyMs < 200:
atomic.AddInt64(&c.latencyBuckets.ms50_200, 1)
case latencyMs < 1000:
atomic.AddInt64(&c.latencyBuckets.ms200_1000, 1)
default:
atomic.AddInt64(&c.latencyBuckets.gt1s, 1)
}
// 记录引用来源
if m.Request != nil {
referer := m.Request.Referer()
if referer != "" {
var refererMetrics *models.PathMetrics
if existingMetrics, ok := c.refererStats.Load(referer); ok {
refererMetrics = existingMetrics
} else {
refererMetrics = &models.PathMetrics{Path: referer}
c.refererStats.Store(referer, refererMetrics)
}
refererMetrics.AddRequest()
if m.Status >= 400 {
refererMetrics.AddError()
}
refererMetrics.AddBytes(m.Bytes)
refererMetrics.AddLatency(m.Latency.Nanoseconds())
// 更新最后访问时间
refererMetrics.LastAccessTime.Store(time.Now().Unix())
}
}
// 更新最近请求记录
c.recentRequests.Push(models.RequestLog{
Time: time.Now(),
Path: m.Path,
Status: m.Status,
Latency: int64(m.Latency),
BytesSent: m.Bytes,
ClientIP: m.ClientIP,
})
// 先收集所有路径及其请求数
type pathInfo struct {
path string
count int64
}
var paths []pathInfo
c.pathStats.Range(func(key, value interface{}) bool {
path := key.(string)
stats := value.(*models.PathMetrics)
count := stats.GetRequestCount()
pathsCount++
totalRequests += count
paths = append(paths, pathInfo{path, count})
return true
})
// 按请求数排序
sort.Slice(paths, func(i, j int) bool {
return paths[i].count > paths[j].count
})
// 只保留前100个请求数最多的路径或者请求数占总请求数1%以上的路径
threshold := totalRequests / 100 // 1%的阈值
if threshold < 10 {
threshold = 10 // 至少保留请求数>=10的路径
}
// 标记要删除的路径
for _, pi := range paths {
if len(paths)-len(pathsToRemove) <= 100 {
// 已经只剩下100个路径了不再删除
break
}
if pi.count < threshold {
pathsToRemove = append(pathsToRemove, pi.path)
}
}
// 删除标记的路径
for _, path := range pathsToRemove {
c.pathStats.Delete(path)
}
// 清理引用来源统计 - 类似地处理
var referersToRemove []string
var referersCount int
var totalRefererRequests int64
// 先收集所有引用来源及其请求数
type refererInfo struct {
referer string
count int64
}
var referers []refererInfo
c.refererStats.Range(func(key, value interface{}) bool {
referer := key.(string)
stats := value.(*models.PathMetrics)
count := stats.GetRequestCount()
referersCount++
totalRefererRequests += count
referers = append(referers, refererInfo{referer, count})
return true
})
// 按请求数排序
sort.Slice(referers, func(i, j int) bool {
return referers[i].count > referers[j].count
})
// 只保留前50个请求数最多的引用来源或者请求数占总请求数2%以上的引用来源
refThreshold := totalRefererRequests / 50 // 2%的阈值
if refThreshold < 5 {
refThreshold = 5 // 至少保留请求数>=5的引用来源
}
// 标记要删除的引用来源
for _, ri := range referers {
if len(referers)-len(referersToRemove) <= 50 {
// 已经只剩下50个引用来源了不再删除
break
}
if ri.count < refThreshold {
referersToRemove = append(referersToRemove, ri.referer)
}
}
// 删除标记的引用来源
for _, referer := range referersToRemove {
c.refererStats.Delete(referer)
}
// 清理带宽历史 - 只保留最近的记录
c.bandwidthStats.Lock()
if len(c.bandwidthStats.history) > 10 {
// 找出最旧的记录并删除
var oldestKeys []string
var oldestTimes []time.Time
for k := range c.bandwidthStats.history {
t, err := time.Parse("01-02 15:04", k)
if err != nil {
continue
}
oldestTimes = append(oldestTimes, t)
oldestKeys = append(oldestKeys, k)
}
// 按时间排序
sort.Slice(oldestKeys, func(i, j int) bool {
return oldestTimes[i].Before(oldestTimes[j])
})
// 删除最旧的记录只保留最近10条
for i := 0; i < len(oldestKeys)-10; i++ {
delete(c.bandwidthStats.history, oldestKeys[i])
}
}
c.bandwidthStats.Unlock()
// 强制进行一次GC
runtime.GC()
// 打印内存使用情况
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
log.Printf("[Metrics] 清理完成: 删除了 %d/%d 个路径, %d/%d 个引用来源, 当前内存使用: %s",
len(pathsToRemove), pathsCount,
len(referersToRemove), referersCount,
utils.FormatBytes(int64(mem.Alloc)))
}

View File

@ -2,25 +2,47 @@ package metrics
import (
"log"
"path/filepath"
"proxy-go/internal/config"
"time"
)
func Init(cfg *config.Config) error {
// 初始化收集器
var (
metricsStorage *MetricsStorage
)
// InitMetricsStorage 初始化指标存储服务
func InitMetricsStorage(cfg *config.Config) error {
// 确保收集器已初始化
if err := InitCollector(cfg); err != nil {
log.Printf("[Metrics] 初始化收集器失败: %v", err)
//继续运行
return err
}
// 初始化指标存储服务
if err := InitMetricsStorage(cfg); err != nil {
log.Printf("[Metrics] 初始化指标存储服务失败: %v", 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)
return err
}
log.Printf("[Metrics] 初始化完成")
log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval)
return nil
}
// StopMetricsStorage 停止指标存储服务
func StopMetricsStorage() {
if metricsStorage != nil {
metricsStorage.Stop()
log.Printf("[Metrics] 指标存储服务已停止")
}
}
// GetMetricsStorage 获取指标存储服务实例
func GetMetricsStorage() *MetricsStorage {
return metricsStorage
}

View File

@ -1,44 +0,0 @@
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
}

View File

@ -6,9 +6,9 @@ import (
"log"
"os"
"path/filepath"
"proxy-go/internal/models"
"proxy-go/internal/utils"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
@ -16,14 +16,17 @@ import (
// MetricsStorage 指标存储结构
type MetricsStorage struct {
collector *Collector
saveInterval time.Duration
dataDir string
stopChan chan struct{}
wg sync.WaitGroup
lastSaveTime time.Time
mutex sync.RWMutex
statusCodeFile string
collector *Collector
saveInterval time.Duration
dataDir string
stopChan chan struct{}
wg sync.WaitGroup
lastSaveTime time.Time
mutex sync.RWMutex
metricsFile string
pathStatsFile string
statusCodeFile string
refererStatsFile string
}
// NewMetricsStorage 创建新的指标存储
@ -33,11 +36,14 @@ func NewMetricsStorage(collector *Collector, dataDir string, saveInterval time.D
}
return &MetricsStorage{
collector: collector,
saveInterval: saveInterval,
dataDir: dataDir,
stopChan: make(chan struct{}),
statusCodeFile: filepath.Join(dataDir, "status_codes.json"),
collector: collector,
saveInterval: saveInterval,
dataDir: dataDir,
stopChan: make(chan struct{}),
metricsFile: filepath.Join(dataDir, "metrics.json"),
pathStatsFile: filepath.Join(dataDir, "path_stats.json"),
statusCodeFile: filepath.Join(dataDir, "status_codes.json"),
refererStatsFile: filepath.Join(dataDir, "referer_stats.json"),
}
}
@ -100,12 +106,40 @@ func (ms *MetricsStorage) SaveMetrics() error {
// 获取当前指标数据
stats := ms.collector.GetStats()
// 保存基本指标 - 只保存必要的字段
basicMetrics := map[string]interface{}{
"uptime": stats["uptime"],
"total_bytes": stats["total_bytes"],
"avg_response_time": stats["avg_response_time"],
"save_time": time.Now().Format(time.RFC3339),
}
// 单独保存延迟统计,避免嵌套结构导致的内存占用
if latencyStats, ok := stats["latency_stats"].(map[string]interface{}); ok {
basicMetrics["latency_min"] = latencyStats["min"]
basicMetrics["latency_max"] = latencyStats["max"]
}
if err := saveJSONToFile(ms.metricsFile, basicMetrics); err != nil {
return fmt.Errorf("保存基本指标失败: %v", err)
}
// 保存路径统计 - 限制数量
topPaths := stats["top_paths"]
if err := saveJSONToFile(ms.pathStatsFile, topPaths); err != nil {
return fmt.Errorf("保存路径统计失败: %v", err)
}
// 保存状态码统计
if err := saveJSONToFile(ms.statusCodeFile, stats["status_code_stats"]); err != nil {
return fmt.Errorf("保存状态码统计失败: %v", err)
}
// 不再保存引用来源统计,因为它现在只保存在内存中
// 保存引用来源统计 - 限制数量
topReferers := stats["top_referers"]
if err := saveJSONToFile(ms.refererStatsFile, topReferers); err != nil {
return fmt.Errorf("保存引用来源统计失败: %v", err)
}
// 单独保存延迟分布
if latencyStats, ok := stats["latency_stats"].(map[string]interface{}); ok {
@ -116,6 +150,10 @@ func (ms *MetricsStorage) SaveMetrics() error {
}
}
ms.mutex.Lock()
ms.lastSaveTime = time.Now()
ms.mutex.Unlock()
// 强制进行一次GC
runtime.GC()
@ -133,79 +171,157 @@ func (ms *MetricsStorage) LoadMetrics() error {
start := time.Now()
log.Printf("[MetricsStorage] 开始加载指标数据...")
// 不再加载 basicMetricsmetrics.json
// 检查文件是否存在
if !fileExists(ms.metricsFile) {
return fmt.Errorf("指标数据文件不存在")
}
// 1. 加载状态码统计(如果文件存在)
// 加载基本指标
var basicMetrics map[string]interface{}
if err := loadJSONFromFile(ms.metricsFile, &basicMetrics); err != nil {
return fmt.Errorf("加载基本指标失败: %v", err)
}
// 将加载的数据应用到收集器
// 1. 应用总字节数
if totalBytes, ok := basicMetrics["total_bytes"].(float64); ok {
atomic.StoreInt64(&ms.collector.totalBytes, int64(totalBytes))
}
// 2. 加载路径统计(如果文件存在)
if fileExists(ms.pathStatsFile) {
var pathStats []map[string]interface{}
if err := loadJSONFromFile(ms.pathStatsFile, &pathStats); err != nil {
log.Printf("[MetricsStorage] 加载路径统计失败: %v", err)
} else {
// 只加载前10个路径统计
maxPaths := 10
if len(pathStats) > maxPaths {
pathStats = pathStats[:maxPaths]
}
for _, pathStat := range pathStats {
path, ok := pathStat["path"].(string)
if !ok {
continue
}
requestCount, _ := pathStat["request_count"].(float64)
errorCount, _ := pathStat["error_count"].(float64)
bytesTransferred, _ := pathStat["bytes_transferred"].(float64)
// 创建或更新路径统计
var pathMetrics *models.PathMetrics
if existingMetrics, ok := ms.collector.pathStats.Load(path); ok {
pathMetrics = existingMetrics.(*models.PathMetrics)
} else {
pathMetrics = &models.PathMetrics{Path: path}
ms.collector.pathStats.Store(path, pathMetrics)
}
// 设置统计值
pathMetrics.RequestCount.Store(int64(requestCount))
pathMetrics.ErrorCount.Store(int64(errorCount))
pathMetrics.BytesTransferred.Store(int64(bytesTransferred))
}
log.Printf("[MetricsStorage] 加载了 %d 条路径统计", len(pathStats))
}
}
// 3. 加载状态码统计(如果文件存在)
if fileExists(ms.statusCodeFile) {
var statusCodeStats map[string]interface{}
if err := loadJSONFromFile(ms.statusCodeFile, &statusCodeStats); err != nil {
log.Printf("[MetricsStorage] 加载状态码统计失败: %v", err)
} else {
// 由于新的 StatusCodeStats 结构,我们需要手动设置值
loadedCount := 0
for codeStr, countValue := range statusCodeStats {
// 解析状态码
if code, err := strconv.Atoi(codeStr); err == nil {
// 解析计数值
var count int64
switch v := countValue.(type) {
case float64:
count = int64(v)
case int64:
count = v
case int:
count = int64(v)
default:
continue
}
for statusCode, count := range statusCodeStats {
countValue, ok := count.(float64)
if !ok {
continue
}
// 手动设置到新的 StatusCodeStats 结构中
ms.collector.statusCodeStats.mu.Lock()
if _, exists := ms.collector.statusCodeStats.stats[code]; !exists {
ms.collector.statusCodeStats.stats[code] = new(int64)
}
atomic.StoreInt64(ms.collector.statusCodeStats.stats[code], count)
ms.collector.statusCodeStats.mu.Unlock()
loadedCount++
// 创建或更新状态码统计
if counter, ok := ms.collector.statusCodeStats.Load(statusCode); ok {
atomic.StoreInt64(counter.(*int64), int64(countValue))
} else {
counter := new(int64)
*counter = int64(countValue)
ms.collector.statusCodeStats.Store(statusCode, counter)
}
}
log.Printf("[MetricsStorage] 成功加载了 %d 条状态码统计", loadedCount)
log.Printf("[MetricsStorage] 加载了 %d 条状态码统计", len(statusCodeStats))
}
}
// 不再加载引用来源统计,因为它现在只保存在内存中
// 4. 加载引用来源统计(如果文件存在)
if fileExists(ms.refererStatsFile) {
var refererStats []map[string]interface{}
if err := loadJSONFromFile(ms.refererStatsFile, &refererStats); err != nil {
log.Printf("[MetricsStorage] 加载引用来源统计失败: %v", err)
} else {
// 只加载前10个引用来源统计
maxReferers := 10
if len(refererStats) > maxReferers {
refererStats = refererStats[:maxReferers]
}
// 3. 加载延迟分布(如果文件存在)
for _, refererStat := range refererStats {
referer, ok := refererStat["path"].(string)
if !ok {
continue
}
requestCount, _ := refererStat["request_count"].(float64)
errorCount, _ := refererStat["error_count"].(float64)
bytesTransferred, _ := refererStat["bytes_transferred"].(float64)
// 创建或更新引用来源统计
var refererMetrics *models.PathMetrics
if existingMetrics, ok := ms.collector.refererStats.Load(referer); ok {
refererMetrics = existingMetrics.(*models.PathMetrics)
} else {
refererMetrics = &models.PathMetrics{Path: referer}
ms.collector.refererStats.Store(referer, refererMetrics)
}
// 设置统计值
refererMetrics.RequestCount.Store(int64(requestCount))
refererMetrics.ErrorCount.Store(int64(errorCount))
refererMetrics.BytesTransferred.Store(int64(bytesTransferred))
}
log.Printf("[MetricsStorage] 加载了 %d 条引用来源统计", len(refererStats))
}
}
// 5. 加载延迟分布(如果文件存在)
latencyDistributionFile := filepath.Join(ms.dataDir, "latency_distribution.json")
if fileExists(latencyDistributionFile) {
var distribution map[string]interface{}
if err := loadJSONFromFile(latencyDistributionFile, &distribution); err != nil {
log.Printf("[MetricsStorage] 加载延迟分布失败: %v", err)
} else {
// 由于新的 LatencyBuckets 结构,我们需要手动设置值
for bucket, count := range distribution {
countValue, ok := count.(float64)
if !ok {
continue
}
// 根据桶名称设置对应的值
switch bucket {
case "lt10ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.lt10ms, int64(countValue))
case "10-50ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.ms10_50, int64(countValue))
case "50-200ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.ms50_200, int64(countValue))
case "200-1000ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.ms200_1000, int64(countValue))
case "gt1s":
atomic.StoreInt64(&ms.collector.latencyBuckets.gt1s, int64(countValue))
if counter, ok := ms.collector.latencyBuckets.Load(bucket); ok {
atomic.StoreInt64(counter.(*int64), int64(countValue))
}
}
log.Printf("[MetricsStorage] 加载了延迟分布数据")
}
}
ms.mutex.Lock()
if saveTime, ok := basicMetrics["save_time"].(string); ok {
if t, err := time.Parse(time.RFC3339, saveTime); err == nil {
ms.lastSaveTime = t
}
}
ms.mutex.Unlock()
// 强制进行一次GC
runtime.GC()

View File

@ -1,86 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"proxy-go/internal/security"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
)
// SecurityMiddleware 安全中间件
type SecurityMiddleware struct {
banManager *security.IPBanManager
}
// NewSecurityMiddleware 创建安全中间件
func NewSecurityMiddleware(banManager *security.IPBanManager) *SecurityMiddleware {
return &SecurityMiddleware{
banManager: banManager,
}
}
// IPBanMiddleware IP封禁中间件
func (sm *SecurityMiddleware) IPBanMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := iputil.GetClientIP(r)
// 检查IP是否被封禁
if sm.banManager.IsIPBanned(clientIP) {
banned, banEndTime := sm.banManager.GetBanInfo(clientIP)
if banned {
// 返回429状态码和封禁信息
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", time.Until(banEndTime).Seconds()))
w.WriteHeader(http.StatusTooManyRequests)
remainingTime := time.Until(banEndTime)
response := fmt.Sprintf(`{
"error": "IP temporarily banned due to excessive 404 errors",
"message": "您的IP因频繁访问不存在的资源而被暂时封禁",
"ban_end_time": "%s",
"remaining_seconds": %.0f
}`, banEndTime.Format("2006-01-02 15:04:05"), remainingTime.Seconds())
w.Write([]byte(response))
return
}
}
// 创建响应写入器包装器来捕获状态码
wrapper := &responseWrapper{
ResponseWriter: w,
statusCode: http.StatusOK,
}
// 继续处理请求
next.ServeHTTP(wrapper, r)
// 如果响应是404记录错误
if wrapper.statusCode == http.StatusNotFound {
sm.banManager.RecordError(clientIP)
}
})
}
// responseWrapper 响应包装器,用于捕获状态码
type responseWrapper struct {
http.ResponseWriter
statusCode int
}
// WriteHeader 重写WriteHeader方法来捕获状态码
func (rw *responseWrapper) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Write 重写Write方法确保状态码被正确设置
func (rw *responseWrapper) Write(b []byte) (int, error) {
// 如果还没有设置状态码默认为200
if rw.statusCode == 0 {
rw.statusCode = http.StatusOK
}
return rw.ResponseWriter.Write(b)
}

View File

@ -19,7 +19,6 @@ type PathMetrics struct {
TotalLatency atomic.Int64 `json:"-"`
BytesTransferred atomic.Int64 `json:"bytes_transferred"`
AvgLatency string `json:"avg_latency"`
LastAccessTime atomic.Int64 `json:"last_access_time"` // 最后访问时间戳
}
// PathMetricsJSON 用于 JSON 序列化的路径统计信息
@ -29,7 +28,6 @@ type PathMetricsJSON struct {
ErrorCount int64 `json:"error_count"`
BytesTransferred int64 `json:"bytes_transferred"`
AvgLatency string `json:"avg_latency"`
LastAccessTime int64 `json:"last_access_time"` // 最后访问时间戳
}
// GetRequestCount 获取请求数
@ -79,8 +77,7 @@ func (p *PathMetrics) ToJSON() PathMetricsJSON {
RequestCount: p.RequestCount.Load(),
ErrorCount: p.ErrorCount.Load(),
BytesTransferred: p.BytesTransferred.Load(),
AvgLatency: p.AvgLatency,
LastAccessTime: p.LastAccessTime.Load(),
AvgLatency: p.AvgLatency,
}
}

View File

@ -1,278 +0,0 @@
package security
import (
"log"
"sync"
"time"
)
// IPBanManager IP封禁管理器
type IPBanManager struct {
// 404错误计数器 map[ip]count
errorCounts sync.Map
// IP封禁列表 map[ip]banEndTime
bannedIPs sync.Map
// 配置参数
config *IPBanConfig
// 清理任务停止信号
stopCleanup chan struct{}
// 清理任务等待组
cleanupWG sync.WaitGroup
}
// IPBanConfig IP封禁配置
type IPBanConfig struct {
// 404错误阈值超过此数量将被封禁
ErrorThreshold int `json:"error_threshold"`
// 统计窗口时间(分钟)
WindowMinutes int `json:"window_minutes"`
// 封禁时长(分钟)
BanDurationMinutes int `json:"ban_duration_minutes"`
// 清理间隔(分钟)
CleanupIntervalMinutes int `json:"cleanup_interval_minutes"`
}
// errorRecord 错误记录
type errorRecord struct {
count int
firstTime time.Time
lastTime time.Time
}
// DefaultIPBanConfig 默认配置
func DefaultIPBanConfig() *IPBanConfig {
return &IPBanConfig{
ErrorThreshold: 10, // 10次404错误
WindowMinutes: 5, // 5分钟内
BanDurationMinutes: 5, // 封禁5分钟
CleanupIntervalMinutes: 1, // 每分钟清理一次
}
}
// NewIPBanManager 创建IP封禁管理器
func NewIPBanManager(config *IPBanConfig) *IPBanManager {
if config == nil {
config = DefaultIPBanConfig()
}
manager := &IPBanManager{
config: config,
stopCleanup: make(chan struct{}),
}
// 启动清理任务
manager.startCleanupTask()
log.Printf("[Security] IP封禁管理器已启动 - 阈值: %d次/%.0f分钟, 封禁时长: %.0f分钟",
config.ErrorThreshold,
float64(config.WindowMinutes),
float64(config.BanDurationMinutes))
return manager
}
// RecordError 记录404错误
func (m *IPBanManager) RecordError(ip string) {
now := time.Now()
windowStart := now.Add(-time.Duration(m.config.WindowMinutes) * time.Minute)
// 加载或创建错误记录
value, _ := m.errorCounts.LoadOrStore(ip, &errorRecord{
count: 0,
firstTime: now,
lastTime: now,
})
record := value.(*errorRecord)
// 如果第一次记录时间超出窗口,重置计数
if record.firstTime.Before(windowStart) {
record.count = 1
record.firstTime = now
record.lastTime = now
} else {
record.count++
record.lastTime = now
}
// 检查是否需要封禁
if record.count >= m.config.ErrorThreshold {
m.banIP(ip, now)
// 重置计数器,避免重复封禁
record.count = 0
record.firstTime = now
}
log.Printf("[Security] 记录404错误 IP: %s, 当前计数: %d/%d (窗口: %.0f分钟)",
ip, record.count, m.config.ErrorThreshold, float64(m.config.WindowMinutes))
}
// banIP 封禁IP
func (m *IPBanManager) banIP(ip string, banTime time.Time) {
banEndTime := banTime.Add(time.Duration(m.config.BanDurationMinutes) * time.Minute)
m.bannedIPs.Store(ip, banEndTime)
log.Printf("[Security] IP已被封禁: %s, 封禁至: %s (%.0f分钟)",
ip, banEndTime.Format("15:04:05"), float64(m.config.BanDurationMinutes))
}
// IsIPBanned 检查IP是否被封禁
func (m *IPBanManager) IsIPBanned(ip string) bool {
value, exists := m.bannedIPs.Load(ip)
if !exists {
return false
}
banEndTime := value.(time.Time)
now := time.Now()
// 检查封禁是否已过期
if now.After(banEndTime) {
m.bannedIPs.Delete(ip)
log.Printf("[Security] IP封禁已过期自动解封: %s", ip)
return false
}
return true
}
// GetBanInfo 获取IP封禁信息
func (m *IPBanManager) GetBanInfo(ip string) (bool, time.Time) {
value, exists := m.bannedIPs.Load(ip)
if !exists {
return false, time.Time{}
}
banEndTime := value.(time.Time)
now := time.Now()
if now.After(banEndTime) {
m.bannedIPs.Delete(ip)
return false, time.Time{}
}
return true, banEndTime
}
// UnbanIP 手动解封IP
func (m *IPBanManager) UnbanIP(ip string) bool {
_, exists := m.bannedIPs.Load(ip)
if exists {
m.bannedIPs.Delete(ip)
log.Printf("[Security] 手动解封IP: %s", ip)
return true
}
return false
}
// GetBannedIPs 获取所有被封禁的IP列表
func (m *IPBanManager) GetBannedIPs() map[string]time.Time {
result := make(map[string]time.Time)
now := time.Now()
m.bannedIPs.Range(func(key, value interface{}) bool {
ip := key.(string)
banEndTime := value.(time.Time)
// 清理过期的封禁
if now.After(banEndTime) {
m.bannedIPs.Delete(ip)
} else {
result[ip] = banEndTime
}
return true
})
return result
}
// GetStats 获取统计信息
func (m *IPBanManager) GetStats() map[string]interface{} {
bannedCount := 0
errorRecordCount := 0
m.bannedIPs.Range(func(key, value interface{}) bool {
bannedCount++
return true
})
m.errorCounts.Range(func(key, value interface{}) bool {
errorRecordCount++
return true
})
return map[string]interface{}{
"banned_ips_count": bannedCount,
"error_records_count": errorRecordCount,
"config": m.config,
}
}
// startCleanupTask 启动清理任务
func (m *IPBanManager) startCleanupTask() {
m.cleanupWG.Add(1)
go func() {
defer m.cleanupWG.Done()
ticker := time.NewTicker(time.Duration(m.config.CleanupIntervalMinutes) * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.cleanup()
case <-m.stopCleanup:
return
}
}
}()
}
// cleanup 清理过期数据
func (m *IPBanManager) cleanup() {
now := time.Now()
windowStart := now.Add(-time.Duration(m.config.WindowMinutes) * time.Minute)
// 清理过期的错误记录
var expiredIPs []string
m.errorCounts.Range(func(key, value interface{}) bool {
ip := key.(string)
record := value.(*errorRecord)
// 如果最后一次错误时间超出窗口,删除记录
if record.lastTime.Before(windowStart) {
expiredIPs = append(expiredIPs, ip)
}
return true
})
for _, ip := range expiredIPs {
m.errorCounts.Delete(ip)
}
// 清理过期的封禁记录
var expiredBans []string
m.bannedIPs.Range(func(key, value interface{}) bool {
ip := key.(string)
banEndTime := value.(time.Time)
if now.After(banEndTime) {
expiredBans = append(expiredBans, ip)
}
return true
})
for _, ip := range expiredBans {
m.bannedIPs.Delete(ip)
}
if len(expiredIPs) > 0 || len(expiredBans) > 0 {
log.Printf("[Security] 清理任务完成 - 清理错误记录: %d, 清理过期封禁: %d",
len(expiredIPs), len(expiredBans))
}
}
// Stop 停止IP封禁管理器
func (m *IPBanManager) Stop() {
close(m.stopCleanup)
m.cleanupWG.Wait()
log.Printf("[Security] IP封禁管理器已停止")
}

View File

@ -1,240 +0,0 @@
package service
import (
"fmt"
"log"
"net/http"
"proxy-go/internal/config"
"proxy-go/internal/utils"
"strings"
)
// RuleService 规则选择服务
type RuleService struct {
cacheManager CacheManager
}
// CacheManager 缓存管理器接口
type CacheManager interface {
GetExtensionMatcher(pathKey string, rules []config.ExtensionRule) *utils.ExtensionMatcher
}
// NewRuleService 创建规则选择服务
func NewRuleService(cacheManager CacheManager) *RuleService {
return &RuleService{
cacheManager: cacheManager,
}
}
// SelectBestRule 选择最合适的规则
func (rs *RuleService) SelectBestRule(client *http.Client, pathConfig config.PathConfig, path string, requestHost string) (*config.ExtensionRule, bool, bool) {
// 如果没有扩展名规则返回nil
if len(pathConfig.ExtRules) == 0 {
return nil, false, false
}
// 提取扩展名
ext := extractExtension(path)
var matcher *utils.ExtensionMatcher
// 尝试使用缓存管理器
if rs.cacheManager != nil {
pathKey := fmt.Sprintf("path_%p", &pathConfig)
matcher = rs.cacheManager.GetExtensionMatcher(pathKey, pathConfig.ExtRules)
} else {
// 直接创建新的匹配器
matcher = utils.NewExtensionMatcher(pathConfig.ExtRules)
}
// 获取匹配的规则
matchingRules := matcher.GetMatchingRules(ext)
if len(matchingRules) == 0 {
return nil, false, false
}
// 过滤符合域名条件的规则
var domainMatchingRules []*config.ExtensionRule
for _, rule := range matchingRules {
if rs.isDomainMatching(rule, requestHost) {
domainMatchingRules = append(domainMatchingRules, rule)
}
}
// 如果没有域名匹配的规则返回nil
if len(domainMatchingRules) == 0 {
log.Printf("[SelectRule] %s -> 没有找到匹配域名 %s 的扩展名规则", path, requestHost)
return nil, false, false
}
// 检查是否需要获取文件大小
// 如果所有匹配的规则都没有设置大小阈值(都是默认值),则跳过文件大小检查
needSizeCheck := false
for _, rule := range domainMatchingRules {
if rule.SizeThreshold > 0 || rule.MaxSize < (1<<63-1) {
needSizeCheck = true
break
}
}
if !needSizeCheck {
// 不需要检查文件大小,直接使用第一个匹配的规则
for _, rule := range domainMatchingRules {
if utils.IsTargetAccessible(client, rule.Target+path) {
log.Printf("[SelectRule] %s -> 选中规则 (域名: %s, 跳过大小检查)", path, requestHost)
return rule, true, true
}
}
return nil, false, false
}
// 获取文件大小(使用同步检查)
contentLength, err := utils.GetFileSize(client, pathConfig.DefaultTarget+path)
if err != nil {
log.Printf("[SelectRule] %s -> 获取文件大小出错: %v使用宽松模式回退", path, err)
// 宽松模式:如果无法获取文件大小,尝试使用第一个匹配的规则
for _, rule := range domainMatchingRules {
if utils.IsTargetAccessible(client, rule.Target+path) {
log.Printf("[SelectRule] %s -> 使用宽松模式选中规则 (域名: %s, 跳过大小检查)", path, requestHost)
return rule, true, true
}
}
return nil, false, false
}
// 根据文件大小找出最匹配的规则(规则已经预排序)
for _, rule := range domainMatchingRules {
// 检查文件大小是否在阈值范围内
if contentLength >= rule.SizeThreshold && contentLength <= rule.MaxSize {
// 找到匹配的规则
log.Printf("[SelectRule] %s -> 选中规则 (域名: %s, 文件大小: %s, 在区间 %s 到 %s 之间)",
path, requestHost, utils.FormatBytes(contentLength),
utils.FormatBytes(rule.SizeThreshold), utils.FormatBytes(rule.MaxSize))
// 检查目标是否可访问
if utils.IsTargetAccessible(client, rule.Target+path) {
return rule, true, true
} else {
log.Printf("[SelectRule] %s -> 规则目标不可访问,继续查找", path)
// 继续查找下一个匹配的规则
continue
}
}
}
// 没有找到合适的规则
return nil, false, false
}
// isDomainMatching 检查规则的域名是否匹配请求的域名
func (rs *RuleService) isDomainMatching(rule *config.ExtensionRule, requestHost string) bool {
// 如果规则没有指定域名,则匹配所有域名
if len(rule.Domains) == 0 {
return true
}
// 提取请求域名(去除端口号)
host := requestHost
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
host = host[:colonIndex]
}
// 检查是否匹配任一指定的域名
for _, domain := range rule.Domains {
if strings.EqualFold(host, domain) {
return true
}
}
return false
}
// RuleSelectionResult 规则选择结果
type RuleSelectionResult struct {
Rule *config.ExtensionRule
Found bool
UsedAltTarget bool
TargetURL string
ShouldRedirect bool
}
// SelectRuleForRedirect 专门为302跳转优化的规则选择函数
func (rs *RuleService) SelectRuleForRedirect(client *http.Client, pathConfig config.PathConfig, path string, requestHost string) *RuleSelectionResult {
result := &RuleSelectionResult{}
// 快速检查如果没有任何302跳转配置直接返回
if !pathConfig.RedirectMode && len(pathConfig.ExtRules) == 0 {
return result
}
// 优先检查扩展名规则即使根级别配置了302跳转
if len(pathConfig.ExtRules) > 0 {
// 尝试选择最佳规则(包括文件大小检测)
if rule, found, usedAlt := rs.SelectBestRule(client, pathConfig, path, requestHost); found && rule != nil && rule.RedirectMode {
result.Rule = rule
result.Found = found
result.UsedAltTarget = usedAlt
result.ShouldRedirect = true
result.TargetURL = rule.Target
return result
}
// 注意:这里不再进行"忽略大小"的回退匹配
// 如果SelectBestRule没有找到合适的规则说明
// 1. 扩展名不匹配,或者
// 2. 扩展名匹配但文件大小不在配置范围内,或者
// 3. 无法获取文件大小,或者
// 4. 目标服务器不可访问,或者
// 5. 域名不匹配
// 在这些情况下,我们不应该强制使用扩展名规则
}
// 如果没有匹配的扩展名规则且默认目标配置了302跳转使用默认目标
if pathConfig.RedirectMode {
result.Found = true
result.ShouldRedirect = true
result.TargetURL = pathConfig.DefaultTarget
return result
}
return result
}
// GetTargetURL 根据路径和配置决定目标URL
func (rs *RuleService) GetTargetURL(client *http.Client, r *http.Request, pathConfig config.PathConfig, path string) (string, bool) {
// 默认使用默认目标
targetBase := pathConfig.DefaultTarget
usedAltTarget := false
// 如果没有扩展名规则,直接返回默认目标
if len(pathConfig.ExtRules) == 0 {
ext := extractExtension(path)
if ext == "" {
log.Printf("[Route] %s -> %s (无扩展名)", path, targetBase)
}
return targetBase, false
}
// 使用严格的规则选择逻辑
rule, found, usedAlt := rs.SelectBestRule(client, pathConfig, path, r.Host)
if found && rule != nil {
targetBase = rule.Target
usedAltTarget = usedAlt
log.Printf("[Route] %s -> %s (使用选中的规则)", path, targetBase)
} else {
// 如果没有找到合适的规则,使用默认目标
// 不再进行"基于扩展名直接匹配"的回退
log.Printf("[Route] %s -> %s (使用默认目标,扩展名规则不匹配)", path, targetBase)
}
return targetBase, usedAltTarget
}
// extractExtension 提取文件扩展名
func extractExtension(path string) string {
lastDotIndex := strings.LastIndex(path, ".")
if lastDotIndex > 0 && lastDotIndex < len(path)-1 {
return strings.ToLower(path[lastDotIndex+1:])
}
return ""
}

32
internal/utils/signal.go Normal file
View File

@ -0,0 +1,32 @@
package utils
import (
"os"
"os/signal"
"sync"
"syscall"
)
func SetupCloseHandler(callback func()) {
c := make(chan os.Signal, 1)
done := make(chan bool, 1)
var once sync.Once
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
once.Do(func() {
callback()
done <- true
})
}()
go func() {
select {
case <-done:
os.Exit(0)
case <-c:
os.Exit(1)
}
}()
}

View File

@ -6,211 +6,80 @@ import (
"encoding/hex"
"fmt"
"log"
"net"
"net/http"
neturl "net/url"
"path/filepath"
"proxy-go/internal/config"
"runtime"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Goroutine 池相关结构
type GoroutinePool struct {
maxWorkers int
taskQueue chan func()
wg sync.WaitGroup
once sync.Once
stopped int32
}
// 全局 goroutine 池
var (
globalPool *GoroutinePool
poolOnce sync.Once
defaultWorkers = runtime.NumCPU() * 4 // 默认工作协程数量
)
// GetGoroutinePool 获取全局 goroutine 池
func GetGoroutinePool() *GoroutinePool {
poolOnce.Do(func() {
globalPool = NewGoroutinePool(defaultWorkers)
})
return globalPool
}
// NewGoroutinePool 创建新的 goroutine 池
func NewGoroutinePool(maxWorkers int) *GoroutinePool {
if maxWorkers <= 0 {
maxWorkers = runtime.NumCPU() * 2
}
pool := &GoroutinePool{
maxWorkers: maxWorkers,
taskQueue: make(chan func(), maxWorkers*10), // 缓冲区为工作协程数的10倍
}
// 启动工作协程
for i := 0; i < maxWorkers; i++ {
pool.wg.Add(1)
go pool.worker()
}
return pool
}
// worker 工作协程
func (p *GoroutinePool) worker() {
defer p.wg.Done()
for {
select {
case task, ok := <-p.taskQueue:
if !ok {
return // 通道关闭,退出
}
// 执行任务,捕获 panic
func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[GoroutinePool] Worker panic: %v\n", r)
}
}()
task()
}()
}
}
}
// Submit 提交任务到池中
func (p *GoroutinePool) Submit(task func()) error {
if atomic.LoadInt32(&p.stopped) == 1 {
return fmt.Errorf("goroutine pool is stopped")
}
select {
case p.taskQueue <- task:
return nil
case <-time.After(100 * time.Millisecond): // 100ms 超时
return fmt.Errorf("goroutine pool is busy")
}
}
// SubmitWithTimeout 提交任务到池中,带超时
func (p *GoroutinePool) SubmitWithTimeout(task func(), timeout time.Duration) error {
if atomic.LoadInt32(&p.stopped) == 1 {
return fmt.Errorf("goroutine pool is stopped")
}
select {
case p.taskQueue <- task:
return nil
case <-time.After(timeout):
return fmt.Errorf("goroutine pool submit timeout")
}
}
// Stop 停止 goroutine 池
func (p *GoroutinePool) Stop() {
p.once.Do(func() {
atomic.StoreInt32(&p.stopped, 1)
close(p.taskQueue)
p.wg.Wait()
})
}
// Size 返回池中工作协程数量
func (p *GoroutinePool) Size() int {
return p.maxWorkers
}
// QueueSize 返回当前任务队列大小
func (p *GoroutinePool) QueueSize() int {
return len(p.taskQueue)
}
// 异步执行函数的包装器
func GoSafe(fn func()) {
pool := GetGoroutinePool()
err := pool.Submit(fn)
if err != nil {
// 如果池满了,直接启动 goroutine降级处理
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[GoSafe] Panic: %v\n", r)
}
}()
fn()
}()
}
}
// 带超时的异步执行
func GoSafeWithTimeout(fn func(), timeout time.Duration) error {
pool := GetGoroutinePool()
return pool.SubmitWithTimeout(fn, timeout)
}
// 文件大小缓存相关
// 文件大小缓存项
type fileSizeCache struct {
size int64
timestamp time.Time
}
// 可访问性缓存项
type accessibilityCache struct {
accessible bool
timestamp time.Time
}
// 全局缓存
var (
sizeCache sync.Map
accessCache sync.Map
cacheTTL = 5 * time.Minute
accessTTL = 2 * time.Minute
// 文件大小缓存过期时间5分钟
sizeCache sync.Map
// 可访问性缓存过期时间30秒
accessCache sync.Map
cacheTTL = 5 * time.Minute
accessTTL = 30 * time.Second
maxCacheSize = 10000 // 最大缓存条目数
)
// 初始化函数
func init() {
// 启动定期清理缓存的协程
GoSafe(func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cleanExpiredCache()
}
})
}
// 清理过期缓存
func cleanExpiredCache() {
now := time.Now()
// 清理文件大小缓存
sizeCache.Range(func(key, value interface{}) bool {
if cache, ok := value.(fileSizeCache); ok {
if now.Sub(cache.timestamp) > cacheTTL {
sizeCache.Delete(key)
func init() {
go func() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
// 清理文件大小缓存
var items []struct {
key interface{}
timestamp time.Time
}
}
return true
})
// 清理可访问性缓存
accessCache.Range(func(key, value interface{}) bool {
if cache, ok := value.(accessibilityCache); ok {
if now.Sub(cache.timestamp) > accessTTL {
accessCache.Delete(key)
sizeCache.Range(func(key, value interface{}) bool {
cache := value.(fileSizeCache)
if now.Sub(cache.timestamp) > cacheTTL {
sizeCache.Delete(key)
} else {
items = append(items, struct {
key interface{}
timestamp time.Time
}{key, cache.timestamp})
}
return true
})
if len(items) > maxCacheSize {
sort.Slice(items, func(i, j int) bool {
return items[i].timestamp.Before(items[j].timestamp)
})
for i := 0; i < len(items)/2; i++ {
sizeCache.Delete(items[i].key)
}
}
// 清理可访问性缓存
accessCache.Range(func(key, value interface{}) bool {
cache := value.(accessibilityCache)
if now.Sub(cache.timestamp) > accessTTL {
accessCache.Delete(key)
}
return true
})
}
return true
})
}()
}
// GenerateRequestID 生成唯一的请求ID
@ -223,11 +92,21 @@ func GenerateRequestID() string {
return hex.EncodeToString(b)
}
func GetClientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return ip
}
return r.RemoteAddr
}
// 获取请求来源
func GetRequestSource(r *http.Request) string {
if r == nil {
return ""
}
referer := r.Header.Get("Referer")
if referer != "" {
return fmt.Sprintf(" (from: %s)", referer)
@ -265,7 +144,7 @@ func IsImageRequest(path string) bool {
return imageExts[ext]
}
// GetFileSize 发送HEAD请求获取文件大小(保持向后兼容)
// GetFileSize 发送HEAD请求获取文件大小
func GetFileSize(client *http.Client, url string) (int64, error) {
// 先查缓存
if cache, ok := sizeCache.Load(url); ok {
@ -303,122 +182,127 @@ func GetFileSize(client *http.Client, url string) (int64, error) {
return resp.ContentLength, nil
}
// ExtensionMatcher 扩展名匹配器,用于优化扩展名匹配性能
type ExtensionMatcher struct {
exactMatches map[string][]*config.ExtensionRule // 精确匹配的扩展名
wildcardRules []*config.ExtensionRule // 通配符规则
hasRedirectRule bool // 是否有任何302跳转规则
}
// GetTargetURL 根据路径和配置决定目标URL
func GetTargetURL(client *http.Client, r *http.Request, pathConfig config.PathConfig, path string) string {
// 默认使用默认目标
targetBase := pathConfig.DefaultTarget
// NewExtensionMatcher 创建扩展名匹配器
func NewExtensionMatcher(rules []config.ExtensionRule) *ExtensionMatcher {
matcher := &ExtensionMatcher{
exactMatches: make(map[string][]*config.ExtensionRule),
wildcardRules: make([]*config.ExtensionRule, 0),
}
for i := range rules {
rule := &rules[i]
// 处理阈值默认值
if rule.SizeThreshold < 0 {
rule.SizeThreshold = 0
}
if rule.MaxSize <= 0 {
rule.MaxSize = 1<<63 - 1
}
// 检查是否有302跳转规则
if rule.RedirectMode {
matcher.hasRedirectRule = true
}
// 分类存储规则
for _, ext := range rule.Extensions {
if ext == "*" {
matcher.wildcardRules = append(matcher.wildcardRules, rule)
} else {
if matcher.exactMatches[ext] == nil {
matcher.exactMatches[ext] = make([]*config.ExtensionRule, 0, 1)
// 如果配置了扩展名映射
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
}
matcher.exactMatches[ext] = append(matcher.exactMatches[ext], rule)
// 如果没有设置最小阈值,使用默认值 500KB
minThreshold := pathConfig.SizeThreshold
if minThreshold <= 0 {
minThreshold = 500 * 1024
}
// 如果没有设置最大阈值,使用默认值 10MB
maxThreshold := pathConfig.MaxSize
if maxThreshold <= 0 {
maxThreshold = 10 * 1024 * 1024
}
if contentLength > minThreshold && contentLength <= maxThreshold {
// 创建一个带超时的 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, altTarget+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 (size: %s > %s and <= %s)",
path, altTarget, FormatBytes(contentLength),
FormatBytes(minThreshold), FormatBytes(maxThreshold))
return altTarget
}
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)
}
} else {
log.Printf("[Route] %s -> %s (no extension)", path, targetBase)
}
} else {
log.Printf("[Route] %s -> %s (no extension map)", path, targetBase)
}
// 预排序所有规则组
for ext := range matcher.exactMatches {
sortRulesByThreshold(matcher.exactMatches[ext])
}
sortRulesByThreshold(matcher.wildcardRules)
return matcher
return targetBase
}
// sortRulesByThreshold 按阈值排序规则
func sortRulesByThreshold(rules []*config.ExtensionRule) {
sort.Slice(rules, func(i, j int) bool {
if rules[i].SizeThreshold == rules[j].SizeThreshold {
return rules[i].MaxSize > rules[j].MaxSize
}
return rules[i].SizeThreshold < rules[j].SizeThreshold
})
}
// GetMatchingRules 获取匹配的规则
func (em *ExtensionMatcher) GetMatchingRules(ext string) []*config.ExtensionRule {
// 先查找精确匹配
if rules, exists := em.exactMatches[ext]; exists {
return rules
}
// 返回通配符规则
return em.wildcardRules
}
// HasRedirectRule 检查是否有任何302跳转规则
func (em *ExtensionMatcher) HasRedirectRule() bool {
return em.hasRedirectRule
}
// IsTargetAccessible 检查目标URL是否可访问
func IsTargetAccessible(client *http.Client, targetURL string) bool {
// isTargetAccessible 检查目标URL是否可访问
func isTargetAccessible(client *http.Client, url string) bool {
// 先查缓存
if cache, ok := accessCache.Load(targetURL); ok {
if cache, ok := accessCache.Load(url); ok {
cacheItem := cache.(accessibilityCache)
if time.Since(cacheItem.timestamp) < accessTTL {
return cacheItem.accessible
}
accessCache.Delete(targetURL)
accessCache.Delete(url)
}
req, err := http.NewRequest("HEAD", targetURL, nil)
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
log.Printf("[Check] Failed to create request for %s: %v", targetURL, err)
log.Printf("[Check] Failed to create request for %s: %v", url, err)
return false
}
// 添加浏览器User-Agent
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
// 设置Referer为目标域名
if parsedURL, parseErr := neturl.Parse(targetURL); parseErr == nil {
req.Header.Set("Referer", fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host))
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
log.Printf("[Check] Failed to access %s: %v", targetURL, err)
log.Printf("[Check] Failed to access %s: %v", url, err)
return false
}
defer resp.Body.Close()
accessible := resp.StatusCode >= 200 && resp.StatusCode < 400
// 缓存结果
accessCache.Store(targetURL, accessibilityCache{
accessCache.Store(url, accessibilityCache{
accessible: accessible,
timestamp: time.Now(),
})
@ -459,26 +343,6 @@ func SafeString(v interface{}, defaultValue string) string {
return defaultValue
}
func SafeFloat64(v interface{}) float64 {
if v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case float32:
return float64(val)
case int64:
return float64(val)
case int:
return float64(val)
case int32:
return float64(val)
default:
return 0
}
}
// Max 返回两个 int64 中的较大值
func Max(a, b int64) int64 {
if a > b {
@ -504,29 +368,3 @@ func ParseInt(s string, defaultValue int) int {
}
return result
}
// ClearAccessibilityCache 清理可访问性缓存
func ClearAccessibilityCache() {
count := 0
accessCache.Range(func(key, value interface{}) bool {
accessCache.Delete(key)
count++
return true
})
if count > 0 {
log.Printf("[AccessibilityCache] 清理了 %d 个可访问性缓存项", count)
}
}
// ClearFileSizeCache 清理文件大小缓存
func ClearFileSizeCache() {
count := 0
sizeCache.Range(func(key, value interface{}) bool {
sizeCache.Delete(key)
count++
return true
})
if count > 0 {
log.Printf("[FileSizeCache] 清理了 %d 个文件大小缓存项", count)
}
}

121
main.go
View File

@ -10,12 +10,9 @@ 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"
"proxy-go/internal/security"
"strings"
"sync/atomic"
"syscall"
)
@ -28,72 +25,36 @@ type Route struct {
}
func main() {
// 初始化应用程序(包括配置迁移)
configPath := "data/config.json"
initapp.Init(configPath)
// 初始化配置管理器
configManager, err := config.Init(configPath)
// 加载配置
cfg, err := config.Load("data/config.json")
if err != nil {
log.Fatal("Error initializing config manager:", err)
log.Fatal("Error loading config:", err)
}
// 获取配置
cfg := configManager.GetConfig()
// 更新常量配置
constants.UpdateFromConfig(cfg)
// 初始化统计服务
metrics.Init(cfg)
// 初始化指标收集器
if err := metrics.InitCollector(cfg); err != nil {
log.Fatal("Error initializing metrics collector:", err)
}
// 创建压缩管理器使用atomic.Value来支持动态更新
var compManagerAtomic atomic.Value
// 初始化指标存储服务
if err := metrics.InitMetricsStorage(cfg); err != nil {
log.Printf("Warning: Failed to initialize metrics storage: %v", err)
// 不致命,继续运行
}
// 创建压缩管理器
compManager := compression.NewManager(compression.Config{
Gzip: compression.CompressorConfig(cfg.Compression.Gzip),
Brotli: compression.CompressorConfig(cfg.Compression.Brotli),
})
compManagerAtomic.Store(compManager)
// 创建安全管理器
var banManager *security.IPBanManager
var securityMiddleware *middleware.SecurityMiddleware
if cfg.Security.IPBan.Enabled {
banConfig := &security.IPBanConfig{
ErrorThreshold: cfg.Security.IPBan.ErrorThreshold,
WindowMinutes: cfg.Security.IPBan.WindowMinutes,
BanDurationMinutes: cfg.Security.IPBan.BanDurationMinutes,
CleanupIntervalMinutes: cfg.Security.IPBan.CleanupIntervalMinutes,
}
banManager = security.NewIPBanManager(banConfig)
securityMiddleware = middleware.NewSecurityMiddleware(banManager)
}
// 创建代理处理器
mirrorHandler := handler.NewMirrorProxyHandler()
proxyHandler := handler.NewProxyHandler(cfg)
// 创建配置处理器
configHandler := handler.NewConfigHandler(configManager)
// 创建安全管理处理器
var securityHandler *handler.SecurityHandler
if banManager != nil {
securityHandler = handler.NewSecurityHandler(banManager)
}
// 注册压缩配置更新回调
config.RegisterUpdateCallback(func(newCfg *config.Config) {
// 更新压缩管理器
newCompManager := compression.NewManager(compression.Config{
Gzip: compression.CompressorConfig(newCfg.Compression.Gzip),
Brotli: compression.CompressorConfig(newCfg.Compression.Brotli),
})
compManagerAtomic.Store(newCompManager)
log.Printf("[Config] 压缩管理器配置已更新")
})
// 定义API路由
apiRoutes := []Route{
{http.MethodGet, "/admin/api/auth", proxyHandler.LoginHandler, false},
@ -104,8 +65,8 @@ func main() {
}, true},
{http.MethodPost, "/admin/api/logout", proxyHandler.LogoutHandler, false},
{http.MethodGet, "/admin/api/metrics", proxyHandler.MetricsHandler, true},
{http.MethodGet, "/admin/api/config/get", configHandler.ServeHTTP, true},
{http.MethodPost, "/admin/api/config/save", configHandler.ServeHTTP, true},
{http.MethodGet, "/admin/api/config/get", handler.NewConfigHandler(cfg).ServeHTTP, true},
{http.MethodPost, "/admin/api/config/save", handler.NewConfigHandler(cfg).ServeHTTP, true},
{http.MethodGet, "/admin/api/cache/stats", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).GetCacheStats, true},
{http.MethodPost, "/admin/api/cache/enable", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).SetCacheEnabled, true},
{http.MethodPost, "/admin/api/cache/clear", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).ClearCache, true},
@ -113,41 +74,11 @@ func main() {
{http.MethodPost, "/admin/api/cache/config", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).UpdateCacheConfig, true},
}
// 添加安全API路由如果启用了安全功能
if securityHandler != nil {
securityRoutes := []Route{
{http.MethodGet, "/admin/api/security/banned-ips", securityHandler.GetBannedIPs, true},
{http.MethodPost, "/admin/api/security/unban", securityHandler.UnbanIP, true},
{http.MethodGet, "/admin/api/security/stats", securityHandler.GetSecurityStats, true},
{http.MethodGet, "/admin/api/security/check-ip", securityHandler.CheckIPStatus, true},
}
apiRoutes = append(apiRoutes, securityRoutes...)
}
// 创建路由处理器
handlers := []struct {
matcher func(*http.Request) bool
handler http.Handler
}{
// favicon.ico 处理器
{
matcher: func(r *http.Request) bool {
return r.URL.Path == "/favicon.ico"
},
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 检查是否有自定义favicon文件
faviconPath := "favicon/favicon.ico"
if _, err := os.Stat(faviconPath); err == nil {
// 设置正确的Content-Type和缓存头
w.Header().Set("Content-Type", "image/x-icon")
w.Header().Set("Cache-Control", "public, max-age=31536000") // 1年缓存
http.ServeFile(w, r, faviconPath)
} else {
// 如果没有自定义favicon返回404
http.NotFound(w, r)
}
}),
},
// 管理路由处理器
{
matcher: func(r *http.Request) bool {
@ -216,21 +147,10 @@ func main() {
http.NotFound(w, r)
})
// 构建中间件链
var handler http.Handler = mainHandler
// 添加安全中间件(最外层,优先级最高)
if securityMiddleware != nil {
handler = securityMiddleware.IPBanMiddleware(handler)
}
// 添加压缩中间件
var handler http.Handler = mainHandler
if cfg.Compression.Gzip.Enabled || cfg.Compression.Brotli.Enabled {
// 创建动态压缩中间件包装器
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentCompManager := compManagerAtomic.Load().(compression.Manager)
middleware.CompressionMiddleware(currentCompManager)(handler).ServeHTTP(w, r)
})
handler = middleware.CompressionMiddleware(compManager)(handler)
}
// 创建服务器
@ -246,11 +166,6 @@ func main() {
<-sigChan
log.Println("Shutting down server...")
// 停止安全管理器
if banManager != nil {
banManager.Stop()
}
// 停止指标存储服务
metrics.StopMetricsStorage()

144
readme.md
View File

@ -2,14 +2,21 @@
A 'simple' reverse proxy server written in Go.
使用方法: https://www.sunai.net/t/topic/165
使用方法: https://www.q58.club/t/topic/165
```
最新镜像地址: woodchen/proxy-go:latest
稳定版镜像地址: woodchen/proxy-go:v1.0.4
持续稳定版镜像地址: woodchen/proxy-go:stable
```
## 新版统计仪表盘
![image](https://github.com/user-attachments/assets/0b87863e-5566-4ee6-a3b7-94a994cdd572)
## 图片
![image](https://github.com/user-attachments/assets/99b1767f-9470-4838-a4eb-3ce70bbe2094)
@ -18,14 +25,19 @@ A 'simple' reverse proxy server written in Go.
![image](https://github.com/user-attachments/assets/e09d0eb1-e1bb-435b-8f90-b04bc474477b)
### 配置页
![image](https://github.com/user-attachments/assets/5acddc06-57f5-417c-9fec-87e906dc22af)
### 缓存页
![image](https://github.com/user-attachments/assets/6225b909-c5ff-4374-bb07-c472fbec791d)
## 说明
1. 支持gzip和brotli压缩
@ -36,135 +48,5 @@ A 'simple' reverse proxy server written in Go.
6. 适配Cloudflare Images的图片自适应功能, 透传`Accept`头, 支持`format=auto`
7. 支持网页端监控和管理
## 功能特性
- 🚀 **多路径代理**: 根据不同路径代理到不同的目标服务器
- 🔄 **扩展名规则**: 根据文件扩展名和大小智能选择目标服务器
- 🌐 **域名过滤**: 支持根据请求域名应用不同的扩展规则
- 📦 **压缩支持**: 支持Gzip和Brotli压缩
- 🎯 **302跳转**: 支持302跳转模式
- 📊 **缓存管理**: 智能缓存机制提升性能
- 📈 **监控指标**: 内置监控和指标收集
## 域名过滤功能
### 功能介绍
新增的域名过滤功能允许你为不同的请求域名配置不同的扩展规则。这在以下场景中非常有用:
1. **多域名服务**: 一个代理服务绑定多个域名(如 a.com 和 b.com
2. **差异化配置**: 不同域名使用不同的CDN或存储服务
3. **精细化控制**: 根据域名和文件类型组合进行精确路由
### 配置示例
```json
{
"MAP": {
"/images": {
"DefaultTarget": "https://default-cdn.com",
"ExtensionMap": [
{
"Extensions": "jpg,png,webp",
"Target": "https://a-domain-cdn.com",
"SizeThreshold": 1024,
"MaxSize": 2097152,
"Domains": "a.com",
"RedirectMode": false
},
{
"Extensions": "jpg,png,webp",
"Target": "https://b-domain-cdn.com",
"SizeThreshold": 1024,
"MaxSize": 2097152,
"Domains": "b.com",
"RedirectMode": true
},
{
"Extensions": "mp4,avi",
"Target": "https://video-cdn.com",
"SizeThreshold": 1048576,
"MaxSize": 52428800
// 不指定Domains对所有域名生效
}
]
}
}
}
```
### 使用场景
#### 场景1: 多域名图片CDN
```
请求: https://a.com/images/photo.jpg (1MB)
结果: 代理到 https://a-domain-cdn.com/photo.jpg
请求: https://b.com/images/photo.jpg (1MB)
结果: 302跳转到 https://b-domain-cdn.com/photo.jpg
请求: https://c.com/images/photo.jpg (1MB)
结果: 代理到 https://default-cdn.com/photo.jpg (使用默认目标)
```
#### 场景2: 域名+扩展名组合规则
```
请求: https://a.com/files/video.mp4 (10MB)
结果: 代理到 https://video-cdn.com/video.mp4 (匹配通用视频规则)
请求: https://b.com/files/video.mp4 (10MB)
结果: 代理到 https://video-cdn.com/video.mp4 (匹配通用视频规则)
```
### 配置字段说明
- **Domains**: 逗号分隔的域名列表,指定该规则适用的域名
- 为空或不设置:匹配所有域名
- 单个域名:`"a.com"`
- 多个域名:`"a.com,b.com,c.com"`
- **Extensions**: 文件扩展名(与之前相同)
- **Target**: 目标服务器(与之前相同)
- **SizeThreshold/MaxSize**: 文件大小范围(与之前相同)
- **RedirectMode**: 是否使用302跳转与之前相同
### 匹配优先级
1. **域名匹配**: 首先筛选出匹配请求域名的规则
2. **扩展名匹配**: 在域名匹配的规则中筛选扩展名匹配的规则
3. **文件大小匹配**: 根据文件大小选择最合适的规则
4. **目标可用性**: 检查目标服务器是否可访问
5. **默认回退**: 如果没有匹配的规则,使用默认目标
### 日志输出
启用域名过滤后,日志会包含域名信息:
```
[SelectRule] /image.jpg -> 选中规则 (域名: a.com, 文件大小: 1.2MB, 在区间 1KB 到 2MB 之间)
[Redirect] /image.jpg -> 使用选中规则进行302跳转 (域名: b.com): https://b-domain-cdn.com/image.jpg
```
## 原有功能
### 功能作用
主要是最好有一台国外服务器, 回国又不慢的, 可以反代国外资源, 然后在proxy-go外面套个cloudfront或者Edgeone, 方便国内访问.
config里MAP的功能
目前我的主要使用是反代B2, R2, Oracle存储桶之类的. 也可以反代网站静态资源, 可以一并在CDN环节做缓存.
根据config示例作示范
访问https://proxy-go/path1/123.jpg, 实际是访问 https://path1.com/path/path/path/123.jpg
访问https://proxy-go/path2/749.movie, 实际是访问https://path2.com/749.movie
### mirror 固定路由
比较适合接口类的CORS问题
访问https://proxy-go/mirror/https://example.com/path/to/resource
会实际访问https://example.com/path/to/resource

View File

@ -7,24 +7,7 @@ import { useToast } from "@/components/ui/use-toast"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useRouter } from "next/navigation"
import {
HardDrive,
Database,
TrendingUp,
TrendingDown,
Activity,
Image as ImageIcon,
FileText,
RefreshCw,
Trash2,
Settings,
Info,
Zap,
Target,
RotateCcw
} from "lucide-react"
interface CacheStats {
total_items: number
@ -34,9 +17,6 @@ interface CacheStats {
hit_rate: number
bytes_saved: number
enabled: boolean
format_fallback_hit: number
image_cache_hit: number
regular_cache_hit: number
}
interface CacheConfig {
@ -276,19 +256,15 @@ export default function CachePage() {
const config = configs[type]
return (
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-800"></h3>
</div>
<div className="space-y-4 mt-4">
<h3 className="text-sm font-medium"></h3>
<div className="grid gap-4">
<div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-max-age`} className="text-sm"></Label>
<Label htmlFor={`${type}-max-age`}></Label>
<Input
id={`${type}-max-age`}
type="number"
value={config.max_age}
className="h-8"
onChange={(e) => {
const newConfigs = { ...configs }
newConfigs[type].max_age = parseInt(e.target.value)
@ -298,12 +274,11 @@ export default function CachePage() {
/>
</div>
<div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-cleanup-tick`} className="text-sm"></Label>
<Label htmlFor={`${type}-cleanup-tick`}></Label>
<Input
id={`${type}-cleanup-tick`}
type="number"
value={config.cleanup_tick}
className="h-8"
onChange={(e) => {
const newConfigs = { ...configs }
newConfigs[type].cleanup_tick = parseInt(e.target.value)
@ -313,12 +288,11 @@ export default function CachePage() {
/>
</div>
<div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-max-cache-size`} className="text-sm">GB</Label>
<Label htmlFor={`${type}-max-cache-size`}>GB</Label>
<Input
id={`${type}-max-cache-size`}
type="number"
value={config.max_cache_size}
className="h-8"
onChange={(e) => {
const newConfigs = { ...configs }
newConfigs[type].max_cache_size = parseInt(e.target.value)
@ -336,7 +310,6 @@ export default function CachePage() {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-blue-500" />
<div className="text-lg font-medium">...</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
@ -345,352 +318,112 @@ export default function CachePage() {
}
return (
<TooltipProvider>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Database className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold"></h1>
</div>
<Button
variant="outline"
onClick={() => handleClearCache("all")}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-6">
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => handleClearCache("all")}>
</Button>
</div>
{/* 智能缓存汇总 */}
<Card className="border-2 border-blue-100 bg-gradient-to-r from-blue-50 to-purple-50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-blue-800">
<Zap className="h-5 w-5" />
</CardTitle>
<div className="grid gap-6 md:grid-cols-2">
{/* 代理缓存 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle></CardTitle>
<div className="flex items-center space-x-2">
<Switch
checked={stats?.proxy.enabled ?? false}
onCheckedChange={(checked) => handleToggleCache("proxy", checked)}
/>
<Button
variant="outline"
size="sm"
onClick={() => handleClearCache("proxy")}
>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<FileText className="h-5 w-5 text-blue-600" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-blue-600">
{(stats?.proxy.regular_cache_hit ?? 0) + (stats?.mirror.regular_cache_hit ?? 0)}
</div>
<div className="text-sm text-gray-600 font-medium"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<ImageIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-green-600">
{(stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0)}
</div>
<div className="text-sm text-gray-600 font-medium"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<RotateCcw className="h-5 w-5 text-orange-600" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-orange-600">
{(stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)}
</div>
<div className="text-sm text-gray-600 font-medium">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<Target className="h-5 w-5 text-purple-600" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-purple-600">
{(() => {
const totalImageRequests = (stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0) + (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)
const fallbackHits = (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)
return totalImageRequests > 0 ? ((fallbackHits / totalImageRequests) * 100).toFixed(1) : '0.0'
})()}%
</div>
<div className="text-sm text-gray-600 font-medium">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
</div>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{stats?.proxy.total_items ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{stats?.proxy.hit_count ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{stats?.proxy.miss_count ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
</div>
</dl>
{renderCacheConfig("proxy")}
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
{/* 代理缓存 */}
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-blue-600" />
</CardTitle>
<div className="flex items-center space-x-2">
<Switch
checked={stats?.proxy.enabled ?? false}
onCheckedChange={(checked) => handleToggleCache("proxy", checked)}
/>
<Button
variant="outline"
size="sm"
onClick={() => handleClearCache("proxy")}
className="flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
</Button>
{/* 镜像缓存 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle></CardTitle>
<div className="flex items-center space-x-2">
<Switch
checked={stats?.mirror.enabled ?? false}
onCheckedChange={(checked) => handleToggleCache("mirror", checked)}
/>
<Button
variant="outline"
size="sm"
onClick={() => handleClearCache("mirror")}
>
</Button>
</div>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{stats?.mirror.total_items ?? 0}</dd>
</div>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Database className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{stats?.proxy.total_items ?? 0}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<HardDrive className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-green-50 rounded">
<dt className="text-sm font-medium text-green-700 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-green-800">{stats?.proxy.hit_count ?? 0}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-red-50 rounded">
<dt className="text-sm font-medium text-red-700 flex items-center gap-2">
<TrendingDown className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-red-800">{stats?.proxy.miss_count ?? 0}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-blue-50 rounded">
<dt className="text-sm font-medium text-blue-700 flex items-center gap-2">
<Activity className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-blue-800">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd>
</div>
<div className="flex justify-between items-center p-2 bg-purple-50 rounded">
<dt className="text-sm font-medium text-purple-700 flex items-center gap-2">
<Zap className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-purple-800">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
</div>
</dl>
<div className="border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="h-4 w-4 text-gray-600" />
<div className="text-sm font-medium text-gray-800"></div>
</div>
<div className="grid grid-cols-3 gap-3">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-blue-50 rounded-lg border cursor-help hover:bg-blue-100 transition-colors">
<FileText className="h-4 w-4 mx-auto mb-1 text-blue-600" />
<div className="text-lg font-bold text-blue-600">{stats?.proxy.regular_cache_hit ?? 0}</div>
<div className="text-xs text-blue-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-green-50 rounded-lg border cursor-help hover:bg-green-100 transition-colors">
<ImageIcon className="h-4 w-4 mx-auto mb-1 text-green-600" aria-hidden="true" />
<div className="text-lg font-bold text-green-600">{stats?.proxy.image_cache_hit ?? 0}</div>
<div className="text-xs text-green-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-orange-50 rounded-lg border cursor-help hover:bg-orange-100 transition-colors">
<RotateCcw className="h-4 w-4 mx-auto mb-1 text-orange-600" />
<div className="text-lg font-bold text-orange-600">{stats?.proxy.format_fallback_hit ?? 0}</div>
<div className="text-xs text-orange-700">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退WebP但提供JPEG</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
</div>
{renderCacheConfig("proxy")}
</CardContent>
</Card>
{/* 镜像缓存 */}
<Card className="border-l-4 border-l-green-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-green-600" />
</CardTitle>
<div className="flex items-center space-x-2">
<Switch
checked={stats?.mirror.enabled ?? false}
onCheckedChange={(checked) => handleToggleCache("mirror", checked)}
/>
<Button
variant="outline"
size="sm"
onClick={() => handleClearCache("mirror")}
className="flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
</Button>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{stats?.mirror.hit_count ?? 0}</dd>
</div>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Database className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{stats?.mirror.total_items ?? 0}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<HardDrive className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-green-50 rounded">
<dt className="text-sm font-medium text-green-700 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-green-800">{stats?.mirror.hit_count ?? 0}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-red-50 rounded">
<dt className="text-sm font-medium text-red-700 flex items-center gap-2">
<TrendingDown className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-red-800">{stats?.mirror.miss_count ?? 0}</dd>
</div>
<div className="flex justify-between items-center p-2 bg-blue-50 rounded">
<dt className="text-sm font-medium text-blue-700 flex items-center gap-2">
<Activity className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-blue-800">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd>
</div>
<div className="flex justify-between items-center p-2 bg-purple-50 rounded">
<dt className="text-sm font-medium text-purple-700 flex items-center gap-2">
<Zap className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-purple-800">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
</div>
</dl>
<div className="border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="h-4 w-4 text-gray-600" />
<div className="text-sm font-medium text-gray-800"></div>
</div>
<div className="grid grid-cols-3 gap-3">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-blue-50 rounded-lg border cursor-help hover:bg-blue-100 transition-colors">
<FileText className="h-4 w-4 mx-auto mb-1 text-blue-600" />
<div className="text-lg font-bold text-blue-600">{stats?.mirror.regular_cache_hit ?? 0}</div>
<div className="text-xs text-blue-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-green-50 rounded-lg border cursor-help hover:bg-green-100 transition-colors">
<ImageIcon className="h-4 w-4 mx-auto mb-1 text-green-600" aria-hidden="true" />
<div className="text-lg font-bold text-green-600">{stats?.mirror.image_cache_hit ?? 0}</div>
<div className="text-xs text-green-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-orange-50 rounded-lg border cursor-help hover:bg-orange-100 transition-colors">
<RotateCcw className="h-4 w-4 mx-auto mb-1 text-orange-600" />
<div className="text-lg font-bold text-orange-600">{stats?.mirror.format_fallback_hit ?? 0}</div>
<div className="text-xs text-orange-700">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退WebP但提供JPEG</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{stats?.mirror.miss_count ?? 0}</dd>
</div>
{renderCacheConfig("mirror")}
</CardContent>
</Card>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500"></dt>
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
</div>
</dl>
{renderCacheConfig("mirror")}
</CardContent>
</Card>
</div>
</TooltipProvider>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
import Link from "next/link"
interface Metrics {
uptime: string
@ -18,6 +17,13 @@ interface Metrics {
bytes_per_second: number
error_rate: number
status_code_stats: Record<string, number>
top_paths: Array<{
path: string
request_count: number
error_count: number
avg_latency: string
bytes_transferred: number
}>
recent_requests: Array<{
Time: string
Path: string
@ -31,17 +37,20 @@ interface Metrics {
max: string
distribution: Record<string, number>
}
error_stats: {
client_errors: number
server_errors: number
types: Record<string, number>
}
bandwidth_history: Record<string, string>
current_bandwidth: string
total_bytes: number
current_session_requests: number
top_referers: Array<{
path: string
request_count: number
error_count: number
avg_latency: string
bytes_transferred: number
last_access_time: number
}>
}
@ -147,18 +156,6 @@ export default function DashboardPage() {
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics.active_requests}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics.total_requests || Object.values(metrics.status_code_stats || {}).reduce((a, b) => a + (b as number), 0)}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold text-red-600">{metrics.total_errors || 0}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold text-red-600">{((metrics.error_rate || 0) * 100).toFixed(2)}%</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div>
@ -167,14 +164,6 @@ export default function DashboardPage() {
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{formatBytes(metrics.bytes_per_second)}/s</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold text-blue-600">{metrics.current_session_requests || 0}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500">5</div>
<div className="text-lg font-semibold">{(metrics.requests_per_second || 0).toFixed(2)}</div>
</div>
</div>
</CardContent>
</Card>
@ -198,8 +187,10 @@ export default function DashboardPage() {
<div className="text-lg font-semibold">{metrics.avg_response_time}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics.current_bandwidth}</div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">
{metrics.requests_per_second.toFixed(2)}
</div>
</div>
</div>
</CardContent>
@ -208,10 +199,7 @@ export default function DashboardPage() {
<Card>
<CardHeader>
<CardTitle>
<span className="ml-2 text-sm font-normal text-gray-500 align-middle">(: {Object.values(metrics.status_code_stats || {}).reduce((a, b) => a + (b as number), 0)})</span>
</CardTitle>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
@ -337,16 +325,70 @@ export default function DashboardPage() {
</Card>
</div>
{/* 错误统计卡片 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"> (4xx)</div>
<div className="text-2xl font-semibold text-yellow-600">
{metrics.error_stats?.client_errors || 0}
</div>
<div className="text-sm text-gray-500">
{metrics.total_requests ?
((metrics.error_stats?.client_errors || 0) / metrics.total_requests * 100).toFixed(2) : 0}%
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"> (5xx)</div>
<div className="text-2xl font-semibold text-red-600">
{metrics.error_stats?.server_errors || 0}
</div>
<div className="text-sm text-gray-500">
{metrics.total_requests ?
((metrics.error_stats?.server_errors || 0) / metrics.total_requests * 100).toFixed(2) : 0}%
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-2xl font-semibold">
{(metrics.error_rate * 100).toFixed(2)}%
</div>
<div className="text-sm text-gray-500">
: {metrics.total_errors || 0}
</div>
</div>
</div>
{metrics.error_stats?.types && Object.keys(metrics.error_stats.types).length > 0 && (
<div className="mt-6">
<div className="text-sm font-medium text-gray-500 mb-2"></div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(metrics.error_stats.types).map(([type, count]) => (
<div key={type} className="p-3 rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="text-sm font-medium text-gray-500">{type}</div>
<div className="text-lg font-semibold">{count}</div>
<div className="text-xs text-gray-500 mt-1">
{metrics.total_errors ? ((count / metrics.total_errors) * 100).toFixed(1) : 0}%
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 引用来源统计卡片 */}
{metrics.top_referers && metrics.top_referers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>
<span className="ml-2 text-sm font-normal text-gray-500 align-middle">
(24, {metrics.top_referers.length} )
</span>
</CardTitle>
<CardTitle> (Top {metrics.top_referers.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
@ -356,49 +398,24 @@ export default function DashboardPage() {
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2">访</th>
</tr>
</thead>
<tbody>
{metrics.top_referers
.sort((a, b) => b.request_count - a.request_count)
.map((referer, index) => {
const errorRate = ((referer.error_count / referer.request_count) * 100).toFixed(1);
const lastAccessTime = new Date(referer.last_access_time * 1000);
const timeAgo = getTimeAgo(lastAccessTime);
return (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="p-2 max-w-xs truncate">
<a
href={referer.path}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{referer.path}
</a>
</td>
<td className="p-2">{referer.request_count}</td>
<td className="p-2">{referer.error_count}</td>
<td className="p-2">
<span className={errorRate === "0.0" ? "text-green-600" : "text-red-600"}>
{errorRate}%
</span>
</td>
<td className="p-2">{referer.avg_latency}</td>
<td className="p-2">{formatBytes(referer.bytes_transferred)}</td>
<td className="p-2">
<span title={lastAccessTime.toLocaleString()}>
{timeAgo}
</span>
</td>
</tr>
);
})}
{metrics.top_referers.map((referer, index) => (
<tr key={index} className="border-b">
<td className="p-2 max-w-xs truncate">
<span className="text-blue-600">
{referer.path}
</span>
</td>
<td className="p-2">{referer.request_count}</td>
<td className="p-2">{referer.error_count}</td>
<td className="p-2">{referer.avg_latency}</td>
<td className="p-2">{formatBytes(referer.bytes_transferred)}</td>
</tr>
))}
</tbody>
</table>
</div>
@ -406,6 +423,47 @@ export default function DashboardPage() {
</Card>
)}
<Card>
<CardHeader>
<CardTitle> (Top 10)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{(metrics.top_paths || []).map((path, index) => (
<tr key={index} className="border-b">
<td className="p-2 max-w-xs truncate">
<a
href={path.path}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{path.path}
</a>
</td>
<td className="p-2">{path.request_count}</td>
<td className="p-2">{path.error_count}</td>
<td className="p-2">{path.avg_latency}</td>
<td className="p-2">{formatBytes(path.bytes_transferred)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
@ -450,11 +508,7 @@ export default function DashboardPage() {
</td>
<td className="p-2">{formatLatency(req.Latency)}</td>
<td className="p-2">{formatBytes(req.BytesSent)}</td>
<td className="p-2">
<Link href={`https://ipinfo.io/${req.ClientIP}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 hover:underline">
{req.ClientIP}
</Link>
</td>
<td className="p-2">{req.ClientIP}</td>
</tr>
))}
</tbody>
@ -494,30 +548,9 @@ function formatLatency(nanoseconds: number) {
}
}
function getTimeAgo(date: Date) {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds}秒前`;
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes}分钟前`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours}小时前`;
}
return date.toLocaleString();
}
function getStatusColor(status: number) {
if (status >= 500) return "bg-red-100 text-red-800"
if (status >= 400) return "bg-yellow-100 text-yellow-800"
if (status >= 300) return "bg-blue-100 text-blue-800"
return "bg-green-100 text-green-800"
}
}

View File

@ -1,386 +0,0 @@
"use client"
import React, { useEffect, useState, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Shield, Ban, Clock, Trash2, RefreshCw } from "lucide-react"
interface BannedIP {
ip: string
ban_end_time: string
remaining_seconds: number
}
interface SecurityStats {
banned_ips_count: number
error_records_count: number
config: {
ErrorThreshold: number
WindowMinutes: number
BanDurationMinutes: number
CleanupIntervalMinutes: number
}
}
interface IPStatus {
ip: string
banned: boolean
ban_end_time?: string
remaining_seconds?: number
}
export default function SecurityPage() {
const [bannedIPs, setBannedIPs] = useState<BannedIP[]>([])
const [stats, setStats] = useState<SecurityStats | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [checkingIP, setCheckingIP] = useState("")
const [ipStatus, setIPStatus] = useState<IPStatus | null>(null)
const [unbanning, setUnbanning] = useState<string | null>(null)
const { toast } = useToast()
const router = useRouter()
const fetchData = useCallback(async () => {
try {
const token = localStorage.getItem("token")
if (!token) {
router.push("/login")
return
}
const [bannedResponse, statsResponse] = await Promise.all([
fetch("/admin/api/security/banned-ips", {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch("/admin/api/security/stats", {
headers: { 'Authorization': `Bearer ${token}` }
})
])
if (bannedResponse.status === 401 || statsResponse.status === 401) {
localStorage.removeItem("token")
router.push("/login")
return
}
if (bannedResponse.ok) {
const bannedData = await bannedResponse.json()
setBannedIPs(bannedData.banned_ips || [])
}
if (statsResponse.ok) {
const statsData = await statsResponse.json()
setStats(statsData)
}
} catch (error) {
console.error("获取安全数据失败:", error)
toast({
title: "错误",
description: "获取安全数据失败",
variant: "destructive",
})
} finally {
setLoading(false)
setRefreshing(false)
}
}, [router, toast])
useEffect(() => {
fetchData()
// 每30秒自动刷新一次数据
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const handleRefresh = () => {
setRefreshing(true)
fetchData()
}
const checkIPStatus = async () => {
if (!checkingIP.trim()) return
try {
const token = localStorage.getItem("token")
if (!token) {
router.push("/login")
return
}
const response = await fetch(`/admin/api/security/check-ip?ip=${encodeURIComponent(checkingIP)}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.status === 401) {
localStorage.removeItem("token")
router.push("/login")
return
}
if (response.ok) {
const data = await response.json()
setIPStatus(data)
} else {
throw new Error("检查IP状态失败")
}
} catch {
toast({
title: "错误",
description: "检查IP状态失败",
variant: "destructive",
})
}
}
const unbanIP = async (ip: string) => {
try {
const token = localStorage.getItem("token")
if (!token) {
router.push("/login")
return
}
const response = await fetch("/admin/api/security/unban", {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ ip })
})
if (response.status === 401) {
localStorage.removeItem("token")
router.push("/login")
return
}
if (response.ok) {
const data = await response.json()
if (data.success) {
toast({
title: "成功",
description: `IP ${ip} 已解封`,
})
fetchData() // 刷新数据
} else {
toast({
title: "提示",
description: data.message,
})
}
} else {
throw new Error("解封IP失败")
}
} catch {
toast({
title: "错误",
description: "解封IP失败",
variant: "destructive",
})
} finally {
setUnbanning(null)
}
}
const formatTime = (seconds: number) => {
if (seconds <= 0) return "已过期"
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes > 0) {
return `${minutes}${remainingSeconds}`
}
return `${remainingSeconds}`
}
if (loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium">...</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
</div>
)
}
return (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
</CardTitle>
<Button onClick={handleRefresh} disabled={refreshing} variant="outline">
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-red-50 p-4 rounded-lg">
<div className="flex items-center gap-2">
<Ban className="w-5 h-5 text-red-600" />
<div>
<div className="text-2xl font-bold text-red-600">{stats.banned_ips_count}</div>
<div className="text-sm text-red-600">IP</div>
</div>
</div>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
<div>
<div className="text-2xl font-bold text-yellow-600">{stats.error_records_count}</div>
<div className="text-sm text-yellow-600"></div>
</div>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-sm text-blue-600 mb-1"></div>
<div className="text-lg font-bold text-blue-600">
{stats.config.ErrorThreshold}/{stats.config.WindowMinutes}
</div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-sm text-green-600 mb-1"></div>
<div className="text-lg font-bold text-green-600">
{stats.config.BanDurationMinutes}
</div>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<Label>IP状态</Label>
<div className="flex gap-2 mt-1">
<Input
placeholder="输入IP地址"
value={checkingIP}
onChange={(e) => setCheckingIP(e.target.value)}
/>
<Button onClick={checkIPStatus}></Button>
</div>
</div>
</div>
{ipStatus && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-4">
<div>
<strong>IP: {ipStatus.ip}</strong>
</div>
<div className={`px-2 py-1 rounded text-sm ${
ipStatus.banned
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}>
{ipStatus.banned ? '已封禁' : '正常'}
</div>
{ipStatus.banned && ipStatus.remaining_seconds && ipStatus.remaining_seconds > 0 && (
<div className="text-sm text-muted-foreground">
: {formatTime(ipStatus.remaining_seconds)}
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>IP列表</CardTitle>
</CardHeader>
<CardContent>
{bannedIPs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
IP
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>IP地址</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bannedIPs.map((bannedIP) => (
<TableRow key={bannedIP.ip}>
<TableCell className="font-mono">{bannedIP.ip}</TableCell>
<TableCell>{bannedIP.ban_end_time}</TableCell>
<TableCell>
<span className={bannedIP.remaining_seconds <= 0 ? 'text-muted-foreground' : 'text-orange-600'}>
{formatTime(bannedIP.remaining_seconds)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => setUnbanning(bannedIP.ip)}
disabled={bannedIP.remaining_seconds <= 0}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<AlertDialog open={!!unbanning} onOpenChange={(open) => !open && setUnbanning(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
IP地址 &ldquo;{unbanning}&rdquo;
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => unbanning && unbanIP(unbanning)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -8,102 +8,57 @@ body {
@layer base {
:root {
--background: 30 12.5000% 96.8627%;
--foreground: 0 0% 0%;
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--primary: 23.8835 44.9782% 55.0980%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96.0784%;
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 89.8039%;
--muted-foreground: 0 0% 45.0980%;
--accent: 23.8835 44.9782% 55.0980%;
--accent-foreground: 0 0% 0%;
--destructive: 11.7857 44.0945% 50.1961%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 89.8039%;
--input: 0 0% 89.8039%;
--ring: 23.8835 44.9782% 55.0980%;
--chart-1: 23.8835 44.9782% 55.0980%;
--chart-2: 11.7857 44.0945% 50.1961%;
--chart-3: 120 25.0000% 42.3529%;
--chart-4: 346.0563 93.4211% 70.1961%;
--chart-5: 60 76.5432% 68.2353%;
--sidebar: 0 0% 96.0784%;
--sidebar-foreground: 0 0% 0%;
--sidebar-primary: 23.8835 44.9782% 55.0980%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 23.8835 44.9782% 55.0980%;
--sidebar-accent-foreground: 0 0% 0%;
--sidebar-border: 0 0% 89.8039%;
--sidebar-ring: 23.8835 44.9782% 55.0980%;
--font-sans: #000000;
--font-serif: #000000;
--font-mono: #000000;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--shadow-2xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-sm: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0 0 0 0 hsl(0 0% 0% / 0.00);
--tracking-normal: -0.025em;
}
.dark {
--background: 0 0% 10.1961%;
--foreground: 30 12.5000% 96.8627%;
--card: 0 0% 14.9020%;
--card-foreground: 30 12.5000% 96.8627%;
--popover: 0 0% 14.9020%;
--popover-foreground: 30 12.5000% 96.8627%;
--primary: 23.8835 44.9782% 55.0980%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 25.0980%;
--secondary-foreground: 30 12.5000% 96.8627%;
--muted: 0 0% 25.0980%;
--muted-foreground: 0 0% 63.9216%;
--accent: 23.8835 44.9782% 55.0980%;
--accent-foreground: 0 0% 0%;
--destructive: 11.7857 44.0945% 50.1961%;
--destructive-foreground: 0 0% 0%;
--border: 0 0% 25.0980%;
--input: 0 0% 14.9020%;
--ring: 23.8835 44.9782% 55.0980%;
--chart-1: 23.8835 44.9782% 55.0980%;
--chart-2: 11.7857 44.0945% 50.1961%;
--chart-3: 120 25.0000% 42.3529%;
--chart-4: 346.0563 93.4211% 70.1961%;
--chart-5: 60 76.5432% 68.2353%;
--sidebar: 0 0% 12.1569%;
--sidebar-foreground: 30 12.5000% 96.8627%;
--sidebar-primary: 23.8835 44.9782% 55.0980%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 23.8835 44.9782% 55.0980%;
--sidebar-accent-foreground: 0 0% 0%;
--sidebar-border: 0 0% 20%;
--sidebar-ring: 23.8835 44.9782% 55.0980%;
--font-sans: #F8F7F6;
--font-serif: #F8F7F6;
--font-mono: #F8F7F6;
--radius: 0.5rem;
--shadow-2xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-sm: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0 0 0 0 hsl(0 0% 0% / 0.00);
}
body {
letter-spacing: var(--tracking-normal);
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

View File

@ -55,12 +55,6 @@ export function Nav() {
>
</Link>
<Link
href="/dashboard/security"
className={pathname === "/dashboard/security" ? "text-primary" : "text-muted-foreground"}
>
</Link>
</div>
<Button variant="ghost" onClick={handleLogout}>
退

View File

@ -1,32 +0,0 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

425
web/package-lock.json generated
View File

@ -17,7 +17,6 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.475.0",
@ -1543,397 +1542,6 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
"integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@ -1967,39 +1575,6 @@
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",

View File

@ -18,7 +18,6 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.475.0",