diff --git a/database/database.go b/database/database.go index fe899cd..49345a1 100644 --- a/database/database.go +++ b/database/database.go @@ -82,6 +82,8 @@ func autoMigrate() error { &model.DataSource{}, &model.URLReplaceRule{}, &model.Config{}, + &model.DomainStats{}, + &model.DailyDomainStats{}, ) } diff --git a/handler/admin_handler.go b/handler/admin_handler.go index 42f6d70..4c61e4a 100644 --- a/handler/admin_handler.go +++ b/handler/admin_handler.go @@ -1123,3 +1123,36 @@ func (h *AdminHandler) DeleteConfigByKey(w http.ResponseWriter, r *http.Request) "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, + }, + }) +} diff --git a/initapp/init.go b/initapp/init.go index 019ff45..3f93eff 100644 --- a/initapp/init.go +++ b/initapp/init.go @@ -14,23 +14,27 @@ func InitData() error { log.Println("开始初始化应用数据...") start := time.Now() - // 1. 初始化端点服务(这会启动预加载器) + // 1. 初始化域名统计服务 + _ = service.GetDomainStatsService() + log.Println("✓ 域名统计服务已初始化") + + // 2. 初始化端点服务(这会启动预加载器) endpointService := service.GetEndpointService() log.Println("✓ 端点服务已初始化") - // 2. 暂停预加载器的定期刷新,避免与初始化冲突 + // 3. 暂停预加载器的定期刷新,避免与初始化冲突 preloader := endpointService.GetPreloader() preloader.PausePeriodicRefresh() log.Println("✓ 已暂停预加载器定期刷新") - // 3. 获取所有活跃的端点和数据源 + // 4. 获取所有活跃的端点和数据源 endpoints, err := endpointService.ListEndpoints() if err != nil { log.Printf("获取端点列表失败: %v", err) return err } - // 4. 统计需要预加载的数据源 + // 5. 统计需要预加载的数据源 var activeDataSources []model.DataSource var totalDataSources, disabledDataSources, apiDataSources int @@ -69,7 +73,7 @@ func InitData() error { return nil } - // 5. 并发预加载所有数据源 + // 6. 并发预加载所有数据源 var wg sync.WaitGroup var successCount, failCount int var mutex sync.Mutex @@ -108,7 +112,7 @@ func InitData() error { log.Printf("✓ 数据源预加载完成: 成功 %d 个,失败 %d 个", successCount, failCount) - // 6. 预热URL统计缓存 + // 7. 预热URL统计缓存 log.Println("预热URL统计缓存...") if err := preloadURLStats(endpointService, endpoints); err != nil { log.Printf("预热URL统计缓存失败: %v", err) @@ -116,12 +120,12 @@ func InitData() error { log.Println("✓ URL统计缓存预热完成") } - // 7. 预加载配置 + // 8. 预加载配置 log.Println("预加载系统配置...") preloadConfigs() log.Println("✓ 系统配置预加载完成") - // 8. 恢复预加载器定期刷新 + // 9. 恢复预加载器定期刷新 preloader.ResumePeriodicRefresh() log.Println("✓ 已恢复预加载器定期刷新") diff --git a/middleware/auth.go b/middleware/auth.go index 46ca66a..3cd9c87 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -3,9 +3,10 @@ package middleware import ( "encoding/json" "fmt" - "log" "net/http" "strings" + "sync" + "time" ) // UserInfo OAuth用户信息结构 @@ -17,12 +18,23 @@ type UserInfo struct { Avatar string `json:"avatar"` } +// TokenCache token缓存项 +type TokenCache struct { + UserInfo *UserInfo + ExpiresAt time.Time +} + // AuthMiddleware 认证中间件 -type AuthMiddleware struct{} +type AuthMiddleware struct { + tokenCache sync.Map // map[string]*TokenCache + cacheTTL time.Duration +} // NewAuthMiddleware 创建新的认证中间件 func NewAuthMiddleware() *AuthMiddleware { - return &AuthMiddleware{} + return &AuthMiddleware{ + cacheTTL: 30 * time.Minute, // token缓存30分钟 + } } // RequireAuth 认证中间件,验证 OAuth 令牌 @@ -47,16 +59,34 @@ func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc { 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) if err != nil { - log.Printf("Token validation failed: %v", err) http.Error(w, "Invalid token", http.StatusUnauthorized) 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) } } @@ -70,7 +100,9 @@ func (am *AuthMiddleware) getUserInfo(accessToken string) (*UserInfo, error) { req.Header.Set("Authorization", "Bearer "+accessToken) - client := &http.Client{} + client := &http.Client{ + Timeout: 10 * time.Second, // 添加超时时间 + } resp, err := client.Do(req) if err != nil { 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 } + +// 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(), + } +} diff --git a/middleware/metrics.go b/middleware/metrics.go index 5a4ed57..b5f935d 100644 --- a/middleware/metrics.go +++ b/middleware/metrics.go @@ -3,7 +3,9 @@ package middleware import ( "net/http" "random-api-go/monitoring" + "random-api-go/service" "random-api-go/utils" + "strings" "time" "golang.org/x/time/rate" @@ -32,6 +34,13 @@ func MetricsMiddleware(next http.Handler) http.Handler { IP: utils.GetRealIP(r), Referer: r.Referer(), }) + + // 对于管理后台API请求,跳过域名统计以提升性能 + if !strings.HasPrefix(r.URL.Path, "/api/admin/") { + // 记录域名统计 + domainStatsService := service.GetDomainStatsService() + domainStatsService.RecordRequest(r.URL.Path, r.Referer()) + } }) } diff --git a/model/api_endpoint.go b/model/api_endpoint.go index f131724..249eb5d 100644 --- a/model/api_endpoint.go +++ b/model/api_endpoint.go @@ -129,3 +129,31 @@ type S3Config struct { IncludeSubfolders bool `json:"include_subfolders"` // 是否提取所有子文件夹 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"` +} diff --git a/router/router.go b/router/router.go index 13a2291..286d2b0 100644 --- a/router/router.go +++ b/router/router.go @@ -10,6 +10,7 @@ type Router struct { mux *http.ServeMux staticHandler StaticHandler authMiddleware *middleware.AuthMiddleware + middlewares []func(http.Handler) http.Handler } // Handler 接口定义处理器需要的方法 @@ -64,12 +65,19 @@ type AdminHandler interface { ListConfigs(w http.ResponseWriter, r *http.Request) CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request) DeleteConfigByKey(w http.ResponseWriter, r *http.Request) + + // 域名统计 + GetDomainStats(w http.ResponseWriter, r *http.Request) } func New() *Router { return &Router{ mux: http.NewServeMux(), 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) } })) + + // 域名统计路由 - 需要认证 + r.HandleFunc("/api/admin/domain-stats", r.authMiddleware.RequireAuth(adminHandler.GetDomainStats)) } 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 } - // 否则使用默认的路由处理 - 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 判断是否应该由静态文件处理器处理 diff --git a/service/data_source_fetcher.go b/service/data_source_fetcher.go index bbc4ee8..a57e739 100644 --- a/service/data_source_fetcher.go +++ b/service/data_source_fetcher.go @@ -68,7 +68,6 @@ func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string, func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource, skipCache bool) ([]string, error) { // API类型的数据源直接实时请求,不使用缓存 if dataSource.Type == "api_get" || dataSource.Type == "api_post" { - log.Printf("实时请求API数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID) return dsf.fetchAPIURLs(dataSource) } @@ -78,11 +77,8 @@ func (dsf *DataSourceFetcher) FetchURLsWithOptions(dataSource *model.DataSource, // 如果不跳过缓存,先检查内存缓存 if !skipCache { if cachedURLs, exists := dsf.cacheManager.GetFromMemoryCache(cacheKey); exists && len(cachedURLs) > 0 { - log.Printf("从内存缓存获取到 %d 个URL (数据源ID: %d)", len(cachedURLs), dataSource.ID) return cachedURLs, nil } - } else { - log.Printf("跳过缓存,强制从数据源获取最新数据 (数据源ID: %d)", dataSource.ID) } var urls []string diff --git a/service/domain_stats_service.go b/service/domain_stats_service.go new file mode 100644 index 0000000..d9ba941 --- /dev/null +++ b/service/domain_stats_service.go @@ -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 +} diff --git a/service/endpoint_service.go b/service/endpoint_service.go index 2c0ce4d..0421c61 100644 --- a/service/endpoint_service.go +++ b/service/endpoint_service.go @@ -2,7 +2,6 @@ package service import ( "fmt" - "log" "math/rand" "random-api-go/database" "random-api-go/model" @@ -174,7 +173,6 @@ func (s *EndpointService) GetRandomURL(url string) (string, error) { // 如果包含实时数据源,不使用内存缓存,直接实时获取 if hasRealtimeDataSource { - log.Printf("端点包含实时数据源,使用实时请求模式: %s", url) return s.getRandomURLRealtime(endpoint) } @@ -198,7 +196,6 @@ func (s *EndpointService) getRandomURLRealtime(endpoint *model.APIEndpoint) (str // 先随机选择一个数据源 selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))] - log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID) // 只从选中的数据源获取URL urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource) @@ -256,7 +253,6 @@ func (s *EndpointService) getRandomURLWithCache(endpoint *model.APIEndpoint) (st // 先随机选择一个数据源 selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))] - log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID) // 从选中的数据源获取URL(会使用缓存) urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource) @@ -303,7 +299,6 @@ func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string { // 获取端点的替换规则 endpoint, err := s.GetEndpointByURL(endpointURL) if err != nil { - log.Printf("Failed to get endpoint for URL replacement: %v", err) return url } diff --git a/web/app/admin/layout.tsx b/web/app/admin/layout.tsx index 2c12bd7..c7bcbfa 100644 --- a/web/app/admin/layout.tsx +++ b/web/app/admin/layout.tsx @@ -16,6 +16,7 @@ const navItems = [ { key: 'endpoints', label: 'API端点', href: '/admin' }, { key: 'rules', label: 'URL替换规则', href: '/admin/rules' }, { key: 'home', label: '首页配置', href: '/admin/home' }, + { key: 'stats', label: '域名统计', href: '/admin/stats' }, ] export default function AdminLayout({ diff --git a/web/app/admin/stats/page.tsx b/web/app/admin/stats/page.tsx new file mode 100644 index 0000000..8999de8 --- /dev/null +++ b/web/app/admin/stats/page.tsx @@ -0,0 +1,5 @@ +import DomainStatsTab from '@/components/admin/DomainStatsTab' + +export default function StatsPage() { + return +} \ No newline at end of file diff --git a/web/components/admin/DomainStatsTab.tsx b/web/components/admin/DomainStatsTab.tsx new file mode 100644 index 0000000..72c2802 --- /dev/null +++ b/web/components/admin/DomainStatsTab.tsx @@ -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 ( + + + {title} + + +
+
加载中...
+
+
+
+ ) + } + + return ( + + + {title} + + + {!data || data.length === 0 ? ( +

暂无数据

+ ) : ( + + + + 排名 + 域名 + 访问次数 + + + + {data.map((item, index) => ( + + {index + 1} + + + {item.domain === 'direct' ? '直接访问' : + item.domain === 'unknown' ? '未知来源' : + item.domain} + + + + {formatNumber(item.count)} + + + ))} + +
+ )} +
+
+ ) +} + +export default function DomainStatsTab() { + // 状态管理 + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [stats24h, setStats24h] = useState(null) + const [statsTotal, setStatsTotal] = useState(null) + const [error, setError] = useState(null) + const [autoRefresh, setAutoRefresh] = useState(true) + const [lastUpdateTime, setLastUpdateTime] = useState(null) + const intervalRef = useRef(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 ( +
+
{error}
+ +
+ ) + } + + return ( +
+
+
+

域名访问统计

+ {lastUpdateTime && ( +

+ 最后更新: {formatUpdateTime(lastUpdateTime)} + {refreshing && 刷新中...} +

+ )} +
+
+
+ + +
+ +
+
+ +
+ + + +
+
+ ) +} \ No newline at end of file diff --git a/web/types/admin.ts b/web/types/admin.ts index e18243d..11f9f87 100644 --- a/web/types/admin.ts +++ b/web/types/admin.ts @@ -44,4 +44,14 @@ export interface URLReplaceRule { export interface OAuthConfig { client_id: string base_url: string +} + +export interface DomainStatsResult { + domain: string + count: number +} + +export interface DomainStatsData { + top_24_hours: DomainStatsResult[] + top_total: DomainStatsResult[] } \ No newline at end of file