初始化
This commit is contained in:
361
cmd/transit/main.go
Normal file
361
cmd/transit/main.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// 监管中转 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())
|
||||
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 日志目录=%s(Ctrl+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 * * *"
|
||||
}
|
||||
Reference in New Issue
Block a user