Compare commits

...

9 Commits
v1.0.1 ... main

Author SHA1 Message Date
0b7b741d8f 更新部署讨论链接至新的网址 2025-06-24 20:03:22 +08:00
c577a827a2 调整定期保存任务的时间间隔,从每5分钟改为每1分钟,并优化域名统计结果的排序逻辑,增加了在访问次数相同时按域名首字母排序的功能。 2025-06-20 14:24:09 +08:00
d944c11afb 新增域名统计功能,包括模型、服务和路由的实现,优化管理后台以支持域名访问统计的获取。 2025-06-19 19:59:58 +08:00
3a25300b10 调整GORM日志级别,从Info级别改为Warn级别,以减少日志输出并提高系统性能。 2025-06-19 18:58:55 +08:00
32b1cd94ff 新增FetchURLsWithOptions方法以支持跳过缓存的URL获取,并更新RefreshDataSource方法以实现强制刷新数据源的功能。同时,调整Preloader中的数据源刷新逻辑以使用新方法。 2025-06-18 17:17:44 +08:00
1949c8e9f3 修复当a端点引用b端点, a端点的url替换规则不生效的问题 2025-06-16 09:28:02 +08:00
d7c60d578c 调整S3存储的刷新间隔时间,从每6小时改为每24小时,以统一与其他数据源的刷新策略。 2025-06-15 23:39:27 +08:00
4150df03cf 新增对S3对象存储的支持,更新数据源配置和管理界面,优化数据源预加载逻辑,重构相关数据处理逻辑,提升系统灵活性和可维护性。 2025-06-15 23:38:55 +08:00
e21b5dac5f 在首页添加GitHub链接,优化导航体验。 2025-06-15 20:27:24 +08:00
23 changed files with 1673 additions and 147 deletions

View File

@ -41,7 +41,7 @@ func Initialize(dataDir string) error {
// 配置GORM
config := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: logger.Default.LogMode(logger.Warn),
}
var err error
@ -61,6 +61,11 @@ func Initialize(dataDir string) error {
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour)
// 在自动迁移之前清理旧的CHECK约束
if err := cleanupOldConstraints(); err != nil {
return fmt.Errorf("failed to cleanup old constraints: %w", err)
}
// 自动迁移数据库结构
if err := autoMigrate(); err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
@ -77,9 +82,85 @@ func autoMigrate() error {
&model.DataSource{},
&model.URLReplaceRule{},
&model.Config{},
&model.DomainStats{},
&model.DailyDomainStats{},
)
}
// cleanupOldConstraints 清理旧的CHECK约束
func cleanupOldConstraints() error {
// 检查data_sources表是否存在且包含CHECK约束
var count int64
err := DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='data_sources'").Scan(&count).Error
if err != nil {
return err
}
// 如果表不存在,直接返回
if count == 0 {
log.Println("data_sources表不存在跳过约束清理")
return nil
}
// 检查是否有CHECK约束
var constraintCount int64
err = DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='data_sources' AND sql LIKE '%CHECK%'").Scan(&constraintCount).Error
if err != nil {
return err
}
// 如果没有CHECK约束直接返回
if constraintCount == 0 {
log.Println("data_sources表没有CHECK约束跳过清理")
return nil
}
log.Println("检测到旧的CHECK约束开始清理...")
// 重建表去掉CHECK约束
// 1. 创建新表不包含CHECK约束
createNewTableSQL := `
CREATE TABLE data_sources_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id INTEGER NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
last_sync DATETIME,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME
)`
if err := DB.Exec(createNewTableSQL).Error; err != nil {
return fmt.Errorf("创建新表失败: %w", err)
}
// 2. 复制数据
copyDataSQL := `
INSERT INTO data_sources_new (id, endpoint_id, name, type, config, is_active, last_sync, created_at, updated_at, deleted_at)
SELECT id, endpoint_id, name, type, config, is_active, last_sync, created_at, updated_at, deleted_at
FROM data_sources`
if err := DB.Exec(copyDataSQL).Error; err != nil {
return fmt.Errorf("复制数据失败: %w", err)
}
// 3. 删除旧表
if err := DB.Exec("DROP TABLE data_sources").Error; err != nil {
return fmt.Errorf("删除旧表失败: %w", err)
}
// 4. 重命名新表
if err := DB.Exec("ALTER TABLE data_sources_new RENAME TO data_sources").Error; err != nil {
return fmt.Errorf("重命名表失败: %w", err)
}
log.Println("旧的CHECK约束清理完成")
return nil
}
// Close 关闭数据库连接
func Close() error {
if DB != nil {

18
go.mod
View File

@ -5,12 +5,30 @@ go 1.23.0
toolchain go1.23.1
require (
github.com/aws/aws-sdk-go-v2 v1.36.4
github.com/aws/aws-sdk-go-v2/config v1.29.16
github.com/aws/aws-sdk-go-v2/credentials v1.17.69
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2
github.com/glebarez/sqlite v1.11.0
golang.org/x/time v0.12.0
gorm.io/gorm v1.30.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/google/uuid v1.5.0 // indirect

36
go.sum
View File

@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.36.4 h1:GySzjhVvx0ERP6eyfAbAuAXLtAda5TEy19E5q5W8I9E=
github.com/aws/aws-sdk-go-v2 v1.36.4/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.16 h1:XkruGnXX1nEZ+Nyo9v84TzsX+nj86icbFAeust6uo8A=
github.com/aws/aws-sdk-go-v2/config v1.29.16/go.mod h1:uCW7PNjGwZ5cOGZ5jr8vCWrYkGIhPoTNV23Q/tpHKzg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.69 h1:8B8ZQboRc3uaIKjshve/XlvJ570R7BKNy3gftSbS178=
github.com/aws/aws-sdk-go-v2/credentials v1.17.69/go.mod h1:gPME6I8grR1jCqBFEGthULiolzf/Sexq/Wy42ibKK9c=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 h1:oQWSGexYasNpYp4epLGZxxjsDo8BMBh6iNWkTXQvkwk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31/go.mod h1:nc332eGUU+djP3vrMI6blS0woaCfHTe3KiSQUVTMRq0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 h1:o1v1VFfPcDVlK3ll1L5xHsaQAFdNtZ5GXnNR7SwueC4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35/go.mod h1:rZUQNYMNG+8uZxz9FOerQJ+FceCiodXvixpeRtdESrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 h1:R5b82ubO2NntENm3SAm0ADME+H630HomNJdgv+yZ3xw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35/go.mod h1:FuA+nmgMRfkzVKYDNEqQadvEMxtxl9+RLT9ribCwEMs=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35 h1:th/m+Q18CkajTw1iqx2cKkLCij/uz8NMwJFPK91p2ug=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35/go.mod h1:dkJuf0a1Bc8HAA0Zm2MoTGm/WDC18Td9vSbrQ1+VqE8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3 h1:VHPZakq2L7w+RLzV54LmQavbvheFaR2u1NomJRSEfcU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3/go.mod h1:DX1e/lkbsAt0MkY3NgLYuH4jQvRfw8MYxTe9feR7aXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 h1:/ldKrPPXTC421bTNWrUIpq3CxwHwRI/kpc+jPUTJocM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16/go.mod h1:5vkf/Ws0/wgIMJDQbjI4p2op86hNW6Hie5QtebrDgT8=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16 h1:2HuI7vWKhFWsBhIr2Zq8KfFZT6xqaId2XXnXZjkbEuc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16/go.mod h1:BrwWnsfbFtFeRjdx0iM1ymvlqDX1Oz68JsQaibX/wG8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2 h1:T6Wu+8E2LeTUqzqQ/Bh1EoFNj1u4jUyveMgmTlu9fDU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2/go.mod h1:chSY8zfqmS0OnhZoO/hpPx/BHfAIL80m77HwhRLYScY=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 h1:EU58LP8ozQDVroOEyAfcq0cGc5R/FTZjVoYJ6tvby3w=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.4/go.mod h1:CrtOgCcysxMvrCoHnvNAD7PHWclmoFG78Q2xLK0KKcs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 h1:XB4z0hbQtpmBnb1FQYvKaCM7UsS6Y/u8jVBwIUGeCTk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2/go.mod h1:hwRpqkRxnQ58J9blRDrB4IanlXCpcKmsC83EhG77upg=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 h1:nyLjs8sYJShFYj6aiyjCBI3EcLn1udWrQTjEF+SOXB0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.21/go.mod h1:EhdxtZ+g84MSGrSrHzZiUm9PYiZkrADNja15wtRJSJo=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=

View File

@ -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,
},
})
}

