mirror of
https://github.com/woodchen-ink/random-api-go.git
synced 2025-07-17 21:32:01 +08:00
新增域名统计功能,包括模型、服务和路由的实现,优化管理后台以支持域名访问统计的获取。
This commit is contained in:
parent
3a25300b10
commit
d944c11afb
@ -82,6 +82,8 @@ func autoMigrate() error {
|
||||
&model.DataSource{},
|
||||
&model.URLReplaceRule{},
|
||||
&model.Config{},
|
||||
&model.DomainStats{},
|
||||
&model.DailyDomainStats{},
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -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("✓ 已恢复预加载器定期刷新")
|
||||
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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 判断是否应该由静态文件处理器处理
|
||||
|
@ -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
|
||||
|
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 (
|
||||
"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
|
||||
}
|
||||
|
||||
|
@ -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({
|
||||
|
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 {
|
||||
client_id: 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