Files
杨豪 5a34e739bb
Build and Publish Docker Image / build-and-push (pull_request) Successful in 15m54s
feat: HH-773 合集重命名接入 egress geo 缓存、geo 失败降级与每小时补探测
- service: EgressCacheKey 从 handler 下沉复用;MergeCachedEgressGeo 在合集
  重命名前合并缓存 geo 字段(纯缓存读、零网络 I/O);StripEgressGeoFields
  防止 geo 字段泄漏进订阅输出
- filter: geo 检测失败的节点保留 '[别名] 原名'(或纯原名)参与编号/排序,
  不再丢弃
- handler: 每小时 StartEgressRefresher 对启用源补探测缺失/过期的 egress
  缓存(含 TTL 内 error 结果一律跳过);RegisterRoutes 返回 *Deps;
  cmd/server.go 启动定时任务并在 shutdown 时取消
- tests: service 缓存 key 稳定性/合并/管道测试、handler 补探测跳过/TTL/
  过期/取消测试、filter 降级测试;修复 2 个过时测试(mihomo YAML 配置、
  缓存命中路径)
2026-08-28 16:40:11 +08:00

207 lines
5.7 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/gofiber/fiber/v3"
"github.com/jmoiron/sqlx"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/peterqiu0516/sub-store/internal/config"
"github.com/peterqiu0516/sub-store/internal/database"
"github.com/peterqiu0516/sub-store/internal/geoip"
"github.com/peterqiu0516/sub-store/internal/handler"
"github.com/peterqiu0516/sub-store/internal/middleware"
)
var configFile string
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the Sub-Store HTTP server",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return RunServer(cfg)
},
}
func init() {
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringVarP(&configFile, "config", "c", "", "config file path")
}
// RunServer opens the database, applies migrations (idempotent — goose tracks
// applied versions in goose_db_version), then starts the HTTP server.
func RunServer(cfg *config.Config) error {
db, err := database.InitDB(cfg.Database.Path)
if err != nil {
return err
}
defer db.Close()
// Auto-migrate on every startup; goose is idempotent so this is safe to call repeatedly.
if err := database.RunMigrations(db); err != nil {
return err
}
// Ensure GeoIP database exists (download in background if missing)
mmdbPath := filepath.Join(filepath.Dir(cfg.Database.Path), "GeoLite2-City.mmdb")
geoip.SetMmdbPath(mmdbPath)
if _, err := os.Stat(mmdbPath); err != nil {
logrus.Info("GeoIP database not found, downloading in background...")
go func() {
if err := geoip.DownloadMmdb(mmdbPath); err != nil {
logrus.WithError(err).Warn("GeoIP download failed, name-based detection will be used as fallback")
} else {
logrus.Info("GeoIP database downloaded successfully")
// Reload the reader
geoip.SetMmdbPath(mmdbPath)
}
}()
} else {
logrus.Info("GeoIP database found")
}
return startFiber(cfg, db)
}
func startFiber(cfg *config.Config, db *sqlx.DB) error {
logrus.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
logrus.SetLevel(logrus.InfoLevel)
// Start cache cleaner — review-resolution #32
cacheCtx, cacheCancel := context.WithCancel(context.Background())
defer cacheCancel()
cacheInterval := cfg.Fetcher.CacheTTL
if cacheInterval <= 0 {
cacheInterval = 300 * time.Second
}
database.StartCacheCleaner(cacheCtx, db, cacheInterval)
app := fiber.New(fiber.Config{
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
BodyLimit: cfg.Server.BodyLimit,
ErrorHandler: func(c fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
message := "Internal server error"
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
code = fiberErr.Code
message = fiberErr.Message
}
logrus.WithError(err).WithFields(logrus.Fields{
"method": c.Method(),
"path": c.Path(),
}).Error("request error")
return c.Status(code).JSON(fiber.Map{
"status": "failed",
"error": fiber.Map{"code": code, "message": message},
})
},
})
// Global middleware
app.Use(middleware.SecurityHeaders())
app.Use(middleware.CORS("*"))
app.Use(middleware.HandleOptions())
app.Use(middleware.DownloadHostIsolation(cfg.Auth.DownloadHosts))
// Register routes
deps := handler.RegisterRoutes(app, cfg, db)
// Start egress geo refresher — HH-773: hourly low-frequency background
// re-probe of nodes with missing/expired egress cache entries.
egressCtx, egressCancel := context.WithCancel(context.Background())
defer egressCancel()
handler.StartEgressRefresher(egressCtx, deps, time.Hour)
// Health check
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
// Serve frontend static files with SPA fallback.
// Registered after API/download routes so those take priority.
frontendDir := cfg.Server.FrontendDir
if frontendDir == "" {
frontendDir = "./frontend/dist"
}
registerStaticFrontend(app, frontendDir)
// Start server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
logrus.Infof("Starting %s on %s", cfg.App.Name, addr)
go func() {
if err := app.Listen(addr); err != nil {
logrus.WithError(err).Error("server failed")
}
}()
// Graceful shutdown — review-resolution #34
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logrus.Info("Shutting down server...")
cacheCancel()
egressCancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
go func() {
<-shutdownCtx.Done()
}()
if err := app.Shutdown(); err != nil {
logrus.WithError(err).Error("shutdown error")
}
logrus.Info("Server stopped")
return nil
}
// registerStaticFrontend serves static files from the given directory with
// SPA fallback: any GET request that doesn't match a real file returns
// index.html (so client-side routing works on fresh page loads).
func registerStaticFrontend(app *fiber.App, dir string) {
if _, err := os.Stat(dir); err != nil {
logrus.WithError(err).Warn("frontend directory not found, static serving disabled")
return
}
indexFile := filepath.Join(dir, "index.html")
// Catch-all GET handler: try to serve the requested file from the dist
// directory; if the file doesn't exist, fall back to index.html (SPA).
app.Use(func(c fiber.Ctx) error {
if c.Method() != "GET" {
return c.Next()
}
path := c.Path()
// Try the exact file in the dist directory
filePath := filepath.Join(dir, filepath.Clean(path))
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
return c.SendFile(filePath)
}
// SPA fallback → index.html
return c.SendFile(indexFile)
})
}