gcli2api / cmd /launcher /main.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
14 kB
// 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)
}
}