mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 08:31:55 +08:00
Compare commits
63 Commits
Author | SHA1 | Date | |
---|---|---|---|
4ac2c1c43c | |||
8e484f29e9 | |||
775814eb24 | |||
19c25b8aca | |||
1e77085e10 | |||
6fd69ba870 | |||
5750062168 | |||
818dd11dda | |||
7e81e90113 | |||
ef2ab55fe6 | |||
4d9162f5e8 | |||
cc677bcf72 | |||
febe460baa | |||
30e2f1360e | |||
52fec424ae | |||
ceb92d663e | |||
f07b05e61a | |||
c04f600332 | |||
f31c601c20 | |||
da3200c605 | |||
f126dbb9dc | |||
aed0f755c8 | |||
4e3cc382e1 | |||
|
f54454a6e0 | ||
0db0b1f6b1 | |||
35db35e4ce | |||
ef03d71375 | |||
5790b41a03 | |||
83c544bd5b | |||
370bd1b74f | |||
605b26b883 | |||
1c9d5bc326 | |||
9e45b3e38a | |||
8dd410fad4 | |||
4447e690db | |||
f229455db9 | |||
1a2c7bd06d | |||
6bdcaf6f83 | |||
|
83ed8dffaa | ||
18a22e2792 | |||
d1db2835b4 | |||
|
38955fa9c7 | ||
|
87ca33755e | ||
0335640df5 | |||
4156b64ac6 | |||
1d84c0c614 | |||
|
964a9672c6 | ||
c2266a60d6 | |||
5418e89e3b | |||
ef1bec7710 | |||
a141672243 | |||
11378a7e0c | |||
1aed50444e | |||
cc45cac622 | |||
c85d08d7a4 | |||
9c2bc25bfa | |||
e98b2c3efe | |||
50021c1a09 | |||
de2209d177 | |||
64423b00e2 | |||
7f4a964163 | |||
2626f63770 | |||
07e63eea5f |
12
.gitignore
vendored
12
.gitignore
vendored
@ -24,5 +24,15 @@ vendor/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
data/config.json
|
||||
data/config.json
|
||||
kaifa.md
|
||||
.cursor
|
||||
data/cache/config.json
|
||||
data/mirror_cache/config.json
|
||||
data/metrics/latency_distribution.json
|
||||
data/metrics/metrics.json
|
||||
data/metrics/path_stats.json
|
||||
data/metrics/referer_stats.json
|
||||
data/metrics/status_codes.json
|
||||
data/config.json
|
||||
.env
|
||||
data/cache
|
||||
|
70
data/config.example.json
Normal file
70
data/config.example.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
@ -6,6 +6,7 @@ services:
|
||||
- "3336:3336"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./favicon:/app/favicon
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- OAUTH_CLIENT_ID=your_client_id
|
||||
|
2
favicon/.gitkeep
Normal file
2
favicon/.gitkeep
Normal file
@ -0,0 +1,2 @@
|
||||
# 这个文件确保 favicon 目录被 git 跟踪
|
||||
# 用户可以在这个目录中放置自定义的 favicon.ico 文件
|
32
favicon/README.md
Normal file
32
favicon/README.md
Normal file
@ -0,0 +1,32 @@
|
||||
# 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
9
go.mod
@ -1,10 +1,13 @@
|
||||
module proxy-go
|
||||
|
||||
go 1.23.1
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.1
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
golang.org/x/net v0.37.0
|
||||
github.com/woodchen-ink/go-web-utils v1.0.0
|
||||
golang.org/x/net v0.40.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.23.0 // indirect
|
||||
require golang.org/x/text v0.25.0 // indirect
|
||||
|
10
go.sum
10
go.sum
@ -1,8 +1,10 @@
|
||||
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.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=
|
||||
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=
|
||||
|
216
internal/cache/extension_matcher.go
vendored
Normal file
216
internal/cache/extension_matcher.go
vendored
Normal file
@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
512
internal/cache/manager.go
vendored
512
internal/cache/manager.go
vendored
@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"proxy-go/internal/config"
|
||||
"proxy-go/internal/utils"
|
||||
"sort"
|
||||
"strings"
|
||||
@ -18,6 +19,174 @@ 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
|
||||
@ -54,23 +223,28 @@ 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"` // 缓存开关状态
|
||||
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"` // 常规缓存命中次数
|
||||
}
|
||||
|
||||
// CacheManager 缓存管理器
|
||||
type CacheManager struct {
|
||||
cacheDir string
|
||||
items sync.Map
|
||||
items sync.Map // 保持原有的 sync.Map 用于文件缓存
|
||||
lruCache *LRUCache // 新增LRU缓存用于热点数据
|
||||
maxAge time.Duration
|
||||
cleanupTick time.Duration
|
||||
maxCacheSize int64
|
||||
@ -80,6 +254,14 @@ 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 创建新的缓存管理器
|
||||
@ -90,10 +272,14 @@ 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) // 默认启用缓存
|
||||
@ -127,10 +313,79 @@ 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: r.URL.String(),
|
||||
AcceptHeaders: r.Header.Get("Accept"),
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,7 +395,117 @@ 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)
|
||||
@ -168,8 +533,12 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
|
||||
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
|
||||
}
|
||||
|
||||
@ -223,7 +592,11 @@ func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*Ca
|
||||
}
|
||||
|
||||
cm.items.Store(key, item)
|
||||
log.Printf("[Cache] NEW %s %s (%s) from %s", resp.Request.Method, key.URL, formatBytes(item.Size), utils.GetRequestSource(resp.Request))
|
||||
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))
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@ -325,13 +698,16 @@ 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(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -374,6 +750,9 @@ 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
|
||||
}
|
||||
@ -444,15 +823,41 @@ func (cm *CacheManager) Commit(key CacheKey, tempPath string, resp *http.Respons
|
||||
return fmt.Errorf("cache is disabled")
|
||||
}
|
||||
|
||||
// 生成最终的缓存文件名
|
||||
h := sha256.New()
|
||||
h.Write([]byte(key.String()))
|
||||
hashStr := hex.EncodeToString(h.Sum(nil))
|
||||
ext := filepath.Ext(key.URL)
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
// 读取临时文件内容以计算哈希
|
||||
tempData, err := os.ReadFile(tempPath)
|
||||
if err != nil {
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to read temp file: %v", err)
|
||||
}
|
||||
filePath := filepath.Join(cm.cacheDir, hashStr+ext)
|
||||
|
||||
// 计算内容哈希,与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)
|
||||
|
||||
// 重命名临时文件
|
||||
if err := os.Rename(tempPath, filePath); err != nil {
|
||||
@ -591,3 +996,54 @@ 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
64
internal/cache/manager_test.go
vendored
@ -1,64 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
@ -2,23 +2,14 @@ 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
|
||||
)
|
||||
@ -26,30 +17,185 @@ var (
|
||||
type ConfigManager struct {
|
||||
config atomic.Value
|
||||
configPath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewConfigManager(path string) *ConfigManager {
|
||||
cm := &ConfigManager{configPath: path}
|
||||
cm.loadConfig()
|
||||
go cm.watchConfig()
|
||||
return cm
|
||||
}
|
||||
|
||||
func (cm *ConfigManager) watchConfig() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
for range ticker.C {
|
||||
cm.loadConfig()
|
||||
func NewConfigManager(configPath string) (*ConfigManager, error) {
|
||||
cm := &ConfigManager{
|
||||
configPath: configPath,
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
config, err := 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
|
||||
}
|
||||
|
||||
// Load 加载配置
|
||||
func Load(path string) (*Config, error) {
|
||||
var err error
|
||||
once.Do(func() {
|
||||
instance = &configImpl{}
|
||||
err = instance.reload(path)
|
||||
})
|
||||
return &instance.Config, err
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// RegisterUpdateCallback 注册配置更新回调函数
|
||||
@ -61,53 +207,33 @@ 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))
|
||||
}
|
||||
|
||||
// Update 更新配置并触发回调
|
||||
func (c *configImpl) Update(newConfig *Config) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
// 为了向后兼容,保留Load函数,但现在它使用ConfigManager
|
||||
var globalConfigManager *ConfigManager
|
||||
|
||||
// 更新配置
|
||||
c.MAP = newConfig.MAP
|
||||
c.Compression = newConfig.Compression
|
||||
|
||||
// 触发回调
|
||||
for _, callback := range c.onConfigUpdate {
|
||||
callback(newConfig)
|
||||
// Load 加载配置(向后兼容)
|
||||
func Load(path string) (*Config, error) {
|
||||
if globalConfigManager == nil {
|
||||
var err error
|
||||
globalConfigManager, err = NewConfigManager(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return globalConfigManager.GetConfig(), nil
|
||||
}
|
||||
|
16
internal/config/init.go
Normal file
16
internal/config/init.go
Normal file
@ -0,0 +1,16 @@
|
||||
package config
|
||||
|
||||
import "log"
|
||||
|
||||
func Init(configPath string) (*ConfigManager, error) {
|
||||
log.Printf("[Config] 初始化配置管理器...")
|
||||
|
||||
configManager, err := NewConfigManager(configPath)
|
||||
if err != nil {
|
||||
log.Printf("[Config] 初始化配置管理器失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[Config] 配置管理器初始化成功")
|
||||
return configManager, nil
|
||||
}
|
@ -1,22 +1,30 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig
|
||||
MAP map[string]PathConfig `json:"MAP"` // 路径映射配置
|
||||
Compression CompressionConfig `json:"Compression"`
|
||||
Security SecurityConfig `json:"Security"` // 安全配置
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
Path string `json:"Path"`
|
||||
DefaultTarget string `json:"DefaultTarget"`
|
||||
ExtensionMap map[string]string `json:"ExtensionMap"`
|
||||
SizeThreshold int64 `json:"SizeThreshold"` // 最小文件大小阈值
|
||||
MaxSize int64 `json:"MaxSize"` // 最大文件大小阈值
|
||||
processedExtMap map[string]string // 内部使用,存储拆分后的映射
|
||||
DefaultTarget string `json:"DefaultTarget"` // 默认目标URL
|
||||
ExtensionMap []ExtRuleConfig `json:"ExtensionMap"` // 扩展名映射规则
|
||||
ExtRules []ExtensionRule `json:"-"` // 内部使用,存储处理后的扩展名规则
|
||||
RedirectMode bool `json:"RedirectMode"` // 是否使用302跳转模式
|
||||
}
|
||||
|
||||
// ExtensionRule 表示一个扩展名映射规则(内部使用)
|
||||
type ExtensionRule struct {
|
||||
Extensions []string // 支持的扩展名列表
|
||||
Target string // 目标服务器
|
||||
SizeThreshold int64 // 最小阈值
|
||||
MaxSize int64 // 最大阈值
|
||||
RedirectMode bool // 是否使用302跳转模式
|
||||
Domains []string // 支持的域名列表,为空表示匹配所有域名
|
||||
}
|
||||
|
||||
type CompressionConfig struct {
|
||||
@ -29,84 +37,99 @@ type CompressorConfig struct {
|
||||
Level int `json:"Level"`
|
||||
}
|
||||
|
||||
// 添加一个辅助方法来处理字符串到 PathConfig 的转换
|
||||
func (c *Config) UnmarshalJSON(data []byte) error {
|
||||
// 创建一个临时结构来解析原始JSON
|
||||
type TempConfig struct {
|
||||
MAP map[string]json.RawMessage `json:"MAP"`
|
||||
Compression CompressionConfig `json:"Compression"`
|
||||
}
|
||||
|
||||
var temp TempConfig
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化 MAP
|
||||
c.MAP = make(map[string]PathConfig)
|
||||
|
||||
// 处理每个路径配置
|
||||
for key, raw := range temp.MAP {
|
||||
// 尝试作为字符串解析
|
||||
var strValue string
|
||||
if err := json.Unmarshal(raw, &strValue); err == nil {
|
||||
pathConfig := PathConfig{
|
||||
DefaultTarget: strValue,
|
||||
}
|
||||
pathConfig.ProcessExtensionMap() // 处理扩展名映射
|
||||
c.MAP[key] = pathConfig
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果不是字符串,尝试作为PathConfig解析
|
||||
var pathConfig PathConfig
|
||||
if err := json.Unmarshal(raw, &pathConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
pathConfig.ProcessExtensionMap() // 处理扩展名映射
|
||||
c.MAP[key] = pathConfig
|
||||
}
|
||||
|
||||
// 复制其他字段
|
||||
c.Compression = temp.Compression
|
||||
|
||||
return nil
|
||||
type SecurityConfig struct {
|
||||
IPBan IPBanConfig `json:"IPBan"` // IP封禁配置
|
||||
}
|
||||
|
||||
// 添加处理扩展名映射的方法
|
||||
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
|
||||
}
|
||||
|
||||
p.processedExtMap = make(map[string]string)
|
||||
for exts, target := range p.ExtensionMap {
|
||||
// 分割扩展名
|
||||
for _, ext := range strings.Split(exts, ",") {
|
||||
ext = strings.TrimSpace(ext) // 移除可能的空格
|
||||
// 处理扩展名规则
|
||||
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)
|
||||
if ext != "" {
|
||||
p.processedExtMap[ext] = target
|
||||
extRule.Extensions = append(extRule.Extensions, ext)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理域名列表
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加获取目标URL的方法
|
||||
func (p *PathConfig) GetTargetForExt(ext string) string {
|
||||
if p.processedExtMap == nil {
|
||||
p.ProcessExtensionMap()
|
||||
// GetProcessedExtTarget 快速获取扩展名对应的目标URL,如果存在返回true
|
||||
func (p *PathConfig) GetProcessedExtTarget(ext string) (string, bool) {
|
||||
if p.ExtRules == nil {
|
||||
return "", false
|
||||
}
|
||||
if target, exists := p.processedExtMap[ext]; exists {
|
||||
return target
|
||||
|
||||
for _, rule := range p.ExtRules {
|
||||
for _, e := range rule.Extensions {
|
||||
if e == ext {
|
||||
return rule.Target, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.DefaultTarget
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 添加检查扩展名是否存在的方法
|
||||
func (p *PathConfig) GetExtensionTarget(ext string) (string, bool) {
|
||||
if p.processedExtMap == nil {
|
||||
p.ProcessExtensionMap()
|
||||
// GetProcessedExtRule 获取扩展名对应的完整规则信息,包括RedirectMode
|
||||
func (p *PathConfig) GetProcessedExtRule(ext string) (*ExtensionRule, bool) {
|
||||
if p.ExtRules == nil {
|
||||
return nil, false
|
||||
}
|
||||
target, exists := p.processedExtMap[ext]
|
||||
return target, exists
|
||||
|
||||
for _, rule := range p.ExtRules {
|
||||
for _, e := range rule.Extensions {
|
||||
if e == ext {
|
||||
return &rule, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
@ -1,22 +0,0 @@
|
||||
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
|
||||
}
|
@ -14,10 +14,13 @@ 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 {
|
||||
@ -41,9 +44,10 @@ type OAuthUserInfo struct {
|
||||
}
|
||||
|
||||
type OAuthToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
type tokenInfo struct {
|
||||
@ -52,6 +56,11 @@ type tokenInfo struct {
|
||||
username string
|
||||
}
|
||||
|
||||
type stateInfo struct {
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type authManager struct {
|
||||
tokens sync.Map
|
||||
states sync.Map
|
||||
@ -60,6 +69,7 @@ type authManager struct {
|
||||
func newAuthManager() *authManager {
|
||||
am := &authManager{}
|
||||
go am.cleanExpiredTokens()
|
||||
go am.cleanExpiredStates()
|
||||
return am
|
||||
}
|
||||
|
||||
@ -69,6 +79,27 @@ 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(),
|
||||
@ -102,6 +133,20 @@ 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)
|
||||
@ -111,7 +156,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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@ -119,7 +164,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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Auth] %s %s -> 200 (%s) logout success from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
@ -132,14 +177,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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, iputil.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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@ -150,30 +195,48 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
// getCallbackURL 从请求中获取回调地址
|
||||
func getCallbackURL(r *http.Request) string {
|
||||
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"
|
||||
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
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, r.Host)
|
||||
log.Printf("[Auth] WARNING Invalid OAUTH_REDIRECT_URI format: %s", redirectURI)
|
||||
}
|
||||
|
||||
// 更可靠地检测协议
|
||||
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.generateToken()
|
||||
h.auth.states.Store(state, time.Now())
|
||||
|
||||
state := h.auth.generateState()
|
||||
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())
|
||||
|
||||
@ -185,17 +248,21 @@ 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 _, 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))
|
||||
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))
|
||||
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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Auth] ERR %s %s -> 400 (%s) missing code parameter from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Missing code parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@ -207,11 +274,15 @@ 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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) missing OAuth credentials from %s", r.Method, r.URL.Path, iputil.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"},
|
||||
@ -221,7 +292,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, utils.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, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -229,22 +300,24 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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))
|
||||
// 读取错误响应内容
|
||||
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))
|
||||
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, utils.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, iputil.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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
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))
|
||||
http.Error(w, "Received invalid token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -255,7 +328,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, utils.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, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to get user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -264,7 +337,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, utils.GetClientIP(r), userResp.Status, utils.GetRequestSource(r))
|
||||
r.Method, r.URL.Path, userResp.StatusCode, iputil.GetClientIP(r), userResp.Status, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to get user info: "+userResp.Status, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -273,7 +346,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, utils.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to read user info response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -285,7 +358,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, utils.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -320,7 +393,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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Invalid user information: missing username", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@ -329,7 +402,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, utils.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, iputil.GetClientIP(r), userInfo.Username, utils.GetRequestSource(r))
|
||||
|
||||
// 返回登录成功页面
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
@ -11,13 +11,13 @@ import (
|
||||
|
||||
// ConfigHandler 配置管理处理器
|
||||
type ConfigHandler struct {
|
||||
config *config.Config
|
||||
configManager *config.ConfigManager
|
||||
}
|
||||
|
||||
// NewConfigHandler 创建新的配置管理处理器
|
||||
func NewConfigHandler(cfg *config.Config) *ConfigHandler {
|
||||
func NewConfigHandler(configManager *config.ConfigManager) *ConfigHandler {
|
||||
return &ConfigHandler{
|
||||
config: cfg,
|
||||
configManager: configManager,
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,29 +67,14 @@ func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// 将新配置格式化为JSON
|
||||
configData, err := json.MarshalIndent(newConfig, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("格式化配置失败: %v", err), http.StatusInternalServerError)
|
||||
// 使用ConfigManager更新配置
|
||||
if err := h.configManager.UpdateConfig(&newConfig); err != nil {
|
||||
http.Error(w, fmt.Sprintf("更新配置失败: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到临时文件
|
||||
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)
|
||||
// 添加日志
|
||||
fmt.Printf("[Config] 配置已更新: %d 个路径映射\n", len(newConfig.MAP))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"message": "配置已更新并生效"}`))
|
||||
|
@ -25,8 +25,9 @@ type Metrics struct {
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
|
||||
// 性能指标
|
||||
AverageResponseTime string `json:"avg_response_time"`
|
||||
RequestsPerSecond float64 `json:"requests_per_second"`
|
||||
AverageResponseTime string `json:"avg_response_time"`
|
||||
RequestsPerSecond float64 `json:"requests_per_second"`
|
||||
CurrentSessionRequests int64 `json:"current_session_requests"`
|
||||
|
||||
// 传输指标
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
@ -35,9 +36,6 @@ type Metrics struct {
|
||||
// 状态码统计
|
||||
StatusCodeStats map[string]int64 `json:"status_code_stats"`
|
||||
|
||||
// 路径统计
|
||||
TopPaths []models.PathMetricsJSON `json:"top_paths"`
|
||||
|
||||
// 最近请求
|
||||
RecentRequests []models.RequestLog `json:"recent_requests"`
|
||||
|
||||
@ -51,13 +49,6 @@ 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"`
|
||||
@ -71,21 +62,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),
|
||||
"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),
|
||||
"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),
|
||||
"latency_stats": map[string]interface{}{
|
||||
"min": "0ms",
|
||||
"max": "0ms",
|
||||
@ -115,45 +106,27 @@ 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: 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"),
|
||||
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"),
|
||||
}
|
||||
|
||||
// 填充延迟统计数据
|
||||
@ -197,11 +170,6 @@ 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)
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"proxy-go/internal/cache"
|
||||
@ -11,6 +12,17 @@ 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 {
|
||||
@ -19,10 +31,38 @@ type MirrorProxyHandler struct {
|
||||
}
|
||||
|
||||
func NewMirrorProxyHandler() *MirrorProxyHandler {
|
||||
// 创建优化的拨号器
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// 创建优化的传输层
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
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
|
||||
}
|
||||
|
||||
// 初始化缓存管理器
|
||||
@ -34,7 +74,13 @@ func NewMirrorProxyHandler() *MirrorProxyHandler {
|
||||
return &MirrorProxyHandler{
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: mirrorTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Cache: cacheManager,
|
||||
}
|
||||
@ -56,7 +102,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),
|
||||
utils.GetClientIP(r), "-", r.URL.Path)
|
||||
iputil.GetClientIP(r), "-", r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
@ -66,7 +112,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),
|
||||
utils.GetClientIP(r), "-", r.URL.Path)
|
||||
iputil.GetClientIP(r), "-", r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
@ -74,13 +120,33 @@ 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),
|
||||
utils.GetClientIP(r), "-", actualURL, err)
|
||||
iputil.GetClientIP(r), "-", actualURL, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -98,7 +164,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),
|
||||
utils.GetClientIP(r), "-", actualURL, err)
|
||||
iputil.GetClientIP(r), "-", actualURL, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -116,40 +182,20 @@ 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),
|
||||
utils.GetClientIP(r), "-", actualURL, err)
|
||||
iputil.GetClientIP(r), "-", actualURL, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 复制响应头
|
||||
copyHeader(w.Header(), resp.Header)
|
||||
w.Header().Set("Proxy-Go-Cache", "MISS")
|
||||
w.Header().Set("Proxy-Go-Cache-HIT", "0")
|
||||
|
||||
// 设置状态码
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
@ -183,9 +229,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),
|
||||
utils.GetClientIP(r), utils.FormatBytes(written),
|
||||
iputil.GetClientIP(r), utils.FormatBytes(written),
|
||||
utils.GetRequestSource(r), actualURL)
|
||||
|
||||
// 记录统计信息
|
||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, utils.GetClientIP(r), r)
|
||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, iputil.GetClientIP(r), r)
|
||||
}
|
||||
|
@ -11,10 +11,13 @@ 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"
|
||||
)
|
||||
|
||||
@ -22,9 +25,9 @@ const (
|
||||
// 超时时间常量
|
||||
clientConnTimeout = 10 * time.Second
|
||||
proxyRespTimeout = 60 * time.Second
|
||||
backendServTimeout = 40 * time.Second
|
||||
idleConnTimeout = 120 * time.Second
|
||||
tlsHandshakeTimeout = 10 * time.Second
|
||||
backendServTimeout = 30 * time.Second
|
||||
idleConnTimeout = 90 * time.Second
|
||||
tlsHandshakeTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// 添加 hop-by-hop 头部映射
|
||||
@ -40,17 +43,93 @@ 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
|
||||
client *http.Client
|
||||
startTime time.Time
|
||||
config *config.Config
|
||||
auth *authManager
|
||||
errorHandler ErrorHandler
|
||||
Cache *cache.CacheManager
|
||||
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])
|
||||
})
|
||||
}
|
||||
|
||||
// NewProxyHandler 创建新的代理处理器
|
||||
@ -62,29 +141,30 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: dialer.DialContext,
|
||||
MaxIdleConns: 1000,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
MaxIdleConns: maxIdleConns,
|
||||
MaxIdleConnsPerHost: maxIdleConnsPerHost,
|
||||
IdleConnTimeout: idleConnTimeout,
|
||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
MaxConnsPerHost: 200,
|
||||
MaxConnsPerHost: maxConnsPerHost,
|
||||
DisableKeepAlives: false,
|
||||
DisableCompression: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
WriteBufferSize: writeBufferSize,
|
||||
ReadBufferSize: readBufferSize,
|
||||
ResponseHeaderTimeout: backendServTimeout,
|
||||
MaxResponseHeaderBytes: 64 * 1024,
|
||||
MaxResponseHeaderBytes: 128 * 1024, // 增加响应头缓冲区
|
||||
}
|
||||
|
||||
// 设置HTTP/2传输配置
|
||||
http2Transport, err := http2.ConfigureTransports(transport)
|
||||
if err == nil && http2Transport != nil {
|
||||
http2Transport.ReadIdleTimeout = 10 * time.Second
|
||||
http2Transport.PingTimeout = 5 * time.Second
|
||||
http2Transport.ReadIdleTimeout = 30 * time.Second // 增加读空闲超时
|
||||
http2Transport.PingTimeout = 10 * time.Second // 增加ping超时
|
||||
http2Transport.AllowHTTP = false
|
||||
http2Transport.MaxReadFrameSize = 32 * 1024
|
||||
http2Transport.MaxReadFrameSize = maxReadFrameSize // 使用常量
|
||||
http2Transport.StrictMaxConcurrentStreams = true
|
||||
|
||||
}
|
||||
|
||||
// 初始化缓存管理器
|
||||
@ -93,8 +173,12 @@ 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,
|
||||
pathMap: cfg.MAP,
|
||||
prefixTree: newPrefixMatcher(cfg.MAP), // 初始化前缀匹配树
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: proxyRespTimeout,
|
||||
@ -105,10 +189,12 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
startTime: time.Now(),
|
||||
config: cfg,
|
||||
auth: newAuthManager(),
|
||||
Cache: cacheManager,
|
||||
startTime: time.Now(),
|
||||
config: cfg,
|
||||
auth: newAuthManager(),
|
||||
Cache: cacheManager,
|
||||
ruleService: ruleService,
|
||||
redirectHandler: NewRedirectHandler(ruleService), // 初始化302跳转处理器
|
||||
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)
|
||||
@ -118,9 +204,22 @@ 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
|
||||
log.Printf("[Config] 配置已更新并生效")
|
||||
|
||||
// 清理ExtensionMatcher缓存,确保使用新配置
|
||||
if handler.Cache != nil {
|
||||
handler.Cache.InvalidateAllExtensionMatchers()
|
||||
log.Printf("[Config] ExtensionMatcher缓存已清理")
|
||||
}
|
||||
|
||||
// 清理URL可访问性缓存和文件大小缓存
|
||||
utils.ClearAccessibilityCache()
|
||||
utils.ClearFileSizeCache()
|
||||
|
||||
log.Printf("[Config] 代理处理器配置已更新: %d 个路径映射", len(newCfg.MAP))
|
||||
})
|
||||
|
||||
return handler
|
||||
@ -150,35 +249,41 @@ 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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Proxy] %s %s -> %d (%s) from %s", r.Method, r.URL.Path, http.StatusOK, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
return
|
||||
}
|
||||
|
||||
// 查找匹配的代理路径
|
||||
var matchedPrefix string
|
||||
var pathConfig config.PathConfig
|
||||
// 使用前缀匹配树快速查找匹配的路径
|
||||
matchedPrefix, pathConfig, matched := h.prefixTree.match(r.URL.Path)
|
||||
|
||||
// 首先尝试完全匹配路径段
|
||||
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
|
||||
// 如果没有找到匹配,返回404
|
||||
if !matched {
|
||||
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)
|
||||
|
||||
@ -189,8 +294,15 @@ 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 := utils.GetTargetURL(h.client, r, pathConfig, decodedPath)
|
||||
targetBase, usedAltTarget := h.ruleService.GetTargetURL(h.client, r, pathConfig, decodedPath)
|
||||
|
||||
// 重新编码路径,保留 '/'
|
||||
parts := strings.Split(decodedPath, "/")
|
||||
@ -200,6 +312,11 @@ 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 {
|
||||
@ -214,34 +331,61 @@ 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")
|
||||
|
||||
// 根据 Accept 头设置合适的图片格式
|
||||
if strings.Contains(accept, "image/avif") {
|
||||
// 使用switch语句优化条件分支
|
||||
switch {
|
||||
case strings.Contains(accept, "image/avif"):
|
||||
proxyReq.Header.Set("Accept", "image/avif")
|
||||
} else if strings.Contains(accept, "image/webp") {
|
||||
case strings.Contains(accept, "image/webp"):
|
||||
proxyReq.Header.Set("Accept", "image/webp")
|
||||
}
|
||||
|
||||
// 设置 Cloudflare 特定的头部
|
||||
proxyReq.Header.Set("CF-Image-Format", "auto") // 让 Cloudflare 根据 Accept 头自动选择格式
|
||||
proxyReq.Header.Set("CF-Image-Format", "auto")
|
||||
}
|
||||
|
||||
// 设置其他必要的头部
|
||||
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)
|
||||
// 设置最小必要的代理头部
|
||||
clientIP := iputil.GetClientIP(r)
|
||||
proxyReq.Header.Set("X-Real-IP", clientIP)
|
||||
|
||||
// 添加或更新 X-Forwarded-For
|
||||
if clientIP := utils.GetClientIP(r); clientIP != "" {
|
||||
// 添加或更新 X-Forwarded-For - 减少重复获取客户端IP
|
||||
if clientIP != "" {
|
||||
if prior := proxyReq.Header.Get("X-Forwarded-For"); prior != "" {
|
||||
proxyReq.Header.Set("X-Forwarded-For", prior+", "+clientIP)
|
||||
} else {
|
||||
@ -262,35 +406,15 @@ 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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Proxy] ERR %s %s -> 408 (%s) timeout from %s", r.Method, r.URL.Path, iputil.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, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Proxy] ERR %s %s -> 502 (%s) proxy error from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -298,7 +422,26 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 复制响应头
|
||||
copyHeader(w.Header(), resp.Header)
|
||||
w.Header().Set("Proxy-Go-Cache", "MISS")
|
||||
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.WriteHeader(resp.StatusCode)
|
||||
@ -309,28 +452,47 @@ 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.Copy(w, teeReader)
|
||||
written, err = io.CopyBuffer(w, teeReader, buf)
|
||||
|
||||
if err == nil {
|
||||
h.Cache.Commit(cacheKey, cacheFile.Name(), resp, written)
|
||||
// 异步提交缓存,不阻塞当前请求处理
|
||||
fileName := cacheFile.Name()
|
||||
respClone := *resp // 创建响应的浅拷贝
|
||||
go func() {
|
||||
h.Cache.Commit(cacheKey, fileName, &respClone, written)
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
written, err = io.Copy(w, resp.Body)
|
||||
// 使用缓冲的复制提高性能
|
||||
bufSize := 32 * 1024 // 32KB 缓冲区
|
||||
buf := make([]byte, bufSize)
|
||||
|
||||
written, err = io.CopyBuffer(w, resp.Body, buf)
|
||||
if err != nil && !isConnectionClosed(err) {
|
||||
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
written, err = io.Copy(w, resp.Body)
|
||||
// 使用缓冲的复制提高性能
|
||||
bufSize := 32 * 1024 // 32KB 缓冲区
|
||||
buf := make([]byte, bufSize)
|
||||
|
||||
written, err = io.CopyBuffer(w, resp.Body, buf)
|
||||
if err != nil && !isConnectionClosed(err) {
|
||||
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 记录统计信息
|
||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), written, utils.GetClientIP(r), r)
|
||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), written, iputil.GetClientIP(r), r)
|
||||
}
|
||||
|
||||
func copyHeader(dst, src http.Header) {
|
||||
@ -347,9 +509,17 @@ func copyHeader(dst, src http.Header) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用局部 map 快速查找,跳过 hop-by-hop 头部
|
||||
// 添加需要过滤的安全头部
|
||||
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 头部和安全头部
|
||||
for k, vv := range src {
|
||||
if !hopHeaders[k] {
|
||||
if !hopHeaders[k] && !securityHeaders[k] {
|
||||
for _, v := range vv {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
|
124
internal/handler/redirect.go
Normal file
124
internal/handler/redirect.go
Normal file
@ -0,0 +1,124 @@
|
||||
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
|
||||
}
|
130
internal/handler/security.go
Normal file
130
internal/handler/security.go
Normal file
@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
14
internal/initapp/init.go
Normal file
14
internal/initapp/init.go
Normal file
@ -0,0 +1,14 @@
|
||||
package initapp
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
func Init(configPath string) error {
|
||||
|
||||
log.Printf("[Init] 开始初始化应用程序...")
|
||||
|
||||
// 迁移配置文件已移除,不再需要
|
||||
log.Printf("[Init] 应用程序初始化完成")
|
||||
return nil
|
||||
}
|
@ -10,12 +10,163 @@ import (
|
||||
"proxy-go/internal/utils"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"strconv"
|
||||
"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
|
||||
@ -24,10 +175,9 @@ type Collector struct {
|
||||
latencySum int64
|
||||
maxLatency int64 // 最大响应时间
|
||||
minLatency int64 // 最小响应时间
|
||||
pathStats sync.Map
|
||||
statusCodeStats sync.Map
|
||||
latencyBuckets sync.Map // 响应时间分布
|
||||
refererStats sync.Map // 引用来源统计
|
||||
statusCodeStats *StatusCodeStats
|
||||
latencyBuckets *LatencyBuckets // 使用结构体替代 sync.Map
|
||||
refererStats *RefererStats // 使用分片哈希表
|
||||
bandwidthStats struct {
|
||||
sync.RWMutex
|
||||
window time.Duration
|
||||
@ -36,10 +186,33 @@ 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
|
||||
@ -49,10 +222,13 @@ var (
|
||||
func InitCollector(cfg *config.Config) error {
|
||||
once.Do(func() {
|
||||
instance = &Collector{
|
||||
startTime: time.Now(),
|
||||
recentRequests: models.NewRequestQueue(100),
|
||||
config: cfg,
|
||||
minLatency: math.MaxInt64,
|
||||
startTime: time.Now(),
|
||||
recentRequests: models.NewRequestQueue(100),
|
||||
config: cfg,
|
||||
minLatency: math.MaxInt64,
|
||||
statusCodeStats: NewStatusCodeStats(),
|
||||
latencyBuckets: &LatencyBuckets{},
|
||||
refererStats: NewRefererStats(),
|
||||
}
|
||||
|
||||
// 初始化带宽统计
|
||||
@ -60,14 +236,37 @@ 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
|
||||
instance.latencyBuckets.Store(bucket, counter)
|
||||
// 根据 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化异步指标收集通道
|
||||
requestChan = make(chan RequestMetric, 10000)
|
||||
instance.startAsyncMetricsUpdater()
|
||||
|
||||
// 启动数据一致性检查器
|
||||
instance.startConsistencyChecker()
|
||||
|
||||
@ -92,131 +291,22 @@ func (c *Collector) EndRequest() {
|
||||
atomic.AddInt64(&c.activeRequests, -1)
|
||||
}
|
||||
|
||||
// RecordRequest 记录请求
|
||||
// RecordRequest 记录请求(异步写入channel)
|
||||
func (c *Collector) RecordRequest(path string, status int, latency time.Duration, bytes int64, clientIP string, r *http.Request) {
|
||||
// 更新状态码统计
|
||||
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)
|
||||
metric := RequestMetric{
|
||||
Path: path,
|
||||
Status: status,
|
||||
Latency: latency,
|
||||
Bytes: bytes,
|
||||
ClientIP: clientIP,
|
||||
Request: r,
|
||||
}
|
||||
|
||||
// 更新总字节数和带宽统计
|
||||
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"
|
||||
select {
|
||||
case requestChan <- metric:
|
||||
// ok
|
||||
default:
|
||||
bucketKey = "gt1s"
|
||||
// channel 满了,丢弃或降级处理
|
||||
}
|
||||
|
||||
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 格式化运行时间
|
||||
@ -249,78 +339,40 @@ func (c *Collector) GetStats() map[string]interface{} {
|
||||
|
||||
// 计算总请求数和平均延迟
|
||||
var totalRequests int64
|
||||
c.statusCodeStats.Range(func(key, value interface{}) bool {
|
||||
if counter, ok := value.(*int64); ok {
|
||||
totalRequests += atomic.LoadInt64(counter)
|
||||
} else {
|
||||
totalRequests += value.(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
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
avgLatency := float64(0)
|
||||
if totalRequests > 0 {
|
||||
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / 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]
|
||||
// 计算错误率
|
||||
errorRate := float64(0)
|
||||
if totalRequests > 0 {
|
||||
errorRate = float64(totalErrors) / float64(totalRequests)
|
||||
}
|
||||
|
||||
// 转换为值切片
|
||||
pathMetricsValues := make([]models.PathMetricsJSON, len(pathMetrics))
|
||||
for i, metric := range pathMetrics {
|
||||
pathMetricsValues[i] = metric.ToJSON()
|
||||
}
|
||||
// 计算当前会话的请求数(基于本次启动后的实际请求)
|
||||
sessionRequests := atomic.LoadInt64(&c.sessionRequests)
|
||||
|
||||
// 计算最近5分钟的平均每秒请求数
|
||||
requestsPerSecond := c.getRecentRequestsPerSecond()
|
||||
|
||||
// 收集状态码统计(已经在上面获取了)
|
||||
|
||||
// 收集引用来源统计
|
||||
var refererMetrics []*models.PathMetrics
|
||||
refererCount := 0
|
||||
c.refererStats.Range(func(key, value interface{}) bool {
|
||||
stats := value.(*models.PathMetrics)
|
||||
c.refererStats.Range(func(key string, value *models.PathMetrics) bool {
|
||||
stats := value
|
||||
requestCount := stats.GetRequestCount()
|
||||
if requestCount > 0 {
|
||||
totalLatency := stats.GetTotalLatency()
|
||||
@ -344,9 +396,9 @@ func (c *Collector) GetStats() map[string]interface{} {
|
||||
return refererMetrics[i].Path < refererMetrics[j].Path
|
||||
})
|
||||
|
||||
// 只保留前10个
|
||||
if len(refererMetrics) > 10 {
|
||||
refererMetrics = refererMetrics[:10]
|
||||
// 只保留前20个
|
||||
if len(refererMetrics) > 20 {
|
||||
refererMetrics = refererMetrics[:20]
|
||||
}
|
||||
|
||||
// 转换为值切片
|
||||
@ -357,21 +409,11 @@ func (c *Collector) GetStats() map[string]interface{} {
|
||||
|
||||
// 收集延迟分布
|
||||
latencyDistribution := make(map[string]int64)
|
||||
|
||||
// 确保所有桶都存在,即使计数为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
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
// 获取最近请求记录(使用读锁)
|
||||
recentRequests := c.recentRequests.GetAll()
|
||||
@ -389,6 +431,9 @@ 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)),
|
||||
@ -396,7 +441,6 @@ 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{}{
|
||||
@ -404,8 +448,9 @@ 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",
|
||||
"bandwidth_history": bandwidthHistory,
|
||||
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
|
||||
"current_session_requests": sessionRequests,
|
||||
}
|
||||
}
|
||||
|
||||
@ -443,36 +488,12 @@ func (c *Collector) validateLoadedData() error {
|
||||
|
||||
// 验证状态码统计
|
||||
var statusCodeTotal int64
|
||||
c.statusCodeStats.Range(func(key, value interface{}) bool {
|
||||
count := atomic.LoadInt64(value.(*int64))
|
||||
statusStats := c.statusCodeStats.GetStats()
|
||||
for _, count := range statusStats {
|
||||
if count < 0 {
|
||||
return false
|
||||
return fmt.Errorf("invalid negative status code count")
|
||||
}
|
||||
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
|
||||
@ -555,6 +576,76 @@ 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()
|
||||
@ -567,177 +658,137 @@ 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() {
|
||||
// 先立即执行一次清理
|
||||
c.cleanupOldData()
|
||||
|
||||
ticker := time.NewTicker(15 * time.Minute) // 每15分钟清理一次
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.cleanupOldData()
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// cleanupOldData 清理旧数据
|
||||
func (c *Collector) cleanupOldData() {
|
||||
log.Printf("[Metrics] 开始清理旧数据...")
|
||||
|
||||
// 清理路径统计 - 只保留有请求且请求数较多的路径
|
||||
var pathsToRemove []string
|
||||
var pathsCount int
|
||||
var totalRequests int64
|
||||
|
||||
// 先收集所有路径及其请求数
|
||||
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
|
||||
// 异步批量处理请求指标
|
||||
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]
|
||||
}
|
||||
}
|
||||
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)))
|
||||
}()
|
||||
}
|
||||
|
||||
// 批量更新指标
|
||||
func (c *Collector) updateMetricsBatch(batch []RequestMetric) {
|
||||
for _, m := range batch {
|
||||
// 增加当前会话请求计数
|
||||
atomic.AddInt64(&c.sessionRequests, 1)
|
||||
|
||||
// 更新请求窗口统计
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -2,47 +2,25 @@ package metrics
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"proxy-go/internal/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsStorage *MetricsStorage
|
||||
)
|
||||
|
||||
// InitMetricsStorage 初始化指标存储服务
|
||||
func InitMetricsStorage(cfg *config.Config) error {
|
||||
// 确保收集器已初始化
|
||||
func Init(cfg *config.Config) error {
|
||||
// 初始化收集器
|
||||
if err := InitCollector(cfg); err != nil {
|
||||
log.Printf("[Metrics] 初始化收集器失败: %v", err)
|
||||
//继续运行
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建指标存储服务
|
||||
dataDir := filepath.Join("data", "metrics")
|
||||
saveInterval := 30 * time.Minute // 默认30分钟保存一次,减少IO操作
|
||||
|
||||
metricsStorage = NewMetricsStorage(GetCollector(), dataDir, saveInterval)
|
||||
|
||||
// 启动指标存储服务
|
||||
if err := metricsStorage.Start(); err != nil {
|
||||
log.Printf("[Metrics] 启动指标存储服务失败: %v", err)
|
||||
// 初始化指标存储服务
|
||||
if err := InitMetricsStorage(cfg); err != nil {
|
||||
log.Printf("[Metrics] 初始化指标存储服务失败: %v", err)
|
||||
//继续运行
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval)
|
||||
log.Printf("[Metrics] 初始化完成")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopMetricsStorage 停止指标存储服务
|
||||
func StopMetricsStorage() {
|
||||
if metricsStorage != nil {
|
||||
metricsStorage.Stop()
|
||||
log.Printf("[Metrics] 指标存储服务已停止")
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetricsStorage 获取指标存储服务实例
|
||||
func GetMetricsStorage() *MetricsStorage {
|
||||
return metricsStorage
|
||||
}
|
||||
|
44
internal/metrics/metricsstorage.go
Normal file
44
internal/metrics/metricsstorage.go
Normal file
@ -0,0 +1,44 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"proxy-go/internal/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsStorage *MetricsStorage
|
||||
)
|
||||
|
||||
// InitMetricsStorage 初始化指标存储服务
|
||||
func InitMetricsStorage(cfg *config.Config) error {
|
||||
|
||||
// 创建指标存储服务
|
||||
dataDir := filepath.Join("data", "metrics")
|
||||
saveInterval := 30 * time.Minute // 默认30分钟保存一次,减少IO操作
|
||||
|
||||
metricsStorage = NewMetricsStorage(GetCollector(), dataDir, saveInterval)
|
||||
|
||||
// 启动指标存储服务
|
||||
if err := metricsStorage.Start(); err != nil {
|
||||
log.Printf("[Metrics] 启动指标存储服务失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopMetricsStorage 停止指标存储服务
|
||||
func StopMetricsStorage() {
|
||||
if metricsStorage != nil {
|
||||
metricsStorage.Stop()
|
||||
log.Printf("[Metrics] 指标存储服务已停止")
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetricsStorage 获取指标存储服务实例
|
||||
func GetMetricsStorage() *MetricsStorage {
|
||||
return metricsStorage
|
||||
}
|
@ -6,9 +6,9 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"proxy-go/internal/models"
|
||||
"proxy-go/internal/utils"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -16,17 +16,14 @@ import (
|
||||
|
||||
// MetricsStorage 指标存储结构
|
||||
type MetricsStorage struct {
|
||||
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
|
||||
collector *Collector
|
||||
saveInterval time.Duration
|
||||
dataDir string
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
lastSaveTime time.Time
|
||||
mutex sync.RWMutex
|
||||
statusCodeFile string
|
||||
}
|
||||
|
||||
// NewMetricsStorage 创建新的指标存储
|
||||
@ -36,14 +33,11 @@ func NewMetricsStorage(collector *Collector, dataDir string, saveInterval time.D
|
||||
}
|
||||
|
||||
return &MetricsStorage{
|
||||
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"),
|
||||
collector: collector,
|
||||
saveInterval: saveInterval,
|
||||
dataDir: dataDir,
|
||||
stopChan: make(chan struct{}),
|
||||
statusCodeFile: filepath.Join(dataDir, "status_codes.json"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,40 +100,12 @@ 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 {
|
||||
@ -150,10 +116,6 @@ func (ms *MetricsStorage) SaveMetrics() error {
|
||||
}
|
||||
}
|
||||
|
||||
ms.mutex.Lock()
|
||||
ms.lastSaveTime = time.Now()
|
||||
ms.mutex.Unlock()
|
||||
|
||||
// 强制进行一次GC
|
||||
runtime.GC()
|
||||
|
||||
@ -171,157 +133,79 @@ func (ms *MetricsStorage) LoadMetrics() error {
|
||||
start := time.Now()
|
||||
log.Printf("[MetricsStorage] 开始加载指标数据...")
|
||||
|
||||
// 检查文件是否存在
|
||||
if !fileExists(ms.metricsFile) {
|
||||
return fmt.Errorf("指标数据文件不存在")
|
||||
}
|
||||
// 不再加载 basicMetrics(metrics.json)
|
||||
|
||||
// 加载基本指标
|
||||
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. 加载状态码统计(如果文件存在)
|
||||
// 1. 加载状态码统计(如果文件存在)
|
||||
if fileExists(ms.statusCodeFile) {
|
||||
var statusCodeStats map[string]interface{}
|
||||
if err := loadJSONFromFile(ms.statusCodeFile, &statusCodeStats); err != nil {
|
||||
log.Printf("[MetricsStorage] 加载状态码统计失败: %v", err)
|
||||
} else {
|
||||
for statusCode, count := range statusCodeStats {
|
||||
countValue, ok := count.(float64)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 由于新的 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
|
||||
}
|
||||
|
||||
// 创建或更新状态码统计
|
||||
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)
|
||||
// 手动设置到新的 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++
|
||||
}
|
||||
}
|
||||
log.Printf("[MetricsStorage] 加载了 %d 条状态码统计", len(statusCodeStats))
|
||||
log.Printf("[MetricsStorage] 成功加载了 %d 条状态码统计", loadedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
// 不再加载引用来源统计,因为它现在只保存在内存中
|
||||
|
||||
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. 加载延迟分布(如果文件存在)
|
||||
// 3. 加载延迟分布(如果文件存在)
|
||||
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
|
||||
}
|
||||
|
||||
if counter, ok := ms.collector.latencyBuckets.Load(bucket); ok {
|
||||
atomic.StoreInt64(counter.(*int64), int64(countValue))
|
||||
// 根据桶名称设置对应的值
|
||||
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))
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
|
86
internal/middleware/security.go
Normal file
86
internal/middleware/security.go
Normal file
@ -0,0 +1,86 @@
|
||||
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)
|
||||
}
|
@ -19,6 +19,7 @@ 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 序列化的路径统计信息
|
||||
@ -28,6 +29,7 @@ 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 获取请求数
|
||||
@ -77,7 +79,8 @@ func (p *PathMetrics) ToJSON() PathMetricsJSON {
|
||||
RequestCount: p.RequestCount.Load(),
|
||||
ErrorCount: p.ErrorCount.Load(),
|
||||
BytesTransferred: p.BytesTransferred.Load(),
|
||||
AvgLatency: p.AvgLatency,
|
||||
AvgLatency: p.AvgLatency,
|
||||
LastAccessTime: p.LastAccessTime.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
|
278
internal/security/rate_limiter.go
Normal file
278
internal/security/rate_limiter.go
Normal file
@ -0,0 +1,278 @@
|
||||
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封禁管理器已停止")
|
||||
}
|
240
internal/service/rule_service.go
Normal file
240
internal/service/rule_service.go
Normal file
@ -0,0 +1,240 @@
|
||||
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 ""
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
@ -6,80 +6,211 @@ 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 (
|
||||
// 文件大小缓存,过期时间5分钟
|
||||
sizeCache sync.Map
|
||||
// 可访问性缓存,过期时间30秒
|
||||
accessCache sync.Map
|
||||
cacheTTL = 5 * time.Minute
|
||||
accessTTL = 30 * time.Second
|
||||
maxCacheSize = 10000 // 最大缓存条目数
|
||||
sizeCache sync.Map
|
||||
accessCache sync.Map
|
||||
cacheTTL = 5 * time.Minute
|
||||
accessTTL = 2 * time.Minute
|
||||
)
|
||||
|
||||
// 清理过期缓存
|
||||
// 初始化函数
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
// 清理文件大小缓存
|
||||
var items []struct {
|
||||
key interface{}
|
||||
timestamp time.Time
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 启动定期清理缓存的协程
|
||||
GoSafe(func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 清理可访问性缓存
|
||||
accessCache.Range(func(key, value interface{}) bool {
|
||||
cache := value.(accessibilityCache)
|
||||
if now.Sub(cache.timestamp) > accessTTL {
|
||||
accessCache.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 清理可访问性缓存
|
||||
accessCache.Range(func(key, value interface{}) bool {
|
||||
if cache, ok := value.(accessibilityCache); ok {
|
||||
if now.Sub(cache.timestamp) > accessTTL {
|
||||
accessCache.Delete(key)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateRequestID 生成唯一的请求ID
|
||||
@ -92,21 +223,11 @@ 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)
|
||||
@ -144,7 +265,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 {
|
||||
@ -182,127 +303,122 @@ func GetFileSize(client *http.Client, url string) (int64, error) {
|
||||
return resp.ContentLength, nil
|
||||
}
|
||||
|
||||
// GetTargetURL 根据路径和配置决定目标URL
|
||||
func GetTargetURL(client *http.Client, r *http.Request, pathConfig config.PathConfig, path string) string {
|
||||
// 默认使用默认目标
|
||||
targetBase := pathConfig.DefaultTarget
|
||||
|
||||
// 如果配置了扩展名映射
|
||||
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
|
||||
}
|
||||
|
||||
// 如果没有设置最小阈值,使用默认值 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)
|
||||
}
|
||||
|
||||
return targetBase
|
||||
// ExtensionMatcher 扩展名匹配器,用于优化扩展名匹配性能
|
||||
type ExtensionMatcher struct {
|
||||
exactMatches map[string][]*config.ExtensionRule // 精确匹配的扩展名
|
||||
wildcardRules []*config.ExtensionRule // 通配符规则
|
||||
hasRedirectRule bool // 是否有任何302跳转规则
|
||||
}
|
||||
|
||||
// isTargetAccessible 检查目标URL是否可访问
|
||||
func isTargetAccessible(client *http.Client, url string) bool {
|
||||
// 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)
|
||||
}
|
||||
matcher.exactMatches[ext] = append(matcher.exactMatches[ext], rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预排序所有规则组
|
||||
for ext := range matcher.exactMatches {
|
||||
sortRulesByThreshold(matcher.exactMatches[ext])
|
||||
}
|
||||
sortRulesByThreshold(matcher.wildcardRules)
|
||||
|
||||
return matcher
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 先查缓存
|
||||
if cache, ok := accessCache.Load(url); ok {
|
||||
if cache, ok := accessCache.Load(targetURL); ok {
|
||||
cacheItem := cache.(accessibilityCache)
|
||||
if time.Since(cacheItem.timestamp) < accessTTL {
|
||||
return cacheItem.accessible
|
||||
}
|
||||
accessCache.Delete(url)
|
||||
accessCache.Delete(targetURL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("HEAD", url, nil)
|
||||
req, err := http.NewRequest("HEAD", targetURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("[Check] Failed to create request for %s: %v", url, err)
|
||||
log.Printf("[Check] Failed to create request for %s: %v", targetURL, err)
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// 添加浏览器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)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[Check] Failed to access %s: %v", url, err)
|
||||
log.Printf("[Check] Failed to access %s: %v", targetURL, err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
accessible := resp.StatusCode >= 200 && resp.StatusCode < 400
|
||||
// 缓存结果
|
||||
accessCache.Store(url, accessibilityCache{
|
||||
accessCache.Store(targetURL, accessibilityCache{
|
||||
accessible: accessible,
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
@ -343,6 +459,26 @@ 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 {
|
||||
@ -368,3 +504,29 @@ 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
121
main.go
@ -10,9 +10,12 @@ 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"
|
||||
)
|
||||
|
||||
@ -25,36 +28,72 @@ type Route struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg, err := config.Load("data/config.json")
|
||||
|
||||
// 初始化应用程序(包括配置迁移)
|
||||
configPath := "data/config.json"
|
||||
initapp.Init(configPath)
|
||||
|
||||
// 初始化配置管理器
|
||||
configManager, err := config.Init(configPath)
|
||||
if err != nil {
|
||||
log.Fatal("Error loading config:", err)
|
||||
log.Fatal("Error initializing config manager:", err)
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
cfg := configManager.GetConfig()
|
||||
|
||||
// 更新常量配置
|
||||
constants.UpdateFromConfig(cfg)
|
||||
|
||||
// 初始化指标收集器
|
||||
if err := metrics.InitCollector(cfg); err != nil {
|
||||
log.Fatal("Error initializing metrics collector:", err)
|
||||
}
|
||||
// 初始化统计服务
|
||||
metrics.Init(cfg)
|
||||
|
||||
// 初始化指标存储服务
|
||||
if err := metrics.InitMetricsStorage(cfg); err != nil {
|
||||
log.Printf("Warning: Failed to initialize metrics storage: %v", err)
|
||||
// 不致命,继续运行
|
||||
}
|
||||
|
||||
// 创建压缩管理器
|
||||
// 创建压缩管理器(使用atomic.Value来支持动态更新)
|
||||
var compManagerAtomic atomic.Value
|
||||
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},
|
||||
@ -65,8 +104,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", handler.NewConfigHandler(cfg).ServeHTTP, true},
|
||||
{http.MethodPost, "/admin/api/config/save", handler.NewConfigHandler(cfg).ServeHTTP, true},
|
||||
{http.MethodGet, "/admin/api/config/get", configHandler.ServeHTTP, true},
|
||||
{http.MethodPost, "/admin/api/config/save", configHandler.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},
|
||||
@ -74,11 +113,41 @@ 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 {
|
||||
@ -147,10 +216,21 @@ func main() {
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// 添加压缩中间件
|
||||
// 构建中间件链
|
||||
var handler http.Handler = mainHandler
|
||||
|
||||
// 添加安全中间件(最外层,优先级最高)
|
||||
if securityMiddleware != nil {
|
||||
handler = securityMiddleware.IPBanMiddleware(handler)
|
||||
}
|
||||
|
||||
// 添加压缩中间件
|
||||
if cfg.Compression.Gzip.Enabled || cfg.Compression.Brotli.Enabled {
|
||||
handler = middleware.CompressionMiddleware(compManager)(handler)
|
||||
// 创建动态压缩中间件包装器
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentCompManager := compManagerAtomic.Load().(compression.Manager)
|
||||
middleware.CompressionMiddleware(currentCompManager)(handler).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// 创建服务器
|
||||
@ -166,6 +246,11 @@ func main() {
|
||||
<-sigChan
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// 停止安全管理器
|
||||
if banManager != nil {
|
||||
banManager.Stop()
|
||||
}
|
||||
|
||||
// 停止指标存储服务
|
||||
metrics.StopMetricsStorage()
|
||||
|
||||
|
144
readme.md
144
readme.md
@ -2,21 +2,14 @@
|
||||
|
||||
A 'simple' reverse proxy server written in Go.
|
||||
|
||||
使用方法: https://www.q58.club/t/topic/165
|
||||
使用方法: https://www.sunai.net/t/topic/165
|
||||
|
||||
```
|
||||
最新镜像地址: woodchen/proxy-go:latest
|
||||
|
||||
稳定版镜像地址: woodchen/proxy-go:v1.0.4
|
||||
持续稳定版镜像地址: woodchen/proxy-go:stable
|
||||
```
|
||||
|
||||
## 新版统计仪表盘
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## 图片
|
||||
|
||||

|
||||
@ -25,19 +18,14 @@ A 'simple' reverse proxy server written in Go.
|
||||
|
||||

|
||||
|
||||
|
||||
### 配置页
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
### 缓存页
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## 说明
|
||||
|
||||
1. 支持gzip和brotli压缩
|
||||
@ -48,5 +36,135 @@ 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
|
||||
|
||||
|
||||
|
||||
|
469
web/app/dashboard/cache/page.tsx
vendored
469
web/app/dashboard/cache/page.tsx
vendored
@ -7,7 +7,24 @@ 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
|
||||
@ -17,6 +34,9 @@ interface CacheStats {
|
||||
hit_rate: number
|
||||
bytes_saved: number
|
||||
enabled: boolean
|
||||
format_fallback_hit: number
|
||||
image_cache_hit: number
|
||||
regular_cache_hit: number
|
||||
}
|
||||
|
||||
interface CacheConfig {
|
||||
@ -256,15 +276,19 @@ export default function CachePage() {
|
||||
|
||||
const config = configs[type]
|
||||
return (
|
||||
<div className="space-y-4 mt-4">
|
||||
<h3 className="text-sm font-medium">缓存配置</h3>
|
||||
<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="grid gap-4">
|
||||
<div className="grid grid-cols-2 items-center gap-4">
|
||||
<Label htmlFor={`${type}-max-age`}>最大缓存时间(分钟)</Label>
|
||||
<Label htmlFor={`${type}-max-age`} className="text-sm">最大缓存时间(分钟)</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)
|
||||
@ -274,11 +298,12 @@ export default function CachePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-center gap-4">
|
||||
<Label htmlFor={`${type}-cleanup-tick`}>清理间隔(分钟)</Label>
|
||||
<Label htmlFor={`${type}-cleanup-tick`} className="text-sm">清理间隔(分钟)</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)
|
||||
@ -288,11 +313,12 @@ export default function CachePage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-center gap-4">
|
||||
<Label htmlFor={`${type}-max-cache-size`}>最大缓存大小(GB)</Label>
|
||||
<Label htmlFor={`${type}-max-cache-size`} className="text-sm">最大缓存大小(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)
|
||||
@ -310,6 +336,7 @@ 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>
|
||||
@ -318,112 +345,352 @@ export default function CachePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => handleClearCache("all")}>
|
||||
清理所有缓存
|
||||
</Button>
|
||||
</div>
|
||||
<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="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>
|
||||
{/* 智能缓存汇总 */}
|
||||
<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>
|
||||
</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?.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")}
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 镜像缓存 */}
|
||||
<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 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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
{renderCacheConfig("mirror")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ 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
|
||||
@ -17,13 +18,6 @@ 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
|
||||
@ -37,20 +31,17 @@ 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
|
||||
}>
|
||||
}
|
||||
|
||||
@ -156,6 +147,18 @@ 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>
|
||||
@ -164,6 +167,14 @@ 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>
|
||||
@ -187,10 +198,8 @@ 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.requests_per_second.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-500">当前带宽</div>
|
||||
<div className="text-lg font-semibold">{metrics.current_bandwidth}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -199,7 +208,10 @@ export default function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>状态码统计</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
@ -325,70 +337,16 @@ 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>引用来源统计 (Top {metrics.top_referers.length})</CardTitle>
|
||||
<CardTitle>
|
||||
引用来源统计
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 align-middle">
|
||||
(近24小时, 共 {metrics.top_referers.length} 条记录)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
@ -398,24 +356,49 @@ 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.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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -423,47 +406,6 @@ 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>
|
||||
@ -508,7 +450,11 @@ 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">{req.ClientIP}</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -548,9 +494,30 @@ 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"
|
||||
}
|
||||
}
|
386
web/app/dashboard/security/page.tsx
Normal file
386
web/app/dashboard/security/page.tsx
Normal file
@ -0,0 +1,386 @@
|
||||
"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地址 “{unbanning}” 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => unbanning && unbanIP(unbanning)}>
|
||||
确认解封
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -8,57 +8,102 @@ body {
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--background: 30 12.5000% 96.8627%;
|
||||
--foreground: 0 0% 0%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 0%;
|
||||
--popover: 0 0% 100%;
|
||||
--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%;
|
||||
--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;
|
||||
--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% 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%;
|
||||
--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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,6 +55,12 @@ export function Nav() {
|
||||
>
|
||||
缓存
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/security"
|
||||
className={pathname === "/dashboard/security" ? "text-primary" : "text-muted-foreground"}
|
||||
>
|
||||
安全
|
||||
</Link>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={handleLogout}>
|
||||
退出登录
|
||||
|
32
web/components/ui/tooltip.tsx
Normal file
32
web/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"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
425
web/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"@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",
|
||||
@ -1542,6 +1543,397 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -1575,6 +1967,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
@ -18,6 +18,7 @@
|
||||
"@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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user