View File

@ -14,37 +14,56 @@ 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
for _, endpoint := range endpoints {
if !endpoint.IsActive {
continue
}
for _, ds := range endpoint.DataSources {
if ds.IsActive && ds.Type != "api_get" && ds.Type != "api_post" {
// API类型的数据源不需要预加载使用实时请求
activeDataSources = append(activeDataSources, ds)
totalDataSources++
if !ds.IsActive {
disabledDataSources++
log.Printf("跳过禁用的数据源: %s (ID: %d)", ds.Name, ds.ID)
continue
}
if ds.Type == "api_get" || ds.Type == "api_post" {
apiDataSources++
log.Printf("跳过API类型数据源: %s (ID: %d, 类型: %s) - 使用实时请求", ds.Name, ds.ID, ds.Type)
continue
}
// 需要预加载的数据源
activeDataSources = append(activeDataSources, ds)
}
}
log.Printf("发现 %d 个端点,%d 个需要预加载的数据源", len(endpoints), len(activeDataSources))
log.Printf("发现 %d 个端点,总共 %d 个数据源", len(endpoints), totalDataSources)
log.Printf("其中: 禁用 %d 个API类型 %d 个,需要预加载 %d 个", disabledDataSources, apiDataSources, len(activeDataSources))
if len(activeDataSources) == 0 {
log.Println("✓ 没有需要预加载的数据源")
@ -54,7 +73,7 @@ func InitData() error {
return nil
}
// 5. 并发预加载所有数据源
// 6. 并发预加载所有数据源
var wg sync.WaitGroup
var successCount, failCount int
var mutex sync.Mutex
@ -93,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)
@ -101,12 +120,12 @@ func InitData() error {
log.Println("✓ URL统计缓存预热完成")
}
// 7. 预加载配置
// 8. 预加载配置
log.Println("预加载系统配置...")
preloadConfigs()
log.Println("✓ 系统配置预加载完成")
// 8. 恢复预加载器定期刷新
// 9. 恢复预加载器定期刷新
preloader.ResumePeriodicRefresh()
log.Println("✓ 已恢复预加载器定期刷新")

View File

@ -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(),
}
}

