commit f3e691742163b99440cff07d0da875d78a5b9c3a Author: 李琦 Date: Fri May 22 08:06:07 2026 +0800 初始化 diff --git a/.env b/.env new file mode 100644 index 0000000..909bfe4 --- /dev/null +++ b/.env @@ -0,0 +1,20 @@ +# 复制自 .env.example,请在 Windows 上填写真实 token 与密码 +# XK_API_BASE_URL=https://api.xiaokang88.com +XK_API_BASE_URL=http://127.0.0.1:18001 +XK_API_TOKEN=1f88c2fe1c55840414b8e9025ff41c491864f692126e0401efe2f7ceb8713ef7beac450737657c90050a8da861062f66eeb705ca6745a731d81f6161faee5e15 + +HY_APP_KEY=bq2920250604092032834q7mmkk53a9i +HY_APP_SECRET=1e953ea8b1a6cc5c +HY_AES_KEY=c075f448a05e2cb0 + +FORWARD_BASE_URL=http://127.0.0.1:16001 +MYSQL_DSN="root:root@tcp(127.0.0.1:3306)/xk_hy_transit?parseTime=true&charset=utf8mb4" + +ANCHOR_OFFSET_DAYS=1 +XK_API_CALLBACK_PATH=/api/hy/transit/batch/callback +CRON_EXPR=0 22 * * * +# CRON_EXPR=*/2 * * * * + +# 本地 PDF 落盘根目录(默认:可执行文件所在目录) +# 文件路径:{PDF_SAVE_ROOT}/{YYYY-MM-DD}/{患者姓名} {YYYY-MM-DD HH:mm:ss}.pdf +PDF_SAVE_ROOT="D:/worker/code/xk-hy-transit-go/pdf" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39f0743 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# 云端 xk-api +XK_API_BASE_URL=https://api.xiaokang88.com +# 与云端 HY_TRANSIT_API_TOKEN 一致 +XK_API_TOKEN= + +# 监管平台凭证(与 xk-api config/xk.php hy 段一致) +# HY_AES_KEY 对应 xk-api 的 HY_APP_ENCODING_ASE_KEY +HY_APP_KEY= +HY_APP_SECRET= +HY_AES_KEY= + +# 内网 xk-hy-forward-go 地址(监管 JSON + PDF 上传均经此转发) +FORWARD_BASE_URL=http://127.0.0.1:8080 + +# true:PDF 上传走 FORWARD_BASE_URL/mng/file/auth/upload,不直连政务云 28211 +FILE_UPLOAD_VIA_FORWARD=true + +# 本机中转库(先执行 sql/hy_transit_schema.sql);含 & 或 ? 时请加双引号 +MYSQL_DSN="root:password@tcp(127.0.0.1:3306)/xk_hy_transit?parseTime=true&charset=utf8mb4" + +# 锚定日:默认推前 N 天(1=昨日) +ANCHOR_OFFSET_DAYS=1 + +# 批量回调路径(一般无需改) +XK_API_CALLBACK_PATH=/api/hy/transit/batch/callback + +# 定时任务(serve 子命令):CRON_EXPR 优先于 SCHEDULE_TIME;含空格须加双引号 +CRON_EXPR="0 22 * * *" +# SCHEDULE_TIME=22:00 + +# 处方 HTML 转 PDF(chromedp / headless Chrome,可选指定路径) +# CHROME_PATH=C:\Program Files\Google\Chrome\Application\chrome.exe + +# 本地 PDF 落盘:{程序目录}/pdf/{YYYY-MM-DD}/{患者姓名}{诊所名} {处方号} {YYYY-MM-DD HH-mm-ss}.pdf +# 打包后为 exe 同级 pdf/;go run 时为当前工作目录下的 pdf/(请在 xk-hy-transit-go 下执行) + +# 监管文件上传 HTTPS(政务网自签证书,默认 true) +FILE_UPLOAD_INSECURE_SKIP_VERIFY=true + +# 应用日志(默认 {程序目录}/log/transit/,含 pull/push/app 按日文件;每次执行有 BEGIN/END 分隔符) +# LOG_DIR 可覆盖根目录,实际路径为 {LOG_DIR}/transit/ +# LOG_DIR=D:\worker\code\xk-hy-transit-go\log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2103e04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.exe +dist/ +pdf/ +../log/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e700898 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/xk-hy-transit-go.iml b/.idea/xk-hy-transit-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/xk-hy-transit-go.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b995325 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# xk-hy-transit-go + +外网 Windows 中转:从云端 xk-api 拉取监管组包 → 本机 MySQL 落库 → AES 签名 → 经内网 `xk-hy-forward-go` **双通道**上报政务云(28212 业务数据 + 28211 处方 PDF)→ 批量回调云端状态。 + +## 前置 + +1. 云端配置 `HY_TRANSIT_API_TOKEN`,部署 `routes/hy.php` 与 `xk_hy_transit_cloud.sql`。 +2. 本机执行 [`sql/hy_transit_schema.sql`](../sql/hy_transit_schema.sql)。 +3. 内网部署 `xk-hy-forward-go`。 + +## 环境配置 + +```bash +cp .env.example .env +# 编辑 .env 填写 XK_API_TOKEN、HY_*、FORWARD_BASE_URL、FILE_UPLOAD_VIA_FORWARD、MYSQL_DSN +``` + +程序启动时自动 `godotenv.Load()` 读取 `.env`。 + +## 运行 + +**运营推荐**:直接双击或运行 `transit.exe`(无参数),按中文菜单选择「单次执行」或「定时执行」。 + +**完整命令说明**(单次 / 定时 / 计划任务 / 排错):见 **[docs/COMMANDS.md](docs/COMMANDS.md)**。 + +```bash +# 交互菜单(无参数) +go run ./cmd/transit + +# 单次同步 +go run ./cmd/transit sync --step=all --date=2026-05-18 + +# 定时常驻(默认每天 22:00) +go run ./cmd/transit serve +``` + +步骤:`consult` | `referral` | `recipe` | `verification` | `all` + +应用日志默认目录:`log/transit/`(`pull` / `push` / `app` 按日滚动;控制台仅开始/结束/错误),详见 [docs/COMMANDS.md](docs/COMMANDS.md)。 + +## 接口文档 + +云端 API 说明:[`xk-api/docs/hy-transit-api.md`](../xk-api/docs/hy-transit-api.md)(WSL 路径以实际仓库为准)。 + +## Windows 部署 + +- 推荐:`transit.exe` 无参数 → 菜单选「定时执行」,或 `transit.exe serve` 注册为服务。 +- 计划任务单次:见 `docs/COMMANDS.md` 中 `schtasks` 示例。 diff --git a/cmd/transit/main.go b/cmd/transit/main.go new file mode 100644 index 0000000..4b08eb9 --- /dev/null +++ b/cmd/transit/main.go @@ -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 * * *" +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..e98ab30 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,210 @@ +# xk-hy-transit-go 运行命令说明 + +## 前置 + +1. 复制环境配置:`cp .env.example .env`,填写 `XK_API_TOKEN`、`HY_*`、`FORWARD_BASE_URL`、`MYSQL_DSN`。 +2. 本机 MySQL 已执行 [`sql/hy_transit_schema.sql`](../../sql/hy_transit_schema.sql)。 +3. 内网已启动 **xk-hy-forward-go**(`forward.exe` 或 `go run .`)。 +4. 云端已部署 `xk_hy_transit_cloud.sql` 与 `routes/hy.php`。 + +--- + +## 运营推荐:直接运行(交互菜单) + +无参数启动程序,按中文提示选择,无需记忆子命令: + +```bat +transit.exe +``` + +或开发环境: + +```bash +go run ./cmd/transit +``` + +菜单示例: + +``` +======== 互联网医院监管中转 ======== + 1) 单次执行(跑一轮后退出) + 2) 定时执行(常驻,按 .env 中 CRON 每天跑) + 0) 退出 +请选择 [0-2]: +``` + +- 选 **1**:再输入 `step`(默认 all)、`date`(回车=按 ANCHOR_OFFSET_DAYS 推算,默认昨日)。 +- 选 **2**:显示当前 cron 配置,确认后进程常驻,到点自动 `step=all`。 +- 选 **0**:退出。 + +--- + +## 单次执行(跑完即退出) + +适用于:补跑某天数据、联调、Windows 计划任务每日调一次。 + +| 场景 | 命令 | +|------|------| +| 全量四步(推荐) | `go run ./cmd/transit sync --step=all` | +| 指定锚定日 | `go run ./cmd/transit sync --step=all --date=2026-05-18` | +| 只跑咨询 | `go run ./cmd/transit sync --step=consult` | +| 只跑处方 | `go run ./cmd/transit sync --step=recipe` | +| 编译后 exe | `transit.exe sync --step=all` | +| 兼容旧写法 | `go run ./cmd/transit --step=all --date=2026-05-18` | + +`--step` 可选:`consult` | `referral` | `recipe` | `verification` | `all`。 + +未传 `--date` 时,锚定日 = 今天减去 `.env` 中 `ANCHOR_OFFSET_DAYS`(默认 1,即昨日)。 + +### Windows 计划任务(每日单次,不常驻) + +```bat +schtasks /Create /TN "HyTransitSync" /TR "D:\path\to\transit.exe sync --step=all" /SC DAILY /ST 22:00 +``` + +--- + +## 定时任务执行(进程常驻) + +适用于:外网机 7×24 保活,由内置 Cron 到点执行。 + +| 场景 | 命令 / 配置 | +|------|-------------| +| 默认每天 22:00 | `go run ./cmd/transit serve` 或 `transit.exe serve` | +| 自定义 cron | `.env` 设置 `CRON_EXPR=0 30 22 * * *`(分 时 日 月 周) | +| 简写时间 | `SCHEDULE_TIME=22:30`(仅当未设置 `CRON_EXPR` 时生效) | + +说明:`serve` 启动后阻塞不退出;到点执行 `step=all` + 默认锚定日。修改 cron 需重启进程。 + +优先级:`CRON_EXPR` 环境变量存在 > `SCHEDULE_TIME` > 默认 `0 22 * * *`。 + +--- + +## 与 xk-hy-forward-go 配合(双通道) + +外网 **transit-go** 只访问内网 **forward-go**,由 forward 转发至政务云 `59.202.52.129`: + +| 通道 | transit 请求路径 | forward 转发目标 | +|------|------------------|------------------| +| 监管业务 JSON | `POST {FORWARD_BASE_URL}/province/supervise/data` | `28212` `/province/supervise/data` | +| 处方 PDF 上传 | `POST {FORWARD_BASE_URL}/mng/file/auth/upload` | `28211` `/mng/file/auth/upload` | + +内网机先启动转发服务: + +```bash +cd xk-hy-forward-go +go run . +``` + +或 `forward.exe`。详见 [forward-go README](../xk-hy-forward-go/README.md)。 + +`.env` 建议: + +```env +FORWARD_BASE_URL=http://192.168.1.10:8080 +FILE_UPLOAD_VIA_FORWARD=true +``` + +`FILE_UPLOAD_VIA_FORWARD=true`(默认)时 PDF **不直连**政务云,避免外网机访问 `59.202.52.129:28211` 失败。 + +--- + +## 处方 PDF 本地落盘 + +生成处方 PDF 并上传监管前,会同步写入本地文件(失败仅告警,不阻断上传): + +```text +{程序目录}/pdf/{YYYY-MM-DD}/{患者姓名}{诊所名} {处方号} {YYYY-MM-DD HH-mm-ss}.pdf +``` + +| 场景 | 程序目录 | +|------|----------| +| 打包运行 `transit.exe` | exe 所在目录 | +| 开发 `go run ./cmd/transit` | 当前工作目录(建议在 `xk-hy-transit-go` 下执行) | + +| 配置 | 说明 | +|------|------| +| `CHROME_PATH` | 可选:Chrome/Edge 路径;未设则自动探测 | +| xk-api | 打印 HTML 由 `GET /api/hy/transit/prescription/detail` 生成(与 PC 处方详情版式一致) | + +示例:`D:\deploy\pdf\2026-05-19\张三萧康中医馆 ZY8181791680594661 2026-05-19 14-30-05.pdf`(exe 同级 `pdf` 目录) + +流程:**拉取 HTML → chromedp 转 PDF → 落盘 `pdf/` → 上传监管**。 + +### 单独测 PDF(不走上报) + +```bash +go run ./cmd/transit pdf-test --prescription-id=12345 +# 或本地 HTML +go run ./cmd/transit pdf-test --html-file=./test.html --patient=张三 --store=测试诊所 --prescription-no=ZY123 +``` + +成功时应看到 `saved local pdf: ...\pdf\2026-05-19\...pdf`。 + +### 单独测监管文件上传 + +需 xk-api 配置 `HY_APP_KEY`、`HY_APP_SECRET`、`HY_FILE_BUCKET`(否则 `uploadToken` 为空): + +```bash +go run ./cmd/transit upload-test --pdf=./test.pdf +``` + +成功输出 `upload ok fileId=...`。 + +--- + +## 应用日志 + +默认写入项目内 `{程序目录}/log/transit/`(可用 `LOG_DIR` 覆盖根路径,实际为 `{LOG_DIR}/transit/`): + +```text +xk-hy-transit-go\log\transit\ + pull-YYYY-MM-DD.log # 批次、拉取、校验跳过 + push-YYYY-MM-DD.log # 每条上报、PDF 上传、code/msgCode + app-YYYY-MM-DD.log # 启动、cron、同步摘要 +``` + +每次 `sync` / 定时 cron 执行会在三个日文件中写入 `======== BEGIN ... ========` 与 `======== END ... ========` 分隔行。 + +与 MySQL `hy_push_log` 互补。**控制台**仅输出开始、结束、失败摘要(`[同步]` / `[定时]`);详情见上述日志文件。 + +定时常驻启动时会打印 `日志目录=...`,请以此路径为准,勿与空的 `xk-hy-transit-go\log\` 下未使用目录混淆。 + +内网 forward 日志仍在同级 `log/forward/`(forward-go 进程)。 + +监管业务上报成功条件(Ver2.0): +- 有 `body.msgCode`(或 `body.body.msgCode`):HTTP 200 且 JSON `code=200` 且 `msgCode=200` +- **无** `msgCode` 字段:HTTP 200 且 JSON `code=200` 即可 + +--- + +## 排错 + +| 现象 | 处理 | +|------|------| +| `chromedp: context canceled` | 已修复:勿在浏览器初始化后立即 cancel allocator;请拉最新代码后重试 | +| `缺少环境变量 XK_API_TOKEN` | 检查 `.env` 与云端 `HY_TRANSIT_API_TOKEN` 一致 | +| `batch_id required` | 云端拉取接口需先 `batch/create`;请用新版 sync 流程 | +| `pull consult http 401` | Token 错误或过期 | +| 转发超时 | 检查 forward-go 是否启动、`ALLOW_IPS` 是否拦了中转机 IP | +| 交互菜单不出现 | 非 TTY 环境(CI/重定向)会只打印用法;请用 `sync`/`serve` 子命令 | +| PDF 未生成 / chromedp 报错 | 安装 Chrome 或设置 `CHROME_PATH`;首次需 `go mod tidy` 拉取 chromedp 依赖 | +| 样式与 PC 不一致 | 对比 `GET /api/hy/transit/prescription/detail` 的 `recipe_file_html` 与后台处方打印预览 | +| `upload token empty` | xk-api `.env` 配置 `HY_APP_KEY` / `HY_APP_SECRET` / `HY_FILE_BUCKET` | +| PDF 上传连接超时 | 确认 `FILE_UPLOAD_VIA_FORWARD=true` 且 forward 已监听 `/mng/file/auth/upload`;检查 forward `.env` 中 `FILE_TARGET_URL` | +| 推送显示 success 但监管未入库 | 若响应含 `msgCode` 须为 200;无 `msgCode` 时看 `code` 与 HTTP | +| `上传凭证无效或过期` | 文件上传 HTTP 403,重新拉取 config 或检查 token | + +--- + +## 子命令速查 + +```text +transit # 无参数 → 交互菜单(运营) +transit sync ... # 单次同步 +transit serve # 定时常驻 +transit upload-test --pdf=... # 单独测文件上传 +transit --step=all # 兼容旧单次参数 +``` + +接口说明见云端 [hy-transit-api.md](../../xk-api/docs/hy-transit-api.md)(路径以实际部署为准)。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4200216 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module xk-hy-transit-go + +go 1.22 + +require ( + github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 + github.com/chromedp/chromedp v0.9.5 + github.com/go-sql-driver/mysql v1.8.1 + github.com/joho/godotenv v1.5.1 + github.com/robfig/cron/v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.3.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + golang.org/x/sys v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9d739dc --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8= +github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= +github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= +github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/applog/applog.go b/internal/applog/applog.go new file mode 100644 index 0000000..02dfe20 --- /dev/null +++ b/internal/applog/applog.go @@ -0,0 +1,190 @@ +// Package applog 监管中转运行日志(与云端 xk_hy_transit_* 表互补,供外网机排错)。 +// +// - Pullf:步骤 ①② 拉取/建批次(pull-YYYY-MM-DD.log) +// - Pushf:步骤 ③ 转发政务云、PDF 上传(push-YYYY-MM-DD.log) +// - Appf:进程级事件、定时任务、配置告警(app-YYYY-MM-DD.log) +// - Consolef:仅控制台(开始/结束/错误摘要) +// +// 详情默认只写文件;SetQuietConsole(false) 时 Pullf/Pushf/Appf 同时 echo 到控制台(调试)。 +package applog + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var ( + mu sync.Mutex + logRoot string + logService string + currentDay string + pullF *os.File + pushF *os.File + appF *os.File + quietConsole = true + stdFlags = log.LstdFlags +) + +// Init 初始化日志目录。service 如 transit、forward。 +// LOG_DIR 可覆盖根目录(默认 {程序目录}/log/{service})。 +func Init(service string) error { + mu.Lock() + defer mu.Unlock() + + logService = service + logRoot = resolveLogRoot(service) + if err := os.MkdirAll(logRoot, 0o755); err != nil { + return fmt.Errorf("mkdir log root: %w", err) + } + if err := rotateDailyFilesLocked(); err != nil { + return err + } + log.Printf("applog: service=%s dir=%s", service, logRoot) + return nil +} + +// Dir 返回当前日志根目录({root}/{service})。 +func Dir() string { + mu.Lock() + defer mu.Unlock() + if logRoot != "" && logService != "" { + return logRoot + } + return resolveLogRoot("transit") +} + +// SetQuietConsole 为 true 时 Pullf/Pushf/Appf 只写文件;false 时同时输出到控制台。 +func SetQuietConsole(quiet bool) { + mu.Lock() + quietConsole = quiet + mu.Unlock() +} + +// Consolef 仅输出到控制台(定时/单次任务的开始、结束、错误摘要)。 +func Consolef(format string, args ...any) { + line := fmt.Sprintf(format, args...) + log.New(os.Stdout, "", stdFlags).Print(line) +} + +// RunSeparator 在 app/pull/push 三个日文件中写入执行分隔行。 +func RunSeparator(title string) { + mu.Lock() + defer mu.Unlock() + _ = rotateDailyFilesLocked() + line := strings.Repeat("=", 8) + " " + title + " " + strings.Repeat("=", 8) + writeLineLocked(appF, line) + writeLineLocked(pullF, line) + writeLineLocked(pushF, line) +} + +func resolveLogRoot(service string) string { + if v := strings.TrimSpace(os.Getenv("LOG_DIR")); v != "" { + return filepath.Join(v, service) + } + base := programDir() + return filepath.Join(base, "log", service) +} + +func programDir() string { + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + if strings.Contains(dir, "go-build") { + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + } + return dir + } + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + return "." +} + +func rotateDailyFilesLocked() error { + day := time.Now().Format("2006-01-02") + if day == currentDay && pullF != nil && pushF != nil && appF != nil { + return nil + } + closeFile(&pullF) + closeFile(&pushF) + closeFile(&appF) + currentDay = day + var err error + pullF, err = openDailyFile(logRoot, "pull-"+day+".log") + if err != nil { + return err + } + pushF, err = openDailyFile(logRoot, "push-"+day+".log") + if err != nil { + return err + } + appF, err = openDailyFile(logRoot, "app-"+day+".log") + if err != nil { + return err + } + return nil +} + +func openDailyFile(dir, name string) (*os.File, error) { + f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.Printf("applog: open %s: %v", name, err) + return nil, err + } + return f, nil +} + +func closeFile(f **os.File) { + if f == nil || *f == nil { + return + } + _ = (*f).Close() + *f = nil +} + +func writeLineLocked(f *os.File, line string) { + if f == nil { + return + } + _, _ = fmt.Fprintf(f, "%s\n", line) +} + +func writeLocked(tag string, f *os.File, format string, args ...any) { + line := fmt.Sprintf(format, args...) + if f != nil { + log.New(f, "["+tag+"] ", stdFlags).Print(line) + } + if !quietConsole { + log.New(os.Stdout, "["+tag+"] ", stdFlags).Print(line) + } +} + +// Pullf 拉取阶段日志(写 pull 日文件)。 +func Pullf(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + _ = rotateDailyFilesLocked() + writeLocked("pull", pullF, format, args...) +} + +// Pushf 推送阶段日志(写 push 日文件)。 +func Pushf(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + _ = rotateDailyFilesLocked() + writeLocked("push", pushF, format, args...) +} + +// Appf 应用级日志(写 app 日文件)。 +func Appf(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + _ = rotateDailyFilesLocked() + writeLocked("app", appF, format, args...) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fe5a459 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,89 @@ +// Package config 从环境变量或 .env 加载配置(main 启动时 godotenv.Load)。 +// +// 与主流程的关系: +// - XK_API_* / XkAPICallbackPath → 步骤 ①②④⑤ 调云端 xk-api +// - HY_* → 步骤 ③ 监管报文 AES 加密与 HMAC 签名 +// - FORWARD_BASE_URL → 步骤 ③ 内网 xk-hy-forward-go 入口(监管 JSON + PDF 上传双通道) +// - FILE_UPLOAD_VIA_FORWARD → true 时 PDF 上传走 forward,不直连政务云 28211 +// - MYSQL_DSN → 本机 hy_sync_job / hy_push_* 审计表 +// - CRON_EXPR / SCHEDULE_TIME → serve 子命令定时 +// - ANCHOR_OFFSET_DAYS → 未传 --date 时的默认锚定日(通常=昨日) +package config + +import ( + "os" + "strconv" + "strings" +) + +// Config 监管中转运行所需的全部配置项。 +type Config struct { + XkAPIBaseURL string // 云端根地址,如 https://api.xiaokang88.com + XkAPIToken string // 对应云端 HY_TRANSIT_API_TOKEN,Header: X-Hy-Transit-Token + HyAppKey string // 监管平台 AppKey(加密上报) + HyAppSecret string // 监管平台 Secret(签名) + HyAesKey string // 监管平台 AES 密钥(body 加密) + ForwardBaseURL string // xk-hy-forward-go 地址,如 http://192.168.1.10:8080 + FileUploadViaForward bool // true:PDF 上传经 forward /mng/file/auth/upload,不直连政务云 + MySQLDSN string // 本机中转库 DSN(hy_transit_schema.sql) + AnchorOffsetDays int // 锚定日 = 今天 - N 天,默认 1 + XkAPICallbackPath string // 批量回调路径,默认 /api/hy/transit/batch/callback + CronExpr string // serve 用,默认 0 22 * * * + ScheduleTime string // HH:MM,仅当未设 CRON_EXPR 时转为 cron + ChromePath string // 可选:Chrome/Edge 路径,未设则自动探测 + FileUploadInsecureSkipVerify bool // 文件上传 HTTPS 跳过证书校验(政务网) +} + +// Load 读取环境变量;未设置时使用默认值。 +func Load() Config { + return Config{ + XkAPIBaseURL: env("XK_API_BASE_URL", "https://api.xiaokang88.com"), + XkAPIToken: env("XK_API_TOKEN", ""), + HyAppKey: env("HY_APP_KEY", ""), + HyAppSecret: env("HY_APP_SECRET", ""), + HyAesKey: env("HY_AES_KEY", ""), + ForwardBaseURL: strings.TrimRight(env("FORWARD_BASE_URL", "http://127.0.0.1:8080"), "/"), + FileUploadViaForward: envBool("FILE_UPLOAD_VIA_FORWARD", true), + MySQLDSN: env("MYSQL_DSN", "root:password@tcp(127.0.0.1:3306)/xk_hy_transit?parseTime=true&charset=utf8mb4"), + AnchorOffsetDays: envInt("ANCHOR_OFFSET_DAYS", 1), + XkAPICallbackPath: env("XK_API_CALLBACK_PATH", "/api/hy/transit/batch/callback"), + CronExpr: env("CRON_EXPR", "*/2 * * * *"), + ScheduleTime: env("SCHEDULE_TIME", ""), + ChromePath: env("CHROME_PATH", ""), + FileUploadInsecureSkipVerify: envBool("FILE_UPLOAD_INSECURE_SKIP_VERIFY", true), + } +} + +func envBool(key string, def bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if v == "" { + return def + } + switch v { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return def + } +} + +func env(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} diff --git a/internal/config/envfile.go b/internal/config/envfile.go new file mode 100644 index 0000000..41f452c --- /dev/null +++ b/internal/config/envfile.go @@ -0,0 +1,81 @@ +package config + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" +) + +// LoadEnvFiles 从多个候选路径加载 .env(不覆盖已存在的 OS 环境变量)。 +// 查找顺序:CWD → programDir → programDir/.. 。 +func LoadEnvFiles() { + candidates := envFileCandidates() + loaded := false + for _, p := range candidates { + if _, err := os.Stat(p); err != nil { + continue + } + if err := godotenv.Load(p); err != nil { + log.Printf("config: 加载 %s 失败: %v", p, err) + continue + } + log.Printf("config: 已加载 %s", p) + loaded = true + } + if !loaded { + log.Print("config: 未找到 .env,请复制 .env.example 为 .env 并填写 XK_API_TOKEN、HY_*") + } +} + +// WarnIfRequiredEnvMissing 在关键变量仍为空时提示检查 .env 路径与语法。 +func WarnIfRequiredEnvMissing() { + var missing []string + for _, key := range []string{"XK_API_TOKEN", "HY_APP_KEY", "HY_APP_SECRET", "HY_AES_KEY"} { + if strings.TrimSpace(os.Getenv(key)) == "" { + missing = append(missing, key) + } + } + if len(missing) > 0 { + log.Printf("config: 以下变量仍为空: %s(请检查 .env 是否在项目根且语法正确,含引号)", strings.Join(missing, ", ")) + } +} + +func envFileCandidates() []string { + seen := make(map[string]struct{}) + var out []string + add := func(p string) { + p = filepath.Clean(p) + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + + if wd, err := os.Getwd(); err == nil && wd != "" { + add(filepath.Join(wd, ".env")) + } + base := programDir() + add(filepath.Join(base, ".env")) + add(filepath.Join(base, "..", ".env")) + return out +} + +func programDir() string { + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + if strings.Contains(dir, "go-build") { + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + } + return dir + } + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + return "." +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..228434f --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,122 @@ +// Package db 本机 MySQL 审计库(sql/hy_transit_schema.sql)。 +// +// 与云端 xk_hy_transit_* 的分工: +// - 云端表:运营在 xk-admin 查看拉取/回调状态(权威) +// - 本机表:外网机离线排错、重试统计,sync 过程双写 hy_push_record / hy_push_log +// +// 不替代云端 batch/record;BatchCreate/Pull/Callback 仍以 xk-api 为准。 +package db + +import ( + "database/sql" + "encoding/json" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// Store 本机中转库连接。 +type Store struct { + db *sql.DB +} + +// Open 连接 MySQL 并 Ping。 +func Open(dsn string) (*Store, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + db.SetMaxOpenConns(10) + return &Store{db: db}, nil +} + +// Close 关闭连接。 +func (s *Store) Close() error { + return s.db.Close() +} + +// UpsertJob 写入/更新 hy_sync_job(按 anchor_date + step 维度汇总一次任务)。 +func (s *Store) UpsertJob(anchorDate, step, status string, total, success, failed int, errMsg string) (int64, error) { + res, err := s.db.Exec(` +INSERT INTO hy_sync_job (anchor_date, step, status, total_count, success_count, failed_count, error_message, started_at) +VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) +ON DUPLICATE KEY UPDATE + status=VALUES(status), + total_count=VALUES(total_count), + success_count=VALUES(success_count), + failed_count=VALUES(failed_count), + error_message=VALUES(error_message), + updated_at=NOW()`, + anchorDate, step, status, total, success, failed, errMsg, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// PushRecord 本机推送记录行(查询用)。 +type PushRecord struct { + ID int64 + BizKey string + PushStatus string + ValidationErrors []string +} + +// SaveRecord 写入 hy_push_record;有 validationErrors 时 push_status=skipped。 +func (s *Store) SaveRecord(jobID int64, anchorDate, method, serviceMethod, bussID, bizKey, payloadHash, payloadJSON string, validationErrors []string) error { + ve, _ := json.Marshal(validationErrors) + status := "pending" + if len(validationErrors) > 0 { + status = "skipped" + } + _, err := s.db.Exec(` +INSERT INTO hy_push_record (job_id, anchor_date, method, service_method, buss_id, biz_key, payload_hash, payload_json, validation_errors, push_status) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + payload_json=VALUES(payload_json), + validation_errors=VALUES(validation_errors), + push_status=IF(VALUES(validation_errors) IS NOT NULL AND JSON_LENGTH(VALUES(validation_errors))>0, 'skipped', push_status), + updated_at=NOW()`, + jobID, anchorDate, method, serviceMethod, bussID, bizKey, payloadHash, payloadJSON, string(ve), status, + ) + return err +} + +// UpdateRecordStatus 更新本机推送结果。 +func (s *Store) UpdateRecordStatus(bizKey, status, lastError string) error { + _, err := s.db.Exec(`UPDATE hy_push_record SET push_status=?, last_error=?, updated_at=NOW() WHERE biz_key=?`, status, lastError, bizKey) + return err +} + +// IncRetry 增加重试计数。 +func (s *Store) IncRetry(bizKey string) error { + _, err := s.db.Exec(`UPDATE hy_push_record SET retry_count=retry_count+1, updated_at=NOW() WHERE biz_key=?`, bizKey) + return err +} + +// SaveLog 写入单次 HTTP 上报日志 hy_push_log。 +func (s *Store) SaveLog(recordID int64, httpCode, msgCode int, msg, traceID string, bodyLen int, responseBody string, durationMs int) error { + _, err := s.db.Exec(` +INSERT INTO hy_push_log (record_id, http_code, msg_code, msg, trace_id, request_body_len, response_body, duration_ms) +VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + recordID, httpCode, msgCode, msg, traceID, bodyLen, responseBody, durationMs, + ) + return err +} + +// GetRecordID 按 biz_key 查本机记录 id。 +func (s *Store) GetRecordID(bizKey string) (int64, error) { + var id int64 + err := s.db.QueryRow(`SELECT id FROM hy_push_record WHERE biz_key=?`, bizKey).Scan(&id) + return id, err +} + +// FinishJob 标记 hy_sync_job 结束。 +func (s *Store) FinishJob(anchorDate, step, status string) error { + _, err := s.db.Exec(`UPDATE hy_sync_job SET status=?, finished_at=? WHERE anchor_date=? AND step=?`, status, time.Now(), anchorDate, step) + return err +} diff --git a/internal/hy/crypto.go b/internal/hy/crypto.go new file mode 100644 index 0000000..beab2db --- /dev/null +++ b/internal/hy/crypto.go @@ -0,0 +1,153 @@ +// Package hy 监管平台上报:AES 加密、HMAC 签名、经 forward-go 转发。 +// +// 与 xk-api / PHP HyApiClient 算法一致,保证政务云可解密验签。 +// BuildUpload 产出 UploadRequest;PostForward 发往内网 forward /province/supervise/data。 +package hy + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +var signHeaderOrder = []string{ + "requestBody", + "secret", + "X-Ca-Appkey", + "X-Ca-Encryption", + "X-Ca-Nonce", + "X-Ca-Timestamp", + "X-Service-Id", + "X-Service-Method", +} + +// EncryptBody AES-128-ECB + PKCS7,与 PHP AesUtilService 一致 +func EncryptBody(plain string, key string) (string, error) { + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return "", err + } + data := pkcs7Pad([]byte(plain), block.BlockSize()) + out := make([]byte, len(data)) + ecb := newECBEncrypter(block) + ecb.CryptBlocks(out, data) + return base64.StdEncoding.EncodeToString(out), nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + pad := blockSize - (len(data) % blockSize) + padding := make([]byte, pad) + for i := range padding { + padding[i] = byte(pad) + } + return append(data, padding...) +} + +type ecbEncrypter struct { + b cipher.Block + blockSize int +} + +func newECBEncrypter(b cipher.Block) *ecbEncrypter { + return &ecbEncrypter{b: b, blockSize: b.BlockSize()} +} + +func (x *ecbEncrypter) CryptBlocks(dst, src []byte) { + if len(src)%x.blockSize != 0 { + panic("invalid padding") + } + for len(src) > 0 { + x.b.Encrypt(dst, src[:x.blockSize]) + src = src[x.blockSize:] + dst = dst[x.blockSize:] + } +} + +func Sign(secret string, headers map[string]string) string { + parts := make([]string, 0, len(signHeaderOrder)) + for _, k := range signHeaderOrder { + parts = append(parts, fmt.Sprintf("%s=%s", k, headers[k])) + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(strings.Join(parts, "&"))) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +type UploadRequest struct { + Headers map[string]string + Body string +} + +// BuildUpload 组包监管上报请求:合并机构字段 → JSON → AES 加密 → HMAC 签名头。 +func BuildUpload( + serviceMethod string, + payload map[string]any, + organ OrganConfig, + appKey, appSecret, aesKey string, +) (*UploadRequest, error) { + merged := make(map[string]any, len(payload)+4) + for k, v := range payload { + merged[k] = v + } + merged["unitID"] = organ.UnitID + merged["organID"] = organ.OrganID + merged["organName"] = organ.OrganName + + raw, err := json.Marshal([]map[string]any{merged}) + if err != nil { + return nil, err + } + encrypted, err := EncryptBody(string(raw), aesKey) + if err != nil { + return nil, err + } + + ts := fmt.Sprintf("%d", time.Now().UnixMilli()) + nonce := randomNonce() + headers := map[string]string{ + "secret": appSecret, + "X-Ca-Appkey": appKey, + "X-Ca-Encryption": "AES", + "X-Ca-Nonce": nonce, + "X-Ca-Timestamp": ts, + "X-Service-Id": "his.provinceDataUploadService", + "X-Service-Method": serviceMethod, + "requestBody": encrypted, + } + sig := Sign(appSecret, headers) + headers["X-Ca-Signature"] = sig + delete(headers, "secret") + + return &UploadRequest{Headers: headers, Body: encrypted}, nil +} + +type OrganConfig struct { + UnitID string `json:"unitID"` + OrganID string `json:"organID"` + OrganName string `json:"organName"` +} + +func randomNonce() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// SortedHeaderKeys for debug only +func SortedHeaderKeys(h map[string]string) []string { + keys := make([]string, 0, len(h)) + for k := range h { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/hy/crypto_test.go b/internal/hy/crypto_test.go new file mode 100644 index 0000000..7c7c20c --- /dev/null +++ b/internal/hy/crypto_test.go @@ -0,0 +1,40 @@ +package hy + +import ( + "encoding/json" + "testing" +) + +func TestEncryptAndSign(t *testing.T) { + key := "1234567890123456" + payload := map[string]any{"bussID": "1", "platForm": "03"} + raw, _ := json.Marshal([]map[string]any{payload}) + enc, err := EncryptBody(string(raw), key) + if err != nil || enc == "" { + t.Fatalf("encrypt: %v", err) + } + headers := map[string]string{ + "secret": "secret", + "X-Ca-Appkey": "appkey", + "X-Ca-Encryption": "AES", + "X-Ca-Nonce": "nonce", + "X-Ca-Timestamp": "123", + "X-Service-Id": "his.provinceDataUploadService", + "X-Service-Method": "uploadConsultIndicators", + "requestBody": enc, + } + sig := Sign("secret", headers) + if sig == "" { + t.Fatal("empty signature") + } +} + +func TestBizKey(t *testing.T) { + k := BizKey("recipe", "2026-05-18", map[string]any{ + "bussID": "10", + "recipeUniqueID": "RX001", + }) + if k == "" { + t.Fatal("empty biz key") + } +} diff --git a/internal/hy/paths.go b/internal/hy/paths.go new file mode 100644 index 0000000..c7ba9f1 --- /dev/null +++ b/internal/hy/paths.go @@ -0,0 +1,21 @@ +// Package hy 内网转发路径常量(与 xk-hy-forward-go 监听路径一致)。 +package hy + +import "strings" + +const ( + // ForwardPathSuperviseData 监管业务上报:经 forward-go 转发至 28212。 + ForwardPathSuperviseData = "/province/supervise/data" + // ForwardPathFileUpload 处方 PDF 上传:经 forward-go 转发至 28211。 + ForwardPathFileUpload = "/mng/file/auth/upload" +) + +// ForwardFileUploadURL 拼接经内网 forward 的文件上传地址。 +func ForwardFileUploadURL(forwardBase string) string { + return strings.TrimRight(forwardBase, "/") + ForwardPathFileUpload +} + +// ForwardSuperviseURL 拼接经内网 forward 的监管业务上报地址。 +func ForwardSuperviseURL(forwardBase string) string { + return strings.TrimRight(forwardBase, "/") + ForwardPathSuperviseData +} diff --git a/internal/hy/response.go b/internal/hy/response.go new file mode 100644 index 0000000..ff34d13 --- /dev/null +++ b/internal/hy/response.go @@ -0,0 +1,110 @@ +package hy + +import ( + "encoding/json" + "fmt" + "strconv" +) + +// UploadResult 监管业务上报经 forward-go 返回的解析结果。 +type UploadResult struct { + HTTPCode int + GatewayCode int // JSON 顶层 code(网关层) + Body string + TraceID string + MsgCode int // body.msgCode 或 body.body.msgCode(业务层) + Msg string + HasMsgCode bool // JSON 中是否显式包含 msgCode 字段 +} + +// ParseSuperviseResponse 解析监管平台 JSON 响应(Ver2.0:网关 code + 可选业务 msgCode/msg)。 +// hasMsgCode 为 true 表示响应中存在 msgCode 字段(含值为 0 的情况)。 +func ParseSuperviseResponse(body []byte) (gatewayCode, msgCode int, msg string, hasMsgCode bool) { + var root map[string]json.RawMessage + if err := json.Unmarshal(body, &root); err != nil { + return 0, 0, "", false + } + gatewayCode = rawJSONInt(root["code"]) + + bodyRaw, ok := root["body"] + if !ok { + return gatewayCode, 0, "", false + } + var bodyMap map[string]json.RawMessage + if err := json.Unmarshal(bodyRaw, &bodyMap); err != nil { + return gatewayCode, 0, "", false + } + + if _, ok := bodyMap["msgCode"]; ok { + return gatewayCode, rawJSONInt(bodyMap["msgCode"]), rawJSONString(bodyMap["msg"]), true + } + + nestedRaw, ok := bodyMap["body"] + if !ok { + return gatewayCode, 0, "", false + } + var nested map[string]json.RawMessage + if err := json.Unmarshal(nestedRaw, &nested); err != nil { + return gatewayCode, 0, "", false + } + if _, ok := nested["msgCode"]; ok { + return gatewayCode, rawJSONInt(nested["msgCode"]), rawJSONString(nested["msg"]), true + } + return gatewayCode, 0, "", false +} + +func rawJSONInt(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + var n int + if err := json.Unmarshal(raw, &n); err == nil { + return n + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + v, _ := strconv.Atoi(s) + return v + } + return 0 +} + +func rawJSONString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return "" +} + +// IsSuperviseOK 判定监管上报是否成功: +// - HTTP 200 且网关 code=200 为前置条件; +// - 无 msgCode 字段时,满足前置即成功; +// - 有 msgCode 时, additionally 要求 msgCode=200。 +func IsSuperviseOK(r *UploadResult) bool { + if r == nil { + return false + } + if r.HTTPCode != 200 || r.GatewayCode != 200 { + return false + } + if !r.HasMsgCode { + return true + } + return r.MsgCode == 200 +} + +// SuperviseErrorSummary 失败时用于日志与回调的错误摘要。 +func SuperviseErrorSummary(r *UploadResult) string { + if r == nil { + return "nil result" + } + mc := "absent" + if r.HasMsgCode { + mc = fmt.Sprintf("%d", r.MsgCode) + } + return fmt.Sprintf("http=%d code=%d msgCode=%s msg=%s", r.HTTPCode, r.GatewayCode, mc, r.Msg) +} diff --git a/internal/hy/upload.go b/internal/hy/upload.go new file mode 100644 index 0000000..106a809 --- /dev/null +++ b/internal/hy/upload.go @@ -0,0 +1,75 @@ +// Package hy 监管平台上报(sync 步骤 ③)。 +// +// BuildUpload:合并机构字段 → AES 加密 body → HMAC 签名头。 +// PostForward:POST 到 forward-go 的 /province/supervise/data,由 forward-go 透明转发政务云。 +// BizKey:与云端 HyTransitRecordService::makeBizKey 规则一致,用于幂等。 +package hy + +import ( + "crypto/tls" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// PostForward 将加密后的监管报文 POST 到内网 forward-go,由 forward 透明转发至政务云 28212。 +func PostForward(forwardBaseURL string, req *UploadRequest) (*UploadResult, error) { + url := ForwardSuperviseURL(forwardBaseURL) + httpReq, err := http.NewRequest(http.MethodPost, url, strings.NewReader(req.Body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + for k, v := range req.Headers { + httpReq.Header.Set(k, v) + } + + client := &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 经内网 forward 访问 + }, + } + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + result := &UploadResult{ + HTTPCode: resp.StatusCode, + Body: body, + TraceID: resp.Header.Get("X-Ca-Request-Id"), + } + if result.TraceID == "" { + result.TraceID = resp.Header.Get("x-ca-request-id") + } + + gw, mc, msg, hasMC := ParseSuperviseResponse(bodyBytes) + result.GatewayCode = gw + result.MsgCode = mc + result.Msg = msg + result.HasMsgCode = hasMC + + return result, nil +} + +var StepMethods = map[string]string{ + "consult": "uploadConsultIndicators", + "referral": "uploadReferralIndicators", + "recipe": "uploadRecipeIndicators", + "verification": "uploadRecipeVerificationIndicators", +} + +func BizKey(step, anchorDate string, payload map[string]any) string { + buss := fmt.Sprintf("%v", payload["bussID"]) + recipe := fmt.Sprintf("%v", payload["recipeUniqueID"]) + if recipe == "" || recipe == "" { + recipe = "-" + } + return fmt.Sprintf("%s|%s|%s|%s", step, anchorDate, buss, recipe) +} diff --git a/internal/hy/upload_test.go b/internal/hy/upload_test.go new file mode 100644 index 0000000..e30039b --- /dev/null +++ b/internal/hy/upload_test.go @@ -0,0 +1,64 @@ +package hy + +import "testing" + +func TestParseSuperviseResponse_docShape(t *testing.T) { + gw, mc, msg, has := ParseSuperviseResponse([]byte(`{"code":200,"body":{"msgCode":200,"msg":""}}`)) + if gw != 200 || mc != 200 || msg != "" || !has { + t.Fatalf("got gw=%d mc=%d msg=%q has=%v", gw, mc, msg, has) + } +} + +func TestParseSuperviseResponse_phpNestedBody(t *testing.T) { + gw, mc, msg, has := ParseSuperviseResponse([]byte(`{"code":200,"body":{"body":{"msgCode":-99,"msg":"field empty"}}}`)) + if gw != 200 || mc != -99 || msg != "field empty" || !has { + t.Fatalf("got gw=%d mc=%d msg=%q has=%v", gw, mc, msg, has) + } +} + +func TestParseSuperviseResponse_noMsgCode(t *testing.T) { + for _, raw := range []string{ + `{"code":200}`, + `{"code":200,"body":{}}`, + } { + gw, mc, _, has := ParseSuperviseResponse([]byte(raw)) + if gw != 200 || mc != 0 || has { + t.Fatalf("raw=%s got gw=%d mc=%d has=%v", raw, gw, mc, has) + } + } +} + +func TestParseSuperviseResponse_msgCodeZeroPresent(t *testing.T) { + gw, mc, _, has := ParseSuperviseResponse([]byte(`{"code":200,"body":{"msgCode":0}}`)) + if gw != 200 || mc != 0 || !has { + t.Fatalf("got gw=%d mc=%d has=%v", gw, mc, has) + } +} + +func TestIsSuperviseOK(t *testing.T) { + ok := &UploadResult{HTTPCode: 200, GatewayCode: 200, MsgCode: 200, HasMsgCode: true} + if !IsSuperviseOK(ok) { + t.Fatal("expected success with msgCode 200") + } + + fail := &UploadResult{HTTPCode: 200, GatewayCode: 200, MsgCode: -99, Msg: "x", HasMsgCode: true} + if IsSuperviseOK(fail) { + t.Fatal("expected failure for msgCode -99") + } + + if IsSuperviseOK(&UploadResult{HTTPCode: 200, GatewayCode: 200, MsgCode: 0, HasMsgCode: true}) { + t.Fatal("msgCode 0 with field present must not count as success") + } + + if !IsSuperviseOK(&UploadResult{HTTPCode: 200, GatewayCode: 200, HasMsgCode: false}) { + t.Fatal("expected success when msgCode absent and http/code ok") + } + + if IsSuperviseOK(&UploadResult{HTTPCode: 500, GatewayCode: 200, HasMsgCode: false}) { + t.Fatal("expected failure for bad http") + } + + if IsSuperviseOK(&UploadResult{HTTPCode: 200, GatewayCode: 500, HasMsgCode: false}) { + t.Fatal("expected failure for bad gateway code") + } +} diff --git a/internal/hyfile/local.go b/internal/hyfile/local.go new file mode 100644 index 0000000..04fa35f --- /dev/null +++ b/internal/hyfile/local.go @@ -0,0 +1,149 @@ +package hyfile + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var invalidFilenameChars = regexp.MustCompile(`[\\/:*?"<>|]`) + +// PDFNameInput 监管处方 PDF 文件名组成字段。 +type PDFNameInput struct { + PatientName string + StoreName string + PrescriptionNo string + At time.Time +} + +// BuildPDFFilename 生成 {患者}{诊所} {处方号} {时间}.pdf +func BuildPDFFilename(in PDFNameInput) string { + patient := sanitizeFilename(strings.TrimSpace(in.PatientName)) + if patient == "" || patient == "未知患者" { + patient = "未知患者" + } + store := sanitizeFilename(strings.TrimSpace(in.StoreName)) + prefix := patient + if store != "" && store != "未知患者" { + prefix = patient + store + } + if prefix == "" { + prefix = "未知患者" + } + no := sanitizeFilename(strings.TrimSpace(in.PrescriptionNo)) + if no == "" { + no = "未知处方号" + } + at := in.At + if at.IsZero() { + at = time.Now() + } + stamp := at.Format("2006-01-02 15-04-05") + base := fmt.Sprintf("%s %s %s.pdf", prefix, no, stamp) + base = sanitizeFilename(strings.TrimSuffix(base, ".pdf")) + ".pdf" + return base +} + +// SavePDFLocal 将 PDF 保存到 {程序目录}/pdf/{YYYY-MM-DD}/{患者}{诊所} {处方号} {时间}.pdf +func SavePDFLocal(pdf []byte, in PDFNameInput) (string, error) { + if len(pdf) == 0 { + return "", fmt.Errorf("empty pdf bytes") + } + + root := resolveProgramDir() + at := in.At + if at.IsZero() { + at = time.Now() + } + dateDir := at.Format("2006-01-02") + baseName := BuildPDFFilename(in) + + dir := filepath.Join(root, "pdf", dateDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", dir, err) + } + + fullPath := filepath.Join(dir, baseName) + fullPath, err := uniquePath(fullPath) + if err != nil { + return "", err + } + if err := os.WriteFile(fullPath, pdf, 0o644); err != nil { + return "", fmt.Errorf("write pdf: %w", err) + } + + return fullPath, nil +} + +// ProgramDir 返回 PDF 落盘根目录(与 SavePDFLocal 一致)。 +func ProgramDir() string { + return resolveProgramDir() +} + +// resolveProgramDir 返回可执行文件所在目录;go run 时使用当前工作目录。 +func resolveProgramDir() string { + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + if strings.Contains(dir, "go-build") { + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + } + return dir + } + if wd, err := os.Getwd(); err == nil && wd != "" { + return wd + } + return "." +} + +func sanitizeFilename(name string) string { + s := invalidFilenameChars.ReplaceAllString(name, "_") + s = strings.TrimSpace(s) + if s == "" { + return "" + } + return s +} + +// ParseTimeString 解析处方开具时间等字符串(供 pdf-test 与 sync 共用)。 +func ParseTimeString(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + layouts := []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04", + time.RFC3339, + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { + return t + } + } + return time.Time{} +} + +func uniquePath(path string) (string, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return path, nil + } else if err != nil { + return "", err + } + ext := filepath.Ext(path) + base := strings.TrimSuffix(path, ext) + for i := 2; i < 1000; i++ { + candidate := fmt.Sprintf("%s_%d%s", base, i, ext) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate, nil + } else if err != nil { + return "", err + } + } + return "", fmt.Errorf("too many duplicate filenames for %s", path) +} diff --git a/internal/hyfile/pdf.go b/internal/hyfile/pdf.go new file mode 100644 index 0000000..94336d9 --- /dev/null +++ b/internal/hyfile/pdf.go @@ -0,0 +1,196 @@ +// Package hyfile 处方监管 PDF:chromedp 将打印 HTML 转为 PDF,并经 forward 上传政务云。 +package hyfile + +import ( + "context" + + "encoding/base64" + + "fmt" + + "os" + + "strings" + + "time" + + "github.com/chromedp/cdproto/page" + + "github.com/chromedp/chromedp" + + "xk-hy-transit-go/internal/applog" +) + +var pdfChromePath string + +// SetChromePath 可选:指定 Chrome/Edge 可执行文件路径(环境变量 CHROME_PATH 亦可)。 + +func SetChromePath(path string) { + + pdfChromePath = strings.TrimSpace(path) + +} + +func envChromePath() string { + + if pdfChromePath != "" { + + return pdfChromePath + + } + + return strings.TrimSpace(os.Getenv("CHROME_PATH")) + +} + +func resolveChromePath() string { + + if p := envChromePath(); p != "" { + + if _, err := os.Stat(p); err == nil { + + return p + + } + + } + + candidates := []string{ + + `C:\Program Files\Google\Chrome\Application\chrome.exe`, + + `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, + + `C:\Program Files\Microsoft\Edge\Application\msedge.exe`, + + `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, + } + + for _, c := range candidates { + + if _, err := os.Stat(c); err == nil { + + return c + + } + + } + + return "" + +} + +func chromedpAllocatorOpts() []chromedp.ExecAllocatorOption { + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + + chromedp.Flag("headless", true), + + chromedp.Flag("disable-gpu", true), + + chromedp.Flag("no-sandbox", true), + + chromedp.Flag("disable-dev-shm-usage", true), + + chromedp.Flag("allow-file-access-from-files", true), + ) + + if p := resolveChromePath(); p != "" { + + opts = append(opts, chromedp.ExecPath(p)) + + } + + return opts + +} + +func htmlDataURL(html string) string { + + return "data:text/html;charset=utf-8;base64," + base64.StdEncoding.EncodeToString([]byte(html)) + +} + +// HtmlToPDF 使用 headless Chrome 将完整 HTML 文档转为 PDF。 + +// 每次调用独立启动浏览器,避免复用 context 导致 context canceled。 + +func HtmlToPDF(html string) ([]byte, error) { + + html = strings.TrimSpace(html) + + if html == "" { + + return nil, fmt.Errorf("empty html") + + } + + opts := chromedpAllocatorOpts() + + if p := resolveChromePath(); p != "" { + + applog.Pushf("chromedp: using browser %s", p) + + } else { + + applog.Pushf("chromedp: CHROME_PATH unset, using chromedp default browser lookup") + + } + + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + + defer allocCancel() + + browserCtx, browserCancel := chromedp.NewContext(allocCtx) + + defer browserCancel() + + runCtx, runCancel := context.WithTimeout(browserCtx, 120*time.Second) + + defer runCancel() + + dataURL := htmlDataURL(html) + + applog.Pushf("chromedp: html_len=%d data_url_len=%d", len(html), len(dataURL)) + + var pdf []byte + + err := chromedp.Run(runCtx, + + chromedp.Navigate(dataURL), + + chromedp.WaitReady("body"), + + chromedp.Sleep(500*time.Millisecond), + + chromedp.ActionFunc(func(ctx context.Context) error { + + var printErr error + + pdf, _, printErr = page.PrintToPDF(). + WithPrintBackground(true). + WithPreferCSSPageSize(true). + Do(ctx) + + return printErr + + }), + ) + + if err != nil { + + return nil, fmt.Errorf("chromedp: %w", err) + + } + + if len(pdf) == 0 { + + return nil, fmt.Errorf("chromedp: empty pdf output") + + } + + applog.Pushf("chromedp: pdf bytes=%d", len(pdf)) + + return pdf, nil + +} diff --git a/internal/hyfile/pdf_test.go b/internal/hyfile/pdf_test.go new file mode 100644 index 0000000..a4eb8ff --- /dev/null +++ b/internal/hyfile/pdf_test.go @@ -0,0 +1,21 @@ +package hyfile + +import ( + "bytes" + "testing" +) + +func TestHtmlToPDF(t *testing.T) { + if testing.Short() { + t.Skip("skipping chromedp integration test in short mode") + } + + html := `

