mirror of
https://github.com/woodchen-ink/random-api-go.git
synced 2025-07-18 05:42:01 +08:00
新增域名统计功能,包括模型、服务和路由的实现,优化管理后台以支持域名访问统计的获取。
This commit is contained in:
parent
3a25300b10
commit
d944c11afb
@ -82,6 +82,8 @@ func autoMigrate() error {
|
|||||||
&model.DataSource{},
|
&model.DataSource{},
|
||||||
&model.URLReplaceRule{},
|
&model.URLReplaceRule{},
|
||||||
&model.Config{},
|
&model.Config{},
|
||||||
|
&model.DomainStats{},
|
||||||
|
&model.DailyDomainStats{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1123,3 +1123,36 @@ func (h *AdminHandler) DeleteConfigByKey(w http.ResponseWriter, r *http.Request)
|
|||||||
"message": "Config deleted successfully",
|
"message": "Config deleted successfully",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDomainStats 获取域名访问统计
|
||||||
|
func (h *AdminHandler) GetDomainStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domainStatsService := service.GetDomainStatsService()
|
||||||
|
|
||||||
|
// 获取24小时内统计
|
||||||
|
top24Hours, err := domainStatsService.GetTop24HourDomains()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to get 24-hour domain stats: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取总统计
|
||||||
|
topTotal, err := domainStatsService.GetTopTotalDomains()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to get total domain stats: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"top_24_hours": top24Hours,
|
||||||
|
"top_total": topTotal,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -14,23 +14,27 @@ func InitData() error {
|
|||||||
log.Println("开始初始化应用数据...")
|
log.Println("开始初始化应用数据...")
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// 1. 初始化端点服务(这会启动预加载器)
|
// 1. 初始化域名统计服务
|
||||||
|
_ = service.GetDomainStatsService()
|
||||||
|
log.Println("✓ 域名统计服务已初始化")
|
||||||
|
|
||||||
|
// 2. 初始化端点服务(这会启动预加载器)
|
||||||
endpointService := service.GetEndpointService()
|
endpointService := service.GetEndpointService()
|
||||||
log.Println("✓ 端点服务已初始化")
|
log.Println("✓ 端点服务已初始化")
|
||||||
|
|
||||||
// 2. 暂停预加载器的定期刷新,避免与初始化冲突
|
// 3. 暂停预加载器的定期刷新,避免与初始化冲突
|
||||||
preloader := endpointService.GetPreloader()
|
preloader := endpointService.GetPreloader()
|
||||||
preloader.PausePeriodicRefresh()
|
preloader.PausePeriodicRefresh()
|
||||||
log.Println("✓ 已暂停预加载器定期刷新")
|
log.Println("✓ 已暂停预加载器定期刷新")
|
||||||
|
|
||||||
// 3. 获取所有活跃的端点和数据源
|
// 4. 获取所有活跃的端点和数据源
|
||||||
endpoints, err := endpointService.ListEndpoints()
|
endpoints, err := endpointService.ListEndpoints()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("获取端点列表失败: %v", err)
|
log.Printf("获取端点列表失败: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 统计需要预加载的数据源
|
// 5. 统计需要预加载的数据源
|
||||||
var activeDataSources []model.DataSource
|
var activeDataSources []model.DataSource
|
||||||
var totalDataSources, disabledDataSources, apiDataSources int
|
var totalDataSources, disabledDataSources, apiDataSources int
|
||||||
|
|
||||||
@ -69,7 +73,7 @@ func InitData() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 并发预加载所有数据源
|
// 6. 并发预加载所有数据源
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var successCount, failCount int
|
var successCount, failCount int
|
||||||
var mutex sync.Mutex
|
var mutex sync.Mutex
|
||||||
@ -108,7 +112,7 @@ func InitData() error {
|
|||||||
|
|
||||||
log.Printf("✓ 数据源预加载完成: 成功 %d 个,失败 %d 个", successCount, failCount)
|
log.Printf("✓ 数据源预加载完成: 成功 %d 个,失败 %d 个", successCount, failCount)
|
||||||
|
|
||||||
// 6. 预热URL统计缓存
|
// 7. 预热URL统计缓存
|
||||||
log.Println("预热URL统计缓存...")
|
log.Println("预热URL统计缓存...")
|
||||||
if err := preloadURLStats(endpointService, endpoints); err != nil {
|
if err := preloadURLStats(endpointService, endpoints); err != nil {
|
||||||
log.Printf("预热URL统计缓存失败: %v", err)
|
log.Printf("预热URL统计缓存失败: %v", err)
|
||||||
@ -116,12 +120,12 @@ func InitData() error {
|
|||||||
log.Println("✓ URL统计缓存预热完成")
|
log.Println("✓ URL统计缓存预热完成")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 预加载配置
|
// 8. 预加载配置
|
||||||
log.Println("预加载系统配置...")
|
log.Println("预加载系统配置...")
|
||||||
preloadConfigs()
|
preloadConfigs()
|
||||||
log.Println("✓ 系统配置预加载完成")
|
log.Println("✓ 系统配置预加载完成")
|
||||||
|
|
||||||
// 8. 恢复预加载器定期刷新
|
// 9. 恢复预加载器定期刷新
|
||||||
preloader.ResumePeriodicRefresh()
|
preloader.ResumePeriodicRefresh()
|
||||||
log.Println("✓ 已恢复预加载器定期刷新")
|
log.Println("✓ 已恢复预加载器定期刷新")
|
||||||
|
|
||||||
|
@ -3,9 +3,10 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserInfo OAuth用户信息结构
|
// UserInfo OAuth用户信息结构
|
||||||
@ -17,12 +18,23 @@ type UserInfo struct {
|
|||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenCache token缓存项
|
||||||
|
type TokenCache struct {
|
||||||
|
UserInfo *UserInfo
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// AuthMiddleware 认证中间件
|
// AuthMiddleware 认证中间件
|
||||||
type AuthMiddleware struct{}
|
type AuthMiddleware struct {
|
||||||
|
tokenCache sync.Map // map[string]*TokenCache
|
||||||
|
cacheTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware 创建新的认证中间件
|
// NewAuthMiddleware 创建新的认证中间件
|
||||||
func NewAuthMiddleware() *AuthMiddleware {
|
func NewAuthMiddleware() *AuthMiddleware {
|
||||||
return &AuthMiddleware{}
|
return &AuthMiddleware{
|
||||||
|
cacheTTL: 30 * time.Minute, // token缓存30分钟
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireAuth 认证中间件,验证 OAuth 令牌
|
// RequireAuth 认证中间件,验证 OAuth 令牌
|
||||||
@ -47,16 +59,34 @@ func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证令牌(通过调用用户信息接口)
|
// 先检查缓存
|
||||||
|
if cached, ok := am.tokenCache.Load(token); ok {
|
||||||
|
tokenCache := cached.(*TokenCache)
|
||||||
|
// 检查缓存是否过期
|
||||||
|
if time.Now().Before(tokenCache.ExpiresAt) {
|
||||||
|
// 缓存有效,直接通过
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// 缓存过期,删除
|
||||||
|
am.tokenCache.Delete(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存未命中或已过期,验证令牌
|
||||||
userInfo, err := am.getUserInfo(token)
|
userInfo, err := am.getUserInfo(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Token validation failed: %v", err)
|
|
||||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 令牌有效,继续处理请求
|
// 将结果缓存
|
||||||
log.Printf("Authenticated user: %s (%s)", userInfo.Username, userInfo.Email)
|
am.tokenCache.Store(token, &TokenCache{
|
||||||
|
UserInfo: userInfo,
|
||||||
|
ExpiresAt: time.Now().Add(am.cacheTTL),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证成功,继续处理请求
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,7 +100,9 @@ func (am *AuthMiddleware) getUserInfo(accessToken string) (*UserInfo, error) {
|
|||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second, // 添加超时时间
|
||||||
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||||
@ -88,3 +120,22 @@ func (am *AuthMiddleware) getUserInfo(accessToken string) (*UserInfo, error) {
|
|||||||
|
|
||||||
return &userInfo, nil
|
return &userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateToken 使token缓存失效(用于登出等场景)
|
||||||
|
func (am *AuthMiddleware) InvalidateToken(token string) {
|
||||||
|
am.tokenCache.Delete(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheStats 获取缓存统计信息(用于监控)
|
||||||
|
func (am *AuthMiddleware) GetCacheStats() map[string]interface{} {
|
||||||
|
count := 0
|
||||||
|
am.tokenCache.Range(func(key, value interface{}) bool {
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"cached_tokens": count,
|
||||||
|
"cache_ttl": am.cacheTTL.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,7 +3,9 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"random-api-go/monitoring"
|
"random-api-go/monitoring"
|
||||||
|
"random-api-go/service"
|
||||||
"random-api-go/utils"
|
"random-api-go/utils"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
@ -32,6 +34,13 @@ func MetricsMiddleware(next http.Handler) http.Handler {
|
|||||||
IP: utils.GetRealIP(r),
|
IP: utils.GetRealIP(r),
|
||||||
Referer: r.Referer(),
|
Referer: r.Referer(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 对于管理后台API请求,跳过域名统计以提升性能
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/admin/") {
|
||||||
|
// 记录域名统计
|
||||||
|
domainStatsService := service.GetDomainStatsService()
|
||||||
|
domainStatsService.RecordRequest(r.URL.Path, r.Referer())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,3 +129,31 @@ type S3Config struct {
|
|||||||
IncludeSubfolders bool `json:"include_subfolders"` // 是否提取所有子文件夹
|
IncludeSubfolders bool `json:"include_subfolders"` // 是否提取所有子文件夹
|
||||||
FileExtensions []string `json:"file_extensions"` // 提取的文件格式后缀,支持正则匹配
|
FileExtensions []string `json:"file_extensions"` // 提取的文件格式后缀,支持正则匹配
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DomainStats 域名访问统计模型
|
||||||
|
type DomainStats struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Domain string `json:"domain" gorm:"index;not null"` // 来源域名
|
||||||
|
Count uint64 `json:"count" gorm:"default:0"` // 访问次数
|
||||||
|
LastSeen time.Time `json:"last_seen"` // 最后访问时间
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyDomainStats 每日域名访问统计模型
|
||||||
|
type DailyDomainStats struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Domain string `json:"domain" gorm:"index;not null"` // 来源域名
|
||||||
|
Date time.Time `json:"date" gorm:"index;not null"` // 统计日期
|
||||||
|
Count uint64 `json:"count" gorm:"default:0"` // 当日访问次数
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainStatsResult 域名统计结果
|
||||||
|
type DomainStatsResult struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Count uint64 `json:"count"`
|
||||||
|
}
|
||||||
|
@ -10,6 +10,7 @@ type Router struct {
|
|||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
staticHandler StaticHandler
|
staticHandler StaticHandler
|
||||||
authMiddleware *middleware.AuthMiddleware
|
authMiddleware *middleware.AuthMiddleware
|
||||||
|
middlewares []func(http.Handler) http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler 接口定义处理器需要的方法
|
// Handler 接口定义处理器需要的方法
|
||||||
@ -64,12 +65,19 @@ type AdminHandler interface {
|
|||||||
ListConfigs(w http.ResponseWriter, r *http.Request)
|
ListConfigs(w http.ResponseWriter, r *http.Request)
|
||||||
CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request)
|
CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request)
|
||||||
DeleteConfigByKey(w http.ResponseWriter, r *http.Request)
|
DeleteConfigByKey(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
|
// 域名统计
|
||||||
|
GetDomainStats(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Router {
|
func New() *Router {
|
||||||
return &Router{
|
return &Router{
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
authMiddleware: middleware.NewAuthMiddleware(),
|
authMiddleware: middleware.NewAuthMiddleware(),
|
||||||
|
middlewares: []func(http.Handler) http.Handler{
|
||||||
|
middleware.MetricsMiddleware,
|
||||||
|
middleware.RateLimiter,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +170,9 @@ func (r *Router) setupAdminRoutes(adminHandler AdminHandler) {
|
|||||||
adminHandler.CreateOrUpdateConfig(w, r)
|
adminHandler.CreateOrUpdateConfig(w, r)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 域名统计路由 - 需要认证
|
||||||
|
r.HandleFunc("/api/admin/domain-stats", r.authMiddleware.RequireAuth(adminHandler.GetDomainStats))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
|
func (r *Router) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
|
||||||
@ -175,8 +186,15 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则使用默认的路由处理
|
// 应用中间件链,然后使用路由处理
|
||||||
r.mux.ServeHTTP(w, req)
|
handler := http.Handler(r.mux)
|
||||||
|
|
||||||
|
// 反向应用中间件(因为要从最外层开始包装)
|
||||||
|
for i := len(r.middlewares) - 1; i >= 0; i-- {
|
||||||
|
handler = r.middlewares[i](handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldServeStatic 判断是否应该由静态文件处理器处理
|
// shouldServeStatic 判断是否应该由静态文件处理器处理
|
||||||
|
@ -68,7 +68,6 @@ func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string,
|
|||||||
func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource, skipCache bool) ([]string, error) {
|
func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource, skipCache bool) ([]string, error) {
|
||||||
// API类型的数据源直接实时请求,不使用缓存
|
// API类型的数据源直接实时请求,不使用缓存
|
||||||
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
|
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
|
||||||
log.Printf("实时请求API数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
|
|
||||||
return dsf.fetchAPIURLs(dataSource)
|
return dsf.fetchAPIURLs(dataSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,11 +77,8 @@ func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource,
|
|||||||
// 如果不跳过缓存,先检查内存缓存
|
// 如果不跳过缓存,先检查内存缓存
|
||||||
if !skipCache {
|
if !skipCache {
|
||||||
if cachedURLs, exists := dsf.cacheManager.GetFromMemoryCache(cacheKey); exists && len(cachedURLs) > 0 {
|
if cachedURLs, exists := dsf.cacheManager.GetFromMemoryCache(cacheKey); exists && len(cachedURLs) > 0 {
|
||||||
log.Printf("从内存缓存获取到 %d 个URL (数据源ID: %d)", len(cachedURLs), dataSource.ID)
|
|
||||||
return cachedURLs, nil
|
return cachedURLs, nil
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.Printf("跳过缓存,强制从数据源获取最新数据 (数据源ID: %d)", dataSource.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var urls []string
|
var urls []string
|
||||||
|
369
service/domain_stats_service.go
Normal file
369
service/domain_stats_service.go
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"random-api-go/database"
|
||||||
|
"random-api-go/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DomainStatsService 域名统计服务
|
||||||
|
type DomainStatsService struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
memoryStats map[string]*DomainStatsBatch // 内存中的批量统计
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainStatsBatch 批量统计数据
|
||||||
|
type DomainStatsBatch struct {
|
||||||
|
Count uint64
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
domainStatsService *DomainStatsService
|
||||||
|
domainStatsOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDomainStatsService 获取域名统计服务实例
|
||||||
|
func GetDomainStatsService() *DomainStatsService {
|
||||||
|
domainStatsOnce.Do(func() {
|
||||||
|
domainStatsService = &DomainStatsService{
|
||||||
|
memoryStats: make(map[string]*DomainStatsBatch),
|
||||||
|
}
|
||||||
|
// 启动定期保存任务
|
||||||
|
go domainStatsService.startPeriodicSave()
|
||||||
|
})
|
||||||
|
return domainStatsService
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomain 从Referer中提取域名
|
||||||
|
func (s *DomainStatsService) extractDomain(referer string) string {
|
||||||
|
if referer == "" {
|
||||||
|
return "direct" // 直接访问
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(referer)
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := parsedURL.Hostname()
|
||||||
|
if domain == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRequest 记录请求(忽略静态文件和管理后台)
|
||||||
|
func (s *DomainStatsService) RecordRequest(path, referer string) {
|
||||||
|
// 忽略的路径模式
|
||||||
|
if s.shouldIgnorePath(path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := s.extractDomain(referer)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if batch, exists := s.memoryStats[domain]; exists {
|
||||||
|
batch.Count++
|
||||||
|
batch.LastSeen = now
|
||||||
|
} else {
|
||||||
|
s.memoryStats[domain] = &DomainStatsBatch{
|
||||||
|
Count: 1,
|
||||||
|
LastSeen: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldIgnorePath 判断是否应该忽略此路径的统计
|
||||||
|
func (s *DomainStatsService) shouldIgnorePath(path string) bool {
|
||||||
|
// 忽略管理后台API
|
||||||
|
if strings.HasPrefix(path, "/api/admin/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 忽略系统API
|
||||||
|
if strings.HasPrefix(path, "/api") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 忽略静态文件路径
|
||||||
|
if strings.HasPrefix(path, "/_next/") ||
|
||||||
|
strings.HasPrefix(path, "/static/") ||
|
||||||
|
strings.HasPrefix(path, "/favicon.ico") ||
|
||||||
|
s.hasFileExtension(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 忽略前端路由
|
||||||
|
if strings.HasPrefix(path, "/admin") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 忽略根路径(通常是前端首页)
|
||||||
|
if path == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他路径都统计(包括所有API端点)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFileExtension 检查路径是否包含文件扩展名
|
||||||
|
func (s *DomainStatsService) hasFileExtension(path string) bool {
|
||||||
|
// 获取路径的最后一部分
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
|
||||||
|
// 检查是否包含点号且不是隐藏文件
|
||||||
|
if strings.Contains(lastPart, ".") && !strings.HasPrefix(lastPart, ".") {
|
||||||
|
// 常见的文件扩展名
|
||||||
|
commonExts := []string{
|
||||||
|
".html", ".css", ".js", ".json", ".png", ".jpg", ".jpeg",
|
||||||
|
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot",
|
||||||
|
".txt", ".xml", ".pdf", ".zip", ".mp4", ".mp3", ".webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
if strings.HasSuffix(strings.ToLower(lastPart), ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPeriodicSave 启动定期保存任务(每5分钟保存一次)
|
||||||
|
func (s *DomainStatsService) startPeriodicSave() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// 定期清理任务(每天执行一次)
|
||||||
|
cleanupTicker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer cleanupTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := s.flushToDatabase(); err != nil {
|
||||||
|
log.Printf("Failed to flush domain stats to database: %v", err)
|
||||||
|
}
|
||||||
|
case <-cleanupTicker.C:
|
||||||
|
if err := s.CleanupOldStats(); err != nil {
|
||||||
|
log.Printf("Failed to cleanup old domain stats: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Domain stats cleanup completed successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushToDatabase 将内存中的统计数据保存到数据库
|
||||||
|
func (s *DomainStatsService) flushToDatabase() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
currentStats := s.memoryStats
|
||||||
|
s.memoryStats = make(map[string]*DomainStatsBatch) // 重置内存统计
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(currentStats) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
for domain, batch := range currentStats {
|
||||||
|
// 更新总统计
|
||||||
|
var domainStats model.DomainStats
|
||||||
|
err := tx.Where("domain = ?", domain).First(&domainStats).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// 创建新记录
|
||||||
|
domainStats = model.DomainStats{
|
||||||
|
Domain: domain,
|
||||||
|
Count: batch.Count,
|
||||||
|
LastSeen: batch.LastSeen,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&domainStats).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
// 更新现有记录
|
||||||
|
domainStats.Count += batch.Count
|
||||||
|
domainStats.LastSeen = batch.LastSeen
|
||||||
|
if err := tx.Save(&domainStats).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新每日统计
|
||||||
|
var dailyStats model.DailyDomainStats
|
||||||
|
err = tx.Where("domain = ? AND date = ?", domain, today).First(&dailyStats).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// 创建新记录
|
||||||
|
dailyStats = model.DailyDomainStats{
|
||||||
|
Domain: domain,
|
||||||
|
Date: today,
|
||||||
|
Count: batch.Count,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&dailyStats).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
// 更新现有记录
|
||||||
|
dailyStats.Count += batch.Count
|
||||||
|
if err := tx.Save(&dailyStats).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTop24HourDomains 获取24小时内访问最多的前20个域名
|
||||||
|
func (s *DomainStatsService) GetTop24HourDomains() ([]model.DomainStatsResult, error) {
|
||||||
|
yesterday := time.Now().AddDate(0, 0, -1).Truncate(24 * time.Hour)
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
// 从数据库获取数据
|
||||||
|
var dbResults []model.DomainStatsResult
|
||||||
|
err := database.DB.Model(&model.DailyDomainStats{}).
|
||||||
|
Select("domain, SUM(count) as count").
|
||||||
|
Where("date >= ? AND date <= ?", yesterday, today).
|
||||||
|
Group("domain").
|
||||||
|
Scan(&dbResults).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并内存数据
|
||||||
|
s.mu.RLock()
|
||||||
|
memoryStats := make(map[string]uint64)
|
||||||
|
for domain, batch := range s.memoryStats {
|
||||||
|
memoryStats[domain] = batch.Count
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
// 创建域名计数映射
|
||||||
|
domainCounts := make(map[string]uint64)
|
||||||
|
|
||||||
|
// 添加数据库数据
|
||||||
|
for _, result := range dbResults {
|
||||||
|
domainCounts[result.Domain] += result.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加内存数据
|
||||||
|
for domain, count := range memoryStats {
|
||||||
|
domainCounts[domain] += count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为结果切片并排序
|
||||||
|
var results []model.DomainStatsResult
|
||||||
|
for domain, count := range domainCounts {
|
||||||
|
results = append(results, model.DomainStatsResult{
|
||||||
|
Domain: domain,
|
||||||
|
Count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按访问次数降序排序
|
||||||
|
for i := 0; i < len(results)-1; i++ {
|
||||||
|
for j := i + 1; j < len(results); j++ {
|
||||||
|
if results[i].Count < results[j].Count {
|
||||||
|
results[i], results[j] = results[j], results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制返回前20个
|
||||||
|
if len(results) > 30 {
|
||||||
|
results = results[:30]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopTotalDomains 获取总访问最多的前20个域名
|
||||||
|
func (s *DomainStatsService) GetTopTotalDomains() ([]model.DomainStatsResult, error) {
|
||||||
|
// 从数据库获取数据
|
||||||
|
var dbResults []model.DomainStatsResult
|
||||||
|
err := database.DB.Model(&model.DomainStats{}).
|
||||||
|
Select("domain, count").
|
||||||
|
Scan(&dbResults).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并内存数据
|
||||||
|
s.mu.RLock()
|
||||||
|
memoryStats := make(map[string]uint64)
|
||||||
|
for domain, batch := range s.memoryStats {
|
||||||
|
memoryStats[domain] = batch.Count
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
// 创建域名计数映射
|
||||||
|
domainCounts := make(map[string]uint64)
|
||||||
|
|
||||||
|
// 添加数据库数据
|
||||||
|
for _, result := range dbResults {
|
||||||
|
domainCounts[result.Domain] += result.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加内存数据
|
||||||
|
for domain, count := range memoryStats {
|
||||||
|
domainCounts[domain] += count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为结果切片并排序
|
||||||
|
var results []model.DomainStatsResult
|
||||||
|
for domain, count := range domainCounts {
|
||||||
|
results = append(results, model.DomainStatsResult{
|
||||||
|
Domain: domain,
|
||||||
|
Count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按访问次数降序排序
|
||||||
|
for i := 0; i < len(results)-1; i++ {
|
||||||
|
for j := i + 1; j < len(results); j++ {
|
||||||
|
if results[i].Count < results[j].Count {
|
||||||
|
results[i], results[j] = results[j], results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制返回前20个
|
||||||
|
if len(results) > 30 {
|
||||||
|
results = results[:30]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldStats 清理旧的统计数据(保留最近30天的每日统计)
|
||||||
|
func (s *DomainStatsService) CleanupOldStats() error {
|
||||||
|
cutoffDate := time.Now().AddDate(0, 0, -30).Truncate(24 * time.Hour)
|
||||||
|
return database.DB.Where("date < ?", cutoffDate).Delete(&model.DailyDomainStats{}).Error
|
||||||
|
}
|
@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"random-api-go/database"
|
"random-api-go/database"
|
||||||
"random-api-go/model"
|
"random-api-go/model"
|
||||||
@ -174,7 +173,6 @@ func (s *EndpointService) GetRandomURL(url string) (string, error) {
|
|||||||
|
|
||||||
// 如果包含实时数据源,不使用内存缓存,直接实时获取
|
// 如果包含实时数据源,不使用内存缓存,直接实时获取
|
||||||
if hasRealtimeDataSource {
|
if hasRealtimeDataSource {
|
||||||
log.Printf("端点包含实时数据源,使用实时请求模式: %s", url)
|
|
||||||
return s.getRandomURLRealtime(endpoint)
|
return s.getRandomURLRealtime(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,7 +196,6 @@ func (s *EndpointService) getRandomURLRealtime(endpoint *model.APIEndpoint) (str
|
|||||||
|
|
||||||
// 先随机选择一个数据源
|
// 先随机选择一个数据源
|
||||||
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
|
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
|
||||||
log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID)
|
|
||||||
|
|
||||||
// 只从选中的数据源获取URL
|
// 只从选中的数据源获取URL
|
||||||
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
|
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
|
||||||
@ -256,7 +253,6 @@ func (s *EndpointService) getRandomURLWithCache(endpoint *model.APIEndpoint) (st
|
|||||||
|
|
||||||
// 先随机选择一个数据源
|
// 先随机选择一个数据源
|
||||||
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
|
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
|
||||||
log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID)
|
|
||||||
|
|
||||||
// 从选中的数据源获取URL(会使用缓存)
|
// 从选中的数据源获取URL(会使用缓存)
|
||||||
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
|
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
|
||||||
@ -303,7 +299,6 @@ func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string {
|
|||||||
// 获取端点的替换规则
|
// 获取端点的替换规则
|
||||||
endpoint, err := s.GetEndpointByURL(endpointURL)
|
endpoint, err := s.GetEndpointByURL(endpointURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get endpoint for URL replacement: %v", err)
|
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ const navItems = [
|
|||||||
{ key: 'endpoints', label: 'API端点', href: '/admin' },
|
{ key: 'endpoints', label: 'API端点', href: '/admin' },
|
||||||
{ key: 'rules', label: 'URL替换规则', href: '/admin/rules' },
|
{ key: 'rules', label: 'URL替换规则', href: '/admin/rules' },
|
||||||
{ key: 'home', label: '首页配置', href: '/admin/home' },
|
{ key: 'home', label: '首页配置', href: '/admin/home' },
|
||||||
|
{ key: 'stats', label: '域名统计', href: '/admin/stats' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
|
5
web/app/admin/stats/page.tsx
Normal file
5
web/app/admin/stats/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import DomainStatsTab from '@/components/admin/DomainStatsTab'
|
||||||
|
|
||||||
|
export default function StatsPage() {
|
||||||
|
return <DomainStatsTab />
|
||||||
|
}
|
219
web/components/admin/DomainStatsTab.tsx
Normal file
219
web/components/admin/DomainStatsTab.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { authenticatedFetch } from '@/lib/auth'
|
||||||
|
import type { DomainStatsResult } from '@/types/admin'
|
||||||
|
|
||||||
|
// 表格组件,用于显示域名统计数据
|
||||||
|
const DomainStatsTable = ({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
loading
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
data: DomainStatsResult[] | null;
|
||||||
|
loading: boolean
|
||||||
|
}) => {
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!data || data.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-4">暂无数据</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">排名</TableHead>
|
||||||
|
<TableHead>域名</TableHead>
|
||||||
|
<TableHead className="text-right">访问次数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<TableRow key={`${item.domain}-${item.count}`}>
|
||||||
|
<TableCell className="font-medium">{index + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono">
|
||||||
|
{item.domain === 'direct' ? '直接访问' :
|
||||||
|
item.domain === 'unknown' ? '未知来源' :
|
||||||
|
item.domain}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNumber(item.count)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DomainStatsTab() {
|
||||||
|
// 状态管理
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [stats24h, setStats24h] = useState<DomainStatsResult[] | null>(null)
|
||||||
|
const [statsTotal, setStatsTotal] = useState<DomainStatsResult[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// 加载域名统计数据
|
||||||
|
const loadDomainStats = useCallback(async (isInitialLoad = false) => {
|
||||||
|
try {
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setLoading(true)
|
||||||
|
} else {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const response = await authenticatedFetch('/api/admin/domain-stats')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.data) {
|
||||||
|
setStats24h(data.data.top_24_hours || [])
|
||||||
|
setStatsTotal(data.data.top_total || [])
|
||||||
|
setLastUpdateTime(new Date())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('获取域名统计失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load domain stats:', error)
|
||||||
|
setError('获取域名统计失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
useEffect(() => {
|
||||||
|
loadDomainStats(true)
|
||||||
|
}, [loadDomainStats])
|
||||||
|
|
||||||
|
// 自动刷新设置
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
// 设置自动刷新定时器
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
loadDomainStats(false)
|
||||||
|
}, 5000) // 每5秒刷新一次
|
||||||
|
} else {
|
||||||
|
// 清除定时器
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [autoRefresh, loadDomainStats])
|
||||||
|
|
||||||
|
// 格式化更新时间
|
||||||
|
const formatUpdateTime = (time: Date | null) => {
|
||||||
|
if (!time) return ''
|
||||||
|
return time.toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示错误状态
|
||||||
|
if (error && !stats24h && !statsTotal) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||||
|
<div className="text-red-500">{error}</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => loadDomainStats(true)}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">域名访问统计</h2>
|
||||||
|
{lastUpdateTime && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
最后更新: {formatUpdateTime(lastUpdateTime)}
|
||||||
|
{refreshing && <span className="ml-2 animate-pulse">刷新中...</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={setAutoRefresh}
|
||||||
|
id="auto-refresh"
|
||||||
|
/>
|
||||||
|
<label htmlFor="auto-refresh" className="text-sm">
|
||||||
|
自动刷新 (5秒)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => loadDomainStats(false)}
|
||||||
|
disabled={refreshing}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{refreshing ? '刷新中...' : '手动刷新'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<DomainStatsTable
|
||||||
|
title="24小时内访问最多的域名 (前30)"
|
||||||
|
data={stats24h}
|
||||||
|
loading={loading && !stats24h}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DomainStatsTable
|
||||||
|
title="总访问最多的域名 (前30)"
|
||||||
|
data={statsTotal}
|
||||||
|
loading={loading && !statsTotal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -44,4 +44,14 @@ export interface URLReplaceRule {
|
|||||||
export interface OAuthConfig {
|
export interface OAuthConfig {
|
||||||
client_id: string
|
client_id: string
|
||||||
base_url: string
|
base_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainStatsResult {
|
||||||
|
domain: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainStatsData {
|
||||||
|
top_24_hours: DomainStatsResult[]
|
||||||
|
top_total: DomainStatsResult[]
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user