// 监管中转 CLI 入口。 // // 三种启动方式: // 1. 无参数 transit.exe → 交互菜单(运营推荐) // 2. transit sync [...] → 单次同步后退出(脚本/计划任务) // 3. transit serve → 常驻 + Cron 定时(默认每天 22:00) // // 单次同步主流程见 internal/sync/sync.go 的 runStep(①建批次→②拉取→③转发→④回调→⑤finish)。 package main import ( "bufio" "flag" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/robfig/cron/v3" "xk-hy-transit-go/internal/applog" "xk-hy-transit-go/internal/config" "xk-hy-transit-go/internal/db" "xk-hy-transit-go/internal/hyfile" "xk-hy-transit-go/internal/logweb" syncer "xk-hy-transit-go/internal/sync" "xk-hy-transit-go/internal/syncgate" "xk-hy-transit-go/internal/xkapi" ) func main() { config.LoadEnvFiles() config.WarnIfRequiredEnvMissing() if err := applog.Init("transit"); err != nil { log.Printf("applog init warning: %v", err) } if len(os.Args) < 2 { if !isInteractiveTerminal() { printUsage() os.Exit(1) } runInteractiveMenu() return } cmd := os.Args[1] switch cmd { case "sync": runSync(os.Args[2:]) case "serve": runServe() case "pdf-test": runPDFTest(os.Args[2:]) case "upload-test": runUploadTest(os.Args[2:]) default: // 兼容旧用法:transit --step=all if strings.HasPrefix(cmd, "-") { runSync(os.Args[1:]) return } printUsage() os.Exit(1) } } // isInteractiveTerminal 判断 stdin 是否为控制台(避免 CI 卡在菜单)。 func isInteractiveTerminal() bool { fi, err := os.Stdin.Stat() if err != nil { return false } return (fi.Mode() & os.ModeCharDevice) != 0 } // runInteractiveMenu 无参数时展示中文菜单,供运营选择单次或定时。 func runInteractiveMenu() { reader := bufio.NewReader(os.Stdin) timeout := menuAutoServeTimeout() for { fmt.Println() fmt.Println("======== 互联网医院监管中转 ========") fmt.Println(" 1) 单次执行(跑一轮后退出)") fmt.Println(" 2) 定时执行(常驻,按 .env 中 CRON 每天跑)") fmt.Println(" 3) 查看日志(Web 控制台,不跑定时)") fmt.Println(" 0) 退出") fmt.Printf("%v 内未选择将自动启动「2) 定时执行(常驻)」\n", timeout.Round(time.Second)) fmt.Print("请选择 [0-3]: ") choice, timedOut := readLineTimeout(reader, timeout) if timedOut { choice = "2" fmt.Println("已超时,自动选择: 2) 定时执行(常驻)") } switch choice { case "1": runInteractiveSync(reader) return case "2": runInteractiveServe(reader) return case "3": runInteractiveLogWeb() return case "0", "": fmt.Println("已退出。") return default: fmt.Println("无效选项,请重新输入。") } } } func runInteractiveSync(reader *bufio.Reader) { fmt.Print("step [consult|referral|recipe|verification|all,默认 all]: ") stepLine, _ := reader.ReadString('\n') step := strings.TrimSpace(stepLine) if step == "" { step = "all" } fmt.Print("锚定日 date [YYYY-MM-DD,回车=按 ANCHOR_OFFSET_DAYS 推算]: ") dateLine, _ := reader.ReadString('\n') date := strings.TrimSpace(dateLine) fmt.Printf("即将执行单次同步 step=%s date=%s\n", step, displayDate(date)) fmt.Print("确认执行? [Y/n]: ") confirm, _ := reader.ReadString('\n') confirm = strings.TrimSpace(strings.ToLower(confirm)) if confirm == "n" || confirm == "no" { fmt.Println("已取消。") return } args := []string{"--step=" + step} if date != "" { args = append(args, "--date="+date) } runSync(args) } func runInteractiveServe(reader *bufio.Reader) { cfg := config.Load() expr := resolveCronExpr(cfg) fmt.Println("当前定时配置:") if strings.TrimSpace(os.Getenv("CRON_EXPR")) != "" { fmt.Printf(" CRON_EXPR=%s\n", expr) } else if strings.TrimSpace(cfg.ScheduleTime) != "" { fmt.Printf(" SCHEDULE_TIME=%s → cron: %s\n", cfg.ScheduleTime, expr) } else { fmt.Printf(" 默认 cron: %s(每天 22:00)\n", expr) } fmt.Println("启动后进程将常驻,到点自动执行 step=all。") timeout := menuAutoServeTimeout() fmt.Printf("%v 内未确认将自动启动定时服务\n", timeout.Round(time.Second)) fmt.Print("确认启动定时服务? [Y/n]: ") confirm, timedOut := readLineTimeout(reader, timeout) if timedOut { fmt.Println("已超时,自动确认启动。") } if !serveConfirmYes(confirm, timedOut) { fmt.Println("已取消。") return } runServe() } func displayDate(date string) string { if date == "" { return "(自动:默认昨日)" } return date } func printUsage() { fmt.Println(`用法: transit 无参数进入交互菜单(运营推荐,需控制台) transit sync [--step=all] [--date=YYYY-MM-DD] 单次同步 transit serve 定时常驻 + Web 控制台(默认 :8765) transit pdf-test [--prescription-id=N] 单独测 HTML→PDF 落盘 transit upload-test --pdf=PATH 单独测监管文件上传 transit --step=all [--date=...] 兼容旧单次参数 详见 docs/COMMANDS.md`) } // loadRunner 连接本机库并初始化同步执行器(会拉取云端机构配置)。 func loadRunner() (*syncer.Runner, config.Config) { cfg := config.Load() if cfg.XkAPIToken == "" || cfg.HyAppKey == "" || cfg.HyAppSecret == "" || cfg.HyAesKey == "" { log.Fatal("缺少环境变量: XK_API_TOKEN, HY_APP_KEY, HY_APP_SECRET, HY_AES_KEY") } store, err := db.Open(cfg.MySQLDSN) if err != nil { log.Fatalf("数据库连接失败: %v", err) } runner, err := syncer.NewRunner(cfg, store) if err != nil { store.Close() log.Fatalf("初始化失败: %v", err) } return runner, cfg } // runUploadTest 单独测试监管 Ver2.0 文件上传(与 recipe 同步使用相同 forward 路径)。 func runUploadTest(args []string) { fs := flag.NewFlagSet("upload-test", flag.ExitOnError) pdfPath := fs.String("pdf", "", "本地 PDF 文件路径") _ = fs.Parse(args) if strings.TrimSpace(*pdfPath) == "" { log.Fatal("请指定 --pdf=本地PDF路径") } pdf, err := os.ReadFile(strings.TrimSpace(*pdfPath)) if err != nil { log.Fatalf("读取 PDF 失败: %v", err) } runner, _ := loadRunner() defer runner.StoreClose() if err := runner.RegenerateUploadToken(); err != nil { log.Fatalf("生成 uploadToken 失败: %v", err) } if runner.UploadToken() == "" { log.Fatal("uploadToken 为空,请在 xk-api 配置 HY_APP_KEY / HY_APP_SECRET / HY_FILE_BUCKET") } uploadURL := runner.FileUploadURL() if runner.FileUploadViaForward() { log.Printf("upload-test: 经 forward 上传 url=%s", uploadURL) } fileID, err := hyfile.UploadPDF(pdf, filepath.Base(*pdfPath), uploadURL, runner.UploadToken(), runner.ForwardSharedSecret()) if err != nil { log.Fatalf("上传失败: %v", err) } log.Printf("upload ok fileId=%s url=%s via_forward=%v", fileID, uploadURL, runner.FileUploadViaForward()) } // runPDFTest 不走上报链路,仅验证 chromedp 转 PDF 与本地落盘。 func runPDFTest(args []string) { fs := flag.NewFlagSet("pdf-test", flag.ExitOnError) prescriptionID := fs.Int("prescription-id", 0, "从 xk-api 拉取打印 HTML") htmlFile := fs.String("html-file", "", "本地 HTML 文件路径") patient := fs.String("patient", "测试患者", "落盘文件名中的患者名") store := fs.String("store", "", "落盘文件名中的诊所名") prescriptionNo := fs.String("prescription-no", "", "落盘文件名中的处方号") _ = fs.Parse(args) cfg := config.Load() if cfg.XkAPIToken == "" { log.Fatal("缺少环境变量 XK_API_TOKEN") } if p := strings.TrimSpace(cfg.ChromePath); p != "" { hyfile.SetChromePath(p) } var html string pdfName := hyfile.PDFNameInput{ PatientName: *patient, StoreName: *store, PrescriptionNo: *prescriptionNo, } switch { case *prescriptionID > 0: xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath) detail, err := xk.GetPrescriptionPrintDetail(*prescriptionID) if err != nil { log.Fatalf("拉取处方 HTML 失败: %v", err) } html = detail.RecipeFileHTML if detail.PatientName != "" { pdfName.PatientName = detail.PatientName } if detail.StoreName != "" { pdfName.StoreName = detail.StoreName } if detail.PrescriptionNo != "" { pdfName.PrescriptionNo = detail.PrescriptionNo } if t := hyfile.ParseTimeString(detail.CreatedAt); !t.IsZero() { pdfName.At = t } log.Printf("prescription_id=%d html_len=%d", *prescriptionID, len(html)) case strings.TrimSpace(*htmlFile) != "": b, err := os.ReadFile(strings.TrimSpace(*htmlFile)) if err != nil { log.Fatalf("读取 HTML 文件失败: %v", err) } html = string(b) log.Printf("html_file=%s html_len=%d", *htmlFile, len(html)) default: log.Fatal("请指定 --prescription-id 或 --html-file") } pdf, err := hyfile.HtmlToPDF(html) if err != nil { log.Fatalf("HtmlToPDF 失败: %v", err) } log.Printf("pdf bytes=%d save_root=%s", len(pdf), hyfile.ProgramDir()) saved, err := hyfile.SavePDFLocal(pdf, pdfName) if err != nil { log.Fatalf("SavePDFLocal 失败: %v", err) } log.Printf("saved local pdf: %s", saved) } // runSync 执行一轮或多轮 step 后退出。 func runSync(args []string) { applog.SetQuietConsole(true) fs := flag.NewFlagSet("sync", flag.ExitOnError) step := fs.String("step", "all", "consult|referral|recipe|verification|all") date := fs.String("date", "", "anchor date Y-m-d") _ = fs.Parse(args) runner, cfg := loadRunner() defer runner.StoreClose() anchor := syncer.ResolveAnchorDate(cfg, *date) applog.RunSeparator(fmt.Sprintf("BEGIN sync step=%s date=%s", *step, anchor)) applog.Appf("sync run start step=%s date=%s", *step, anchor) applog.Consolef("[同步] 开始 step=%s date=%s", *step, anchor) start := time.Now() if err := runner.Run(*step, anchor); err != nil { applog.Consolef("[同步] 失败 step=%s date=%s err=%v", *step, anchor, err) applog.Appf("sync failed step=%s date=%s err=%v", *step, anchor, err) applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=false", *step, anchor)) os.Exit(1) } applog.Consolef("[同步] 结束 step=%s date=%s 耗时=%s", *step, anchor, time.Since(start).Round(time.Second)) applog.Appf("sync done step=%s date=%s duration=%s", *step, anchor, time.Since(start).Round(time.Second)) applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=true", *step, anchor)) } func runInteractiveLogWeb() { cfg := config.Load() store, mysqlOK, mysqlErr := tryOpenStore(cfg) if store != nil { defer store.Close() } addr := cfg.LogWebAddr fmt.Printf("Web 控制台: http://%s/\n", addr) fmt.Println("(仅浏览日志与流水;测试同步请用「定时执行」启动 serve)") if err := logweb.Run(addr, logweb.Deps{ LogDir: applog.Dir(), Store: store, Cfg: cfg, AllowTest: false, MySQLOK: mysqlOK, MySQLErr: mysqlErr, }); err != nil { log.Fatal(err) } } func tryOpenStore(cfg config.Config) (*db.Store, bool, string) { if strings.TrimSpace(cfg.MySQLDSN) == "" { return nil, false, "未配置 MYSQL_DSN" } store, err := db.Open(cfg.MySQLDSN) if err != nil { return nil, false, err.Error() } if err := store.Ping(); err != nil { _ = store.Close() return nil, false, err.Error() } return store, true, "" } func buildLogWebDeps(runner *syncer.Runner, cfg config.Config, store *db.Store) logweb.Deps { deps := logweb.Deps{ LogDir: applog.Dir(), Store: store, Cfg: cfg, AllowTest: cfg.LogWebAllowTest && runner != nil, Runner: runner, } if store != nil { if err := store.Ping(); err != nil { deps.MySQLErr = err.Error() } else { deps.MySQLOK = true } } else { deps.MySQLErr = "无数据库连接" } return deps } // runServe 启动 Cron + Web 控制台,阻塞进程直至被终止。 func runServe() { applog.SetQuietConsole(true) runner, cfg := loadRunner() defer runner.StoreClose() addr := cfg.LogWebAddr deps := buildLogWebDeps(runner, cfg, runner.Store()) go func() { if err := logweb.Run(addr, deps); err != nil { log.Printf("logweb 退出: %v", err) } }() expr := resolveCronExpr(cfg) c := cron.New() _, err := c.AddFunc(expr, func() { if syncgate.IsRunning() { applog.Appf("cron skip: sync already running") return } anchor := syncer.ResolveAnchorDate(cfg, "") if !syncgate.TryRun("all", anchor, func() error { applog.RunSeparator(fmt.Sprintf("BEGIN cron step=all date=%s cron=%s", anchor, expr)) applog.Appf("cron trigger step=all date=%s cron=%s", anchor, expr) applog.Consolef("[定时] 开始 step=all date=%s", anchor) start := time.Now() runErr := runner.Run("all", anchor) if runErr != nil { applog.Consolef("[定时] 失败 date=%s err=%v", anchor, runErr) applog.Appf("cron failed date=%s err=%v", anchor, runErr) } else { applog.Consolef("[定时] 结束 date=%s 耗时=%s", anchor, time.Since(start).Round(time.Second)) applog.Appf("cron done date=%s duration=%s", anchor, time.Since(start).Round(time.Second)) } applog.RunSeparator(fmt.Sprintf("END cron step=all date=%s ok=%v", anchor, runErr == nil)) return runErr }) { applog.Appf("cron skip: could not start sync") } }) if err != nil { log.Fatalf("cron 表达式无效 %q: %v", expr, err) } c.Start() applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%s", expr, applog.Dir()) applog.Consolef("Web 控制台 http://%s/ (Ctrl+C 结束进程)", addr) select {} } // resolveCronExpr 解析定时表达式:CRON_EXPR 优先,其次 SCHEDULE_TIME,最后默认 22:00。 func resolveCronExpr(cfg config.Config) string { if strings.TrimSpace(os.Getenv("CRON_EXPR")) != "" { return strings.TrimSpace(cfg.CronExpr) } if t := strings.TrimSpace(cfg.ScheduleTime); t != "" { parts := strings.Split(t, ":") if len(parts) == 2 { return fmt.Sprintf("0 %s %s * * *", parts[1], parts[0]) } } if strings.TrimSpace(cfg.CronExpr) != "" { return strings.TrimSpace(cfg.CronExpr) } return "0 22 * * *" }