// wb2api-launcher —— WorkBuddy2API 的**独立启动器**。 // // 为什么是一个独立 exe 而不是 shell 脚本: // // - 上游作者更新时,你只需要替换 wb2api.exe(网关本体),启动器完全不动。 // 启动器只做三件事——同步凭证、拉起网关、转发信号——不依赖网关的任何内部实现, // 也不依赖 go / python / bash / PowerShell 环境。 // - 同步引擎(internal/syncer)直接**编进这个 exe**,不再需要第二个 wb2api-sync // 二进制;后台定时对账也在同一个进程里跑,不会留下孤儿进程。 // // 启动顺序刻意是「先同步、后启动」:网关只在启动时扫一遍 auths/(没有文件监听), // 在它扫描之前把远端凭证落下来,网关启动时就自然加载到最新的一批账号。 // // 用法: // // wb2api-launcher.exe # 同步 + 启动网关 + 后台对账 // wb2api-launcher.exe -status # 只看两边差异,不启动网关 // wb2api-launcher.exe -sync-only # 同步一次就退出 // wb2api-launcher.exe -no-sync # 离线启动(跳过同步) // wb2api-launcher.exe -no-watch # 不起后台对账 // wb2api-launcher.exe -mode pull # 只拉不推 // // 退出码:网关的退出码原样透出;启动阶段失败返回 1;-status 有差异返回 2。 package main import ( "context" "errors" "fmt" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "syscall" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/syncer" ) // version 版本号(-version)。做成变量便于 -ldflags 注入。 var version = "1.0.0" // 退出码约定。 const ( exitOK = 0 exitError = 1 exitPending = 2 // -status 模式下存在未同步的差异 ) func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "\n[launcher] 失败: %v\n", err) os.Exit(exitError) } } // options 是解析后的启动参数。 type options struct { configPath string serverBin string mode syncer.Mode reportOnly bool noSync bool noWatch bool syncOnly bool quiet bool interval time.Duration shutdownGrace time.Duration // sync 覆盖项(不写 config.json 也能用) bucket string token string endpoint string include string writer string restart string caBundle string relayURL string dryRun bool } func run() error { opt, showVersion, err := parseFlags(os.Args[1:]) if err != nil { return err } if showVersion { fmt.Printf("wb2api-launcher %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) return nil } exePath, err := os.Executable() if err != nil { return fmt.Errorf("无法定位启动器自身路径: %w", err) } exeDir := filepath.Dir(exePath) // 配置文件默认放在启动器旁边——"解压到目录、双击"即可用。 if opt.configPath == "" { opt.configPath = filepath.Join(exeDir, "config.json") } else if !filepath.IsAbs(opt.configPath) { opt.configPath = filepath.Join(exeDir, opt.configPath) } absCfg, err := filepath.Abs(opt.configPath) if err != nil { return err } cfgDir := filepath.Dir(absCfg) ensureConfig(absCfg, exeDir, cfgDir) // 关键:把工作目录切到 config.json 所在目录,再启动网关。 // 这样配置里的相对路径(./auths、./data/state.json)对"启动器的同步"和 // "网关子进程"是**同一个**目录,不会出现同步写 A、网关读 B 的错位。 if err := os.Chdir(cfgDir); err != nil { return fmt.Errorf("切换工作目录到 %s 失败: %w", cfgDir, err) } syncCfg, err := loadSyncConfig(absCfg, opt) if err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() var engine *syncer.Engine if !opt.noSync { engine, err = newEngine(ctx, syncCfg, opt) if err != nil { // 同步配置不全不是致命错误:照样能把网关拉起来(离线自用)。 info("同步未启用:%v", err) } } if engine != nil { if code := preflightSync(ctx, engine, opt); code != exitOK { if opt.syncOnly || opt.reportOnly { os.Exit(code) } // 启动阶段同步失败不阻断启动:断网/中转挂了不该让你连本地网关都用不了。 } } if opt.syncOnly || opt.reportOnly { return nil } serverBin, err := findServer(opt.serverBin, exeDir) if err != nil { return err } info("网关二进制:%s", serverBin) info("配置:%s", absCfg) info("运行目录:%s", cfgDir) child := exec.Command(serverBin, "-config", absCfg) child.Stdin = os.Stdin child.Stdout = os.Stdout child.Stderr = os.Stderr child.Dir = cfgDir if err := child.Start(); err != nil { return fmt.Errorf("启动网关失败 (%s): %w", serverBin, err) } info("网关已启动 (PID %d)", child.Process.Pid) // 后台对账:跑在同一个进程里,不会留下孤儿进程。 if engine != nil && !opt.noWatch { go watchLoop(ctx, engine, opt) } // 等子进程退出。Ctrl-C 在这个控制台里会同时送到启动器与网关, // 网关收到后会 Flush() 落盘再做优雅停机——所以这里给一段宽限期让它自己收尾。 done := make(chan error, 1) go func() { done <- child.Wait() }() select { case werr := <-done: return exitCodeOf(werr) case <-ctx.Done(): info("收到中断信号,等待网关优雅退出(最多 %s)...", opt.shutdownGrace) if err := waitOrKill(child, done, opt.shutdownGrace); err != nil { return err } return nil } } // waitOrKill 给子进程一段宽限期自行退出,超时才强杀。 // // 为什么优先"等"而不是直接 Kill:网关在收到 SIGINT/SIGTERM 时会先 // Pool.Flush() 把内存状态落盘再 Shutdown;直接杀掉会丢掉最后一次状态落盘 // (积分/冷却/连登计数都是攒在内存里定时刷的)。 func waitOrKill(child *exec.Cmd, done <-chan error, grace time.Duration) error { select { case <-done: info("网关已优雅退出") case <-time.After(grace): info("宽限期已到,强制结束网关进程") if child.Process != nil { _ = child.Process.Kill() } <-done } return nil } // exitCodeOf 把 Wait 的错误翻译成退出码。 func exitCodeOf(err error) error { if err == nil { return nil } var ee *exec.ExitError if errors.As(err, &ee) { // 原样透出子进程退出码,便于外层脚本判断。 os.Exit(ee.ExitCode()) } return err } // preflightSync 启动前同步一次。返回退出码。 func preflightSync(ctx context.Context, engine *syncer.Engine, opt options) int { fmt.Println() info("━━ 启动前同步(模式 %s)━━", opt.mode) plan, res, err := engine.Sync(ctx, opt.mode, opt.dryRun) if err != nil { warn("同步失败:%v", err) warn("按本地已有凭证继续启动,本次不做远端对账。") return exitError } printPlan(plan, res, opt) if res.Failed > 0 { warn("%d 个文件同步失败,按本地已有凭证继续启动。", res.Failed) return exitError } if opt.reportOnly && plan.Changes() > 0 { return exitPending } if plan.Changes() == 0 { info("已与中转一致(无改动)") } // 本地一个凭证都没有时,网关起来是空池(/healthz 503),把原因说清楚。 if n := countAuthFiles(engine.Config().AuthDir); n == 0 { warn("auths/ 里没有任何账号 —— 网关会以空池启动(/healthz 会返回 503)。") warn(" 检查 config.json 的 sync.bucket / sync.token。") } return exitOK } // watchLoop 常驻后台对账:把本地运行期刷新出来的新 token 持续推回中转。 func watchLoop(ctx context.Context, engine *syncer.Engine, opt options) { t := time.NewTicker(opt.interval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: plan, res, err := engine.Sync(ctx, syncer.ModeSync, false) switch { case err != nil: warn("[后台对账] 出错:%v", err) case res.Failed > 0: warn("[后台对账] 上传 %d / 下载 %d / 失败 %d", res.Uploaded, res.Downloaded, res.Failed) case plan.Changes() > 0: info("[后台对账] 上传 %d / 下载 %d", res.Uploaded, res.Downloaded) for _, a := range plan.Actions { if a.Op == syncer.OpNoop { continue } info(" %s %s (%s)", opIcon(a.Op), a.Path, a.Reason) } } } } } // --------------------------------------------------------------------------- // 配置 // --------------------------------------------------------------------------- // loadSyncConfig 读取 config.json 的 sync 段并叠加命令行覆盖。 func loadSyncConfig(cfgPath string, opt options) (syncer.Config, error) { cfg, err := syncer.LoadConfigFile(cfgPath) if err != nil && !os.IsNotExist(err) { return syncer.Config{}, fmt.Errorf("读取 %s 失败: %w", cfgPath, err) } if opt.bucket != "" { cfg.Bucket = opt.bucket } if opt.token != "" { cfg.Token = opt.token } if opt.endpoint != "" { cfg.Endpoint = opt.endpoint } if opt.writer != "" { cfg.Writer = opt.writer } if opt.relayURL != "" { cfg.RelayURL = opt.relayURL } if opt.restart != "" { cfg.RestartSpace = opt.restart } if opt.caBundle != "" { cfg.CABundle = opt.caBundle } if opt.include != "" { var list []string for _, p := range strings.Split(opt.include, ",") { if p = strings.TrimSpace(p); p != "" { list = append(list, p) } } cfg.IncludeData = list } if cfg.Token == "" && cfg.TokenFile == "" { if t := strings.TrimSpace(os.Getenv("HF_TOKEN")); t != "" { cfg.Token = t } } cfg.Normalize() if cfg.AuthDir == "" { cfg.AuthDir = "auths" } if cfg.DataDir == "" { cfg.DataDir = "data" } return cfg, nil } // newEngine 构造同步引擎;必要时先从桶里取中转的 api_key(relay 写入通道用)。 func newEngine(ctx context.Context, cfg syncer.Config, opt options) (*syncer.Engine, error) { if cfg.Bucket == "" { return nil, fmt.Errorf("config.json 的 sync.bucket 为空(未配置中转桶)") } eng, err := syncer.New(cfg) if err != nil { return nil, err } if eng.WriterName() == "relay" && eng.Config().RelayKey == "" { if key, kerr := eng.FetchRemoteAPIKey(ctx); kerr == nil { cfg.RelayKey = key if eng, err = syncer.New(cfg); err != nil { return nil, err } } } return eng, nil } // ensureConfig 首启兜底:没有 config.json 时从 config.example.json 复制一份。 // // 刻意不在这里生成带随机 api_key 的最小配置:网关自己首启就会生成推荐配置 // (含 crypto/rand 的 api_key),启动器不该抢着替它写一份半成品。 func ensureConfig(cfgPath, exeDir, cfgDir string) { if _, err := os.Stat(cfgPath); err == nil { return } for _, cand := range []string{ filepath.Join(cfgDir, "config.example.json"), filepath.Join(exeDir, "config.example.json"), } { if raw, err := os.ReadFile(cand); err == nil { if werr := os.WriteFile(cfgPath, raw, 0o600); werr == nil { warn("未找到 config.json,已从 %s 复制一份。请按需修改(尤其是 api_key 与 sync 段)。", filepath.Base(cand)) return } } } info("未找到 config.json,也没有 config.example.json —— 交给网关自行生成默认配置。") } // findServer 定位网关二进制。 // // 查找顺序:-server 显式指定 → 启动器同目录(Windows 下 wb2api.exe)→ PATH。 // 这样"把新下载的 wb2api.exe 丢到启动器旁边"就是完整的升级动作。 func findServer(explicit, exeDir string) (string, error) { names := []string{"wb2api"} if runtime.GOOS == "windows" { names = []string{"wb2api.exe", "wb2api"} } if explicit != "" { p := explicit if !filepath.IsAbs(p) { p = filepath.Join(exeDir, explicit) } if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, nil } return "", fmt.Errorf("-server 指定的文件不存在: %s", p) } for _, n := range names { p := filepath.Join(exeDir, n) if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, nil } } for _, n := range names { if p, err := exec.LookPath(n); err == nil { return p, nil } } return "", fmt.Errorf("找不到网关二进制 wb2api(找了 %s 与 PATH)。\n"+ " 把 wb2api.exe 放在启动器同目录,或用 -server 指定路径", strings.Join(names, " / ")) } // countAuthFiles 数本地凭证文件个数。 func countAuthFiles(dir string) int { entries, err := os.ReadDir(dir) if err != nil { return 0 } n := 0 for _, e := range entries { if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") { n++ } } return n } // --------------------------------------------------------------------------- // 输出 // --------------------------------------------------------------------------- func info(format string, a ...any) { fmt.Printf("[launcher] "+format+"\n", a...) } func warn(format string, a ...any) { fmt.Printf("[launcher] ⚠ "+format+"\n", a...) } func opIcon(op syncer.Op) string { switch op { case syncer.OpUpload: return "↑" case syncer.OpDownload: return "↓" default: return "=" } } // printPlan 打印对账结果(只列有动作的行,一致的不刷屏)。 func printPlan(plan *syncer.Plan, res *syncer.Result, opt options) { cfgWriter := "" if plan.Writer != "" { cfgWriter = plan.Writer } else { cfgWriter = "(只读)" } info("桶 %s · 写入通道 %s · 本地 %d / 远端 %d 个文件", plan.Bucket, cfgWriter, plan.LocalFiles, plan.RemoteFiles) for _, a := range plan.Actions { if a.Op == syncer.OpNoop && !a.Conflict { continue } line := fmt.Sprintf(" %s %-8s %s", opIcon(a.Op), a.Op, a.Path) if a.Conflict { line += " [冲突]" } fmt.Println(line) fmt.Printf(" %s\n", a.Reason) if a.Err != "" { fmt.Printf(" ✗ %s\n", a.Err) } } info("汇总:上传 %d / 下载 %d / 跳过 %d / 失败 %d", res.Uploaded, res.Downloaded, res.Skipped, res.Failed) for _, e := range res.Errors { warn("%s", e) } }