mirror of
https://github.com/woodchen-ink/random-api-go.git
synced 2025-07-18 13:52:02 +08:00
Compare commits
No commits in common. "main" and "v1.0.3" have entirely different histories.
@ -41,7 +41,7 @@ func Initialize(dataDir string) error {
|
|||||||
|
|
||||||
// 配置GORM
|
// 配置GORM
|
||||||
config := &gorm.Config{
|
config := &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Warn),
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@ -82,8 +82,6 @@ func autoMigrate() error {
|
|||||||
&model.DataSource{},
|
&model.DataSource{},
|
||||||
&model.URLReplaceRule{},
|
&model.URLReplaceRule{},
|
||||||
&model.Config{},
|
&model.Config{},
|
||||||
&model.DomainStats{},
|
|
||||||
&model.DailyDomainStats{},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1123,36 +1123,3 @@ 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,27 +14,23 @@ 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("✓ 端点服务已初始化")
|
||||||
|
|
||||||
// 3. 暂停预加载器的定期刷新,避免与初始化冲突
|
// 2. 暂停预加载器的定期刷新,避免与初始化冲突
|
||||||
preloader := endpointService.GetPreloader()
|
preloader := endpointService.GetPreloader()
|
||||||
preloader.PausePeriodicRefresh()
|
preloader.PausePeriodicRefresh()
|
||||||
log.Println("✓ 已暂停预加载器定期刷新")
|
log.Println("✓ 已暂停预加载器定期刷新")
|
||||||
|
|
||||||
// 4. 获取所有活跃的端点和数据源
|
// 3. 获取所有活跃的端点和数据源
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 统计需要预加载的数据源
|
// 4. 统计需要预加载的数据源
|
||||||
var activeDataSources []model.DataSource
|
var activeDataSources []model.DataSource
|
||||||
var totalDataSources, disabledDataSources, apiDataSources int
|
var totalDataSources, disabledDataSources, apiDataSources int
|
||||||
|
|
||||||
@ -73,7 +69,7 @@ func InitData() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 并发预加载所有数据源
|
// 5. 并发预加载所有数据源
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var successCount, failCount int
|
var successCount, failCount int
|
||||||
var mutex sync.Mutex
|
var mutex sync.Mutex
|
||||||
@ -112,7 +108,7 @@ func InitData() error {
|
|||||||
|
|
||||||
log.Printf("✓ 数据源预加载完成: 成功 %d 个,失败 %d 个", successCount, failCount)
|
log.Printf("✓ 数据源预加载完成: 成功 %d 个,失败 %d 个", successCount, failCount)
|
||||||
|
|
||||||
// 7. 预热URL统计缓存
|
// 6. 预热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)
|
||||||
@ -120,12 +116,12 @@ func InitData() error {
|
|||||||
log.Println("✓ URL统计缓存预热完成")
|
log.Println("✓ URL统计缓存预热完成")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 预加载配置
|
// 7. 预加载配置
|
||||||
log.Println("预加载系统配置...")
|
log.Println("预加载系统配置...")
|
||||||
preloadConfigs()
|
preloadConfigs()
|
||||||
log.Println("✓ 系统配置预加载完成")
|
log.Println("✓ 系统配置预加载完成")
|
||||||
|
|
||||||
// 9. 恢复预加载器定期刷新
|
// 8. 恢复预加载器定期刷新
|
||||||
preloader.ResumePeriodicRefresh()
|
preloader.ResumePeriodicRefresh()
|
||||||
log.Println("✓ 已恢复预加载器定期刷新")
|
log.Println("✓ 已恢复预加载器定期刷新")
|
||||||
|
|
||||||
|
@ -3,10 +3,9 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserInfo OAuth用户信息结构
|
// UserInfo OAuth用户信息结构
|
||||||
@ -18,23 +17,12 @@ 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 令牌
|
||||||
@ -59,34 +47,16 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将结果缓存
|
// 令牌有效,继续处理请求
|
||||||
am.tokenCache.Store(token, &TokenCache{
|
log.Printf("Authenticated user: %s (%s)", userInfo.Username, userInfo.Email)
|
||||||
UserInfo: userInfo,
|
|
||||||
ExpiresAt: time.Now().Add(am.cacheTTL),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 验证成功,继续处理请求
|
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,9 +70,7 @@ 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)
|
||||||
@ -120,22 +88,3 @@ 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,9 +3,7 @@ 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"
|
||||||
@ -34,13 +32,6 @@ 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,31 +129,3 @@ 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"`
|
|
||||||
}
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## 部署and讨论
|
## 部署and讨论
|
||||||
|
|
||||||
<https://www.sunai.net/t/topic/127>
|
<https://www.q58.club/t/topic/127>
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ 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 接口定义处理器需要的方法
|
||||||
@ -65,19 +64,12 @@ 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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,9 +162,6 @@ 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)) {
|
||||||
@ -186,15 +175,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用中间件链,然后使用路由处理
|
// 否则使用默认的路由处理
|
||||||
handler := http.Handler(r.mux)
|
r.mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
// 反向应用中间件(因为要从最外层开始包装)
|
|
||||||
for i := len(r.middlewares) - 1; i >= 0; i-- {
|
|
||||||
handler = r.middlewares[i](handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldServeStatic 判断是否应该由静态文件处理器处理
|
// shouldServeStatic 判断是否应该由静态文件处理器处理
|
||||||
|
@ -61,24 +61,19 @@ func getIntConfig(key string, defaultValue int) int {
|
|||||||
|
|
||||||
// FetchURLs 从数据源获取URL列表
|
// FetchURLs 从数据源获取URL列表
|
||||||
func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string, error) {
|
func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string, error) {
|
||||||
return dsf.FetchURLsWithOptions(dataSource, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchURLsWithOptions 从数据源获取URL列表,支持跳过缓存选项
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建内存缓存的key(使用数据源ID)
|
// 构建内存缓存的key(使用数据源ID)
|
||||||
cacheKey := fmt.Sprintf("datasource_%d", dataSource.ID)
|
cacheKey := fmt.Sprintf("datasource_%d", dataSource.ID)
|
||||||
|
|
||||||
// 如果不跳过缓存,先检查内存缓存
|
// 先检查内存缓存
|
||||||
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
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var urls []string
|
var urls []string
|
||||||
@ -201,10 +196,10 @@ func (dsf *DataSourceFetcher) fetchS3URLs(dataSource *model.DataSource) ([]strin
|
|||||||
|
|
||||||
// updateDataSourceSyncTime 更新数据源的同步时间
|
// updateDataSourceSyncTime 更新数据源的同步时间
|
||||||
func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *model.DataSource) error {
|
func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *model.DataSource) error {
|
||||||
if err := database.DB.Model(dataSource).Update("last_sync", dataSource.LastSync).Error; err != nil {
|
// 这里需要导入database包来更新数据库
|
||||||
return fmt.Errorf("failed to update sync time for data source %d: %w", dataSource.ID, err)
|
// 为了避免循环依赖,我们通过回调或者接口来处理
|
||||||
}
|
// 暂时先记录日志,具体实现在主服务中处理
|
||||||
log.Printf("已更新数据源 %d 的同步时间", dataSource.ID)
|
log.Printf("需要更新数据源 %d 的同步时间", dataSource.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,16 +215,3 @@ func (dsf *DataSourceFetcher) PreloadDataSource(dataSource *model.DataSource) er
|
|||||||
log.Printf("数据源 %d 预加载完成", dataSource.ID)
|
log.Printf("数据源 %d 预加载完成", dataSource.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshDataSource 强制刷新数据源(跳过缓存)
|
|
||||||
func (dsf *DataSourceFetcher) RefreshDataSource(dataSource *model.DataSource) error {
|
|
||||||
log.Printf("开始强制刷新数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
|
|
||||||
|
|
||||||
_, err := dsf.FetchURLsWithOptions(dataSource, true) // 跳过缓存
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to refresh data source %d: %w", dataSource.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("数据源 %d 强制刷新完成", dataSource.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
@ -1,371 +0,0 @@
|
|||||||
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 启动定期保存任务(每1分钟保存一次)
|
|
||||||
func (s *DomainStatsService) startPeriodicSave() {
|
|
||||||
ticker := time.NewTicker(1 * 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].Count == results[j].Count && results[i].Domain > results[j].Domain) {
|
|
||||||
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].Count == results[j].Count && results[i].Domain > results[j].Domain) {
|
|
||||||
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,6 +2,7 @@ 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"
|
||||||
@ -173,6 +174,7 @@ func (s *EndpointService) GetRandomURL(url string) (string, error) {
|
|||||||
|
|
||||||
// 如果包含实时数据源,不使用内存缓存,直接实时获取
|
// 如果包含实时数据源,不使用内存缓存,直接实时获取
|
||||||
if hasRealtimeDataSource {
|
if hasRealtimeDataSource {
|
||||||
|
log.Printf("端点包含实时数据源,使用实时请求模式: %s", url)
|
||||||
return s.getRandomURLRealtime(endpoint)
|
return s.getRandomURLRealtime(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +198,7 @@ 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)
|
||||||
@ -253,6 +256,7 @@ 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)
|
||||||
@ -299,6 +303,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -154,7 +154,7 @@ func (p *Preloader) RefreshDataSource(dataSourceID uint) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("手动刷新数据源 %d", dataSourceID)
|
log.Printf("手动刷新数据源 %d", dataSourceID)
|
||||||
return p.dataSourceFetcher.RefreshDataSource(&dataSource)
|
return p.dataSourceFetcher.PreloadDataSource(&dataSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshEndpoint 手动刷新指定端点的所有数据源
|
// RefreshEndpoint 手动刷新指定端点的所有数据源
|
||||||
@ -184,7 +184,7 @@ func (p *Preloader) RefreshEndpoint(endpointID uint) error {
|
|||||||
go func(ds model.DataSource) {
|
go func(ds model.DataSource) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
if err := p.dataSourceFetcher.RefreshDataSource(&ds); err != nil {
|
if err := p.dataSourceFetcher.PreloadDataSource(&ds); err != nil {
|
||||||
log.Printf("刷新数据源 %d 失败: %v", ds.ID, err)
|
log.Printf("刷新数据源 %d 失败: %v", ds.ID, err)
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,6 @@ 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({
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import DomainStatsTab from '@/components/admin/DomainStatsTab'
|
|
||||||
|
|
||||||
export default function StatsPage() {
|
|
||||||
return <DomainStatsTab />
|
|
||||||
}
|
|
@ -1,219 +0,0 @@
|
|||||||
'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,14 +44,4 @@ 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