初始化

This commit is contained in:
李琦
2026-05-22 08:06:07 +08:00
commit f3e6917421
37 changed files with 3219 additions and 0 deletions

361
cmd/transit/main.go Normal file
View 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 日志目录=%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 * * *"
}