test

` + pdf, err := HtmlToPDF(html) + if err != nil { + t.Skipf("chromedp not available: %v", err) + } + if len(pdf) < 4 || !bytes.HasPrefix(pdf, []byte("%PDF")) { + t.Fatalf("expected PDF header, got %d bytes", len(pdf)) + } +} diff --git a/internal/hyfile/upload.go b/internal/hyfile/upload.go new file mode 100644 index 0000000..2b2d925 --- /dev/null +++ b/internal/hyfile/upload.go @@ -0,0 +1,131 @@ +// Package hyfile 处方监管 PDF 生成与上传。 +// +// HtmlToPDF:chromedp 将 xk-api 打印 HTML 转为 PDF。 +// UploadPDF:multipart POST,Header X-Authorization 为 uploadToken; +// 默认请求地址为 forward-go /mng/file/auth/upload,由 forward 转发至政务云 28211。 +package hyfile + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "sync" + "time" +) + +var ( + uploadInsecureSkipVerify bool + uploadMu sync.RWMutex +) + +// SetUploadInsecureSkipVerify 政务网 HTTPS 证书校验(与 forward-go 一致,由配置注入)。 +func SetUploadInsecureSkipVerify(skip bool) { + uploadMu.Lock() + uploadInsecureSkipVerify = skip + defer uploadMu.Unlock() +} + +func uploadTLSConfig() *tls.Config { + uploadMu.RLock() + skip := uploadInsecureSkipVerify + uploadMu.RUnlock() + if !skip { + return nil + } + return &tls.Config{InsecureSkipVerify: true} //nolint:gosec // 政务网自签证书 +} + +func uploadHTTPClient() *http.Client { + transport := &http.Transport{} + if cfg := uploadTLSConfig(); cfg != nil { + transport.TLSClientConfig = cfg + } + return &http.Client{Timeout: 120 * time.Second, Transport: transport} +} + +// UploadPDF 上传 PDF 至监管文件服务(通常经 forward-go),成功时返回 record.fileId。 +// +// uploadURL:FILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。 +// uploadToken:由 xk-api HY_APP_KEY/HY_APP_SECRET + HY_FILE_BUCKET 签名生成,放在 X-Authorization 请求头。 +func UploadPDF(pdf []byte, filename, uploadURL, uploadToken string) (string, error) { + if len(pdf) == 0 { + return "", fmt.Errorf("empty pdf") + } + if uploadURL == "" { + return "", fmt.Errorf("file upload url empty") + } + if uploadToken == "" { + return "", fmt.Errorf("upload token empty") + } + if filename == "" { + filename = "prescription.pdf" + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return "", err + } + if _, err := part.Write(pdf); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPost, uploadURL, body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-Authorization", uploadToken) + + resp, err := uploadHTTPClient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusForbidden { + return "", fmt.Errorf("上传凭证无效或过期 (http 403)") + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("upload http %d: %s", resp.StatusCode, truncate(raw, 512)) + } + + var parsed struct { + Success bool `json:"success"` + Record struct { + FileID string `json:"fileId"` + } `json:"record"` + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("parse upload response: %w", err) + } + if !parsed.Success { + msg := parsed.Message + if msg == "" { + msg = string(truncate(raw, 512)) + } + return "", fmt.Errorf("upload failed: %s", msg) + } + fileID := parsed.Record.FileID + if fileID == "" { + return "", fmt.Errorf("upload response missing record.fileId: %s", truncate(raw, 512)) + } + return fileID, nil +} + +func truncate(b []byte, n int) []byte { + if len(b) <= n { + return b + } + return b[:n] +} diff --git a/internal/hyfile/upload_test.go b/internal/hyfile/upload_test.go new file mode 100644 index 0000000..3ecc17a --- /dev/null +++ b/internal/hyfile/upload_test.go @@ -0,0 +1,58 @@ +package hyfile + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { + const wantToken = "accessKey:sign:policyB64" + var gotAuth string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("X-Authorization") + if r.Method != http.MethodPost { + t.Errorf("method=%s", r.Method) + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + t.Errorf("content-type=%s", r.Header.Get("Content-Type")) + } + _ = r.ParseMultipartForm(1 << 20) + if r.MultipartForm == nil || len(r.MultipartForm.File["file"]) == 0 { + t.Error("missing multipart file field") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "record": map[string]string{"fileId": "abc123"}, + }) + })) + defer srv.Close() + + fileID, err := UploadPDF([]byte("%PDF-1.4 test"), "test.pdf", srv.URL, wantToken) + if err != nil { + t.Fatalf("UploadPDF: %v", err) + } + if fileID != "abc123" { + t.Fatalf("fileId=%q", fileID) + } + if gotAuth != wantToken { + t.Fatalf("X-Authorization=%q want %q", gotAuth, wantToken) + } +} + +func TestUploadPDF_forbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + _, err := UploadPDF([]byte("x"), "x.pdf", srv.URL, "token") + if err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("err=%v", err) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 0000000..5823230 --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,430 @@ +// Package syncer 编排监管中转主流程(与 xk-api HyTransitRecordService 配合)。 +// +// 每个 step(consult/referral/recipe/verification)在 runStep 中顺序执行: +// +// ① xkapi.BatchCreate → 云端 xk_hy_transit_batch 建批次 +// ② xkapi.Pull → 云端组包并写入 xk_hy_transit_record,返回 record_id +// ③ hy.PostForward(逐条) → 经 forward-go 报政务云;本地 hy_push_* 留痕 +// ④ xkapi.BatchCallback → 整批回写 callback_status(非逐条 HTTP) +// ⑤ xkapi.BatchFinish → 标记批次 pull 结束 +// +// 运营可见状态在云端表;本机表仅用于外网机审计与排错。 +package syncer + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "xk-hy-transit-go/internal/applog" + "xk-hy-transit-go/internal/config" + "xk-hy-transit-go/internal/db" + "xk-hy-transit-go/internal/hy" + "xk-hy-transit-go/internal/hyfile" + "xk-hy-transit-go/internal/xkapi" +) + +// Runner 单次或定时同步执行器。 +type Runner struct { + cfg config.Config + xk *xkapi.Client + store *db.Store + organ hy.OrganConfig + fileUploadURL string // 实际上传地址(经 forward 或直连政务云) + uploadToken string // 由 xk-api config 接口生成的 uploadToken + fileUploadViaForward bool +} + +// StoreClose 关闭本机 MySQL 连接。 +func (r *Runner) StoreClose() { + if r.store != nil { + _ = r.store.Close() + } +} + +// NewRunner 加载机构配置并创建 Runner(含 forward 文件上传地址解析)。 +func NewRunner(cfg config.Config, store *db.Store) (*Runner, error) { + if p := strings.TrimSpace(cfg.ChromePath); p != "" { + hyfile.SetChromePath(p) + applog.Appf("chrome path from config: %s", p) + } + hyfile.SetUploadInsecureSkipVerify(cfg.FileUploadInsecureSkipVerify) + xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath) + conf, err := xk.Config() + if err != nil { + return nil, fmt.Errorf("load organ config: %w", err) + } + if strings.TrimSpace(conf.UploadToken) == "" { + applog.Appf("warning: uploadToken empty, recipe file upload will fail (check xk-api HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET)") + } + uploadURL := resolveFileUploadURL(cfg, conf.FileUploadURL) + if cfg.FileUploadViaForward { + applog.Appf("file upload via forward: %s (cloud fileUploadUrl ignored)", uploadURL) + } + return &Runner{ + cfg: cfg, + xk: xk, + store: store, + organ: hy.OrganConfig{ + UnitID: conf.UnitID, + OrganID: conf.OrganID, + OrganName: conf.OrganName, + }, + fileUploadURL: uploadURL, + uploadToken: conf.UploadToken, + fileUploadViaForward: cfg.FileUploadViaForward, + }, nil +} + +// resolveFileUploadURL 决定 PDF 实际上传地址:默认经 forward-go,避免外网机直连政务云 28211。 +func resolveFileUploadURL(cfg config.Config, cloudFileUploadURL string) string { + if cfg.FileUploadViaForward { + return hy.ForwardFileUploadURL(cfg.ForwardBaseURL) + } + return strings.TrimSpace(cloudFileUploadURL) +} + +// Run 执行一个或多个 step。 +func (r *Runner) Run(step, anchorDate string) error { + if step == "all" { + for _, s := range []string{"consult", "referral", "recipe", "verification"} { + if err := r.runStep(s, anchorDate); err != nil { + return err + } + } + return nil + } + return r.runStep(step, anchorDate) +} + +// runStep 执行单个监管类型的完整同步(见包注释 ①~⑤)。 +func (r *Runner) runStep(step, anchorDate string) error { + method, ok := hy.StepMethods[step] + if !ok { + return fmt.Errorf("unknown step: %s", step) + } + + applog.Appf("sync start step=%s date=%s", step, anchorDate) + _, _ = r.store.UpsertJob(anchorDate, step, "running", 0, 0, 0, "") + + // ① 云端建批次 + batchID, err := r.xk.BatchCreate(anchorDate, step) + if err != nil { + _ = r.store.FinishJob(anchorDate, step, "failed") + applog.Pullf("batch create failed step=%s date=%s err=%v", step, anchorDate, err) + return fmt.Errorf("batch create: %w", err) + } + applog.Pullf("batch created step=%s date=%s batch_id=%d", step, anchorDate, batchID) + + // ② 云端组包拉取(含 validation_errors、meta.needs_recipe_upload 等) + pull, err := r.xk.Pull(step, anchorDate, batchID) + if err != nil { + _ = r.xk.BatchFinish(batchID, "failed") + _ = r.store.FinishJob(anchorDate, step, "failed") + applog.Pullf("pull failed step=%s batch_id=%d err=%v", step, batchID, err) + return err + } + + validationCount := 0 + for _, item := range pull.Items { + if len(item.ValidationErrors) > 0 { + validationCount++ + } + } + applog.Pullf("pull done step=%s batch_id=%d items=%d validation_failed=%d", step, batchID, len(pull.Items), validationCount) + + var callbacks []xkapi.CallbackItem + success, failed, skipped := 0, 0, 0 + + // ③ 逐条:recipe 可能先 PDF 上传,再加密经 forward 报政务云 + for _, item := range pull.Items { + cb := r.processItem(step, anchorDate, method, batchID, item) + callbacks = append(callbacks, cb) + switch cb.PushStatus { + case "success": + success++ + case "skipped": + skipped++ + default: + failed++ + } + } + + // ④ 整批回调云端(写 push_status / callback_status) + if err := r.xk.BatchCallback(batchID, callbacks); err != nil { + applog.Appf("batch callback warning batch_id=%d err=%v", batchID, err) + } + // ⑤ 标记批次结束 + _ = r.xk.BatchFinish(batchID, "done") + + total := success + failed + skipped + _, _ = r.store.UpsertJob(anchorDate, step, "done", total, success, failed, "") + _ = r.store.FinishJob(anchorDate, step, "done") + applog.Pullf("step done step=%s batch_id=%d total=%d success=%d failed=%d skipped=%d", step, batchID, total, success, failed, skipped) + return nil +} + +// processItem 处理单条:校验失败则 skipped;否则 BuildUpload + PostForward,组装 CallbackItem。 +func (r *Runner) processItem(step, anchorDate, method string, batchID int, item xkapi.PullItem) xkapi.CallbackItem { + payload := item.Payload + bizKey := item.BizKey + if bizKey == "" && payload != nil { + bizKey = hy.BizKey(step, anchorDate, payload) + } + recordID := item.RecordID + + cb := xkapi.CallbackItem{ + RecordID: recordID, + BizKey: bizKey, + CallbackStatus: "waiting", + } + + if len(item.ValidationErrors) > 0 || payload == nil { + bussID := "" + if payload != nil { + bussID = fmt.Sprintf("%v", payload["bussID"]) + } + payloadJSON, _ := json.Marshal(payload) + _ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, bussID, bizKey, "", string(payloadJSON), item.ValidationErrors) + cb.PushStatus = "skipped" + cb.CallbackStatus = "failed" + cb.ErrorMessage = "validation failed" + if len(item.ValidationErrors) > 0 { + cb.ErrorMessage = item.ValidationErrors[0] + } + applog.Pullf("skip validation biz_key=%s errors=%v", bizKey, item.ValidationErrors) + return cb + } + + // recipe 且需上传处方 PDF:先经 forward 拿到 recipeFileId,再加密上报监管 + if step == "recipe" && metaBool(item.Meta, "needs_recipe_upload") { + if err := r.ensureRecipeFileID(item, payload); err != nil { + _ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", mustJSON(payload), nil) + cb.PushStatus = "failed" + cb.CallbackStatus = "failed" + cb.ErrorMessage = "recipe file upload: " + err.Error() + applog.Pushf("recipe upload fail biz_key=%s err=%v", bizKey, err) + return cb + } + } + + payloadJSON, _ := json.Marshal(payload) + _ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", string(payloadJSON), nil) + + req, err := hy.BuildUpload(method, payload, r.organ, r.cfg.HyAppKey, r.cfg.HyAppSecret, r.cfg.HyAesKey) + if err != nil { + _ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error()) + cb.PushStatus = "failed" + cb.CallbackStatus = "failed" + cb.ErrorMessage = err.Error() + applog.Pushf("build upload fail biz_key=%s err=%v", bizKey, err) + return cb + } + + start := time.Now() + result, err := hy.PostForward(r.cfg.ForwardBaseURL, req) + duration := int(time.Since(start).Milliseconds()) + if err != nil { + _ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error()) + cb.PushStatus = "failed" + cb.CallbackStatus = "failed" + cb.ErrorMessage = err.Error() + applog.Pushf("post forward fail biz_key=%s method=%s err=%v", bizKey, method, err) + return cb + } + + recordLocalID, _ := r.store.GetRecordID(bizKey) + _ = r.store.SaveLog(recordLocalID, result.HTTPCode, result.MsgCode, result.Msg, result.TraceID, len(req.Body), result.Body, duration) + + cb.HTTPCode = result.HTTPCode + cb.MsgCode = result.MsgCode + cb.Msg = result.Msg + cb.TraceID = result.TraceID + cb.ResponseBody = result.Body + + if hy.IsSuperviseOK(result) { + _ = r.store.UpdateRecordStatus(bizKey, "success", "") + cb.PushStatus = "success" + cb.CallbackStatus = "success" + applog.Pushf("ok biz_key=%s method=%s code=%d msgCode=%d trace=%s %dms", bizKey, method, result.GatewayCode, result.MsgCode, result.TraceID, duration) + } else { + errText := hy.SuperviseErrorSummary(result) + _ = r.store.UpdateRecordStatus(bizKey, "failed", errText) + cb.PushStatus = "failed" + cb.CallbackStatus = "failed" + cb.ErrorMessage = errText + applog.Pushf("fail biz_key=%s method=%s %s %dms", bizKey, method, errText, duration) + } + return cb +} + +// ResolveAnchorDate 解析锚定日:命令行优先,否则按配置向前推 N 天。 +func ResolveAnchorDate(cfg config.Config, dateArg string) string { + if dateArg != "" { + return dateArg + } + return time.Now().AddDate(0, 0, -cfg.AnchorOffsetDays).Format("2006-01-02") +} + +// ensureRecipeFileID 处方 PDF 子流程:HTML→PDF→本地落盘→经 forward 上传→写回 recipeFileId。 +func (r *Runner) ensureRecipeFileID(item xkapi.PullItem, payload map[string]any) error { + // 步骤 1:解析处方 ID,必要时从云端拉取打印 HTML + prescriptionID := metaInt(item.Meta, "prescription_id") + if prescriptionID <= 0 { + prescriptionID = metaIntFromPayload(payload, "recipeID") + } + html, _ := item.Meta["recipe_file_html"].(string) + if strings.TrimSpace(html) == "" { + if prescriptionID <= 0 { + return fmt.Errorf("recipe_file_html missing and no prescription_id") + } + applog.Pushf("fetch recipe html prescription_id=%d", prescriptionID) + detail, err := r.xk.GetPrescriptionPrintDetail(prescriptionID) + if err != nil { + return fmt.Errorf("fetch recipe html: %w", err) + } + html = detail.RecipeFileHTML + } + + // 步骤 2:chromedp 将监管打印 HTML 转为 PDF 字节 + applog.Pushf("recipe pdf: prescription_id=%d html_len=%d save_root=%s", prescriptionID, len(html), hyfile.ProgramDir()) + pdf, err := hyfile.HtmlToPDF(html) + if err != nil { + return err + } + + // 步骤 3:落盘到本机 pdf/ 目录,便于运营核对 + pdfName := buildPDFNameInput(item.Meta, payload) + var localPath string + if saved, err := hyfile.SavePDFLocal(pdf, pdfName); err != nil { + applog.Pushf("save local pdf warning prescription_id=%d err=%v", prescriptionID, err) + } else { + localPath = saved + applog.Pushf("saved local pdf: %s", saved) + } + + // 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId + filename := hyfile.BuildPDFFilename(pdfName) + fileID, err := hyfile.UploadPDF(pdf, filename, r.fileUploadURL, r.uploadToken) + if err != nil { + applog.Pushf("upload pdf fail prescription_no=%s local=%s err=%v", pdfName.PrescriptionNo, localPath, err) + return err + } + applog.Pushf("upload pdf ok prescription_no=%s fileId=%s local=%s url=%s", pdfName.PrescriptionNo, fileID, localPath, r.fileUploadURL) + + // 步骤 5:写入组包 payload,并回写云端处方表 + payload["recipeFileId"] = fileID + if prescriptionID > 0 { + if err := r.xk.SaveRecipeFile(prescriptionID, fileID, item.RecordID); err != nil { + applog.Pushf("save recipe file cloud warning prescription_id=%d err=%v", prescriptionID, err) + } + } + return nil +} + +func metaBool(meta map[string]any, key string) bool { + if meta == nil { + return false + } + switch v := meta[key].(type) { + case bool: + return v + case float64: + return v != 0 + case int: + return v != 0 + case string: + s := strings.TrimSpace(strings.ToLower(v)) + return s == "true" || s == "1" || s == "yes" + default: + return false + } +} + +func metaString(meta map[string]any, key string) string { + if meta == nil { + return "" + } + if s, ok := meta[key].(string); ok { + return s + } + return fmt.Sprintf("%v", meta[key]) +} + +func metaInt(meta map[string]any, key string) int { + if meta == nil { + return 0 + } + switch v := meta[key].(type) { + case float64: + return int(v) + case int: + return v + case string: + var n int + fmt.Sscanf(v, "%d", &n) + return n + default: + return 0 + } +} + +func buildPDFNameInput(meta map[string]any, payload map[string]any) hyfile.PDFNameInput { + in := hyfile.PDFNameInput{At: parseMetaTime(meta, payload)} + in.PatientName = metaString(meta, "patient_name") + if in.PatientName == "" { + if s, ok := payload["patientName"].(string); ok { + in.PatientName = s + } + } + in.StoreName = metaString(meta, "store_name") + in.PrescriptionNo = metaString(meta, "prescription_no") + if in.PrescriptionNo == "" { + in.PrescriptionNo = fmt.Sprintf("%v", payload["recipeUniqueID"]) + } + return in +} + +func parseMetaTime(meta map[string]any, payload map[string]any) time.Time { + if meta != nil { + if t := hyfile.ParseTimeString(metaString(meta, "created_at")); !t.IsZero() { + return t + } + } + if s, ok := payload["datein"].(string); ok { + if t := hyfile.ParseTimeString(s); !t.IsZero() { + return t + } + } + return time.Now() +} + +func metaIntFromPayload(payload map[string]any, key string) int { + switch v := payload[key].(type) { + case float64: + return int(v) + case int: + return v + case string: + var n int + fmt.Sscanf(v, "%d", &n) + return n + default: + return 0 + } +} + +func mustJSON(payload map[string]any) string { + b, _ := json.Marshal(payload) + return string(b) +} + +// FileUploadURL 返回实际使用的文件上传地址(upload-test 与排错用)。 +func (r *Runner) FileUploadURL() string { return r.fileUploadURL } + +// FileUploadViaForward 是否经内网 forward 上传 PDF。 +func (r *Runner) FileUploadViaForward() bool { return r.fileUploadViaForward } + +// UploadToken 返回监管文件上传凭证。 +func (r *Runner) UploadToken() string { return r.uploadToken } diff --git a/internal/xkapi/client.go b/internal/xkapi/client.go new file mode 100644 index 0000000..927de99 --- /dev/null +++ b/internal/xkapi/client.go @@ -0,0 +1,256 @@ +// Package xkapi 调用云端 xk-api(前缀 /api/hy,鉴权 X-Hy-Transit-Token)。 +// +// 与 sync.runStep 的对应关系: +// +// BatchCreate → POST /api/hy/transit/batch/create +// Pull → GET /api/hy/supervise/{step}?batch_id=&date= +// BatchCallback→ POST /api/hy/transit/batch/callback(路径可配 XK_API_CALLBACK_PATH) +// BatchFinish → POST /api/hy/transit/batch/finish +// Config → GET /api/hy/supervise/config(机构 ID、uploadToken;PDF 上传地址由 transit 改走 forward) +// GetPrescriptionPrintDetail → GET /api/hy/transit/prescription/detail(recipe PDF HTML) +// SaveRecipeFile → POST /api/hy/transit/prescription/recipe-file(回写 recipeFileId) +// +// 响应统一为 code=0 时 result 字段;见 docs/hy-transit-api.md。 +package xkapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client 云端 API 客户端。 +type Client struct { + baseURL string + token string + callbackPath string + http *http.Client +} + +// New 创建客户端;callbackPath 为批量回调路径,如 /api/hy/transit/batch/callback。 +func New(baseURL, token, callbackPath string) *Client { + if callbackPath == "" { + callbackPath = "/api/hy/transit/batch/callback" + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + callbackPath: callbackPath, + http: &http.Client{Timeout: 120 * time.Second}, + } +} + +// PullResponse 单次拉取响应。 +type PullResponse struct { + AnchorDate string `json:"anchor_date"` + BatchID int `json:"batch_id"` + Items []PullItem `json:"items"` +} + +// PullItem 单条组包记录(含云端 record_id)。 +type PullItem struct { + RecordID int `json:"record_id"` + BizKey string `json:"biz_key"` + Payload map[string]any `json:"payload"` + Meta map[string]any `json:"meta"` + ValidationErrors []string `json:"validation_errors"` +} + +// ConfigResponse 机构公开配置(Runner 启动时拉取一次)。 +// FileUploadURL 在 FILE_UPLOAD_VIA_FORWARD=true 时由 transit 忽略,改走 forward 路径。 +type ConfigResponse struct { + OrganID string `json:"organID"` + UnitID string `json:"unitID"` + OrganName string `json:"organName"` + HosCode string `json:"hosCode"` + HosName string `json:"hosName"` + FileUploadURL string `json:"fileUploadUrl"` + FileBucket string `json:"fileBucket"` + UploadToken string `json:"uploadToken"` +} + +// CallbackItem 批量回调单条结果。 +type CallbackItem struct { + RecordID int `json:"record_id"` + BizKey string `json:"biz_key,omitempty"` + PushStatus string `json:"push_status"` + CallbackStatus string `json:"callback_status"` + HTTPCode int `json:"http_code,omitempty"` + MsgCode int `json:"msg_code,omitempty"` + Msg string `json:"msg,omitempty"` + TraceID string `json:"trace_id,omitempty"` + ResponseBody string `json:"response_body,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +func (c *Client) setAuth(req *http.Request) { + req.Header.Set("X-Hy-Transit-Token", c.token) +} + +func (c *Client) doJSON(req *http.Request, out any) error { + c.setAuth(req) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http %d: %s", resp.StatusCode, string(body)) + } + var envelope struct { + Code int `json:"code"` + Result json.RawMessage `json:"result"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return err + } + if envelope.Code != 0 { + return fmt.Errorf("api code %d: %s", envelope.Code, envelope.Message) + } + if out != nil && len(envelope.Result) > 0 { + return json.Unmarshal(envelope.Result, out) + } + return nil +} + +// BatchCreate 创建同步批次,返回 batch_id。 +func (c *Client) BatchCreate(anchorDate, step string) (int, error) { + payload := map[string]string{ + "anchor_date": anchorDate, + "step": step, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/hy/transit/batch/create", bytes.NewReader(raw)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + var result struct { + BatchID int `json:"batch_id"` + } + if err := c.doJSON(req, &result); err != nil { + return 0, err + } + return result.BatchID, nil +} + +// BatchCallback 整批回写上报结果。 +func (c *Client) BatchCallback(batchID int, items []CallbackItem) error { + payload := map[string]any{ + "batch_id": batchID, + "items": items, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, c.baseURL+c.callbackPath, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.doJSON(req, nil) +} + +// BatchFinish 标记批次拉取结束。 +func (c *Client) BatchFinish(batchID int, pullStatus string) error { + payload := map[string]any{ + "batch_id": batchID, + "pull_status": pullStatus, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/hy/transit/batch/finish", bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.doJSON(req, nil) +} + +// Pull 拉取指定 step 的组包列表(需 batch_id)。 +func (c *Client) Pull(step, date string, batchID int) (*PullResponse, error) { + u, err := url.Parse(c.baseURL + "/api/hy/supervise/" + step) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("date", date) + q.Set("batch_id", fmt.Sprintf("%d", batchID)) + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + var result PullResponse + if err := c.doJSON(req, &result); err != nil { + return nil, fmt.Errorf("pull %s: %w", step, err) + } + return &result, nil +} + +// Config 获取机构配置(加密上报用)。 +func (c *Client) Config() (*ConfigResponse, error) { + req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/hy/supervise/config", nil) + if err != nil { + return nil, err + } + var result ConfigResponse + if err := c.doJSON(req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// PrescriptionPrintDetail transit 处方详情(含打印 HTML)。 +type PrescriptionPrintDetail struct { + RecipeFileHTML string `json:"recipe_file_html"` + PatientName string `json:"patient_name"` + StoreName string `json:"store_name"` + PrescriptionNo string `json:"prescription_no"` + CreatedAt string `json:"created_at"` +} + +// GetPrescriptionPrintDetail 拉取与 PC detail 一致的处方数据及 recipe_file_html。 +func (c *Client) GetPrescriptionPrintDetail(prescriptionID int) (*PrescriptionPrintDetail, error) { + u, err := url.Parse(c.baseURL + "/api/hy/transit/prescription/detail") + if err != nil { + return nil, err + } + q := u.Query() + q.Set("prescription_id", fmt.Sprintf("%d", prescriptionID)) + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + var result PrescriptionPrintDetail + if err := c.doJSON(req, &result); err != nil { + return nil, err + } + if strings.TrimSpace(result.RecipeFileHTML) == "" { + return nil, fmt.Errorf("empty recipe_file_html for prescription %d", prescriptionID) + } + return &result, nil +} + +// SaveRecipeFile 回写处方监管文件 ID。 +func (c *Client) SaveRecipeFile(prescriptionID int, fileID string, recordID int) error { + payload := map[string]any{ + "prescription_id": prescriptionID, + "recipe_file_id": fileID, + "record_id": recordID, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/hy/transit/prescription/recipe-file", bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.doJSON(req, nil) +} diff --git a/log/transit/app-2026-05-20.log b/log/transit/app-2026-05-20.log new file mode 100644 index 0000000..6ce3bff --- /dev/null +++ b/log/transit/app-2026-05-20.log @@ -0,0 +1,23 @@ +[app] 2026/05/20 12:39:20 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=consult date=2026-05-19 ======== +[app] 2026/05/20 12:39:20 sync run start step=consult date=2026-05-19 +[app] 2026/05/20 12:39:20 sync start step=consult date=2026-05-19 +[app] 2026/05/20 12:39:20 sync done step=consult date=2026-05-19 duration=0s +======== END sync step=consult date=2026-05-19 ok=true ======== +[app] 2026/05/20 12:40:50 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN cron step=all date=2026-05-19 cron=*/2 * * * * ======== +[app] 2026/05/20 12:42:00 cron trigger step=all date=2026-05-19 cron=*/2 * * * * +[app] 2026/05/20 12:42:00 sync start step=consult date=2026-05-19 +[app] 2026/05/20 12:42:00 sync start step=referral date=2026-05-19 +[app] 2026/05/20 12:42:00 sync start step=recipe date=2026-05-19 +[app] 2026/05/20 12:42:01 sync start step=verification date=2026-05-19 +[app] 2026/05/20 12:42:01 cron done date=2026-05-19 duration=1s +======== END cron step=all date=2026-05-19 ok=true ======== +======== BEGIN cron step=all date=2026-05-19 cron=*/2 * * * * ======== +[app] 2026/05/20 12:44:00 cron trigger step=all date=2026-05-19 cron=*/2 * * * * +[app] 2026/05/20 12:44:00 sync start step=consult date=2026-05-19 +[app] 2026/05/20 12:44:00 sync start step=referral date=2026-05-19 +[app] 2026/05/20 12:44:00 sync start step=recipe date=2026-05-19 +[app] 2026/05/20 12:44:01 sync start step=verification date=2026-05-19 +[app] 2026/05/20 12:44:01 cron done date=2026-05-19 duration=1s +======== END cron step=all date=2026-05-19 ok=true ======== diff --git a/log/transit/app-2026-05-21.log b/log/transit/app-2026-05-21.log new file mode 100644 index 0000000..2201b1d --- /dev/null +++ b/log/transit/app-2026-05-21.log @@ -0,0 +1,51 @@ +[app] 2026/05/21 15:56:50 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-20 ======== +[app] 2026/05/21 15:56:50 sync run start step=all date=2026-05-20 +[app] 2026/05/21 15:56:50 sync start step=consult date=2026-05-20 +[app] 2026/05/21 15:56:50 sync start step=referral date=2026-05-20 +[app] 2026/05/21 15:56:50 sync start step=recipe date=2026-05-20 +[app] 2026/05/21 15:56:50 sync start step=verification date=2026-05-20 +[app] 2026/05/21 15:56:50 sync done step=all date=2026-05-20 duration=1s +======== END sync step=all date=2026-05-20 ok=true ======== +[app] 2026/05/21 15:57:57 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-20 ======== +[app] 2026/05/21 15:57:57 sync run start step=all date=2026-05-20 +[app] 2026/05/21 15:57:57 sync start step=consult date=2026-05-20 +[app] 2026/05/21 15:57:57 sync start step=referral date=2026-05-20 +[app] 2026/05/21 15:57:57 sync start step=recipe date=2026-05-20 +[app] 2026/05/21 15:57:57 sync start step=verification date=2026-05-20 +[app] 2026/05/21 15:57:58 sync done step=all date=2026-05-20 duration=1s +======== END sync step=all date=2026-05-20 ok=true ======== +[app] 2026/05/21 15:58:11 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-5-21 ======== +[app] 2026/05/21 15:58:11 sync run start step=all date=2026-5-21 +[app] 2026/05/21 15:58:11 sync start step=consult date=2026-5-21 +[app] 2026/05/21 15:58:11 sync failed step=all date=2026-5-21 err=batch create: api code 500: anchor_date 格式应为 Y-m-d +======== END sync step=all date=2026-5-21 ok=false ======== +[app] 2026/05/21 15:58:25 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-21 ======== +[app] 2026/05/21 15:58:25 sync run start step=all date=2026-05-21 +[app] 2026/05/21 15:58:25 sync start step=consult date=2026-05-21 +[app] 2026/05/21 15:58:25 sync start step=referral date=2026-05-21 +[app] 2026/05/21 15:58:26 sync start step=recipe date=2026-05-21 +[app] 2026/05/21 15:58:26 sync start step=verification date=2026-05-21 +[app] 2026/05/21 15:58:26 sync done step=all date=2026-05-21 duration=1s +======== END sync step=all date=2026-05-21 ok=true ======== +[app] 2026/05/21 15:59:01 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-21 ======== +[app] 2026/05/21 15:59:01 sync run start step=all date=2026-05-21 +[app] 2026/05/21 15:59:01 sync start step=consult date=2026-05-21 +[app] 2026/05/21 15:59:01 sync start step=referral date=2026-05-21 +[app] 2026/05/21 15:59:01 sync start step=recipe date=2026-05-21 +[app] 2026/05/21 15:59:03 sync start step=verification date=2026-05-21 +[app] 2026/05/21 15:59:03 sync done step=all date=2026-05-21 duration=2s +======== END sync step=all date=2026-05-21 ok=true ======== +[app] 2026/05/21 16:00:28 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-21 ======== +[app] 2026/05/21 16:00:28 sync run start step=all date=2026-05-21 +[app] 2026/05/21 16:00:28 sync start step=consult date=2026-05-21 +[app] 2026/05/21 16:00:28 sync start step=referral date=2026-05-21 +[app] 2026/05/21 16:00:28 sync start step=recipe date=2026-05-21 +[app] 2026/05/21 16:00:30 sync start step=verification date=2026-05-21 +[app] 2026/05/21 16:00:30 sync done step=all date=2026-05-21 duration=3s +======== END sync step=all date=2026-05-21 ok=true ======== diff --git a/log/transit/pull-2026-05-20.log b/log/transit/pull-2026-05-20.log new file mode 100644 index 0000000..89173ee --- /dev/null +++ b/log/transit/pull-2026-05-20.log @@ -0,0 +1,33 @@ +======== BEGIN sync step=consult date=2026-05-19 ======== +[pull] 2026/05/20 12:39:20 batch created step=consult date=2026-05-19 batch_id=145 +[pull] 2026/05/20 12:39:20 pull done step=consult batch_id=145 items=0 validation_failed=0 +[pull] 2026/05/20 12:39:20 step done step=consult batch_id=145 total=0 success=0 failed=0 skipped=0 +======== END sync step=consult date=2026-05-19 ok=true ======== +======== BEGIN cron step=all date=2026-05-19 cron=*/2 * * * * ======== +[pull] 2026/05/20 12:42:00 batch created step=consult date=2026-05-19 batch_id=146 +[pull] 2026/05/20 12:42:00 pull done step=consult batch_id=146 items=0 validation_failed=0 +[pull] 2026/05/20 12:42:00 step done step=consult batch_id=146 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/20 12:42:00 batch created step=referral date=2026-05-19 batch_id=147 +[pull] 2026/05/20 12:42:00 pull done step=referral batch_id=147 items=0 validation_failed=0 +[pull] 2026/05/20 12:42:00 step done step=referral batch_id=147 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/20 12:42:00 batch created step=recipe date=2026-05-19 batch_id=148 +[pull] 2026/05/20 12:42:00 pull done step=recipe batch_id=148 items=1 validation_failed=0 +[pull] 2026/05/20 12:42:01 step done step=recipe batch_id=148 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/20 12:42:01 batch created step=verification date=2026-05-19 batch_id=149 +[pull] 2026/05/20 12:42:01 pull done step=verification batch_id=149 items=0 validation_failed=0 +[pull] 2026/05/20 12:42:01 step done step=verification batch_id=149 total=0 success=0 failed=0 skipped=0 +======== END cron step=all date=2026-05-19 ok=true ======== +======== BEGIN cron step=all date=2026-05-19 cron=*/2 * * * * ======== +[pull] 2026/05/20 12:44:00 batch created step=consult date=2026-05-19 batch_id=1 +[pull] 2026/05/20 12:44:00 pull done step=consult batch_id=1 items=0 validation_failed=0 +[pull] 2026/05/20 12:44:00 step done step=consult batch_id=1 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/20 12:44:00 batch created step=referral date=2026-05-19 batch_id=2 +[pull] 2026/05/20 12:44:00 pull done step=referral batch_id=2 items=0 validation_failed=0 +[pull] 2026/05/20 12:44:00 step done step=referral batch_id=2 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/20 12:44:00 batch created step=recipe date=2026-05-19 batch_id=3 +[pull] 2026/05/20 12:44:00 pull done step=recipe batch_id=3 items=1 validation_failed=0 +[pull] 2026/05/20 12:44:01 step done step=recipe batch_id=3 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/20 12:44:01 batch created step=verification date=2026-05-19 batch_id=4 +[pull] 2026/05/20 12:44:01 pull done step=verification batch_id=4 items=0 validation_failed=0 +[pull] 2026/05/20 12:44:01 step done step=verification batch_id=4 total=0 success=0 failed=0 skipped=0 +======== END cron step=all date=2026-05-19 ok=true ======== diff --git a/log/transit/pull-2026-05-21.log b/log/transit/pull-2026-05-21.log new file mode 100644 index 0000000..8e7cc2f --- /dev/null +++ b/log/transit/pull-2026-05-21.log @@ -0,0 +1,73 @@ +======== BEGIN sync step=all date=2026-05-20 ======== +[pull] 2026/05/21 15:56:50 batch created step=consult date=2026-05-20 batch_id=5 +[pull] 2026/05/21 15:56:50 pull done step=consult batch_id=5 items=0 validation_failed=0 +[pull] 2026/05/21 15:56:50 step done step=consult batch_id=5 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:56:50 batch created step=referral date=2026-05-20 batch_id=6 +[pull] 2026/05/21 15:56:50 pull done step=referral batch_id=6 items=0 validation_failed=0 +[pull] 2026/05/21 15:56:50 step done step=referral batch_id=6 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:56:50 batch created step=recipe date=2026-05-20 batch_id=7 +[pull] 2026/05/21 15:56:50 pull done step=recipe batch_id=7 items=0 validation_failed=0 +[pull] 2026/05/21 15:56:50 step done step=recipe batch_id=7 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:56:50 batch created step=verification date=2026-05-20 batch_id=8 +[pull] 2026/05/21 15:56:50 pull done step=verification batch_id=8 items=0 validation_failed=0 +[pull] 2026/05/21 15:56:50 step done step=verification batch_id=8 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-20 ok=true ======== +======== BEGIN sync step=all date=2026-05-20 ======== +[pull] 2026/05/21 15:57:57 batch created step=consult date=2026-05-20 batch_id=9 +[pull] 2026/05/21 15:57:57 pull done step=consult batch_id=9 items=0 validation_failed=0 +[pull] 2026/05/21 15:57:57 step done step=consult batch_id=9 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:57:57 batch created step=referral date=2026-05-20 batch_id=10 +[pull] 2026/05/21 15:57:57 pull done step=referral batch_id=10 items=0 validation_failed=0 +[pull] 2026/05/21 15:57:57 step done step=referral batch_id=10 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:57:57 batch created step=recipe date=2026-05-20 batch_id=11 +[pull] 2026/05/21 15:57:57 pull done step=recipe batch_id=11 items=0 validation_failed=0 +[pull] 2026/05/21 15:57:57 step done step=recipe batch_id=11 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:57:57 batch created step=verification date=2026-05-20 batch_id=12 +[pull] 2026/05/21 15:57:57 pull done step=verification batch_id=12 items=0 validation_failed=0 +[pull] 2026/05/21 15:57:58 step done step=verification batch_id=12 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-20 ok=true ======== +======== BEGIN sync step=all date=2026-5-21 ======== +[pull] 2026/05/21 15:58:11 batch create failed step=consult date=2026-5-21 err=api code 500: anchor_date 格式应为 Y-m-d +======== END sync step=all date=2026-5-21 ok=false ======== +======== BEGIN sync step=all date=2026-05-21 ======== +[pull] 2026/05/21 15:58:25 batch created step=consult date=2026-05-21 batch_id=13 +[pull] 2026/05/21 15:58:25 pull done step=consult batch_id=13 items=0 validation_failed=0 +[pull] 2026/05/21 15:58:25 step done step=consult batch_id=13 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:58:26 batch created step=referral date=2026-05-21 batch_id=14 +[pull] 2026/05/21 15:58:26 pull done step=referral batch_id=14 items=0 validation_failed=0 +[pull] 2026/05/21 15:58:26 step done step=referral batch_id=14 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:58:26 batch created step=recipe date=2026-05-21 batch_id=15 +[pull] 2026/05/21 15:58:26 pull done step=recipe batch_id=15 items=0 validation_failed=0 +[pull] 2026/05/21 15:58:26 step done step=recipe batch_id=15 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:58:26 batch created step=verification date=2026-05-21 batch_id=16 +[pull] 2026/05/21 15:58:26 pull done step=verification batch_id=16 items=0 validation_failed=0 +[pull] 2026/05/21 15:58:26 step done step=verification batch_id=16 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-21 ok=true ======== +======== BEGIN sync step=all date=2026-05-21 ======== +[pull] 2026/05/21 15:59:01 batch created step=consult date=2026-05-21 batch_id=17 +[pull] 2026/05/21 15:59:01 pull done step=consult batch_id=17 items=0 validation_failed=0 +[pull] 2026/05/21 15:59:01 step done step=consult batch_id=17 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:59:01 batch created step=referral date=2026-05-21 batch_id=18 +[pull] 2026/05/21 15:59:01 pull done step=referral batch_id=18 items=0 validation_failed=0 +[pull] 2026/05/21 15:59:01 step done step=referral batch_id=18 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 15:59:01 batch created step=recipe date=2026-05-21 batch_id=19 +[pull] 2026/05/21 15:59:02 pull done step=recipe batch_id=19 items=1 validation_failed=0 +[pull] 2026/05/21 15:59:03 step done step=recipe batch_id=19 total=1 success=0 failed=1 skipped=0 +[pull] 2026/05/21 15:59:03 batch created step=verification date=2026-05-21 batch_id=20 +[pull] 2026/05/21 15:59:03 pull done step=verification batch_id=20 items=0 validation_failed=0 +[pull] 2026/05/21 15:59:03 step done step=verification batch_id=20 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-21 ok=true ======== +======== BEGIN sync step=all date=2026-05-21 ======== +[pull] 2026/05/21 16:00:28 batch created step=consult date=2026-05-21 batch_id=21 +[pull] 2026/05/21 16:00:28 pull done step=consult batch_id=21 items=0 validation_failed=0 +[pull] 2026/05/21 16:00:28 step done step=consult batch_id=21 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 16:00:28 batch created step=referral date=2026-05-21 batch_id=22 +[pull] 2026/05/21 16:00:28 pull done step=referral batch_id=22 items=0 validation_failed=0 +[pull] 2026/05/21 16:00:28 step done step=referral batch_id=22 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/21 16:00:28 batch created step=recipe date=2026-05-21 batch_id=23 +[pull] 2026/05/21 16:00:29 pull done step=recipe batch_id=23 items=1 validation_failed=0 +[pull] 2026/05/21 16:00:30 step done step=recipe batch_id=23 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/21 16:00:30 batch created step=verification date=2026-05-21 batch_id=24 +[pull] 2026/05/21 16:00:30 pull done step=verification batch_id=24 items=0 validation_failed=0 +[pull] 2026/05/21 16:00:30 step done step=verification batch_id=24 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-21 ok=true ======== diff --git a/log/transit/push-2026-05-20.log b/log/transit/push-2026-05-20.log new file mode 100644 index 0000000..a755bdc --- /dev/null +++ b/log/transit/push-2026-05-20.log @@ -0,0 +1,8 @@ +======== BEGIN sync step=consult date=2026-05-19 ======== +======== END sync step=consult date=2026-05-19 ok=true ======== +======== BEGIN cron step=all date=2026-05-19 cron=*/2 * * * * ======== +[push] 2026/05/20 12:42:01 ok biz_key=recipe|2026-05-19|258|ZY5660791779236787 method=uploadRecipeIndicators code=200 msgCode=0 trace= 10ms +======== END cron step=all date=2026-05-19 ok=true ======== +======== BEGIN cron step=all date=2026-05-19 cron=*/2 * * * * ======== +[push] 2026/05/20 12:44:01 ok biz_key=recipe|2026-05-19|258|ZY5660791779236787 method=uploadRecipeIndicators code=200 msgCode=0 trace= 13ms +======== END cron step=all date=2026-05-19 ok=true ======== diff --git a/log/transit/push-2026-05-21.log b/log/transit/push-2026-05-21.log new file mode 100644 index 0000000..38e5691 --- /dev/null +++ b/log/transit/push-2026-05-21.log @@ -0,0 +1,26 @@ +======== BEGIN sync step=all date=2026-05-20 ======== +======== END sync step=all date=2026-05-20 ok=true ======== +======== BEGIN sync step=all date=2026-05-20 ======== +======== END sync step=all date=2026-05-20 ok=true ======== +======== BEGIN sync step=all date=2026-5-21 ======== +======== END sync step=all date=2026-5-21 ok=false ======== +======== BEGIN sync step=all date=2026-05-21 ======== +======== END sync step=all date=2026-05-21 ok=true ======== +======== BEGIN sync step=all date=2026-05-21 ======== +[push] 2026/05/21 15:59:02 recipe pdf: prescription_id=399 html_len=144085 save_root=D:\worker\code\xk-hy-transit-go +[push] 2026/05/21 15:59:02 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/21 15:59:02 chromedp: html_len=144083 data_url_len=192148 +[push] 2026/05/21 15:59:03 chromedp: pdf bytes=97344 +[push] 2026/05/21 15:59:03 saved local pdf: D:\worker\code\xk-hy-transit-go\pdf\2026-05-21\李二狗浙江萧康医药有限公司萧山俊良诊所 XY6063121779350252 2026-05-21 15-57-32.pdf +[push] 2026/05/21 15:59:03 upload pdf fail prescription_no=XY6063121779350252 local=D:\worker\code\xk-hy-transit-go\pdf\2026-05-21\李二狗浙江萧康医药有限公司萧山俊良诊所 XY6063121779350252 2026-05-21 15-57-32.pdf err=Post "http://127.0.0.1:16001/mng/file/auth/upload": dial tcp 127.0.0.1:16001: connectex: No connection could be made because the target machine actively refused it. +[push] 2026/05/21 15:59:03 recipe upload fail biz_key=recipe|2026-05-21|259|XY6063121779350252 err=Post "http://127.0.0.1:16001/mng/file/auth/upload": dial tcp 127.0.0.1:16001: connectex: No connection could be made because the target machine actively refused it. +======== END sync step=all date=2026-05-21 ok=true ======== +======== BEGIN sync step=all date=2026-05-21 ======== +[push] 2026/05/21 16:00:29 recipe pdf: prescription_id=399 html_len=144085 save_root=D:\worker\code\xk-hy-transit-go +[push] 2026/05/21 16:00:29 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/21 16:00:29 chromedp: html_len=144083 data_url_len=192148 +[push] 2026/05/21 16:00:30 chromedp: pdf bytes=97344 +[push] 2026/05/21 16:00:30 saved local pdf: D:\worker\code\xk-hy-transit-go\pdf\2026-05-21\李二狗浙江萧康医药有限公司萧山俊良诊所 XY6063121779350252 2026-05-21 15-57-32_2.pdf +[push] 2026/05/21 16:00:30 upload pdf ok prescription_no=XY6063121779350252 fileId=e9fc5fcb6d5c4f7a8b09c7f5c5c local=D:\worker\code\xk-hy-transit-go\pdf\2026-05-21\李二狗浙江萧康医药有限公司萧山俊良诊所 XY6063121779350252 2026-05-21 15-57-32_2.pdf url=http://127.0.0.1:16001/mng/file/auth/upload +[push] 2026/05/21 16:00:30 ok biz_key=recipe|2026-05-21|259|XY6063121779350252 method=uploadRecipeIndicators code=200 msgCode=0 trace= 13ms +======== END sync step=all date=2026-05-21 ok=true ======== diff --git a/scripts/integration-check.md b/scripts/integration-check.md new file mode 100644 index 0000000..55c0b23 --- /dev/null +++ b/scripts/integration-check.md @@ -0,0 +1,37 @@ +# 联调检查清单 + +## 1. xk-api 拉取(云端) + +```bash +curl -s -H "X-Hy-Transit-Token: YOUR_TOKEN" \ + "https://api.example.com/api/hy/supervise/config" + +curl -s -H "X-Hy-Transit-Token: YOUR_TOKEN" \ + "https://api.example.com/api/hy/supervise/consult?date=2026-05-18" +``` + +期望:`code=0`,`result.items[]` 含 `payload` 与 `validation_errors`。 + +## 2. forward-go(内网) + +```bash +curl http://127.0.0.1:8080/health +``` + +## 3. transit-go(外网 Windows) + +```bash +set XK_API_TOKEN=... +set HY_APP_KEY=... +go run ./cmd/transit --step=consult --date=2026-05-18 +``` + +检查本机 `xk_hy_transit.hy_push_record` / `hy_push_log`。 + +## 4. 与预览页对比 + +浏览器打开 `/hy-supervise-preview?key=xk888666&date=2026-05-18`,对比 consult/referral/recipe/verification 的 payload 字段与拉取接口一致。 + +## 5. 监管返回 + +`hy_push_log.msg_code=200` 表示业务成功。