mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 08:31:55 +08:00
Compare commits
119 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 | |||
26af4b2b07 | |||
512ec6707d | |||
a4067a6c66 | |||
0d10e89a0b | |||
0ce0f75b58 | |||
8fb5dec9a4 | |||
cda5db91c3 | |||
10aef5e73e | |||
|
09173f4b0b | ||
22c0d2e301 | |||
006fa9a172 | |||
2a41458bb8 | |||
d2e5020d22 | |||
f2e1b8cbf5 | |||
2cb88a4f5e | |||
b6b77b03ed | |||
f0c806292b | |||
e67a3880f5 | |||
a0cea8f5b8 | |||
095b087fd8 | |||
4276709b3f | |||
015aa6bc15 | |||
|
34c561029c | ||
|
906dea0bcb | ||
5aeaa50c49 | |||
dc5ccbd98d | |||
3810153f8e | |||
|
4418d8d146 | ||
4af1592021 | |||
f614692f33 | |||
929d13157d | |||
|
ec07ae094e | ||
6bc6edfd37 | |||
ed9f3be1ce | |||
627a63caad | |||
f7a52a1be5 | |||
69050adf57 | |||
429664b598 | |||
00f4605e1c | |||
76b549aa90 | |||
|
a5d6543952 | ||
ed63121a00 | |||
55d3a9cebc | |||
d0d752712e | |||
ff24191146 | |||
4b1c774509 | |||
9de17edcbd | |||
dd57ec2bd5 | |||
bee914fc61 | |||
e9aad806f8 | |||
076ff7c269 | |||
92910a608f | |||
a4437b9a39 | |||
621900d227 | |||
5c3fb00d57 | |||
41f5b82661 |
109
.github/workflows/docker-version-build.yml
vendored
Normal file
109
.github/workflows/docker-version-build.yml
vendored
Normal file
@ -0,0 +1,109 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
paths-ignore: [ '**.md','docker-compose.yml' ]
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build web
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-out
|
||||
path: web/out
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
include:
|
||||
- arch: amd64
|
||||
goarch: amd64
|
||||
- arch: arm64
|
||||
goarch: arm64
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: linux
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
go build -o proxy-go-${{ matrix.arch }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: proxy-go-${{ matrix.arch }}
|
||||
path: proxy-go-${{ matrix.arch }}
|
||||
|
||||
docker:
|
||||
needs: [build-web, build-backend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: woodchen
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create Docker build context
|
||||
run: |
|
||||
mkdir -p docker-context
|
||||
cp Dockerfile docker-context/
|
||||
cp proxy-go-amd64/proxy-go-amd64 docker-context/proxy-go.amd64
|
||||
cp proxy-go-arm64/proxy-go-arm64 docker-context/proxy-go.arm64
|
||||
mkdir -p docker-context/web/out
|
||||
cp -r web-out/* docker-context/web/out/
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: docker-context
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
woodchen/proxy-go:${{ github.ref_name }}
|
||||
woodchen/proxy-go:stable
|
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,70 +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"
|
||||
}
|
||||
],
|
||||
"Metrics": {
|
||||
"Password": "admin123",
|
||||
"TokenExpiry": 86400,
|
||||
"FeishuWebhook": "https://open.feishu.cn/open-apis/bot/v2/hook/****",
|
||||
"Alert": {
|
||||
"WindowSize": 12,
|
||||
"WindowInterval": "5m",
|
||||
"DedupeWindow": "15m",
|
||||
"MinRequests": 10,
|
||||
"ErrorRate": 0.8,
|
||||
"AlertInterval": "24h"
|
||||
},
|
||||
"Latency": {
|
||||
"SmallFileSize": 1048576,
|
||||
"MediumFileSize": 10485760,
|
||||
"LargeFileSize": 104857600,
|
||||
"SmallLatency": "3s",
|
||||
"MediumLatency": "8s",
|
||||
"LargeLatency": "30s",
|
||||
"HugeLatency": "300s"
|
||||
},
|
||||
"Performance": {
|
||||
"MaxRequestsPerMinute": 1000,
|
||||
"MaxBytesPerMinute": 104857600,
|
||||
"MaxSaveInterval": "15m"
|
||||
},
|
||||
"Validation": {
|
||||
"max_error_rate": 0.8,
|
||||
"max_data_deviation": 0.01
|
||||
}
|
||||
}
|
||||
}
|
@ -6,19 +6,12 @@ services:
|
||||
- "3336:3336"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./favicon:/app/favicon
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3336/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
- OAUTH_CLIENT_ID=your_client_id
|
||||
- OAUTH_CLIENT_SECRET=your_client_secret
|
||||
#填写公网访问的地址, 需要跟CZL Connect保持一致.
|
||||
#选填, 不填为自动获取
|
||||
- OAUTH_REDIRECT_URI=https://localhost:3336/admin/api/oauth/callback
|
||||
restart: always
|
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.35.0
|
||||
github.com/woodchen-ink/go-web-utils v1.0.0
|
||||
golang.org/x/net v0.40.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.22.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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
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,55 +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
|
||||
c.FixedPaths = newConfig.FixedPaths
|
||||
c.Metrics = newConfig.Metrics
|
||||
|
||||
// 触发回调
|
||||
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"`
|
||||
FixedPaths []FixedPathConfig `json:"FixedPaths"`
|
||||
Metrics MetricsConfig `json:"Metrics"`
|
||||
Security SecurityConfig `json:"Security"` // 安全配置
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
DefaultTarget string `json:"DefaultTarget"` // 默认回源地址
|
||||
ExtensionMap map[string]string `json:"ExtensionMap"` // 特定后缀的回源地址
|
||||
SizeThreshold int64 `json:"SizeThreshold"` // 文件大小阈值(字节),超过此大小才使用ExtensionMap
|
||||
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,100 +37,99 @@ type CompressorConfig struct {
|
||||
Level int `json:"Level"`
|
||||
}
|
||||
|
||||
type FixedPathConfig struct {
|
||||
Path string `json:"Path"`
|
||||
TargetHost string `json:"TargetHost"`
|
||||
TargetURL string `json:"TargetURL"`
|
||||
type SecurityConfig struct {
|
||||
IPBan IPBanConfig `json:"IPBan"` // IP封禁配置
|
||||
}
|
||||
|
||||
// MetricsConfig 监控配置
|
||||
type MetricsConfig struct {
|
||||
Password string `json:"Password"` // 管理密码
|
||||
TokenExpiry int `json:"TokenExpiry"` // Token过期时间(秒)
|
||||
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"` // 清理间隔(分钟)
|
||||
}
|
||||
|
||||
// 添加一个辅助方法来处理字符串到 PathConfig 的转换
|
||||
func (c *Config) UnmarshalJSON(data []byte) error {
|
||||
// 创建一个临时结构来解析原始JSON
|
||||
type TempConfig struct {
|
||||
MAP map[string]json.RawMessage `json:"MAP"`
|
||||
Compression CompressionConfig `json:"Compression"`
|
||||
FixedPaths []FixedPathConfig `json:"FixedPaths"`
|
||||
Metrics MetricsConfig `json:"Metrics"`
|
||||
}
|
||||
|
||||
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
|
||||
c.FixedPaths = temp.FixedPaths
|
||||
c.Metrics = temp.Metrics
|
||||
|
||||
return nil
|
||||
// 扩展名映射配置结构
|
||||
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
|
||||
}
|
@ -4,27 +4,72 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"proxy-go/internal/utils"
|
||||
"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 {
|
||||
ID interface{} `json:"id,omitempty"` // 使用interface{}以接受数字或字符串
|
||||
Email string `json:"email,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"` // 添加avatar字段
|
||||
Admin bool `json:"admin,omitempty"`
|
||||
Moderator bool `json:"moderator,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Upstreams interface{} `json:"upstreams,omitempty"` // 添加upstreams字段
|
||||
|
||||
// 添加可能的替代字段名
|
||||
Sub string `json:"sub,omitempty"`
|
||||
PreferredName string `json:"preferred_username,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
}
|
||||
|
||||
type OAuthToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
type tokenInfo struct {
|
||||
createdAt time.Time
|
||||
expiresIn time.Duration
|
||||
username string
|
||||
}
|
||||
|
||||
type stateInfo struct {
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type authManager struct {
|
||||
tokens sync.Map
|
||||
states sync.Map
|
||||
}
|
||||
|
||||
func newAuthManager() *authManager {
|
||||
am := &authManager{}
|
||||
// 启动token清理goroutine
|
||||
go am.cleanExpiredTokens()
|
||||
go am.cleanExpiredStates()
|
||||
return am
|
||||
}
|
||||
|
||||
@ -34,10 +79,32 @@ func (am *authManager) generateToken() string {
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (am *authManager) addToken(token string, expiry time.Duration) {
|
||||
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(),
|
||||
expiresIn: expiry,
|
||||
username: username,
|
||||
})
|
||||
}
|
||||
|
||||
@ -66,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)
|
||||
@ -75,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
|
||||
}
|
||||
@ -83,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{
|
||||
@ -96,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
|
||||
}
|
||||
@ -112,42 +193,229 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AuthHandler 处理认证请求
|
||||
func (h *ProxyHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
log.Printf("[Auth] ERR %s %s -> 405 (%s) method not allowed", r.Method, r.URL.Path, utils.GetClientIP(r))
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
// getCallbackURL 从请求中获取回调地址
|
||||
func getCallbackURL(r *http.Request) string {
|
||||
if redirectURI := os.Getenv("OAUTH_REDIRECT_URI"); redirectURI != "" {
|
||||
// 验证URI格式
|
||||
if _, err := url.Parse(redirectURI); err == nil {
|
||||
log.Printf("[Auth] DEBUG Using configured OAUTH_REDIRECT_URI: %s", redirectURI)
|
||||
return redirectURI
|
||||
}
|
||||
log.Printf("[Auth] WARNING Invalid OAUTH_REDIRECT_URI format: %s", redirectURI)
|
||||
}
|
||||
|
||||
// 解析表单数据
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Printf("[Auth] ERR %s %s -> 400 (%s) form parse error", r.Method, r.URL.Path, utils.GetClientIP(r))
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
// 更可靠地检测协议
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
password := r.FormValue("password")
|
||||
if password == "" {
|
||||
log.Printf("[Auth] ERR %s %s -> 400 (%s) empty password", r.Method, r.URL.Path, utils.GetClientIP(r))
|
||||
http.Error(w, "Password is required", http.StatusBadRequest)
|
||||
return
|
||||
// 考虑X-Forwarded-Host头
|
||||
host := r.Host
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
|
||||
if password != h.config.Metrics.Password {
|
||||
log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid password", r.Method, r.URL.Path, utils.GetClientIP(r))
|
||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := h.auth.generateToken()
|
||||
h.auth.addToken(token, time.Duration(h.config.Metrics.TokenExpiry)*time.Second)
|
||||
|
||||
log.Printf("[Auth] %s %s -> 200 (%s) login success", r.Method, r.URL.Path, utils.GetClientIP(r))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"token": token,
|
||||
})
|
||||
callbackURL := fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, host)
|
||||
log.Printf("[Auth] DEBUG Generated callback URL: %s", callbackURL)
|
||||
return callbackURL
|
||||
}
|
||||
|
||||
// LoginHandler 处理登录请求,重定向到 OAuth 授权页面
|
||||
func (h *ProxyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
state := h.auth.generateState()
|
||||
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())
|
||||
|
||||
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// OAuthCallbackHandler 处理 OAuth 回调
|
||||
func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
// 记录完整请求信息
|
||||
log.Printf("[Auth] DEBUG %s %s -> Callback received with state=%s, code=%s, full URL: %s",
|
||||
r.Method, r.URL.Path, state, code, r.URL.String())
|
||||
|
||||
// 验证 state
|
||||
if !h.auth.validateState(state) {
|
||||
log.Printf("[Auth] ERR %s %s -> 400 (%s) invalid state '%s' from %s",
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), state, utils.GetRequestSource(r))
|
||||
http.Error(w, "Invalid state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证code参数
|
||||
if code == "" {
|
||||
log.Printf("[Auth] ERR %s %s -> 400 (%s) missing code parameter from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Missing code parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取访问令牌
|
||||
redirectURI := getCallbackURL(r)
|
||||
clientID := os.Getenv("OAUTH_CLIENT_ID")
|
||||
clientSecret := os.Getenv("OAUTH_CLIENT_SECRET")
|
||||
|
||||
// 验证OAuth配置
|
||||
if clientID == "" || clientSecret == "" {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) missing OAuth credentials from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
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"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {clientSecret},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get access token: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to get access token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 读取错误响应内容
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("[Auth] ERR %s %s -> %d (%s) OAuth server returned error: %s, response: %s",
|
||||
r.Method, r.URL.Path, resp.StatusCode, iputil.GetClientIP(r), resp.Status, string(bodyBytes))
|
||||
http.Error(w, "OAuth server error: "+resp.Status, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var token OAuthToken
|
||||
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse token response: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to parse token response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证访问令牌
|
||||
if token.AccessToken == "" {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) received empty access token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Received invalid token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
req, _ := http.NewRequest("GET", "https://connect.czl.net/api/oauth2/userinfo", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
userResp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get user info: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to get user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
|
||||
// 检查用户信息响应状态码
|
||||
if userResp.StatusCode != http.StatusOK {
|
||||
log.Printf("[Auth] ERR %s %s -> %d (%s) userinfo endpoint returned error status: %s from %s",
|
||||
r.Method, r.URL.Path, userResp.StatusCode, iputil.GetClientIP(r), userResp.Status, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to get user info: "+userResp.Status, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取响应体内容并记录
|
||||
bodyBytes, err := io.ReadAll(userResp.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to read user info response body: %v from %s",
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to read user info response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录响应内容(小心敏感信息)
|
||||
log.Printf("[Auth] DEBUG %s %s -> user info response: %s", r.Method, r.URL.Path, string(bodyBytes))
|
||||
|
||||
// 使用更灵活的方式解析JSON
|
||||
var rawUserInfo map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &rawUserInfo); err != nil {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse raw user info: %v from %s",
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
|
||||
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建用户信息对象
|
||||
userInfo := OAuthUserInfo{}
|
||||
|
||||
// 填充用户名(优先级:username > preferred_username > sub > email)
|
||||
if username, ok := rawUserInfo["username"].(string); ok && username != "" {
|
||||
userInfo.Username = username
|
||||
} else if preferred, ok := rawUserInfo["preferred_username"].(string); ok && preferred != "" {
|
||||
userInfo.Username = preferred
|
||||
} else if sub, ok := rawUserInfo["sub"].(string); ok && sub != "" {
|
||||
userInfo.Username = sub
|
||||
} else if email, ok := rawUserInfo["email"].(string); ok && email != "" {
|
||||
// 从邮箱中提取用户名
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) > 0 {
|
||||
userInfo.Username = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 填充头像URL
|
||||
if avatar, ok := rawUserInfo["avatar"].(string); ok && avatar != "" {
|
||||
userInfo.Avatar = avatar
|
||||
} else if avatarURL, ok := rawUserInfo["avatar_url"].(string); ok && avatarURL != "" {
|
||||
userInfo.AvatarURL = avatarURL
|
||||
} else if picture, ok := rawUserInfo["picture"].(string); ok && picture != "" {
|
||||
userInfo.Picture = picture
|
||||
}
|
||||
|
||||
// 验证用户信息
|
||||
if userInfo.Username == "" {
|
||||
log.Printf("[Auth] ERR %s %s -> 500 (%s) could not extract username from user info from %s",
|
||||
r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
|
||||
http.Error(w, "Invalid user information: missing username", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 生成内部访问令牌
|
||||
internalToken := h.auth.generateToken()
|
||||
h.auth.addToken(internalToken, userInfo.Username, tokenExpiry)
|
||||
|
||||
log.Printf("[Auth] %s %s -> 200 (%s) login success for user %s from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), userInfo.Username, utils.GetRequestSource(r))
|
||||
|
||||
// 返回登录成功页面
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `
|
||||
<html>
|
||||
<head><title>登录成功</title></head>
|
||||
<body>
|
||||
<script>
|
||||
localStorage.setItem('token', '%s');
|
||||
localStorage.setItem('user', '%s');
|
||||
window.location.href = '/admin/dashboard';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`, internalToken, userInfo.Username)
|
||||
}
|
||||
|
@ -7,16 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type CacheAdminHandler struct {
|
||||
proxyCache *cache.CacheManager
|
||||
mirrorCache *cache.CacheManager
|
||||
fixedPathCache *cache.CacheManager
|
||||
proxyCache *cache.CacheManager
|
||||
mirrorCache *cache.CacheManager
|
||||
}
|
||||
|
||||
func NewCacheAdminHandler(proxyCache, mirrorCache, fixedPathCache *cache.CacheManager) *CacheAdminHandler {
|
||||
func NewCacheAdminHandler(proxyCache, mirrorCache *cache.CacheManager) *CacheAdminHandler {
|
||||
return &CacheAdminHandler{
|
||||
proxyCache: proxyCache,
|
||||
mirrorCache: mirrorCache,
|
||||
fixedPathCache: fixedPathCache,
|
||||
proxyCache: proxyCache,
|
||||
mirrorCache: mirrorCache,
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,9 +33,8 @@ func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
stats := map[string]cache.CacheStats{
|
||||
"proxy": h.proxyCache.GetStats(),
|
||||
"mirror": h.mirrorCache.GetStats(),
|
||||
"fixedPath": h.fixedPathCache.GetStats(),
|
||||
"proxy": h.proxyCache.GetStats(),
|
||||
"mirror": h.mirrorCache.GetStats(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -52,9 +49,8 @@ func (h *CacheAdminHandler) GetCacheConfig(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
configs := map[string]cache.CacheConfig{
|
||||
"proxy": h.proxyCache.GetConfig(),
|
||||
"mirror": h.mirrorCache.GetConfig(),
|
||||
"fixedPath": h.fixedPathCache.GetConfig(),
|
||||
"proxy": h.proxyCache.GetConfig(),
|
||||
"mirror": h.mirrorCache.GetConfig(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@ -69,7 +65,7 @@ func (h *CacheAdminHandler) UpdateCacheConfig(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type"` // "proxy", "mirror" 或 "fixedPath"
|
||||
Type string `json:"type"` // "proxy", "mirror"
|
||||
Config CacheConfig `json:"config"` // 新的配置
|
||||
}
|
||||
|
||||
@ -84,8 +80,6 @@ func (h *CacheAdminHandler) UpdateCacheConfig(w http.ResponseWriter, r *http.Req
|
||||
targetCache = h.proxyCache
|
||||
case "mirror":
|
||||
targetCache = h.mirrorCache
|
||||
case "fixedPath":
|
||||
targetCache = h.fixedPathCache
|
||||
default:
|
||||
http.Error(w, "Invalid cache type", http.StatusBadRequest)
|
||||
return
|
||||
@ -107,7 +101,7 @@ func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type"` // "proxy", "mirror" 或 "fixedPath"
|
||||
Type string `json:"type"` // "proxy", "mirror"
|
||||
Enabled bool `json:"enabled"` // true 或 false
|
||||
}
|
||||
|
||||
@ -121,8 +115,6 @@ func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Reque
|
||||
h.proxyCache.SetEnabled(req.Enabled)
|
||||
case "mirror":
|
||||
h.mirrorCache.SetEnabled(req.Enabled)
|
||||
case "fixedPath":
|
||||
h.fixedPathCache.SetEnabled(req.Enabled)
|
||||
default:
|
||||
http.Error(w, "Invalid cache type", http.StatusBadRequest)
|
||||
return
|
||||
@ -139,7 +131,7 @@ func (h *CacheAdminHandler) ClearCache(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type"` // "proxy", "mirror", "fixedPath" 或 "all"
|
||||
Type string `json:"type"` // "proxy", "mirror" 或 "all"
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@ -153,16 +145,11 @@ func (h *CacheAdminHandler) ClearCache(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.proxyCache.ClearCache()
|
||||
case "mirror":
|
||||
err = h.mirrorCache.ClearCache()
|
||||
case "fixedPath":
|
||||
err = h.fixedPathCache.ClearCache()
|
||||
case "all":
|
||||
err = h.proxyCache.ClearCache()
|
||||
if err == nil {
|
||||
err = h.mirrorCache.ClearCache()
|
||||
}
|
||||
if err == nil {
|
||||
err = h.fixedPathCache.ClearCache()
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Invalid cache type", http.StatusBadRequest)
|
||||
return
|
||||
|
@ -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": "配置已更新并生效"}`))
|
||||
@ -118,18 +103,5 @@ func (h *ConfigHandler) validateConfig(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证FixedPaths配置
|
||||
for _, fp := range cfg.FixedPaths {
|
||||
if fp.Path == "" {
|
||||
return fmt.Errorf("固定路径不能为空")
|
||||
}
|
||||
if fp.TargetURL == "" {
|
||||
return fmt.Errorf("固定路径 %s 的目标URL不能为空", fp.Path)
|
||||
}
|
||||
if _, err := url.Parse(fp.TargetURL); err != nil {
|
||||
return fmt.Errorf("固定路径 %s 的目标URL无效: %v", fp.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metrics 定义指标结构,与前端期望的数据结构保持一致
|
||||
type Metrics struct {
|
||||
// 基础指标
|
||||
Uptime string `json:"uptime"`
|
||||
@ -24,19 +25,36 @@ 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"`
|
||||
BytesPerSecond float64 `json:"bytes_per_second"`
|
||||
StatusCodeStats map[string]int64 `json:"status_code_stats"`
|
||||
LatencyPercentiles map[string]float64 `json:"latency_percentiles"`
|
||||
TopPaths []models.PathMetrics `json:"top_paths"`
|
||||
RecentRequests []models.RequestLog `json:"recent_requests"`
|
||||
TopReferers []models.PathMetrics `json:"top_referers"`
|
||||
// 传输指标
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
BytesPerSecond float64 `json:"bytes_per_second"`
|
||||
|
||||
// 状态码统计
|
||||
StatusCodeStats map[string]int64 `json:"status_code_stats"`
|
||||
|
||||
// 最近请求
|
||||
RecentRequests []models.RequestLog `json:"recent_requests"`
|
||||
|
||||
// 引用来源统计
|
||||
TopReferers []models.PathMetricsJSON `json:"top_referers"`
|
||||
|
||||
// 延迟统计
|
||||
LatencyStats struct {
|
||||
Min string `json:"min"`
|
||||
Max string `json:"max"`
|
||||
Distribution map[string]int64 `json:"distribution"`
|
||||
} `json:"latency_stats"`
|
||||
|
||||
// 带宽统计
|
||||
BandwidthHistory map[string]string `json:"bandwidth_history"`
|
||||
CurrentBandwidth string `json:"current_bandwidth"`
|
||||
}
|
||||
|
||||
// MetricsHandler 处理指标请求
|
||||
func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
uptime := time.Since(h.startTime)
|
||||
collector := metrics.GetCollector()
|
||||
@ -44,22 +62,28 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if stats == nil {
|
||||
stats = map[string]interface{}{
|
||||
"uptime": uptime.String(),
|
||||
"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),
|
||||
"latency_percentiles": make([]float64, 0),
|
||||
"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",
|
||||
"distribution": make(map[string]int64),
|
||||
},
|
||||
"bandwidth_history": make(map[string]string),
|
||||
"current_bandwidth": "0 B/s",
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,22 +92,82 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
totalBytes := utils.SafeInt64(stats["total_bytes"])
|
||||
uptimeSeconds := uptime.Seconds()
|
||||
|
||||
// 处理延迟统计数据
|
||||
latencyStats := make(map[string]interface{})
|
||||
if stats["latency_stats"] != nil {
|
||||
latencyStats = stats["latency_stats"].(map[string]interface{})
|
||||
}
|
||||
|
||||
// 处理带宽历史数据
|
||||
bandwidthHistory := make(map[string]string)
|
||||
if stats["bandwidth_history"] != nil {
|
||||
for k, v := range stats["bandwidth_history"].(map[string]string) {
|
||||
bandwidthHistory[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 处理状态码统计数据
|
||||
statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"])
|
||||
|
||||
metrics := Metrics{
|
||||
Uptime: uptime.String(),
|
||||
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: models.SafeStatusCodeStats(stats["status_code_stats"]),
|
||||
TopPaths: models.SafePathMetrics(stats["top_paths"]),
|
||||
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
|
||||
TopReferers: models.SafePathMetrics(stats["top_referers"]),
|
||||
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"),
|
||||
}
|
||||
|
||||
// 填充延迟统计数据
|
||||
metrics.LatencyStats.Min = utils.SafeString(latencyStats["min"], "0ms")
|
||||
metrics.LatencyStats.Max = utils.SafeString(latencyStats["max"], "0ms")
|
||||
|
||||
// 处理分布数据
|
||||
if stats["latency_stats"] != nil {
|
||||
latencyStatsMap, ok := stats["latency_stats"].(map[string]interface{})
|
||||
if ok {
|
||||
distribution, ok := latencyStatsMap["distribution"]
|
||||
if ok && distribution != nil {
|
||||
// 尝试直接使用 map[string]int64 类型
|
||||
if distributionMap, ok := distribution.(map[string]int64); ok {
|
||||
metrics.LatencyStats.Distribution = distributionMap
|
||||
} else if distributionMap, ok := distribution.(map[string]interface{}); ok {
|
||||
// 如果不是 map[string]int64,尝试转换 map[string]interface{}
|
||||
metrics.LatencyStats.Distribution = make(map[string]int64)
|
||||
for k, v := range distributionMap {
|
||||
if intValue, ok := v.(float64); ok {
|
||||
metrics.LatencyStats.Distribution[k] = int64(intValue)
|
||||
} else if intValue, ok := v.(int64); ok {
|
||||
metrics.LatencyStats.Distribution[k] = intValue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[MetricsHandler] distribution类型未知: %T", distribution)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果分布数据为空,初始化一个空的分布
|
||||
if metrics.LatencyStats.Distribution == nil {
|
||||
metrics.LatencyStats.Distribution = make(map[string]int64)
|
||||
// 添加默认的延迟桶
|
||||
metrics.LatencyStats.Distribution["lt10ms"] = 0
|
||||
metrics.LatencyStats.Distribution["10-50ms"] = 0
|
||||
metrics.LatencyStats.Distribution["50-200ms"] = 0
|
||||
metrics.LatencyStats.Distribution["200-1000ms"] = 0
|
||||
metrics.LatencyStats.Distribution["gt1s"] = 0
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
@ -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,42 +25,111 @@ 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 头部映射
|
||||
var hopHeadersMap = make(map[string]bool)
|
||||
|
||||
func init() {
|
||||
headers := []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Proxy-Connection",
|
||||
"Te",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
for _, h := range headers {
|
||||
hopHeadersMap[h] = true
|
||||
}
|
||||
var hopHeadersBase = map[string]bool{
|
||||
"Connection": true,
|
||||
"Keep-Alive": true,
|
||||
"Proxy-Authenticate": true,
|
||||
"Proxy-Authorization": true,
|
||||
"Proxy-Connection": true,
|
||||
"Te": true,
|
||||
"Trailer": true,
|
||||
"Transfer-Encoding": true,
|
||||
"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 创建新的代理处理器
|
||||
@ -69,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
|
||||
|
||||
}
|
||||
|
||||
// 初始化缓存管理器
|
||||
@ -100,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,
|
||||
@ -112,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)
|
||||
@ -125,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
|
||||
@ -157,28 +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
|
||||
for prefix, cfg := range h.pathMap {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
matchedPrefix = prefix
|
||||
pathConfig = cfg
|
||||
break
|
||||
}
|
||||
}
|
||||
// 使用前缀匹配树快速查找匹配的路径
|
||||
matchedPrefix, pathConfig, matched := h.prefixTree.match(r.URL.Path)
|
||||
|
||||
// 如果没有匹配的路径,返回 404
|
||||
if matchedPrefix == "" {
|
||||
// 如果没有找到匹配,返回404
|
||||
if !matched {
|
||||
http.NotFound(w, r)
|
||||
log.Printf("[Proxy] %s %s -> 404 (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(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)
|
||||
|
||||
@ -186,12 +291,18 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
decodedPath, err := url.QueryUnescape(targetPath)
|
||||
if err != nil {
|
||||
h.errorHandler(w, r, fmt.Errorf("error decoding path: %v", err))
|
||||
log.Printf("[Proxy] ERR %s %s -> 500 (%s) decode error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
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, "/")
|
||||
@ -201,11 +312,15 @@ 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 {
|
||||
h.errorHandler(w, r, fmt.Errorf("error parsing URL: %v", err))
|
||||
log.Printf("[Proxy] ERR %s %s -> 500 (%s) parse error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
return
|
||||
}
|
||||
|
||||
@ -213,38 +328,64 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
proxyReq, err := http.NewRequestWithContext(ctx, r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
h.errorHandler(w, r, fmt.Errorf("error creating request: %v", err))
|
||||
log.Printf("[Proxy] ERR %s %s -> 500 (%s) create request error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
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 {
|
||||
@ -265,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
|
||||
}
|
||||
@ -301,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)
|
||||
@ -312,41 +452,74 @@ 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) {
|
||||
// 创建一个新的局部 map,复制基础 hop headers
|
||||
hopHeaders := make(map[string]bool, len(hopHeadersBase))
|
||||
for k, v := range hopHeadersBase {
|
||||
hopHeaders[k] = v
|
||||
}
|
||||
|
||||
// 处理 Connection 头部指定的其他 hop-by-hop 头部
|
||||
if connection := src.Get("Connection"); connection != "" {
|
||||
for _, h := range strings.Split(connection, ",") {
|
||||
hopHeadersMap[strings.TrimSpace(h)] = true
|
||||
hopHeaders[strings.TrimSpace(h)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 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 !hopHeadersMap[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,36 +10,209 @@ import (
|
||||
"proxy-go/internal/utils"
|
||||
"runtime"
|
||||
"sort"
|
||||
"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
|
||||
activeRequests int64
|
||||
totalRequests int64
|
||||
totalErrors int64
|
||||
totalBytes int64
|
||||
latencySum int64
|
||||
maxLatency int64 // 最大响应时间
|
||||
minLatency int64 // 最小响应时间
|
||||
clientErrors int64 // 4xx错误
|
||||
serverErrors int64 // 5xx错误
|
||||
pathStats sync.Map
|
||||
statusCodeStats sync.Map
|
||||
latencyBuckets sync.Map // 响应时间分布
|
||||
bandwidthStats sync.Map // 带宽统计
|
||||
errorTypes sync.Map // 错误类型统计
|
||||
recentRequests []models.RequestLog
|
||||
recentRequestsMutex sync.RWMutex
|
||||
pathStatsMutex sync.RWMutex
|
||||
config *config.Config
|
||||
lastMinute time.Time // 用于计算每分钟带宽
|
||||
minuteBytes int64 // 当前分钟的字节数
|
||||
startTime time.Time
|
||||
activeRequests int64
|
||||
totalBytes int64
|
||||
latencySum int64
|
||||
maxLatency int64 // 最大响应时间
|
||||
minLatency int64 // 最小响应时间
|
||||
statusCodeStats *StatusCodeStats
|
||||
latencyBuckets *LatencyBuckets // 使用结构体替代 sync.Map
|
||||
refererStats *RefererStats // 使用分片哈希表
|
||||
bandwidthStats struct {
|
||||
sync.RWMutex
|
||||
window time.Duration
|
||||
lastUpdate time.Time
|
||||
current int64
|
||||
history map[string]int64
|
||||
}
|
||||
recentRequests *models.RequestQueue
|
||||
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,19 +222,56 @@ var (
|
||||
func InitCollector(cfg *config.Config) error {
|
||||
once.Do(func() {
|
||||
instance = &Collector{
|
||||
startTime: time.Now(),
|
||||
lastMinute: time.Now(),
|
||||
recentRequests: make([]models.RequestLog, 0, 1000),
|
||||
config: cfg,
|
||||
minLatency: math.MaxInt64, // 初始化为最大值
|
||||
startTime: time.Now(),
|
||||
recentRequests: models.NewRequestQueue(100),
|
||||
config: cfg,
|
||||
minLatency: math.MaxInt64,
|
||||
statusCodeStats: NewStatusCodeStats(),
|
||||
latencyBuckets: &LatencyBuckets{},
|
||||
refererStats: NewRefererStats(),
|
||||
}
|
||||
|
||||
// 初始化带宽统计
|
||||
instance.bandwidthStats.window = time.Minute
|
||||
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()
|
||||
|
||||
// 初始化延迟分布桶
|
||||
instance.latencyBuckets.Store("<10ms", new(int64))
|
||||
instance.latencyBuckets.Store("10-50ms", new(int64))
|
||||
instance.latencyBuckets.Store("50-200ms", new(int64))
|
||||
instance.latencyBuckets.Store("200-1000ms", new(int64))
|
||||
instance.latencyBuckets.Store(">1s", new(int64))
|
||||
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"}
|
||||
for _, bucket := range buckets {
|
||||
counter := new(int64)
|
||||
*counter = 0
|
||||
// 根据 bucket 名称设置对应的桶计数器
|
||||
switch bucket {
|
||||
case "lt10ms":
|
||||
instance.latencyBuckets.lt10ms = atomic.LoadInt64(counter)
|
||||
case "10-50ms":
|
||||
instance.latencyBuckets.ms10_50 = atomic.LoadInt64(counter)
|
||||
case "50-200ms":
|
||||
instance.latencyBuckets.ms50_200 = atomic.LoadInt64(counter)
|
||||
case "200-1000ms":
|
||||
instance.latencyBuckets.ms200_1000 = atomic.LoadInt64(counter)
|
||||
case "gt1s":
|
||||
instance.latencyBuckets.gt1s = atomic.LoadInt64(counter)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化异步指标收集通道
|
||||
requestChan = make(chan RequestMetric, 10000)
|
||||
instance.startAsyncMetricsUpdater()
|
||||
|
||||
// 启动数据一致性检查器
|
||||
instance.startConsistencyChecker()
|
||||
|
||||
// 启动定期清理任务
|
||||
instance.startCleanupTask()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@ -81,205 +291,132 @@ 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) {
|
||||
// 批量更新基础指标
|
||||
atomic.AddInt64(&c.totalRequests, 1)
|
||||
atomic.AddInt64(&c.totalBytes, 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
|
||||
}
|
||||
metric := RequestMetric{
|
||||
Path: path,
|
||||
Status: status,
|
||||
Latency: latency,
|
||||
Bytes: bytes,
|
||||
ClientIP: clientIP,
|
||||
Request: r,
|
||||
}
|
||||
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 = "<10ms"
|
||||
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 = ">1s"
|
||||
// channel 满了,丢弃或降级处理
|
||||
}
|
||||
if counter, ok := c.latencyBuckets.Load(bucketKey); ok {
|
||||
atomic.AddInt64(counter.(*int64), 1)
|
||||
} else {
|
||||
var count int64 = 1
|
||||
c.latencyBuckets.Store(bucketKey, &count)
|
||||
}
|
||||
|
||||
// 更新错误统计
|
||||
if status >= 400 {
|
||||
atomic.AddInt64(&c.totalErrors, 1)
|
||||
if status >= 500 {
|
||||
atomic.AddInt64(&c.serverErrors, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&c.clientErrors, 1)
|
||||
}
|
||||
errKey := fmt.Sprintf("%d %s", status, http.StatusText(status))
|
||||
if counter, ok := c.errorTypes.Load(errKey); ok {
|
||||
atomic.AddInt64(counter.(*int64), 1)
|
||||
} else {
|
||||
var count int64 = 1
|
||||
c.errorTypes.Store(errKey, &count)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态码统计
|
||||
statusKey := fmt.Sprintf("%d", status)
|
||||
if counter, ok := c.statusCodeStats.Load(statusKey); ok {
|
||||
atomic.AddInt64(counter.(*int64), 1)
|
||||
} else {
|
||||
var count int64 = 1
|
||||
c.statusCodeStats.Store(statusKey, &count)
|
||||
}
|
||||
|
||||
// 更新路径统计
|
||||
c.pathStatsMutex.Lock()
|
||||
if value, ok := c.pathStats.Load(path); ok {
|
||||
stat := value.(*models.PathMetrics)
|
||||
atomic.AddInt64(&stat.RequestCount, 1)
|
||||
if status >= 400 {
|
||||
atomic.AddInt64(&stat.ErrorCount, 1)
|
||||
}
|
||||
atomic.AddInt64(&stat.TotalLatency, int64(latency))
|
||||
atomic.AddInt64(&stat.BytesTransferred, bytes)
|
||||
} else {
|
||||
c.pathStats.Store(path, &models.PathMetrics{
|
||||
Path: path,
|
||||
RequestCount: 1,
|
||||
ErrorCount: map[bool]int64{true: 1, false: 0}[status >= 400],
|
||||
TotalLatency: int64(latency),
|
||||
BytesTransferred: bytes,
|
||||
})
|
||||
}
|
||||
c.pathStatsMutex.Unlock()
|
||||
|
||||
// 更新最近请求记录
|
||||
c.recentRequestsMutex.Lock()
|
||||
c.recentRequests = append([]models.RequestLog{{
|
||||
Time: time.Now(),
|
||||
Path: path,
|
||||
Status: status,
|
||||
Latency: int64(latency),
|
||||
BytesSent: bytes,
|
||||
ClientIP: clientIP,
|
||||
}}, c.recentRequests...)
|
||||
if len(c.recentRequests) > 100 { // 只保留最近100条记录
|
||||
c.recentRequests = c.recentRequests[:100]
|
||||
}
|
||||
c.recentRequestsMutex.Unlock()
|
||||
}
|
||||
|
||||
// formatUptime 格式化运行时间
|
||||
func formatUptime(d time.Duration) string {
|
||||
// FormatUptime 格式化运行时间
|
||||
func FormatUptime(d time.Duration) string {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%d天%d小时%d分钟", days, hours, minutes)
|
||||
return fmt.Sprintf("%d天%d时%d分%d秒", days, hours, minutes, seconds)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d小时%d分钟", hours, minutes)
|
||||
return fmt.Sprintf("%d时%d分%d秒", hours, minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%d分钟", minutes)
|
||||
if minutes > 0 {
|
||||
return fmt.Sprintf("%d分%d秒", minutes, seconds)
|
||||
}
|
||||
return fmt.Sprintf("%d秒", seconds)
|
||||
}
|
||||
|
||||
// GetStats 获取统计数据
|
||||
func (c *Collector) GetStats() map[string]interface{} {
|
||||
// 获取统计数据
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
// 计算平均延迟
|
||||
avgLatency := float64(0)
|
||||
totalReqs := atomic.LoadInt64(&c.totalRequests)
|
||||
if totalReqs > 0 {
|
||||
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalReqs)
|
||||
now := time.Now()
|
||||
totalRuntime := now.Sub(c.startTime)
|
||||
|
||||
// 计算总请求数和平均延迟
|
||||
var totalRequests int64
|
||||
var totalErrors int64
|
||||
statusCodeStats := c.statusCodeStats.GetStats()
|
||||
for statusCode, count := range statusCodeStats {
|
||||
totalRequests += count
|
||||
// 计算错误数(4xx和5xx状态码)
|
||||
if code, err := strconv.Atoi(statusCode); err == nil && code >= 400 {
|
||||
totalErrors += count
|
||||
}
|
||||
}
|
||||
|
||||
// 收集状态码统计
|
||||
statusCodeStats := make(map[string]int64)
|
||||
c.statusCodeStats.Range(func(key, value interface{}) bool {
|
||||
statusCodeStats[key.(string)] = atomic.LoadInt64(value.(*int64))
|
||||
return true
|
||||
})
|
||||
avgLatency := float64(0)
|
||||
if totalRequests > 0 {
|
||||
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalRequests)
|
||||
}
|
||||
|
||||
// 收集路径统计
|
||||
var pathMetrics []models.PathMetrics
|
||||
c.pathStats.Range(func(key, value interface{}) bool {
|
||||
stats := value.(*models.PathMetrics)
|
||||
if stats.RequestCount > 0 {
|
||||
avgLatencyMs := float64(stats.TotalLatency) / float64(stats.RequestCount) / float64(time.Millisecond)
|
||||
// 计算错误率
|
||||
errorRate := float64(0)
|
||||
if totalRequests > 0 {
|
||||
errorRate = float64(totalErrors) / float64(totalRequests)
|
||||
}
|
||||
|
||||
// 计算当前会话的请求数(基于本次启动后的实际请求)
|
||||
sessionRequests := atomic.LoadInt64(&c.sessionRequests)
|
||||
|
||||
// 计算最近5分钟的平均每秒请求数
|
||||
requestsPerSecond := c.getRecentRequestsPerSecond()
|
||||
|
||||
// 收集状态码统计(已经在上面获取了)
|
||||
|
||||
// 收集引用来源统计
|
||||
var refererMetrics []*models.PathMetrics
|
||||
refererCount := 0
|
||||
c.refererStats.Range(func(key string, value *models.PathMetrics) bool {
|
||||
stats := value
|
||||
requestCount := stats.GetRequestCount()
|
||||
if requestCount > 0 {
|
||||
totalLatency := stats.GetTotalLatency()
|
||||
avgLatencyMs := float64(totalLatency) / float64(requestCount) / float64(time.Millisecond)
|
||||
stats.AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs)
|
||||
refererMetrics = append(refererMetrics, stats)
|
||||
}
|
||||
pathMetrics = append(pathMetrics, *stats)
|
||||
return true
|
||||
|
||||
// 限制遍历的数量,避免过多数据导致内存占用过高
|
||||
refererCount++
|
||||
return refererCount < 50 // 最多遍历50个引用来源
|
||||
})
|
||||
|
||||
// 按请求数降序排序
|
||||
sort.Slice(pathMetrics, func(i, j int) bool {
|
||||
return pathMetrics[i].RequestCount > pathMetrics[j].RequestCount
|
||||
// 按请求数降序排序,请求数相同时按引用来源字典序排序
|
||||
sort.Slice(refererMetrics, func(i, j int) bool {
|
||||
countI := refererMetrics[i].GetRequestCount()
|
||||
countJ := refererMetrics[j].GetRequestCount()
|
||||
if countI != countJ {
|
||||
return countI > countJ
|
||||
}
|
||||
return refererMetrics[i].Path < refererMetrics[j].Path
|
||||
})
|
||||
|
||||
// 只保留前10个
|
||||
if len(pathMetrics) > 10 {
|
||||
pathMetrics = pathMetrics[:10]
|
||||
// 只保留前20个
|
||||
if len(refererMetrics) > 20 {
|
||||
refererMetrics = refererMetrics[:20]
|
||||
}
|
||||
|
||||
// 转换为值切片
|
||||
refererMetricsValues := make([]models.PathMetricsJSON, len(refererMetrics))
|
||||
for i, metric := range refererMetrics {
|
||||
refererMetricsValues[i] = metric.ToJSON()
|
||||
}
|
||||
|
||||
// 收集延迟分布
|
||||
latencyDistribution := make(map[string]int64)
|
||||
c.latencyBuckets.Range(func(key, value interface{}) bool {
|
||||
latencyDistribution[key.(string)] = atomic.LoadInt64(value.(*int64))
|
||||
return true
|
||||
})
|
||||
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)
|
||||
|
||||
// 收集错误类型统计
|
||||
errorTypeStats := make(map[string]int64)
|
||||
c.errorTypes.Range(func(key, value interface{}) bool {
|
||||
errorTypeStats[key.(string)] = atomic.LoadInt64(value.(*int64))
|
||||
return true
|
||||
})
|
||||
|
||||
// 收集最近5分钟的带宽统计
|
||||
bandwidthHistory := make(map[string]string)
|
||||
var times []string
|
||||
c.bandwidthStats.Range(func(key, value interface{}) bool {
|
||||
times = append(times, key.(string))
|
||||
return true
|
||||
})
|
||||
sort.Strings(times)
|
||||
if len(times) > 5 {
|
||||
times = times[len(times)-5:]
|
||||
}
|
||||
for _, t := range times {
|
||||
if bytes, ok := c.bandwidthStats.Load(t); ok {
|
||||
bandwidthHistory[t] = utils.FormatBytes(atomic.LoadInt64(bytes.(*int64))) + "/min"
|
||||
}
|
||||
}
|
||||
// 获取最近请求记录(使用读锁)
|
||||
recentRequests := c.recentRequests.GetAll()
|
||||
|
||||
// 获取最小和最大响应时间
|
||||
minLatency := atomic.LoadInt64(&c.minLatency)
|
||||
@ -288,35 +425,43 @@ func (c *Collector) GetStats() map[string]interface{} {
|
||||
minLatency = 0
|
||||
}
|
||||
|
||||
// 收集带宽历史记录
|
||||
bandwidthHistory := c.getBandwidthHistory()
|
||||
|
||||
return map[string]interface{}{
|
||||
"uptime": formatUptime(time.Since(c.startTime)),
|
||||
"active_requests": atomic.LoadInt64(&c.activeRequests),
|
||||
"total_requests": atomic.LoadInt64(&c.totalRequests),
|
||||
"total_errors": atomic.LoadInt64(&c.totalErrors),
|
||||
"total_bytes": atomic.LoadInt64(&c.totalBytes),
|
||||
"num_goroutine": runtime.NumGoroutine(),
|
||||
"memory_usage": utils.FormatBytes(int64(mem.Alloc)),
|
||||
"avg_response_time": fmt.Sprintf("%.2fms", avgLatency/float64(time.Millisecond)),
|
||||
"status_code_stats": statusCodeStats,
|
||||
"top_paths": pathMetrics,
|
||||
"recent_requests": c.recentRequests,
|
||||
"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)),
|
||||
"avg_response_time": fmt.Sprintf("%.2fms", avgLatency/float64(time.Millisecond)),
|
||||
"requests_per_second": requestsPerSecond,
|
||||
"bytes_per_second": float64(atomic.LoadInt64(&c.totalBytes)) / totalRuntime.Seconds(),
|
||||
"status_code_stats": statusCodeStats,
|
||||
"top_referers": refererMetricsValues,
|
||||
"recent_requests": recentRequests,
|
||||
"latency_stats": map[string]interface{}{
|
||||
"min": fmt.Sprintf("%.2fms", float64(minLatency)/float64(time.Millisecond)),
|
||||
"max": fmt.Sprintf("%.2fms", float64(maxLatency)/float64(time.Millisecond)),
|
||||
"distribution": latencyDistribution,
|
||||
},
|
||||
"error_stats": map[string]interface{}{
|
||||
"client_errors": atomic.LoadInt64(&c.clientErrors),
|
||||
"server_errors": atomic.LoadInt64(&c.serverErrors),
|
||||
"types": errorTypeStats,
|
||||
},
|
||||
"bandwidth_history": bandwidthHistory,
|
||||
"current_bandwidth": utils.FormatBytes(atomic.LoadInt64(&c.minuteBytes)) + "/min",
|
||||
"bandwidth_history": bandwidthHistory,
|
||||
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
|
||||
"current_session_requests": sessionRequests,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) SaveMetrics(stats map[string]interface{}) error {
|
||||
lastSaveTime = time.Now()
|
||||
|
||||
// 如果指标存储服务已初始化,则调用它来保存指标
|
||||
if metricsStorage != nil {
|
||||
return metricsStorage.SaveMetrics()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -336,36 +481,19 @@ func (c *Collector) LoadRecentStats() error {
|
||||
// validateLoadedData 验证当前数据的有效性
|
||||
func (c *Collector) validateLoadedData() error {
|
||||
// 验证基础指标
|
||||
if c.totalRequests < 0 ||
|
||||
c.totalErrors < 0 ||
|
||||
c.totalBytes < 0 {
|
||||
return fmt.Errorf("invalid stats values")
|
||||
}
|
||||
|
||||
// 验证错误数不能大于总请求数
|
||||
if c.totalErrors > c.totalRequests {
|
||||
return fmt.Errorf("total errors exceeds total requests")
|
||||
if c.totalBytes < 0 ||
|
||||
c.activeRequests < 0 {
|
||||
return fmt.Errorf("invalid negative stats values")
|
||||
}
|
||||
|
||||
// 验证状态码统计
|
||||
c.statusCodeStats.Range(func(key, value interface{}) bool {
|
||||
return value.(int64) >= 0
|
||||
})
|
||||
|
||||
// 验证路径统计
|
||||
var totalPathRequests int64
|
||||
c.pathStats.Range(func(_, value interface{}) bool {
|
||||
stats := value.(*models.PathMetrics)
|
||||
if stats.RequestCount < 0 || stats.ErrorCount < 0 {
|
||||
return false
|
||||
var statusCodeTotal int64
|
||||
statusStats := c.statusCodeStats.GetStats()
|
||||
for _, count := range statusStats {
|
||||
if count < 0 {
|
||||
return fmt.Errorf("invalid negative status code count")
|
||||
}
|
||||
totalPathRequests += stats.RequestCount
|
||||
return true
|
||||
})
|
||||
|
||||
// 验证总数一致性
|
||||
if totalPathRequests > c.totalRequests {
|
||||
return fmt.Errorf("path stats total exceeds total requests")
|
||||
statusCodeTotal += count
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -381,8 +509,286 @@ func (c *Collector) GetLastSaveTime() time.Time {
|
||||
// CheckDataConsistency 实现 interfaces.MetricsCollector 接口
|
||||
func (c *Collector) CheckDataConsistency() error {
|
||||
// 简单的数据验证
|
||||
if c.totalErrors > c.totalRequests {
|
||||
return fmt.Errorf("total errors exceeds total requests")
|
||||
if err := c.validateLoadedData(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 添加定期检查数据一致性的功能
|
||||
func (c *Collector) startConsistencyChecker() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if err := c.validateLoadedData(); err != nil {
|
||||
log.Printf("[Metrics] Data consistency check failed: %v", err)
|
||||
// 可以在这里添加修复逻辑或报警通知
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// updateBandwidthStats 更新带宽统计
|
||||
func (c *Collector) updateBandwidthStats(bytes int64) {
|
||||
c.bandwidthStats.Lock()
|
||||
defer c.bandwidthStats.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(c.bandwidthStats.lastUpdate) >= c.bandwidthStats.window {
|
||||
// 保存当前时间窗口的数据
|
||||
key := c.bandwidthStats.lastUpdate.Format("01-02 15:04")
|
||||
c.bandwidthStats.history[key] = c.bandwidthStats.current
|
||||
|
||||
// 清理旧数据(保留最近5个时间窗口)
|
||||
if len(c.bandwidthStats.history) > 5 {
|
||||
var oldestTime time.Time
|
||||
var oldestKey string
|
||||
for k := range c.bandwidthStats.history {
|
||||
t, _ := time.Parse("01-02 15:04", k)
|
||||
if oldestTime.IsZero() || t.Before(oldestTime) {
|
||||
oldestTime = t
|
||||
oldestKey = k
|
||||
}
|
||||
}
|
||||
delete(c.bandwidthStats.history, oldestKey)
|
||||
}
|
||||
|
||||
// 重置当前窗口
|
||||
c.bandwidthStats.current = bytes
|
||||
c.bandwidthStats.lastUpdate = now
|
||||
} else {
|
||||
c.bandwidthStats.current += bytes
|
||||
}
|
||||
}
|
||||
|
||||
// getCurrentBandwidth 获取当前带宽
|
||||
func (c *Collector) getCurrentBandwidth() float64 {
|
||||
c.bandwidthStats.RLock()
|
||||
defer c.bandwidthStats.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
duration := now.Sub(c.bandwidthStats.lastUpdate).Seconds()
|
||||
if duration == 0 {
|
||||
return 0
|
||||
}
|
||||
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()
|
||||
defer c.bandwidthStats.RUnlock()
|
||||
|
||||
history := make(map[string]string)
|
||||
for k, v := range c.bandwidthStats.history {
|
||||
history[k] = utils.FormatBytes(v) + "/min"
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
// startCleanupTask 启动定期清理任务
|
||||
func (c *Collector) startCleanupTask() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
<-ticker.C
|
||||
oneDayAgo := time.Now().Add(-24 * time.Hour).Unix()
|
||||
|
||||
// 清理超过24小时的引用来源统计
|
||||
deletedCount := c.refererStats.Cleanup(oneDayAgo)
|
||||
|
||||
if deletedCount > 0 {
|
||||
log.Printf("[Collector] 已清理 %d 条过期的引用来源统计", deletedCount)
|
||||
}
|
||||
|
||||
// 强制GC
|
||||
runtime.GC()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 异步批量处理请求指标
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 批量更新指标
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
26
internal/metrics/init.go
Normal file
26
internal/metrics/init.go
Normal file
@ -0,0 +1,26 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"log"
|
||||
"proxy-go/internal/config"
|
||||
)
|
||||
|
||||
func Init(cfg *config.Config) error {
|
||||
// 初始化收集器
|
||||
if err := InitCollector(cfg); err != nil {
|
||||
log.Printf("[Metrics] 初始化收集器失败: %v", err)
|
||||
//继续运行
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化指标存储服务
|
||||
if err := InitMetricsStorage(cfg); err != nil {
|
||||
log.Printf("[Metrics] 初始化指标存储服务失败: %v", err)
|
||||
//继续运行
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[Metrics] 初始化完成")
|
||||
|
||||
return nil
|
||||
}
|
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
|
||||
}
|
264
internal/metrics/persistence.go
Normal file
264
internal/metrics/persistence.go
Normal file
@ -0,0 +1,264 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"proxy-go/internal/utils"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MetricsStorage 指标存储结构
|
||||
type MetricsStorage struct {
|
||||
collector *Collector
|
||||
saveInterval time.Duration
|
||||
dataDir string
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
lastSaveTime time.Time
|
||||
mutex sync.RWMutex
|
||||
statusCodeFile string
|
||||
}
|
||||
|
||||
// NewMetricsStorage 创建新的指标存储
|
||||
func NewMetricsStorage(collector *Collector, dataDir string, saveInterval time.Duration) *MetricsStorage {
|
||||
if saveInterval < time.Minute {
|
||||
saveInterval = time.Minute
|
||||
}
|
||||
|
||||
return &MetricsStorage{
|
||||
collector: collector,
|
||||
saveInterval: saveInterval,
|
||||
dataDir: dataDir,
|
||||
stopChan: make(chan struct{}),
|
||||
statusCodeFile: filepath.Join(dataDir, "status_codes.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动定时保存任务
|
||||
func (ms *MetricsStorage) Start() error {
|
||||
// 确保数据目录存在
|
||||
if err := os.MkdirAll(ms.dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("创建数据目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 尝试加载现有数据
|
||||
if err := ms.LoadMetrics(); err != nil {
|
||||
log.Printf("[MetricsStorage] 加载指标数据失败: %v", err)
|
||||
// 加载失败不影响启动
|
||||
}
|
||||
|
||||
ms.wg.Add(1)
|
||||
go ms.runSaveTask()
|
||||
log.Printf("[MetricsStorage] 指标存储服务已启动,保存间隔: %v", ms.saveInterval)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止定时保存任务
|
||||
func (ms *MetricsStorage) Stop() {
|
||||
close(ms.stopChan)
|
||||
ms.wg.Wait()
|
||||
|
||||
// 在停止前保存一次数据
|
||||
if err := ms.SaveMetrics(); err != nil {
|
||||
log.Printf("[MetricsStorage] 停止时保存指标数据失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[MetricsStorage] 指标存储服务已停止")
|
||||
}
|
||||
|
||||
// runSaveTask 运行定时保存任务
|
||||
func (ms *MetricsStorage) runSaveTask() {
|
||||
defer ms.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(ms.saveInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := ms.SaveMetrics(); err != nil {
|
||||
log.Printf("[MetricsStorage] 保存指标数据失败: %v", err)
|
||||
}
|
||||
case <-ms.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SaveMetrics 保存指标数据
|
||||
func (ms *MetricsStorage) SaveMetrics() error {
|
||||
start := time.Now()
|
||||
log.Printf("[MetricsStorage] 开始保存指标数据...")
|
||||
|
||||
// 获取当前指标数据
|
||||
stats := ms.collector.GetStats()
|
||||
|
||||
// 保存状态码统计
|
||||
if err := saveJSONToFile(ms.statusCodeFile, stats["status_code_stats"]); err != nil {
|
||||
return fmt.Errorf("保存状态码统计失败: %v", err)
|
||||
}
|
||||
|
||||
// 不再保存引用来源统计,因为它现在只保存在内存中
|
||||
|
||||
// 单独保存延迟分布
|
||||
if latencyStats, ok := stats["latency_stats"].(map[string]interface{}); ok {
|
||||
if distribution, ok := latencyStats["distribution"]; ok {
|
||||
if err := saveJSONToFile(filepath.Join(ms.dataDir, "latency_distribution.json"), distribution); err != nil {
|
||||
log.Printf("[MetricsStorage] 保存延迟分布失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 强制进行一次GC
|
||||
runtime.GC()
|
||||
|
||||
// 打印内存使用情况
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
log.Printf("[MetricsStorage] 指标数据保存完成,耗时: %v, 内存使用: %s",
|
||||
time.Since(start), utils.FormatBytes(int64(mem.Alloc)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMetrics 加载指标数据
|
||||
func (ms *MetricsStorage) LoadMetrics() error {
|
||||
start := time.Now()
|
||||
log.Printf("[MetricsStorage] 开始加载指标数据...")
|
||||
|
||||
// 不再加载 basicMetrics(metrics.json)
|
||||
|
||||
// 1. 加载状态码统计(如果文件存在)
|
||||
if fileExists(ms.statusCodeFile) {
|
||||
var statusCodeStats map[string]interface{}
|
||||
if err := loadJSONFromFile(ms.statusCodeFile, &statusCodeStats); err != nil {
|
||||
log.Printf("[MetricsStorage] 加载状态码统计失败: %v", err)
|
||||
} else {
|
||||
// 由于新的 StatusCodeStats 结构,我们需要手动设置值
|
||||
loadedCount := 0
|
||||
for codeStr, countValue := range statusCodeStats {
|
||||
// 解析状态码
|
||||
if code, err := strconv.Atoi(codeStr); err == nil {
|
||||
// 解析计数值
|
||||
var count int64
|
||||
switch v := countValue.(type) {
|
||||
case float64:
|
||||
count = int64(v)
|
||||
case int64:
|
||||
count = v
|
||||
case int:
|
||||
count = int64(v)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// 手动设置到新的 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 条状态码统计", loadedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 不再加载引用来源统计,因为它现在只保存在内存中
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 根据桶名称设置对应的值
|
||||
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] 加载了延迟分布数据")
|
||||
}
|
||||
}
|
||||
// 强制进行一次GC
|
||||
runtime.GC()
|
||||
|
||||
// 打印内存使用情况
|
||||
var mem runtime.MemStats
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
log.Printf("[MetricsStorage] 指标数据加载完成,耗时: %v, 内存使用: %s",
|
||||
time.Since(start), utils.FormatBytes(int64(mem.Alloc)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastSaveTime 获取最后保存时间
|
||||
func (ms *MetricsStorage) GetLastSaveTime() time.Time {
|
||||
ms.mutex.RLock()
|
||||
defer ms.mutex.RUnlock()
|
||||
return ms.lastSaveTime
|
||||
}
|
||||
|
||||
// 辅助函数:保存JSON到文件
|
||||
func saveJSONToFile(filename string, data interface{}) error {
|
||||
// 创建临时文件
|
||||
tempFile := filename + ".tmp"
|
||||
|
||||
// 将数据编码为JSON
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入临时文件
|
||||
if err := os.WriteFile(tempFile, jsonData, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重命名临时文件为目标文件(原子操作)
|
||||
return os.Rename(tempFile, filename)
|
||||
}
|
||||
|
||||
// 辅助函数:从文件加载JSON
|
||||
func loadJSONFromFile(filename string, data interface{}) error {
|
||||
// 读取文件内容
|
||||
jsonData, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解码JSON数据
|
||||
return json.Unmarshal(jsonData, data)
|
||||
}
|
||||
|
||||
// 辅助函数:检查文件是否存在
|
||||
func fileExists(filename string) bool {
|
||||
_, err := os.Stat(filename)
|
||||
return err == nil
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"proxy-go/internal/cache"
|
||||
"proxy-go/internal/config"
|
||||
"proxy-go/internal/metrics"
|
||||
"proxy-go/internal/utils"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FixedPathConfig struct {
|
||||
Path string `json:"Path"`
|
||||
TargetHost string `json:"TargetHost"`
|
||||
TargetURL string `json:"TargetURL"`
|
||||
}
|
||||
|
||||
var fixedPathCache *cache.CacheManager
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
fixedPathCache, err = cache.NewCacheManager("data/fixed_path_cache")
|
||||
if err != nil {
|
||||
log.Printf("[Cache] Failed to initialize fixed path cache manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func FixedPathProxyMiddleware(configs []config.FixedPathConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startTime := time.Now()
|
||||
collector := metrics.GetCollector()
|
||||
collector.BeginRequest()
|
||||
defer collector.EndRequest()
|
||||
|
||||
// 检查是否匹配任何固定路径
|
||||
for _, cfg := range configs {
|
||||
if strings.HasPrefix(r.URL.Path, cfg.Path) {
|
||||
// 创建新的请求
|
||||
targetPath := strings.TrimPrefix(r.URL.Path, cfg.Path)
|
||||
targetURL := cfg.TargetURL + targetPath
|
||||
|
||||
// 检查是否可以使用缓存
|
||||
if r.Method == http.MethodGet && fixedPathCache != nil {
|
||||
cacheKey := fixedPathCache.GenerateCacheKey(r)
|
||||
if item, hit, notModified := fixedPathCache.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
|
||||
}
|
||||
}
|
||||
|
||||
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Error creating proxy request", http.StatusInternalServerError)
|
||||
log.Printf("[Fixed] ERR %s %s -> 500 (%s) create request error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
return
|
||||
}
|
||||
|
||||
// 复制原始请求的 header
|
||||
for key, values := range r.Header {
|
||||
for _, value := range values {
|
||||
proxyReq.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置必要的头部
|
||||
proxyReq.Host = cfg.TargetHost
|
||||
proxyReq.Header.Set("Host", cfg.TargetHost)
|
||||
proxyReq.Header.Set("X-Real-IP", utils.GetClientIP(r))
|
||||
proxyReq.Header.Set("X-Scheme", r.URL.Scheme)
|
||||
|
||||
// 发送代理请求
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
http.Error(w, "Error forwarding request", http.StatusBadGateway)
|
||||
log.Printf("[Fixed] ERR %s %s -> 502 (%s) proxy error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 复制响应头
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Proxy-Go-Cache", "MISS")
|
||||
|
||||
// 设置响应状态码
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
var written int64
|
||||
// 如果是GET请求且响应成功,使用TeeReader同时写入缓存
|
||||
if r.Method == http.MethodGet && resp.StatusCode == http.StatusOK && fixedPathCache != nil {
|
||||
cacheKey := fixedPathCache.GenerateCacheKey(r)
|
||||
if cacheFile, err := fixedPathCache.CreateTemp(cacheKey, resp); err == nil {
|
||||
defer cacheFile.Close()
|
||||
teeReader := io.TeeReader(resp.Body, cacheFile)
|
||||
written, err = io.Copy(w, teeReader)
|
||||
if err == nil {
|
||||
fixedPathCache.Commit(cacheKey, cacheFile.Name(), resp, written)
|
||||
}
|
||||
} else {
|
||||
written, err = io.Copy(w, resp.Body)
|
||||
}
|
||||
} else {
|
||||
written, err = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// 写入响应错误处理
|
||||
if err != nil && !isConnectionClosed(err) {
|
||||
log.Printf("[Fixed] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r))
|
||||
}
|
||||
|
||||
// 记录统计信息
|
||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, utils.GetClientIP(r), r)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配的固定路径,继续下一个处理器
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isConnectionClosed(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 忽略常见的连接关闭错误
|
||||
if errors.Is(err, syscall.EPIPE) || // broken pipe
|
||||
errors.Is(err, syscall.ECONNRESET) || // connection reset by peer
|
||||
strings.Contains(err.Error(), "broken pipe") ||
|
||||
strings.Contains(err.Error(), "connection reset by peer") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetFixedPathCache 获取固定路径缓存管理器
|
||||
func GetFixedPathCache() *cache.CacheManager {
|
||||
return fixedPathCache
|
||||
}
|
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)
|
||||
}
|
@ -11,14 +11,77 @@ type PathStats struct {
|
||||
LatencySum atomic.Int64
|
||||
}
|
||||
|
||||
// PathMetrics 路径指标
|
||||
// PathMetrics 路径统计信息
|
||||
type PathMetrics struct {
|
||||
Path string `json:"path"`
|
||||
RequestCount atomic.Int64 `json:"request_count"`
|
||||
ErrorCount atomic.Int64 `json:"error_count"`
|
||||
TotalLatency atomic.Int64 `json:"-"`
|
||||
BytesTransferred atomic.Int64 `json:"bytes_transferred"`
|
||||
AvgLatency string `json:"avg_latency"`
|
||||
LastAccessTime atomic.Int64 `json:"last_access_time"` // 最后访问时间戳
|
||||
}
|
||||
|
||||
// PathMetricsJSON 用于 JSON 序列化的路径统计信息
|
||||
type PathMetricsJSON struct {
|
||||
Path string `json:"path"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
TotalLatency int64 `json:"total_latency"`
|
||||
BytesTransferred int64 `json:"bytes_transferred"`
|
||||
AvgLatency string `json:"avg_latency"`
|
||||
LastAccessTime int64 `json:"last_access_time"` // 最后访问时间戳
|
||||
}
|
||||
|
||||
// GetRequestCount 获取请求数
|
||||
func (p *PathMetrics) GetRequestCount() int64 {
|
||||
return p.RequestCount.Load()
|
||||
}
|
||||
|
||||
// GetErrorCount 获取错误数
|
||||
func (p *PathMetrics) GetErrorCount() int64 {
|
||||
return p.ErrorCount.Load()
|
||||
}
|
||||
|
||||
// GetTotalLatency 获取总延迟
|
||||
func (p *PathMetrics) GetTotalLatency() int64 {
|
||||
return p.TotalLatency.Load()
|
||||
}
|
||||
|
||||
// GetBytesTransferred 获取传输字节数
|
||||
func (p *PathMetrics) GetBytesTransferred() int64 {
|
||||
return p.BytesTransferred.Load()
|
||||
}
|
||||
|
||||
// AddRequest 增加请求数
|
||||
func (p *PathMetrics) AddRequest() {
|
||||
p.RequestCount.Add(1)
|
||||
}
|
||||
|
||||
// AddError 增加错误数
|
||||
func (p *PathMetrics) AddError() {
|
||||
p.ErrorCount.Add(1)
|
||||
}
|
||||
|
||||
// AddLatency 增加延迟
|
||||
func (p *PathMetrics) AddLatency(latency int64) {
|
||||
p.TotalLatency.Add(latency)
|
||||
}
|
||||
|
||||
// AddBytes 增加传输字节数
|
||||
func (p *PathMetrics) AddBytes(bytes int64) {
|
||||
p.BytesTransferred.Add(bytes)
|
||||
}
|
||||
|
||||
// ToJSON 转换为 JSON 友好的结构
|
||||
func (p *PathMetrics) ToJSON() PathMetricsJSON {
|
||||
return PathMetricsJSON{
|
||||
Path: p.Path,
|
||||
RequestCount: p.RequestCount.Load(),
|
||||
ErrorCount: p.ErrorCount.Load(),
|
||||
BytesTransferred: p.BytesTransferred.Load(),
|
||||
AvgLatency: p.AvgLatency,
|
||||
LastAccessTime: p.LastAccessTime.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
type HistoricalMetrics struct {
|
||||
|
@ -12,14 +12,21 @@ func SafeStatusCodeStats(v interface{}) map[string]int64 {
|
||||
}
|
||||
|
||||
// SafePathMetrics 安全地将 interface{} 转换为路径指标
|
||||
func SafePathMetrics(v interface{}) []PathMetrics {
|
||||
func SafePathMetrics(v interface{}) []PathMetricsJSON {
|
||||
if v == nil {
|
||||
return []PathMetrics{}
|
||||
return []PathMetricsJSON{}
|
||||
}
|
||||
if m, ok := v.([]PathMetrics); ok {
|
||||
if m, ok := v.([]PathMetricsJSON); ok {
|
||||
return m
|
||||
}
|
||||
return []PathMetrics{}
|
||||
if m, ok := v.([]*PathMetrics); ok {
|
||||
result := make([]PathMetricsJSON, len(m))
|
||||
for i, metric := range m {
|
||||
result[i] = metric.ToJSON()
|
||||
}
|
||||
return result
|
||||
}
|
||||
return []PathMetricsJSON{}
|
||||
}
|
||||
|
||||
// SafeRequestLogs 安全地将 interface{} 转换为请求日志
|
||||
|
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,61 +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
|
||||
cacheTTL = 5 * time.Minute
|
||||
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)
|
||||
// 启动定期清理缓存的协程
|
||||
GoSafe(func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
@ -73,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)
|
||||
@ -125,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 {
|
||||
@ -163,77 +303,127 @@ 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
|
||||
|
||||
// 如果没有设置阈值,使用默认值 500KB
|
||||
threshold := pathConfig.SizeThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = 500 * 1024
|
||||
}
|
||||
|
||||
// 检查文件扩展名
|
||||
if pathConfig.ExtensionMap != nil {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != "" {
|
||||
ext = ext[1:] // 移除开头的点
|
||||
// 先检查是否在扩展名映射中
|
||||
if altTarget, exists := pathConfig.GetExtensionTarget(ext); exists {
|
||||
// 检查文件大小
|
||||
contentLength := r.ContentLength
|
||||
if contentLength <= 0 {
|
||||
// 如果无法获取 Content-Length,尝试发送 HEAD 请求
|
||||
if size, err := GetFileSize(client, pathConfig.DefaultTarget+path); err == nil {
|
||||
contentLength = size
|
||||
log.Printf("[FileSize] Path: %s, Size: %s (from %s)",
|
||||
path, FormatBytes(contentLength),
|
||||
func() string {
|
||||
if isCacheHit(pathConfig.DefaultTarget + path) {
|
||||
return "cache"
|
||||
}
|
||||
return "HEAD request"
|
||||
}())
|
||||
} else {
|
||||
log.Printf("[FileSize] Failed to get size for %s: %v", path, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[FileSize] Path: %s, Size: %s (from Content-Length)",
|
||||
path, FormatBytes(contentLength))
|
||||
}
|
||||
|
||||
// 只有当文件大于阈值时才使用扩展名映射的目标
|
||||
if contentLength > threshold {
|
||||
log.Printf("[Route] %s -> %s (size: %s > %s)",
|
||||
path, altTarget, FormatBytes(contentLength), FormatBytes(threshold))
|
||||
targetBase = altTarget
|
||||
} else {
|
||||
log.Printf("[Route] %s -> %s (size: %s <= %s)",
|
||||
path, targetBase, FormatBytes(contentLength), FormatBytes(threshold))
|
||||
}
|
||||
} 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跳转规则
|
||||
}
|
||||
|
||||
// 检查是否命中缓存
|
||||
func isCacheHit(url string) bool {
|
||||
if cache, ok := sizeCache.Load(url); ok {
|
||||
return time.Since(cache.(fileSizeCache).timestamp) < cacheTTL
|
||||
// NewExtensionMatcher 创建扩展名匹配器
|
||||
func NewExtensionMatcher(rules []config.ExtensionRule) *ExtensionMatcher {
|
||||
matcher := &ExtensionMatcher{
|
||||
exactMatches: make(map[string][]*config.ExtensionRule),
|
||||
wildcardRules: make([]*config.ExtensionRule, 0),
|
||||
}
|
||||
return false
|
||||
|
||||
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(targetURL); ok {
|
||||
cacheItem := cache.(accessibilityCache)
|
||||
if time.Since(cacheItem.timestamp) < accessTTL {
|
||||
return cacheItem.accessible
|
||||
}
|
||||
accessCache.Delete(targetURL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("HEAD", targetURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("[Check] Failed to create request for %s: %v", targetURL, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 添加浏览器User-Agent
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
|
||||
// 设置Referer为目标域名
|
||||
if parsedURL, parseErr := neturl.Parse(targetURL); parseErr == nil {
|
||||
req.Header.Set("Referer", fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
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(targetURL, accessibilityCache{
|
||||
accessible: accessible,
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
|
||||
return accessible
|
||||
}
|
||||
|
||||
// SafeInt64 安全地将 interface{} 转换为 int64
|
||||
@ -269,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 {
|
||||
@ -284,3 +494,39 @@ func MaxFloat64(a, b float64) float64 {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ParseInt 将字符串解析为整数,如果解析失败则返回默认值
|
||||
func ParseInt(s string, defaultValue int) int {
|
||||
var result int
|
||||
_, err := fmt.Sscanf(s, "%d", &result)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
214
main.go
214
main.go
@ -10,43 +10,144 @@ 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"
|
||||
)
|
||||
|
||||
// Route 定义路由结构
|
||||
type Route struct {
|
||||
Method string
|
||||
Pattern string
|
||||
Handler http.HandlerFunc
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 创建压缩管理器
|
||||
// 创建压缩管理器(使用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)
|
||||
fixedPathCache := middleware.GetFixedPathCache()
|
||||
|
||||
// 创建处理器链
|
||||
// 创建配置处理器
|
||||
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},
|
||||
{http.MethodGet, "/admin/api/oauth/callback", proxyHandler.OAuthCallbackHandler, false},
|
||||
{http.MethodGet, "/admin/api/check-auth", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]bool{"authenticated": true})
|
||||
}, true},
|
||||
{http.MethodPost, "/admin/api/logout", proxyHandler.LogoutHandler, false},
|
||||
{http.MethodGet, "/admin/api/metrics", proxyHandler.MetricsHandler, true},
|
||||
{http.MethodGet, "/admin/api/config/get", configHandler.ServeHTTP, true},
|
||||
{http.MethodPost, "/admin/api/config/save", configHandler.ServeHTTP, true},
|
||||
{http.MethodGet, "/admin/api/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},
|
||||
{http.MethodGet, "/admin/api/cache/config", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).GetCacheConfig, true},
|
||||
{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 {
|
||||
@ -55,48 +156,19 @@ func main() {
|
||||
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// API请求处理
|
||||
if strings.HasPrefix(r.URL.Path, "/admin/api/") {
|
||||
switch r.URL.Path {
|
||||
case "/admin/api/auth":
|
||||
if r.Method == http.MethodPost {
|
||||
proxyHandler.AuthHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
for _, route := range apiRoutes {
|
||||
if r.URL.Path == route.Pattern && r.Method == route.Method {
|
||||
if route.RequireAuth {
|
||||
proxyHandler.AuthMiddleware(route.Handler)(w, r)
|
||||
} else {
|
||||
route.Handler(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
case "/admin/api/check-auth":
|
||||
proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]bool{"authenticated": true})
|
||||
}))(w, r)
|
||||
case "/admin/api/logout":
|
||||
if r.Method == http.MethodPost {
|
||||
proxyHandler.LogoutHandler(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case "/admin/api/metrics":
|
||||
proxyHandler.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyHandler.MetricsHandler(w, r)
|
||||
}))(w, r)
|
||||
case "/admin/api/config/get":
|
||||
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||
case "/admin/api/config/save":
|
||||
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||
case "/admin/api/cache/stats":
|
||||
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache, fixedPathCache).GetCacheStats)(w, r)
|
||||
case "/admin/api/cache/enable":
|
||||
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache, fixedPathCache).SetCacheEnabled)(w, r)
|
||||
case "/admin/api/cache/clear":
|
||||
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache, fixedPathCache).ClearCache)(w, r)
|
||||
case "/admin/api/cache/config":
|
||||
if r.Method == http.MethodGet {
|
||||
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache, fixedPathCache).GetCacheConfig)(w, r)
|
||||
} else if r.Method == http.MethodPost {
|
||||
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache, fixedPathCache).UpdateCacheConfig)(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/admin/api/404" {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -107,10 +179,8 @@ func main() {
|
||||
path = "/admin/index.html"
|
||||
}
|
||||
|
||||
// 从web/out目录提供静态文件
|
||||
filePath := "web/out" + strings.TrimPrefix(path, "/admin")
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
// 如果文件不存在,返回index.html(用于客户端路由)
|
||||
filePath = "web/out/index.html"
|
||||
}
|
||||
http.ServeFile(w, r, filePath)
|
||||
@ -123,22 +193,10 @@ func main() {
|
||||
},
|
||||
handler: mirrorHandler,
|
||||
},
|
||||
// 固定路径处理器
|
||||
{
|
||||
matcher: func(r *http.Request) bool {
|
||||
for _, fp := range cfg.FixedPaths {
|
||||
if strings.HasPrefix(r.URL.Path, fp.Path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
handler: middleware.FixedPathProxyMiddleware(cfg.FixedPaths)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
|
||||
},
|
||||
// 默认代理处理器
|
||||
{
|
||||
matcher: func(r *http.Request) bool {
|
||||
return true // 总是匹配,作为默认处理器
|
||||
return true
|
||||
},
|
||||
handler: proxyHandler,
|
||||
},
|
||||
@ -158,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)
|
||||
})
|
||||
}
|
||||
|
||||
// 创建服务器
|
||||
@ -176,6 +245,15 @@ func main() {
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// 停止安全管理器
|
||||
if banManager != nil {
|
||||
banManager.Stop()
|
||||
}
|
||||
|
||||
// 停止指标存储服务
|
||||
metrics.StopMetricsStorage()
|
||||
|
||||
if err := server.Close(); err != nil {
|
||||
log.Printf("Error during server shutdown: %v\n", err)
|
||||
}
|
||||
|
155
readme.md
155
readme.md
@ -2,24 +2,33 @@
|
||||
|
||||
A 'simple' reverse proxy server written in Go.
|
||||
|
||||
使用方法: https://q58.club/t/topic/165?u=wood
|
||||
使用方法: https://www.sunai.net/t/topic/165
|
||||
|
||||
最新镜像地址: woodchen/proxy-go:latest
|
||||
|
||||
## 新版统计仪表盘
|
||||
|
||||

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

|
||||
|
||||
### 仪表统计盘
|
||||

|
||||

|
||||
|
||||
### 配置可在线修改并热重载
|
||||

|
||||

|
||||
|
||||
### 缓存查看和控制
|
||||

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

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

|
||||
|
||||
## 说明
|
||||
|
||||
1. 支持gzip和brotli压缩, 在`config.json`中配置
|
||||
1. 支持gzip和brotli压缩
|
||||
2. 不同路径代理不同站点
|
||||
3. 回源Host修改
|
||||
4. 大文件使用流式传输, 小文件直接提供
|
||||
@ -27,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
|
||||
|
||||
|
||||
|
||||
|
552
web/app/dashboard/cache/page.tsx
vendored
552
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 {
|
||||
@ -28,13 +48,11 @@ interface CacheConfig {
|
||||
interface CacheData {
|
||||
proxy: CacheStats
|
||||
mirror: CacheStats
|
||||
fixedPath: CacheStats
|
||||
}
|
||||
|
||||
interface CacheConfigs {
|
||||
proxy: CacheConfig
|
||||
mirror: CacheConfig
|
||||
fixedPath: CacheConfig
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
@ -129,11 +147,11 @@ export default function CachePage() {
|
||||
fetchConfigs()
|
||||
|
||||
// 设置定时刷新
|
||||
const interval = setInterval(fetchStats, 5000)
|
||||
const interval = setInterval(fetchStats, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStats, fetchConfigs])
|
||||
|
||||
const handleToggleCache = async (type: "proxy" | "mirror" | "fixedPath", enabled: boolean) => {
|
||||
const handleToggleCache = async (type: "proxy" | "mirror", enabled: boolean) => {
|
||||
try {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
@ -160,7 +178,7 @@ export default function CachePage() {
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: `${type === "proxy" ? "代理" : type === "mirror" ? "镜像" : "固定路径"}缓存已${enabled ? "启用" : "禁用"}`,
|
||||
description: `${type === "proxy" ? "代理" : "镜像"}缓存已${enabled ? "启用" : "禁用"}`,
|
||||
})
|
||||
|
||||
fetchStats()
|
||||
@ -173,7 +191,7 @@ export default function CachePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateConfig = async (type: "proxy" | "mirror" | "fixedPath", config: CacheConfig) => {
|
||||
const handleUpdateConfig = async (type: "proxy" | "mirror", config: CacheConfig) => {
|
||||
try {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
@ -213,7 +231,7 @@ export default function CachePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = async (type: "proxy" | "mirror" | "fixedPath" | "all") => {
|
||||
const handleClearCache = async (type: "proxy" | "mirror" | "all") => {
|
||||
try {
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
@ -253,20 +271,24 @@ export default function CachePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const renderCacheConfig = (type: "proxy" | "mirror" | "fixedPath") => {
|
||||
const renderCacheConfig = (type: "proxy" | "mirror" ) => {
|
||||
if (!configs) return null
|
||||
|
||||
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)
|
||||
@ -276,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)
|
||||
@ -290,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)
|
||||
@ -312,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>
|
||||
@ -320,161 +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>
|
||||
<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>
|
||||
|
||||
{/* 智能缓存汇总 */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
{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>
|
||||
</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>
|
||||
{renderCacheConfig("mirror")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
</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")}
|
||||
</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>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-gray-500">总大小</dt>
|
||||
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-gray-500">命中次数</dt>
|
||||
<dd className="text-sm text-gray-900">{stats?.mirror.hit_count ?? 0}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm font-medium text-gray-500">未命中次数</dt>
|
||||
<dd className="text-sm text-gray-900">{stats?.mirror.miss_count ?? 0}</dd>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 固定路径缓存 */}
|
||||
<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?.fixedPath.enabled ?? false}
|
||||
onCheckedChange={(checked) => handleToggleCache("fixedPath", checked)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleClearCache("fixedPath")}
|
||||
>
|
||||
清理
|
||||
</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?.fixedPath.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?.fixedPath.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?.fixedPath.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?.fixedPath.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?.fixedPath.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?.fixedPath.bytes_saved ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{renderCacheConfig("fixedPath")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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
|
||||
@ -15,14 +16,8 @@ interface Metrics {
|
||||
avg_response_time: string
|
||||
requests_per_second: number
|
||||
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
|
||||
@ -36,14 +31,18 @@ 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
|
||||
}>
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
@ -95,7 +94,7 @@ export default function DashboardPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
const interval = setInterval(fetchMetrics, 5000)
|
||||
const interval = setInterval(fetchMetrics, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchMetrics])
|
||||
|
||||
@ -150,11 +149,15 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">总请求数</div>
|
||||
<div className="text-lg font-semibold">{metrics.total_requests}</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">{metrics.total_errors}</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>
|
||||
@ -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,58 +208,203 @@ 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">
|
||||
{Object.entries(metrics.status_code_stats || {})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([status, count]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="p-4 rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
状态码 {status}
|
||||
.map(([status, count]) => {
|
||||
const statusNum = parseInt(status);
|
||||
let colorClass = "text-green-600";
|
||||
if (statusNum >= 500) {
|
||||
colorClass = "text-red-600";
|
||||
} else if (statusNum >= 400) {
|
||||
colorClass = "text-yellow-600";
|
||||
} else if (statusNum >= 300) {
|
||||
colorClass = "text-blue-600";
|
||||
}
|
||||
|
||||
// 计算总请求数
|
||||
const totalRequests = Object.values(metrics.status_code_stats || {}).reduce((a, b) => a + (b as number), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="p-4 rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
状态码 {status}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${colorClass}`}>{count}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{totalRequests ?
|
||||
((count as number / totalRequests) * 100).toFixed(1) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-semibold">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</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">{path.path}</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>
|
||||
|
||||
{/* 新增:延迟统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>延迟统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">最小响应时间</div>
|
||||
<div className="text-lg font-semibold">{metrics.latency_stats?.min || "0ms"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">最大响应时间</div>
|
||||
<div className="text-lg font-semibold">{metrics.latency_stats?.max || "0ms"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-2">响应时间分布</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{metrics.latency_stats?.distribution &&
|
||||
Object.entries(metrics.latency_stats.distribution)
|
||||
.sort((a, b) => {
|
||||
// 按照延迟范围排序
|
||||
const order = ["lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"];
|
||||
return order.indexOf(a[0]) - order.indexOf(b[0]);
|
||||
})
|
||||
.map(([range, count]) => {
|
||||
// 转换桶键为更友好的显示
|
||||
let displayRange = range;
|
||||
if (range === "lt10ms") displayRange = "<10ms";
|
||||
if (range === "gt1s") displayRange = ">1s";
|
||||
if (range === "200-1000ms") displayRange = "0.2-1s";
|
||||
|
||||
return (
|
||||
<div key={range} className="p-3 rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<div className="text-sm font-medium text-gray-500">{displayRange}</div>
|
||||
<div className="text-lg font-semibold">{count}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{Object.values(metrics.latency_stats?.distribution || {}).reduce((sum, val) => sum + val, 0) > 0
|
||||
? ((count / Object.values(metrics.latency_stats?.distribution || {}).reduce((sum, val) => sum + val, 0)) * 100).toFixed(1)
|
||||
: 0}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>带宽统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500">当前带宽</div>
|
||||
<div className="text-lg font-semibold">{metrics.current_bandwidth || "0 B/s"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-500 mb-2">带宽历史</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{metrics.bandwidth_history &&
|
||||
Object.entries(metrics.bandwidth_history)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([time, bandwidth]) => (
|
||||
<div key={time} className="p-3 rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<div className="text-sm font-medium text-gray-500">{time}</div>
|
||||
<div className="text-lg font-semibold">{bandwidth}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 引用来源统计卡片 */}
|
||||
{metrics.top_referers && metrics.top_referers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
引用来源统计
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 align-middle">
|
||||
(近24小时, 共 {metrics.top_referers.length} 条记录)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</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>
|
||||
<th className="text-left p-2">传输大小</th>
|
||||
<th className="text-left p-2">最后访问</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.top_referers
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
.map((referer, index) => {
|
||||
const errorRate = ((referer.error_count / referer.request_count) * 100).toFixed(1);
|
||||
const lastAccessTime = new Date(referer.last_access_time * 1000);
|
||||
const timeAgo = getTimeAgo(lastAccessTime);
|
||||
|
||||
return (
|
||||
<tr key={index} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 max-w-xs truncate">
|
||||
<a
|
||||
href={referer.path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{referer.path}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-2">{referer.request_count}</td>
|
||||
<td className="p-2">{referer.error_count}</td>
|
||||
<td className="p-2">
|
||||
<span className={errorRate === "0.0" ? "text-green-600" : "text-red-600"}>
|
||||
{errorRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">{referer.avg_latency}</td>
|
||||
<td className="p-2">{formatBytes(referer.bytes_transferred)}</td>
|
||||
<td className="p-2">
|
||||
<span title={lastAccessTime.toLocaleString()}>
|
||||
{timeAgo}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -273,28 +427,42 @@ export default function DashboardPage() {
|
||||
{(metrics.recent_requests || [])
|
||||
.slice(0, 20) // 只显示最近20条记录
|
||||
.map((req, index) => (
|
||||
<tr key={index} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2">{formatDate(req.Time)}</td>
|
||||
<td className="p-2 max-w-xs truncate">{req.Path}</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs ${getStatusColor(
|
||||
req.Status
|
||||
)}`}
|
||||
>
|
||||
{req.Status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">{formatLatency(req.Latency)}</td>
|
||||
<td className="p-2">{formatBytes(req.BytesSent)}</td>
|
||||
<td className="p-2">{req.ClientIP}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr key={index} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2">{formatDate(req.Time)}</td>
|
||||
<td className="p-2 max-w-xs truncate">
|
||||
<a
|
||||
href={req.Path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{req.Path}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs ${getStatusColor(
|
||||
req.Status
|
||||
)}`}
|
||||
>
|
||||
{req.Status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">{formatLatency(req.Latency)}</td>
|
||||
<td className="p-2">{formatBytes(req.BytesSent)}</td>
|
||||
<td className="p-2">
|
||||
<Link href={`https://ipinfo.io/${req.ClientIP}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 hover:underline">
|
||||
{req.ClientIP}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -326,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,8 +6,8 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "代理服务管理后台",
|
||||
description: "代理服务管理后台",
|
||||
title: "Proxy Go控制台",
|
||||
description: "Proxy Go控制台",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
@ -1,60 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/admin/api/auth", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `password=${encodeURIComponent(password)}`,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("登录失败")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
localStorage.setItem("token", data.token)
|
||||
|
||||
// 验证token
|
||||
const verifyResponse = await fetch("/admin/api/check-auth", {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${data.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (verifyResponse.ok) {
|
||||
// 登录成功,跳转到仪表盘
|
||||
router.push("/dashboard")
|
||||
} else {
|
||||
throw new Error("Token验证失败")
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error instanceof Error ? error.message : "登录失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const handleLogin = () => {
|
||||
window.location.href = "/admin/api/auth"
|
||||
}
|
||||
|
||||
return (
|
||||
@ -64,24 +15,11 @@ export default function LoginPage() {
|
||||
<CardTitle className="text-2xl text-center">管理员登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
密码
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入管理密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
<div className="space-y-4">
|
||||
<Button onClick={handleLogin} className="w-full">
|
||||
使用 CZL Connect 登录
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -35,7 +35,7 @@ export function Nav() {
|
||||
return (
|
||||
<nav className="border-b bg-white">
|
||||
<div className="container mx-auto flex h-14 items-center px-4">
|
||||
<div className="mr-4 font-bold">代理服务管理后台</div>
|
||||
<div className="mr-4 font-bold">Proxy Go管理后台</div>
|
||||
<div className="flex flex-1 items-center space-x-4 md:space-x-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
@ -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}>
|
||||
退出登录
|
||||
|
141
web/components/ui/alert-dialog.tsx
Normal file
141
web/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@ -53,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants }
|
||||
|
122
web/components/ui/dialog.tsx
Normal file
122
web/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
@ -1,15 +1,14 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@ -20,4 +19,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
export { Input }
|
||||
|
159
web/components/ui/select.tsx
Normal file
159
web/components/ui/select.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
28
web/components/ui/slider.tsx
Normal file
28
web/components/ui/slider.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
120
web/components/ui/table.tsx
Normal file
120
web/components/ui/table.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
55
web/components/ui/tabs.tsx
Normal file
55
web/components/ui/tabs.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
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 }
|
@ -13,8 +13,8 @@ const nextConfig = {
|
||||
}
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://localhost:3336/admin/api/:path*',
|
||||
source: '/admin/api/:path*',
|
||||
destination: 'http://127.0.0.1:3336/admin/api/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
1064
web/package-lock.json
generated
1064
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,20 +3,26 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"dev": "next dev --hostname localhost --port 13001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@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",
|
||||
"next": "15.1.0",
|
||||
"next": "^15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.1",
|
||||
|
Loading…
x
Reference in New Issue
Block a user