Files
xk-hy-transit-go/cmd/transit/main.go
2026-05-22 08:42:14 +08:00

362 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 监管中转 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"
syncer "xk-hy-transit-go/internal/sync"
"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)
for {
fmt.Println()
fmt.Println("======== 互联网医院监管中转 ========")
fmt.Println(" 1) 单次执行(跑一轮后退出)")
fmt.Println(" 2) 定时执行(常驻,按 .env 中 CRON 每天跑)")
fmt.Println(" 0) 退出")
fmt.Print("请选择 [0-2]: ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
switch choice {
case "1":
runInteractiveSync(reader)
return
case "2":
runInteractiveServe(reader)
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。")
fmt.Print("确认启动定时服务? [Y/n]: ")
confirm, _ := reader.ReadString('\n')
confirm = strings.TrimSpace(strings.ToLower(confirm))
if confirm == "n" || confirm == "no" {
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 定时常驻(默认每天 22:00
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 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))
}
// runServe 启动 Cron阻塞进程直至被终止。
func runServe() {
applog.SetQuietConsole(true)
runner, cfg := loadRunner()
defer runner.StoreClose()
expr := resolveCronExpr(cfg)
c := cron.New()
_, err := c.AddFunc(expr, func() {
anchor := syncer.ResolveAnchorDate(cfg, "")
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))
})
if err != nil {
log.Fatalf("cron 表达式无效 %q: %v", expr, err)
}
c.Start()
applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%sCtrl+C 结束进程)", expr, applog.Dir())
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 * * *"
}