// 代打管理平台 · 单文件服务 // // 双击本程序即可启动:内嵌 SQLite、模板与静态资源,无需安装任何环境。 // 部署参数读取同目录的 daili.ini(不存在会自动生成)。 package main import ( "context" "flag" "fmt" "log" "net" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "syscall" "time" "daili/internal/config" "daili/internal/logx" "daili/internal/store" "daili/internal/web" ) const version = "1.6.0" func main() { cfgFlag := flag.String("config", "", "配置文件路径(默认:exe 同级的 "+config.FileName+")") addrFlag := flag.String("addr", "", "临时覆盖监听地址,例如 127.0.0.1:19000") dbFlag := flag.String("db", "", "临时覆盖数据库路径") keepFlag := flag.Int("backup-keep", 0, "临时覆盖备份保留份数") noBackup := flag.Bool("no-backup", false, "本次运行关闭自动备份") noOpen := flag.Bool("no-open", false, "本次运行不自动打开浏览器") flag.Parse() flagsSet := map[string]bool{} flag.Visit(func(f *flag.Flag) { flagsSet[f.Name] = true }) log.SetFlags(log.LstdFlags) log.SetPrefix("[代打台] ") exeDir := executableDir() cfgPath := *cfgFlag if cfgPath == "" { cfgPath = filepath.Join(exeDir, config.FileName) } cfg, err := config.Load(cfgPath, exeDir) if err != nil { // 配置写错时不要吞掉,直接指向具体行 fmt.Fprintf(os.Stderr, "\n[代打台] 配置文件有问题,已停止启动:\n\n%v\n\n", err) os.Exit(1) } // 命令行覆盖(临时调试用,优先于配置文件) if flagsSet["addr"] { if err := applyAddr(cfg, *addrFlag); err != nil { fatal("%v", err) } } if flagsSet["db"] { p := *dbFlag if !filepath.IsAbs(p) { p = filepath.Join(exeDir, p) } cfg.DatabasePath = filepath.Clean(p) } if flagsSet["backup-keep"] { cfg.BackupKeep = *keepFlag } if *noBackup { cfg.BackupEnabled = false } if *noOpen { cfg.OpenBrowser = false } if err := cfg.Validate(); err != nil { fatal("命令行参数不合法:%v", err) } // 运行日志:控制台 + logs/daili-YYYY-MM-DD.log if cfg.LogEnabled { closer, err := logx.Setup(cfg.LogDir) if err != nil { log.Printf("运行日志无法写入 %s:%v(继续只输出到控制台)", cfg.LogDir, err) } else { defer closer.Close() log.Printf("运行日志:%s", filepath.Join(cfg.LogDir, "daili-"+time.Now().Format("2006-01-02")+".log")) } } // 时区:留空跟随系统,否则固定(会影响全部“自然日”计算) if cfg.Timezone != "" { loc, err := time.LoadLocation(cfg.Timezone) if err != nil { fatal("时区 %q 无法加载:%v", cfg.Timezone, err) } time.Local = loc log.Printf("时区已固定为 %s(当前时间 %s)", cfg.Timezone, time.Now().Format("2006-01-02 15:04:05")) } if cfg.Created { log.Printf("未找到配置文件,已生成默认配置:%s(修改后重启生效)", cfg.Path) } else { log.Printf("配置文件:%s", cfg.Path) } db, err := store.Open(cfg.DatabasePath) if err != nil { fatal("打开数据库失败:%v", err) } defer db.Close() if err := db.Migrate(); err != nil { fatal("初始化数据库结构失败:%v", err) } db.PurgeSessions() if cfg.BackupEnabled { db.SetBackupDir(cfg.BackupDir) } if mode, err := db.JournalMode(); err == nil { log.Printf("数据库已就绪(%s,journal_mode=%s:全部数据都在这一个文件里,复制它即可迁移)", db.Path, mode) } srv, err := web.New(db, web.Config{ Addr: cfg.Addr(), DBPath: db.Path, BackupKeep: cfg.BackupKeep, BackupDir: cfg.BackupDir, BackupEnabled: cfg.BackupEnabled, ScreenPublic: cfg.ScreenPublic, ConfigPath: cfg.Path, Version: version, }) if err != nil { fatal("初始化服务失败:%v", err) } if cfg.BackupEnabled { go func() { run := func() { path, err := db.BackupDaily(cfg.BackupKeep) if err != nil { log.Printf("自动备份失败:%v", err) return } if path != "" { log.Printf("已自动备份数据库:%s", path) } } run() ticker := time.NewTicker(time.Hour) defer ticker.Stop() for range ticker.C { run() } }() } ln, err := net.Listen("tcp", cfg.Addr()) if err != nil { fatal("无法监听 %s:%v\n提示:端口被占用时,可修改 %s 里的 listen_port", cfg.Addr(), err, cfg.Path) } httpSrv := &http.Server{ Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, } primaryURL := printBanner(cfg, db.Path) if cfg.OpenBrowser { go func() { time.Sleep(400 * time.Millisecond) openBrowser(primaryURL) }() } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Printf("HTTP 服务异常退出:%v", err) } }() if n, _ := db.CountUsers(); n == 0 { log.Printf("首次运行:请在浏览器中打开上面的地址,按提示创建管理员账号") } <-ctx.Done() log.Printf("正在关闭服务…") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := httpSrv.Shutdown(shutdownCtx); err != nil { log.Printf("关闭服务时出错:%v", err) } } func fatal(format string, args ...any) { fmt.Fprintf(os.Stderr, "\n[代打台] "+format+"\n\n", args...) os.Exit(1) } // applyAddr 解析 -addr(支持 ":19000" 与 "127.0.0.1:19000")。 func applyAddr(cfg *config.Config, v string) error { host, port, err := net.SplitHostPort(v) if err != nil { return fmt.Errorf("-addr 格式应为 ip:端口 或 :端口,当前是 %q", v) } if host != "" { cfg.ListenIP = host } if port != "" { n, err := strconv.Atoi(port) if err != nil { return fmt.Errorf("-addr 的端口必须是数字,当前是 %q", port) } cfg.ListenPort = n } return nil } func executableDir() string { exe, err := os.Executable() if err != nil { if wd, err := os.Getwd(); err == nil { return wd } return "." } if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } return filepath.Dir(exe) } func printBanner(cfg *config.Config, dbPath string) string { port := strconv.Itoa(cfg.ListenPort) fmt.Println() fmt.Println(" ┌───────────────────────────────────────────────┐") fmt.Println(" │ 代 打 台 · 代打管理平台 │") fmt.Println(" └───────────────────────────────────────────────┘") fmt.Printf(" 版本 %s 配置文件 %s\n", version, filepath.Base(cfg.Path)) fmt.Printf(" 数据库 %s\n", dbPath) fmt.Println(" 访问地址:") primary := "" switch { case cfg.LoopbackOnly(): primary = "http://127.0.0.1:" + port fmt.Printf(" 本机 %s\n", primary) fmt.Println(" 仅本机可访问(listen_ip = 127.0.0.1)") case cfg.PublicBind(): primary = "http://127.0.0.1:" + port fmt.Printf(" 本机 %s\n", primary) for _, ip := range web.LocalAddrs() { fmt.Printf(" 局域网 http://%s:%s\n", ip, port) } default: primary = "http://" + net.JoinHostPort(cfg.ListenIP, port) fmt.Printf(" 地址 %s\n", primary) } if cfg.ScreenPublic { fmt.Printf(" 大屏 %s/screen(免登录)\n", primary) } else { fmt.Printf(" 大屏 %s/screen(需登录)\n", primary) } if cfg.PublicBind() && cfg.ScreenPublic { fmt.Println(" 注意:大屏免登录且监听所有网卡,同局域网内任何人都能打开 /screen") } fmt.Println(" 按 Ctrl+C 停止服务") fmt.Println() return primary } func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) case "darwin": cmd = exec.Command("open", url) default: cmd = exec.Command("xdg-open", url) } if err := cmd.Start(); err != nil { log.Printf("自动打开浏览器失败(可手动访问 %s):%v", url, err) } }