Files

454 lines
14 KiB
Go
Raw Permalink Normal View History

2026-05-22 08:06:07 +08:00
// 监管中转 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"
2026-05-22 09:17:39 +08:00
"xk-hy-transit-go/internal/logweb"
2026-05-22 08:06:07 +08:00
syncer "xk-hy-transit-go/internal/sync"
2026-05-22 09:17:39 +08:00
"xk-hy-transit-go/internal/syncgate"
2026-05-22 08:06:07 +08:00
"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)
2026-05-22 10:20:13 +08:00
timeout := menuAutoServeTimeout()
2026-05-22 08:06:07 +08:00
for {
fmt.Println()
fmt.Println("======== 互联网医院监管中转 ========")
fmt.Println(" 1) 单次执行(跑一轮后退出)")
fmt.Println(" 2) 定时执行(常驻,按 .env 中 CRON 每天跑)")
2026-05-22 09:17:39 +08:00
fmt.Println(" 3) 查看日志Web 控制台,不跑定时)")
2026-05-22 08:06:07 +08:00
fmt.Println(" 0) 退出")
2026-05-22 10:20:13 +08:00
fmt.Printf("%v 内未选择将自动启动「2) 定时执行(常驻)」\n", timeout.Round(time.Second))
2026-05-22 09:17:39 +08:00
fmt.Print("请选择 [0-3]: ")
2026-05-22 10:20:13 +08:00
choice, timedOut := readLineTimeout(reader, timeout)
if timedOut {
choice = "2"
fmt.Println("已超时,自动选择: 2) 定时执行(常驻)")
}
2026-05-22 08:06:07 +08:00
switch choice {
case "1":
runInteractiveSync(reader)
return
case "2":
runInteractiveServe(reader)
return
2026-05-22 09:17:39 +08:00
case "3":
runInteractiveLogWeb()
return
2026-05-22 08:06:07 +08:00
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。")
2026-05-22 10:20:13 +08:00
timeout := menuAutoServeTimeout()
fmt.Printf("%v 内未确认将自动启动定时服务\n", timeout.Round(time.Second))
2026-05-22 08:06:07 +08:00
fmt.Print("确认启动定时服务? [Y/n]: ")
2026-05-22 10:20:13 +08:00
confirm, timedOut := readLineTimeout(reader, timeout)
if timedOut {
fmt.Println("已超时,自动确认启动。")
}
if !serveConfirmYes(confirm, timedOut) {
2026-05-22 08:06:07 +08:00
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] 单次同步
2026-05-22 09:17:39 +08:00
transit serve 定时常驻 + Web 控制台默认 :8765
2026-05-22 08:06:07 +08:00
transit pdf-test [--prescription-id=N] 单独测 HTMLPDF 落盘
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()
2026-05-28 16:39:51 +08:00
if err := runner.RegenerateUploadToken(); err != nil {
log.Fatalf("生成 uploadToken 失败: %v", err)
}
2026-05-22 08:06:07 +08:00
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)
}
2026-05-22 08:42:14 +08:00
fileID, err := hyfile.UploadPDF(pdf, filepath.Base(*pdfPath), uploadURL, runner.UploadToken(), runner.ForwardSharedSecret())
2026-05-22 08:06:07 +08:00
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))
}
2026-05-22 09:17:39 +08:00
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 控制台,阻塞进程直至被终止。
2026-05-22 08:06:07 +08:00
func runServe() {
applog.SetQuietConsole(true)
runner, cfg := loadRunner()
defer runner.StoreClose()
2026-05-22 09:17:39 +08:00
addr := cfg.LogWebAddr
deps := buildLogWebDeps(runner, cfg, runner.Store())
go func() {
if err := logweb.Run(addr, deps); err != nil {
log.Printf("logweb 退出: %v", err)
}
}()
2026-05-22 08:06:07 +08:00
expr := resolveCronExpr(cfg)
c := cron.New()
_, err := c.AddFunc(expr, func() {
2026-05-22 09:17:39 +08:00
if syncgate.IsRunning() {
applog.Appf("cron skip: sync already running")
return
}
2026-05-22 08:06:07 +08:00
anchor := syncer.ResolveAnchorDate(cfg, "")
2026-05-22 09:17:39 +08:00
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")
2026-05-22 08:06:07 +08:00
}
})
if err != nil {
log.Fatalf("cron 表达式无效 %q: %v", expr, err)
}
c.Start()
2026-05-22 09:17:39 +08:00
applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%s", expr, applog.Dir())
applog.Consolef("Web 控制台 http://%s/ Ctrl+C 结束进程)", addr)
2026-05-22 08:06:07 +08:00
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 * * *"
}