View File

@ -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())
}
})
}

View File

@ -29,7 +29,7 @@ type DataSource struct {
ID uint `json:"id" gorm:"primaryKey"`
EndpointID uint `json:"endpoint_id" gorm:"not null;index"`
Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null;check:type IN ('lankong', 'manual', 'api_get', 'api_post', 'endpoint')"`
Type string `json:"type" gorm:"not null"`
Config string `json:"config" gorm:"not null"`
IsActive bool `json:"is_active" gorm:"default:true"`
LastSync *time.Time `json:"last_sync,omitempty"`
@ -80,6 +80,9 @@ type DataSourceConfig struct {
// 端点配置
EndpointConfig *EndpointConfig `json:"endpoint_config,omitempty"`
// S3配置
S3Config *S3Config `json:"s3_config,omitempty"`
}
type LankongConfig struct {
@ -103,3 +106,54 @@ type APIConfig struct {
type EndpointConfig struct {
EndpointIDs []uint `json:"endpoint_ids"` // 选中的端点ID列表
}
// S3Config S3通用协议配置
type S3Config struct {
// 基础配置
Endpoint string `json:"endpoint"` // S3端点地址
BucketName string `json:"bucket_name"` // 存储桶名称
Region string `json:"region"` // 地区
AccessKeyID string `json:"access_key_id"` // 访问密钥ID
SecretAccessKey string `json:"secret_access_key"` // 访问密钥
// 高级配置
ListObjectsVersion string `json:"list_objects_version"` // 列出对象版本 (v1/v2)默认v2
UsePathStyle bool `json:"use_path_style"` // 是否使用path style
RemoveBucket bool `json:"remove_bucket"` // 是否从路径中删除bucket名称
// 自定义域名配置
CustomDomain string `json:"custom_domain"` // 自定义访问域名,支持路径
// 文件过滤配置
FolderPath string `json:"folder_path"` // 提取的文件夹路径,例如: /img
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"`
}

104
readme.md
View File

@ -4,7 +4,7 @@
## 功能特性
- 🎯 支持多种数据源兰空图床API、手动配置、通用API接口
- 🎯 支持多种数据源兰空图床API, s3协议对象存储, 手动配置, 通用API接口(GET/POST)
- 🔐 OAuth2.0 管理后台登录CZL Connect
- 💾 SQLite数据库存储
- ⚡ 内存缓存机制
@ -12,107 +12,9 @@
- 📝 可配置首页内容
- 🎨 现代化Web管理界面
## 环境变量配置
## 部署and讨论
复制 `env.example``.env` 并配置以下环境变量:
```bash
# 服务器配置
PORT=:5003 # 服务端口
READ_TIMEOUT=30s # 读取超时
WRITE_TIMEOUT=30s # 写入超时
MAX_HEADER_BYTES=1048576 # 最大请求头大小
# 数据存储目录
DATA_DIR=./data # 数据存储目录
# OAuth2.0 配置 (必需)
OAUTH_CLIENT_ID=your-oauth-client-id # CZL Connect 客户端ID
OAUTH_CLIENT_SECRET=your-oauth-client-secret # CZL Connect 客户端密钥
```
## 快速开始
1. 克隆项目
```bash
git clone <repository-url>
cd random-api-go
```
2. 配置环境变量
```bash
cp env.example .env
# 编辑 .env 文件,填入正确的 OAuth 配置
```
3. 运行服务
```bash
go run main.go
```
4. 访问服务
- 首页: http://localhost:5003
- 管理后台: http://localhost:5003/admin
## OAuth2.0 配置
本项目使用 CZL Connect 作为 OAuth2.0 提供商:
- 授权端点: https://connect.czl.net/oauth2/authorize
- 令牌端点: https://connect.czl.net/api/oauth2/token
- 用户信息端点: https://connect.czl.net/api/oauth2/userinfo
请在 CZL Connect 中注册应用并获取 `client_id``client_secret`
## API 端点
### 公开API
- `GET /` - 首页
- `GET /{endpoint}` - 随机API端点
### 管理API
- `GET /admin/api/oauth-config` - 获取OAuth配置
- `POST /admin/api/oauth-verify` - 验证OAuth授权码
- `GET /admin/api/endpoints` - 列出所有端点
- `POST /admin/api/endpoints/` - 创建端点
- `GET /admin/api/endpoints/{id}` - 获取端点详情
- `PUT /admin/api/endpoints/{id}` - 更新端点
- `DELETE /admin/api/endpoints/{id}` - 删除端点
- `POST /admin/api/data-sources` - 创建数据源
- `GET /admin/api/url-replace-rules` - 列出URL替换规则
- `POST /admin/api/url-replace-rules/` - 创建URL替换规则
- `GET /admin/api/home-config` - 获取首页配置
- `PUT /admin/api/home-config/` - 更新首页配置
## 数据源类型
1. **兰空图床 (lankong)**: 从兰空图床API获取图片
2. **手动配置 (manual)**: 手动配置的URL列表
3. **API GET (api_get)**: 从GET接口获取数据
4. **API POST (api_post)**: 从POST接口获取数据
## 部署
### Docker 部署
```dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o random-api-server main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/random-api-server .
COPY --from=builder /app/web ./web
EXPOSE 5003
CMD ["./random-api-server"]
```
### 环境变量部署
确保在生产环境中正确设置所有必需的环境变量特别是OAuth配置。
<https://www.sunai.net/t/topic/127>
## 许可证

View File

@ -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 判断是否应该由静态文件处理器处理

View File

@ -16,6 +16,7 @@ type DataSourceFetcher struct {
cacheManager *CacheManager
lankongFetcher *LankongFetcher
apiFetcher *APIFetcher
s3Fetcher *S3Fetcher
}
// NewDataSourceFetcher 创建数据源获取器
@ -38,6 +39,7 @@ func NewDataSourceFetcher(cacheManager *CacheManager) *DataSourceFetcher {
cacheManager: cacheManager,
lankongFetcher: lankongFetcher,
apiFetcher: NewAPIFetcher(),
s3Fetcher: NewS3Fetcher(),
}
}
@ -59,19 +61,24 @@ func getIntConfig(key string, defaultValue int) int {
// FetchURLs 从数据源获取URL列表
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类型的数据源直接实时请求不使用缓存
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("实时请求API数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
return dsf.fetchAPIURLs(dataSource)
}
// 构建内存缓存的key使用数据源ID
cacheKey := fmt.Sprintf("datasource_%d", dataSource.ID)
// 先检查内存缓存
if cachedURLs, exists := dsf.cacheManager.GetFromMemoryCache(cacheKey); exists && len(cachedURLs) > 0 {
log.Printf("从内存缓存获取到 %d 个URL (数据源ID: %d)", len(cachedURLs), dataSource.ID)
return cachedURLs, nil
// 如果不跳过缓存,先检查内存缓存
if !skipCache {
if cachedURLs, exists := dsf.cacheManager.GetFromMemoryCache(cacheKey); exists && len(cachedURLs) > 0 {
return cachedURLs, nil
}
}
var urls []string
@ -86,6 +93,8 @@ func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string,
urls, err = dsf.fetchManualURLs(dataSource)
case "endpoint":
urls, err = dsf.fetchEndpointURLs(dataSource)
case "s3":
urls, err = dsf.fetchS3URLs(dataSource)
default:
return nil, fmt.Errorf("unsupported data source type: %s", dataSource.Type)
}
@ -180,12 +189,22 @@ func (dsf *DataSourceFetcher) fetchEndpointURLs(dataSource *model.DataSource) ([
return urls, nil
}
// fetchS3URLs 获取S3存储桶URL
func (dsf *DataSourceFetcher) fetchS3URLs(dataSource *model.DataSource) ([]string, error) {
var config model.S3Config
if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil {
return nil, fmt.Errorf("invalid S3 config: %w", err)
}
return dsf.s3Fetcher.FetchURLs(&config)
}
// updateDataSourceSyncTime 更新数据源的同步时间
func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *model.DataSource) error {
// 这里需要导入database包来更新数据库
// 为了避免循环依赖,我们通过回调或者接口来处理
// 暂时先记录日志,具体实现在主服务中处理
log.Printf("需要更新数据源 %d 的同步时间", dataSource.ID)
if err := database.DB.Model(dataSource).Update("last_sync", dataSource.LastSync).Error; err != nil {
return fmt.Errorf("failed to update sync time for data source %d: %w", dataSource.ID, err)
}
log.Printf("更新数据源 %d 的同步时间", dataSource.ID)
return nil
}
@ -201,3 +220,16 @@ func (dsf *DataSourceFetcher) PreloadDataSource(dataSource *model.DataSource) er
log.Printf("数据源 %d 预加载完成", dataSource.ID)
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
}

View File

@ -0,0 +1,371 @@
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
}

View File

@ -2,7 +2,6 @@ package service
import (
"fmt"
"log"
"math/rand"
"random-api-go/database"
"random-api-go/model"
@ -22,6 +21,26 @@ type EndpointService struct {
var endpointService *EndpointService
var once sync.Once
// 支持的数据源类型列表
var supportedDataSourceTypes = []string{
"lankong",
"manual",
"api_get",
"api_post",
"endpoint",
"s3",
}
// validateDataSourceType 验证数据源类型
func validateDataSourceType(dataSourceType string) error {
for _, supportedType := range supportedDataSourceTypes {
if dataSourceType == supportedType {
return nil
}
}
return fmt.Errorf("unsupported data source type: %s, supported types: %v", dataSourceType, supportedDataSourceTypes)
}
// GetEndpointService 获取端点服务单例
func GetEndpointService() *EndpointService {
once.Do(func() {
@ -154,7 +173,6 @@ func (s *EndpointService) GetRandomURL(url string) (string, error) {
// 如果包含实时数据源,不使用内存缓存,直接实时获取
if hasRealtimeDataSource {
log.Printf("端点包含实时数据源,使用实时请求模式: %s", url)
return s.getRandomURLRealtime(endpoint)
}
@ -178,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)
@ -208,7 +225,13 @@ func (s *EndpointService) getRandomURLRealtime(endpoint *model.APIEndpoint) (str
}
// 递归调用获取目标端点的随机URL
return s.GetRandomURL(targetEndpoint.URL)
targetURL, err := s.GetRandomURL(targetEndpoint.URL)
if err != nil {
return "", err
}
// 对从目标端点获取的URL应用当前端点的替换规则
return s.applyURLReplaceRules(targetURL, endpoint.URL), nil
}
return s.applyURLReplaceRules(randomURL, endpoint.URL), nil
@ -230,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)
@ -260,7 +282,13 @@ func (s *EndpointService) getRandomURLWithCache(endpoint *model.APIEndpoint) (st
}
// 递归调用获取目标端点的随机URL
return s.GetRandomURL(targetEndpoint.URL)
targetURL, err := s.GetRandomURL(targetEndpoint.URL)
if err != nil {
return "", err
}
// 对从目标端点获取的URL应用当前端点的替换规则
return s.applyURLReplaceRules(targetURL, endpoint.URL), nil
}
return s.applyURLReplaceRules(randomURL, endpoint.URL), nil
@ -271,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
}
@ -287,6 +314,11 @@ func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string {
// CreateDataSource 创建数据源
func (s *EndpointService) CreateDataSource(dataSource *model.DataSource) error {
// 验证数据源类型
if err := validateDataSourceType(dataSource.Type); err != nil {
return err
}
if err := database.DB.Create(dataSource).Error; err != nil {
return fmt.Errorf("failed to create data source: %w", err)
}
@ -304,6 +336,11 @@ func (s *EndpointService) CreateDataSource(dataSource *model.DataSource) error {
// UpdateDataSource 更新数据源
func (s *EndpointService) UpdateDataSource(dataSource *model.DataSource) error {
// 验证数据源类型
if err := validateDataSourceType(dataSource.Type); err != nil {
return err
}
if err := database.DB.Save(dataSource).Error; err != nil {
return fmt.Errorf("failed to update data source: %w", err)
}

View File

@ -74,6 +74,12 @@ func (p *Preloader) ResumePeriodicRefresh() {
// PreloadDataSourceOnSave 在保存数据源时预加载数据
func (p *Preloader) PreloadDataSourceOnSave(dataSource *model.DataSource) {
// 检查数据源是否处于活跃状态
if !dataSource.IsActive {
log.Printf("数据源 %d 已禁用,跳过预加载", dataSource.ID)
return
}
// API类型的数据源不需要预加载使用实时请求
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("API数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type)
@ -135,8 +141,20 @@ func (p *Preloader) RefreshDataSource(dataSourceID uint) error {
return err
}
// 检查数据源是否处于活跃状态
if !dataSource.IsActive {
log.Printf("数据源 %d 已禁用,跳过刷新", dataSourceID)
return nil
}
// API类型的数据源不需要预加载使用实时请求
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("API数据源 %d (%s) 使用实时请求,跳过刷新", dataSource.ID, dataSource.Type)
return nil
}
log.Printf("手动刷新数据源 %d", dataSourceID)
return p.dataSourceFetcher.PreloadDataSource(&dataSource)
return p.dataSourceFetcher.RefreshDataSource(&dataSource)
}
// RefreshEndpoint 手动刷新指定端点的所有数据源
@ -166,7 +184,7 @@ func (p *Preloader) RefreshEndpoint(endpointID uint) error {
go func(ds model.DataSource) {
defer wg.Done()
if err := p.dataSourceFetcher.PreloadDataSource(&ds); err != nil {
if err := p.dataSourceFetcher.RefreshDataSource(&ds); err != nil {
log.Printf("刷新数据源 %d 失败: %v", ds.ID, err)
lastErr = err
}
@ -281,6 +299,8 @@ func (p *Preloader) shouldPeriodicRefresh(dataSource *model.DataSource) bool {
switch dataSource.Type {
case "lankong":
refreshInterval = 24 * time.Hour // 兰空图床每24小时刷新一次
case "s3":
refreshInterval = 24 * time.Hour // S3存储每24小时刷新一次
default:
return false
}

330
service/s3_fetcher.go Normal file
View File

@ -0,0 +1,330 @@
package service
import (
"context"
"fmt"
"log"
"net/url"
"random-api-go/model"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// S3Fetcher S3获取器
type S3Fetcher struct {
timeout time.Duration
}
// NewS3Fetcher 创建S3获取器
func NewS3Fetcher() *S3Fetcher {
return &S3Fetcher{
timeout: 30 * time.Second,
}
}
// FetchURLs 从S3存储桶获取文件URL列表
func (sf *S3Fetcher) FetchURLs(s3Config *model.S3Config) ([]string, error) {
if s3Config == nil {
return nil, fmt.Errorf("S3配置不能为空")
}
// 验证必需的配置
if s3Config.Endpoint == "" {
return nil, fmt.Errorf("S3端点地址不能为空")
}
if s3Config.BucketName == "" {
return nil, fmt.Errorf("存储桶名称不能为空")
}
if s3Config.AccessKeyID == "" {
return nil, fmt.Errorf("访问密钥ID不能为空")
}
if s3Config.SecretAccessKey == "" {
return nil, fmt.Errorf("访问密钥不能为空")
}
// 创建S3客户端
client, err := sf.createS3Client(s3Config)
if err != nil {
return nil, fmt.Errorf("创建S3客户端失败: %w", err)
}
// 获取对象列表
objects, err := sf.listObjects(client, s3Config)
if err != nil {
return nil, fmt.Errorf("获取对象列表失败: %w", err)
}
// 过滤和转换为URL
urls := sf.convertObjectsToURLs(objects, s3Config)
log.Printf("从S3存储桶 %s 获取到 %d 个文件URL", s3Config.BucketName, len(urls))
return urls, nil
}
// createS3Client 创建S3客户端
func (sf *S3Fetcher) createS3Client(s3Config *model.S3Config) (*s3.Client, error) {
// 设置默认地区
region := s3Config.Region
if region == "" {
region = "us-east-1"
}
// 创建凭证
creds := credentials.NewStaticCredentialsProvider(
s3Config.AccessKeyID,
s3Config.SecretAccessKey,
"",
)
// 创建配置
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(creds),
)
if err != nil {
return nil, fmt.Errorf("加载AWS配置失败: %w", err)
}
// 创建S3客户端选项
options := func(o *s3.Options) {
if s3Config.Endpoint != "" {
o.BaseEndpoint = aws.String(s3Config.Endpoint)
}
o.UsePathStyle = s3Config.UsePathStyle
}
client := s3.NewFromConfig(cfg, options)
return client, nil
}
// listObjects 列出存储桶中的对象
func (sf *S3Fetcher) listObjects(client *s3.Client, s3Config *model.S3Config) ([]types.Object, error) {
ctx, cancel := context.WithTimeout(context.Background(), sf.timeout)
defer cancel()
var allObjects []types.Object
var continuationToken *string
// 设置前缀(文件夹路径)
prefix := strings.TrimPrefix(s3Config.FolderPath, "/")
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
// 设置分隔符(如果不包含子文件夹)
var delimiter *string
if !s3Config.IncludeSubfolders {
delimiter = aws.String("/")
}
// 确定使用的ListObjects版本
listVersion := s3Config.ListObjectsVersion
if listVersion == "" {
listVersion = "v2" // 默认使用v2
}
for {
if listVersion == "v1" {
// 使用ListObjects (v1)
input := &s3.ListObjectsInput{
Bucket: aws.String(s3Config.BucketName),
Prefix: aws.String(prefix),
Delimiter: delimiter,
MaxKeys: aws.Int32(1000),
}
if continuationToken != nil {
input.Marker = continuationToken
}
result, err := client.ListObjects(ctx, input)
if err != nil {
return nil, fmt.Errorf("ListObjects失败: %w", err)
}
allObjects = append(allObjects, result.Contents...)
if !aws.ToBool(result.IsTruncated) {
break
}
if len(result.Contents) > 0 {
continuationToken = result.Contents[len(result.Contents)-1].Key
}
} else {
// 使用ListObjectsV2 (v2)
input := &s3.ListObjectsV2Input{
Bucket: aws.String(s3Config.BucketName),
Prefix: aws.String(prefix),
Delimiter: delimiter,
MaxKeys: aws.Int32(1000),
ContinuationToken: continuationToken,
}
result, err := client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("ListObjectsV2失败: %w", err)
}
allObjects = append(allObjects, result.Contents...)
if !aws.ToBool(result.IsTruncated) {
break
}
continuationToken = result.NextContinuationToken
}
}
return allObjects, nil
}
// convertObjectsToURLs 将S3对象转换为URL列表
func (sf *S3Fetcher) convertObjectsToURLs(objects []types.Object, s3Config *model.S3Config) []string {
var urls []string
// 编译文件扩展名正则表达式
var extensionRegexes []*regexp.Regexp
for _, ext := range s3Config.FileExtensions {
if ext != "" {
// 确保扩展名以点开头
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
// 转义特殊字符并创建正则表达式
pattern := regexp.QuoteMeta(ext) + "$"
if regex, err := regexp.Compile("(?i)" + pattern); err == nil {
extensionRegexes = append(extensionRegexes, regex)
} else {
log.Printf("警告: 无效的文件扩展名正则表达式 '%s': %v", ext, err)
}
}
}
for _, obj := range objects {
if obj.Key == nil {
continue
}
key := aws.ToString(obj.Key)
// 跳过以/结尾的对象(文件夹)
if strings.HasSuffix(key, "/") {
continue
}
// 如果设置了文件扩展名过滤,检查是否匹配
if len(extensionRegexes) > 0 {
matched := false
for _, regex := range extensionRegexes {
if regex.MatchString(key) {
matched = true
break
}
}
if !matched {
continue
}
}
// 生成URL
fileURL := sf.generateURL(key, s3Config)
if fileURL != "" {
urls = append(urls, fileURL)
}
}
return urls
}
// generateURL 生成文件的访问URL
func (sf *S3Fetcher) generateURL(key string, s3Config *model.S3Config) string {
// 如果设置了自定义域名
if s3Config.CustomDomain != "" {
return sf.generateCustomDomainURL(key, s3Config)
}
// 使用S3标准URL格式
return sf.generateS3URL(key, s3Config)
}
// generateCustomDomainURL 生成自定义域名URL
func (sf *S3Fetcher) generateCustomDomainURL(key string, s3Config *model.S3Config) string {
baseURL := strings.TrimSuffix(s3Config.CustomDomain, "/")
// 处理key路径
path := key
if s3Config.RemoveBucket {
// 如果需要移除bucket名称并且key以bucket名称开头
bucketPrefix := s3Config.BucketName + "/"
if strings.HasPrefix(path, bucketPrefix) {
path = strings.TrimPrefix(path, bucketPrefix)
}
}
// 对路径进行适当的URL编码但保留路径分隔符
path = sf.encodeURLPath(path)
// 确保路径以/开头
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return baseURL + path
}
// generateS3URL 生成S3标准URL
func (sf *S3Fetcher) generateS3URL(key string, s3Config *model.S3Config) string {
// 对key进行适当的URL编码但保留路径分隔符
encodedKey := sf.encodeURLPath(key)
if s3Config.UsePathStyle {
// Path-style URL: http://endpoint/bucket/key
endpoint := strings.TrimSuffix(s3Config.Endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s3Config.BucketName, encodedKey)
} else {
// Virtual-hosted-style URL: http://bucket.endpoint/key
endpoint := strings.TrimSuffix(s3Config.Endpoint, "/")
// 解析endpoint以获取主机名
if parsedURL, err := url.Parse(endpoint); err == nil {
scheme := parsedURL.Scheme
if scheme == "" {
scheme = "https"
}
host := parsedURL.Host
if host == "" {
host = parsedURL.Path
}
return fmt.Sprintf("%s://%s.%s/%s", scheme, s3Config.BucketName, host, encodedKey)
}
// 如果解析失败回退到path-style
return fmt.Sprintf("%s/%s/%s", endpoint, s3Config.BucketName, encodedKey)
}
}
// encodeURLPath 对URL路径进行编码保留路径分隔符但编码其他特殊字符
func (sf *S3Fetcher) encodeURLPath(path string) string {
// 分割路径为各个部分
parts := strings.Split(path, "/")
// 对每个部分进行URL编码
for i, part := range parts {
if part != "" {
// 使用PathEscape对每个路径段进行编码
// 这会将空格编码为%20这是URL路径中的标准做法
parts[i] = url.PathEscape(part)
}
}
// 重新组合路径
return strings.Join(parts, "/")
}

View File

@ -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({

View File

@ -0,0 +1,5 @@
import DomainStatsTab from '@/components/admin/DomainStatsTab'
export default function StatsPage() {
return <DomainStatsTab />
}

View File

@ -436,6 +436,10 @@ export default function Home() {
<div className="text-center mt-8 text-sm text-gray-600 dark:text-gray-400">
<p>API服务 - Next.js Go </p>
<p className="mt-2">
<Link href="https://github.com/woodchen-ink/random-api-go" className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline">
GitHub
</Link>
<span className="mx-2">|</span>
<Link href="/admin" className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline">
</Link>

View File

@ -11,7 +11,7 @@ import { Trash2, Plus } from 'lucide-react'
import { authenticatedFetch } from '@/lib/auth'
interface DataSourceConfigFormProps {
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint'
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3'
config: string
onChange: (config: string) => void
}
@ -40,6 +40,21 @@ interface EndpointConfig {
endpoint_ids: number[]
}
interface S3Config {
endpoint: string
bucket_name: string
region: string
access_key_id: string
secret_access_key: string
list_objects_version: string
use_path_style: boolean
remove_bucket: boolean
custom_domain: string
folder_path: string
include_subfolders: boolean
file_extensions: string[]
}
export default function DataSourceConfigForm({ type, config, onChange }: DataSourceConfigFormProps) {
const [lankongConfig, setLankongConfig] = useState<LankongConfig>({
api_token: '',
@ -59,9 +74,25 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
endpoint_ids: []
})
const [s3Config, setS3Config] = useState<S3Config>({
endpoint: '',
bucket_name: '',
region: '',
access_key_id: '',
secret_access_key: '',
list_objects_version: 'v2',
use_path_style: false,
remove_bucket: false,
custom_domain: '',
folder_path: '',
include_subfolders: true,
file_extensions: []
})
const [availableEndpoints, setAvailableEndpoints] = useState<Array<{id: number, name: string, url: string}>>([])
const [headerPairs, setHeaderPairs] = useState<Array<{key: string, value: string}>>([{key: '', value: ''}])
const [extensionInputs, setExtensionInputs] = useState<string[]>([''])
const [savedTokens, setSavedTokens] = useState<SavedToken[]>([])
const [newTokenName, setNewTokenName] = useState<string>('')
@ -127,6 +158,26 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
setEndpointConfig({
endpoint_ids: parsed.endpoint_ids || []
})
} else if (type === 's3') {
setS3Config({
endpoint: parsed.endpoint || '',
bucket_name: parsed.bucket_name || '',
region: parsed.region || '',
access_key_id: parsed.access_key_id || '',
secret_access_key: parsed.secret_access_key || '',
list_objects_version: parsed.list_objects_version || 'v2',
use_path_style: parsed.use_path_style || false,
remove_bucket: parsed.remove_bucket || false,
custom_domain: parsed.custom_domain || '',
folder_path: parsed.folder_path || '',
include_subfolders: parsed.include_subfolders !== false,
file_extensions: parsed.file_extensions || []
})
// 设置文件扩展名输入框
const extensions = parsed.file_extensions || ['']
if (extensions.length === 0) extensions.push('')
setExtensionInputs(extensions)
}
} catch (error) {
console.error('Failed to parse config:', error)
@ -257,6 +308,33 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
updateEndpointConfig(newIds)
}
// 更新S3配置
const updateS3Config = (field: keyof S3Config, value: string | boolean | string[]) => {
const newConfig = { ...s3Config, [field]: value }
setS3Config(newConfig)
onChange(JSON.stringify(newConfig))
}
// 添加文件扩展名
const addExtension = () => {
const newExtensions = [...extensionInputs, '']
setExtensionInputs(newExtensions)
}
// 删除文件扩展名
const removeExtension = (index: number) => {
const newExtensions = extensionInputs.filter((_, i) => i !== index)
setExtensionInputs(newExtensions)
updateS3Config('file_extensions', newExtensions.filter(ext => ext.trim() !== ''))
}
// 更新文件扩展名
const updateExtension = (index: number, value: string) => {
const newExtensions = extensionInputs.map((ext, i) => i === index ? value : ext)
setExtensionInputs(newExtensions)
updateS3Config('file_extensions', newExtensions.filter(ext => ext.trim() !== ''))
}
if (type === 'manual') {
return (
<div className="space-y-2">
@ -560,5 +638,193 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
)
}
if (type === 's3') {
return (
<div className="space-y-4">
{/* 基础配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="s3-endpoint">S3端点地址</Label>
<Input
id="s3-endpoint"
value={s3Config.endpoint}
onChange={(e) => updateS3Config('endpoint', e.target.value)}
placeholder="https://s3.amazonaws.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s3-bucket"></Label>
<Input
id="s3-bucket"
value={s3Config.bucket_name}
onChange={(e) => updateS3Config('bucket_name', e.target.value)}
placeholder="my-bucket"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="s3-region"></Label>
<Input
id="s3-region"
value={s3Config.region}
onChange={(e) => updateS3Config('region', e.target.value)}
placeholder="us-east-1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s3-version"></Label>
<select
id="s3-version"
value={s3Config.list_objects_version}
onChange={(e) => updateS3Config('list_objects_version', e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="v2">v2</option>
<option value="v1">v1</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="s3-access-key">访ID</Label>
<Input
id="s3-access-key"
value={s3Config.access_key_id}
onChange={(e) => updateS3Config('access_key_id', e.target.value)}
placeholder="AKIAIOSFODNN7EXAMPLE"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s3-secret-key">访</Label>
<Input
id="s3-secret-key"
type="password"
value={s3Config.secret_access_key}
onChange={(e) => updateS3Config('secret_access_key', e.target.value)}
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
/>
</div>
</div>
</CardContent>
</Card>
{/* 高级配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="s3-path-style"
checked={s3Config.use_path_style}
onCheckedChange={(checked) => updateS3Config('use_path_style', checked as boolean)}
/>
<Label htmlFor="s3-path-style">使Path Style URL</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="s3-remove-bucket"
checked={s3Config.remove_bucket}
onCheckedChange={(checked) => updateS3Config('remove_bucket', checked as boolean)}
/>
<Label htmlFor="s3-remove-bucket">bucket名称</Label>
</div>
<div className="space-y-2">
<Label htmlFor="s3-custom-domain">访</Label>
<Input
id="s3-custom-domain"
value={s3Config.custom_domain}
onChange={(e) => updateS3Config('custom_domain', e.target.value)}
placeholder="https://cdn.example.com"
/>
<p className="text-xs text-muted-foreground">
使S3标准URL支持路径如: https://cdn.example.com/path
</p>
</div>
</CardContent>
</Card>
{/* 文件过滤配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="s3-folder-path"></Label>
<Input
id="s3-folder-path"
value={s3Config.folder_path}
onChange={(e) => updateS3Config('folder_path', e.target.value)}
placeholder="/images"
/>
<p className="text-xs text-muted-foreground">
: /images /uploads/photos
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="s3-include-subfolders"
checked={s3Config.include_subfolders}
onCheckedChange={(checked) => updateS3Config('include_subfolders', checked as boolean)}
/>
<Label htmlFor="s3-include-subfolders"></Label>
</div>
<div className="space-y-2">
<Label></Label>
{extensionInputs.map((ext, index) => (
<div key={index} className="flex gap-2">
<Input
value={ext}
onChange={(e) => updateExtension(index, e.target.value)}
placeholder=".jpg 或 .png"
className="flex-1"
/>
{extensionInputs.length > 1 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => removeExtension(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
onClick={addExtension}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<p className="text-xs text-muted-foreground">
支持正则匹配如: .jpg, .png, .gif
</p>
</div>
</CardContent>
</Card>
</div>
)
}
return null
}

View File

@ -26,7 +26,7 @@ export default function DataSourceManagement({
const [editingDataSource, setEditingDataSource] = useState<DataSource | null>(null)
const [formData, setFormData] = useState({
name: '',
type: 'manual' as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint',
type: 'manual' as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3',
config: '',
is_active: true
})
@ -185,6 +185,7 @@ export default function DataSourceManagement({
case 'api_get': return 'GET接口'
case 'api_post': return 'POST接口'
case 'endpoint': return '已有端点'
case 's3': return 'S3对象存储'
default: return type
}
}
@ -245,7 +246,7 @@ export default function DataSourceManagement({
<select
id="ds-type"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' })}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3' })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="manual"></option>
@ -253,6 +254,7 @@ export default function DataSourceManagement({
<option value="api_get">GET接口</option>
<option value="api_post">POST接口</option>
<option value="endpoint"></option>
<option value="s3">S3对象存储</option>
</select>
</div>
</div>

View 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>
)
}

View File

@ -18,6 +18,14 @@ const nextConfig: NextConfig = {
{
source: "/api/:path*",
destination: "http://localhost:5003/api/:path*",
},
{
source: "/pic/:path*",
destination: "http://localhost:5003/pic/:path*",
},
{
source: "/video/:path*",
destination: "http://localhost:5003/video/:path*",
}
];
}

View File

@ -21,7 +21,7 @@ export interface DataSource {
id: number
endpoint_id: number
name: string
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint'
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3'
config: string
is_active: boolean
last_sync?: string
@ -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[]
}