// 我们past后端(Go 版):API 与 PHP 版 backend/api/index.php 完全兼容。 package main import ( "context" "flag" "fmt" "io" "log/slog" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" _ "time/tzdata" // Windows 没有系统时区库,内嵌 tzdata 保证 Asia/Shanghai 可用 "past/internal/config" "past/internal/geo" "past/internal/handler" "past/internal/httpapi" "past/internal/notify" "past/internal/store" "past/internal/upload" "past/internal/wechat" "past/web" ) // version 由构建时 -ldflags 注入 var version = "dev" func main() { cfgPath := flag.String("config", "config.yaml", "配置文件路径") showVer := flag.Bool("version", false, "显示版本") svcCmd := flag.String("service", "", "Windows 服务管理: install | uninstall | start | stop") flag.Parse() if *showVer { fmt.Println("past-server", version) return } if *svcCmd != "" { if err := serviceControl(*svcCmd, *cfgPath); err != nil { fmt.Fprintln(os.Stderr, "错误:", err) os.Exit(1) } return } // 作为 Windows 服务运行时,工作目录是 System32:配置文件相对路径按可执行文件所在目录解析 if !filepath.IsAbs(*cfgPath) { if exe, err := os.Executable(); err == nil { if _, err := os.Stat(*cfgPath); err != nil { *cfgPath = filepath.Join(filepath.Dir(exe), *cfgPath) } } } if runAsServiceIfNeeded(func(stop <-chan struct{}) { serve(*cfgPath, stop) }) { return } // 控制台模式:Ctrl+C / SIGTERM 触发优雅退出 stop := make(chan struct{}) go func() { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit close(stop) }() serve(*cfgPath, stop) } // serve 启动全部组件并阻塞到 stop 关闭 func serve(cfgPath string, stop <-chan struct{}) { // 与 PHP date_default_timezone_set('Asia/Shanghai') 一致 if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil { time.Local = loc } cfg, err := config.Load(cfgPath) if err != nil { slog.Error("配置加载失败", "err", err) os.Exit(1) } // 确保必要目录存在(对应 config.php 中的 $dirs) for _, d := range []string{"data", "data/tmp", "uploads", "uploads/avatars", "uploads/couples", "uploads/users"} { os.MkdirAll(filepath.Join(cfg.DataDir, d), 0755) } // 日志:stdout + data/go-server.log(Windows 服务没有控制台,文件日志是主要途径) logOut := io.Writer(os.Stdout) if f, err := os.OpenFile(filepath.Join(cfg.LogDir(), "go-server.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { logOut = io.MultiWriter(os.Stdout, f) defer f.Close() } slog.SetDefault(slog.New(slog.NewTextHandler(logOut, &slog.HandlerOptions{Level: slog.LevelInfo}))) db, err := store.Open(cfg.DSN()) if err != nil { slog.Error("数据库连接失败", "err", err) os.Exit(1) } db.UploadDir = cfg.UploadDir() db.NumericAsString = cfg.PHPCompat < "8.1" if err := db.InitSchema(); err != nil { slog.Error("数据库初始化失败", "err", err) os.Exit(1) } if err := db.MigrateSmokingRecords(); err != nil { slog.Error("抽烟库存迁移失败", "err", err) os.Exit(1) } if err := db.AdvanceUnbindings(); err != nil { slog.Error("解绑状态处理失败", "err", err) os.Exit(1) } go db.RunUnbindingWorker() wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db) app := &httpapi.App{ Cfg: cfg, DB: db, Wechat: wx, Geo: geo.New(cfg.TencentMapKey), Admin: httpapi.NewAdminSessions(), } uploader := &upload.Uploader{Dir: cfg.UploadDir(), MaxSize: cfg.MaxUploadMB * 1024 * 1024, Checker: wx} router := httpapi.NewRouter(app, web.AdminFS()) handler.Register(router, uploader) slog.Info("已注册 action", "count", len(router.Actions())) if cfg.NotifyIntervalMin > 0 { go notify.Run(db, wx, time.Duration(cfg.NotifyIntervalMin)*time.Minute) } srv := &http.Server{ Addr: cfg.Listen, Handler: router.Engine(cfg.Debug), ReadHeaderTimeout: 30 * time.Second, ReadTimeout: 180 * time.Second, // 大文件上传 WriteTimeout: 180 * time.Second, IdleTimeout: 120 * time.Second, } go func() { slog.Info("服务启动", "version", version, "listen", cfg.Listen, "data_dir", cfg.DataDir) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("服务异常退出", "err", err) os.Exit(1) } }() <-stop ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() srv.Shutdown(ctx) slog.Info("服务已停止") }