初始化

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

20
.env Normal file
View File

@@ -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"

42
.env.example Normal file
View File

@@ -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
# truePDF 上传走 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 转 PDFchromedp / 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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.exe
dist/
pdf/
../log/

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/xk-hy-transit-go.iml" filepath="$PROJECT_DIR$/.idea/xk-hy-transit-go.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

9
.idea/xk-hy-transit-go.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

48
README.md Normal file
View File

@@ -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` 示例。

361
cmd/transit/main.go Normal file
View File

@@ -0,0 +1,361 @@
// 监管中转 CLI 入口。
//
// 三种启动方式:
// 1. 无参数 transit.exe → 交互菜单(运营推荐)
// 2. transit sync [...] → 单次同步后退出(脚本/计划任务)
// 3. transit serve → 常驻 + Cron 定时(默认每天 22:00
//
// 单次同步主流程见 internal/sync/sync.go 的 runStep①建批次→②拉取→③转发→④回调→⑤finish
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/robfig/cron/v3"
"xk-hy-transit-go/internal/applog"
"xk-hy-transit-go/internal/config"
"xk-hy-transit-go/internal/db"
"xk-hy-transit-go/internal/hyfile"
syncer "xk-hy-transit-go/internal/sync"
"xk-hy-transit-go/internal/xkapi"
)
func main() {
config.LoadEnvFiles()
config.WarnIfRequiredEnvMissing()
if err := applog.Init("transit"); err != nil {
log.Printf("applog init warning: %v", err)
}
if len(os.Args) < 2 {
if !isInteractiveTerminal() {
printUsage()
os.Exit(1)
}
runInteractiveMenu()
return
}
cmd := os.Args[1]
switch cmd {
case "sync":
runSync(os.Args[2:])
case "serve":
runServe()
case "pdf-test":
runPDFTest(os.Args[2:])
case "upload-test":
runUploadTest(os.Args[2:])
default:
// 兼容旧用法transit --step=all
if strings.HasPrefix(cmd, "-") {
runSync(os.Args[1:])
return
}
printUsage()
os.Exit(1)
}
}
// isInteractiveTerminal 判断 stdin 是否为控制台(避免 CI 卡在菜单)。
func isInteractiveTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
// runInteractiveMenu 无参数时展示中文菜单,供运营选择单次或定时。
func runInteractiveMenu() {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println()
fmt.Println("======== 互联网医院监管中转 ========")
fmt.Println(" 1) 单次执行(跑一轮后退出)")
fmt.Println(" 2) 定时执行(常驻,按 .env 中 CRON 每天跑)")
fmt.Println(" 0) 退出")
fmt.Print("请选择 [0-2]: ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
switch choice {
case "1":
runInteractiveSync(reader)
return
case "2":
runInteractiveServe(reader)
return
case "0", "":
fmt.Println("已退出。")
return
default:
fmt.Println("无效选项,请重新输入。")
}
}
}
func runInteractiveSync(reader *bufio.Reader) {
fmt.Print("step [consult|referral|recipe|verification|all默认 all]: ")
stepLine, _ := reader.ReadString('\n')
step := strings.TrimSpace(stepLine)
if step == "" {
step = "all"
}
fmt.Print("锚定日 date [YYYY-MM-DD回车=按 ANCHOR_OFFSET_DAYS 推算]: ")
dateLine, _ := reader.ReadString('\n')
date := strings.TrimSpace(dateLine)
fmt.Printf("即将执行单次同步 step=%s date=%s\n", step, displayDate(date))
fmt.Print("确认执行? [Y/n]: ")
confirm, _ := reader.ReadString('\n')
confirm = strings.TrimSpace(strings.ToLower(confirm))
if confirm == "n" || confirm == "no" {
fmt.Println("已取消。")
return
}
args := []string{"--step=" + step}
if date != "" {
args = append(args, "--date="+date)
}
runSync(args)
}
func runInteractiveServe(reader *bufio.Reader) {
cfg := config.Load()
expr := resolveCronExpr(cfg)
fmt.Println("当前定时配置:")
if strings.TrimSpace(os.Getenv("CRON_EXPR")) != "" {
fmt.Printf(" CRON_EXPR=%s\n", expr)
} else if strings.TrimSpace(cfg.ScheduleTime) != "" {
fmt.Printf(" SCHEDULE_TIME=%s → cron: %s\n", cfg.ScheduleTime, expr)
} else {
fmt.Printf(" 默认 cron: %s每天 22:00\n", expr)
}
fmt.Println("启动后进程将常驻,到点自动执行 step=all。")
fmt.Print("确认启动定时服务? [Y/n]: ")
confirm, _ := reader.ReadString('\n')
confirm = strings.TrimSpace(strings.ToLower(confirm))
if confirm == "n" || confirm == "no" {
fmt.Println("已取消。")
return
}
runServe()
}
func displayDate(date string) string {
if date == "" {
return "(自动:默认昨日)"
}
return date
}
func printUsage() {
fmt.Println(`用法:
transit 无参数进入交互菜单(运营推荐,需控制台)
transit sync [--step=all] [--date=YYYY-MM-DD] 单次同步
transit serve 定时常驻(默认每天 22:00
transit pdf-test [--prescription-id=N] 单独测 HTML→PDF 落盘
transit upload-test --pdf=PATH 单独测监管文件上传
transit --step=all [--date=...] 兼容旧单次参数
详见 docs/COMMANDS.md`)
}
// loadRunner 连接本机库并初始化同步执行器(会拉取云端机构配置)。
func loadRunner() (*syncer.Runner, config.Config) {
cfg := config.Load()
if cfg.XkAPIToken == "" || cfg.HyAppKey == "" || cfg.HyAppSecret == "" || cfg.HyAesKey == "" {
log.Fatal("缺少环境变量: XK_API_TOKEN, HY_APP_KEY, HY_APP_SECRET, HY_AES_KEY")
}
store, err := db.Open(cfg.MySQLDSN)
if err != nil {
log.Fatalf("数据库连接失败: %v", err)
}
runner, err := syncer.NewRunner(cfg, store)
if err != nil {
store.Close()
log.Fatalf("初始化失败: %v", err)
}
return runner, cfg
}
// runUploadTest 单独测试监管 Ver2.0 文件上传(与 recipe 同步使用相同 forward 路径)。
func runUploadTest(args []string) {
fs := flag.NewFlagSet("upload-test", flag.ExitOnError)
pdfPath := fs.String("pdf", "", "本地 PDF 文件路径")
_ = fs.Parse(args)
if strings.TrimSpace(*pdfPath) == "" {
log.Fatal("请指定 --pdf=本地PDF路径")
}
pdf, err := os.ReadFile(strings.TrimSpace(*pdfPath))
if err != nil {
log.Fatalf("读取 PDF 失败: %v", err)
}
runner, _ := loadRunner()
defer runner.StoreClose()
if runner.UploadToken() == "" {
log.Fatal("uploadToken 为空,请在 xk-api 配置 HY_APP_KEY / HY_APP_SECRET / HY_FILE_BUCKET")
}
uploadURL := runner.FileUploadURL()
if runner.FileUploadViaForward() {
log.Printf("upload-test: 经 forward 上传 url=%s", uploadURL)
}
fileID, err := hyfile.UploadPDF(pdf, filepath.Base(*pdfPath), uploadURL, runner.UploadToken())
if err != nil {
log.Fatalf("上传失败: %v", err)
}
log.Printf("upload ok fileId=%s url=%s via_forward=%v", fileID, uploadURL, runner.FileUploadViaForward())
}
// runPDFTest 不走上报链路,仅验证 chromedp 转 PDF 与本地落盘。
func runPDFTest(args []string) {
fs := flag.NewFlagSet("pdf-test", flag.ExitOnError)
prescriptionID := fs.Int("prescription-id", 0, "从 xk-api 拉取打印 HTML")
htmlFile := fs.String("html-file", "", "本地 HTML 文件路径")
patient := fs.String("patient", "测试患者", "落盘文件名中的患者名")
store := fs.String("store", "", "落盘文件名中的诊所名")
prescriptionNo := fs.String("prescription-no", "", "落盘文件名中的处方号")
_ = fs.Parse(args)
cfg := config.Load()
if cfg.XkAPIToken == "" {
log.Fatal("缺少环境变量 XK_API_TOKEN")
}
if p := strings.TrimSpace(cfg.ChromePath); p != "" {
hyfile.SetChromePath(p)
}
var html string
pdfName := hyfile.PDFNameInput{
PatientName: *patient,
StoreName: *store,
PrescriptionNo: *prescriptionNo,
}
switch {
case *prescriptionID > 0:
xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath)
detail, err := xk.GetPrescriptionPrintDetail(*prescriptionID)
if err != nil {
log.Fatalf("拉取处方 HTML 失败: %v", err)
}
html = detail.RecipeFileHTML
if detail.PatientName != "" {
pdfName.PatientName = detail.PatientName
}
if detail.StoreName != "" {
pdfName.StoreName = detail.StoreName
}
if detail.PrescriptionNo != "" {
pdfName.PrescriptionNo = detail.PrescriptionNo
}
if t := hyfile.ParseTimeString(detail.CreatedAt); !t.IsZero() {
pdfName.At = t
}
log.Printf("prescription_id=%d html_len=%d", *prescriptionID, len(html))
case strings.TrimSpace(*htmlFile) != "":
b, err := os.ReadFile(strings.TrimSpace(*htmlFile))
if err != nil {
log.Fatalf("读取 HTML 文件失败: %v", err)
}
html = string(b)
log.Printf("html_file=%s html_len=%d", *htmlFile, len(html))
default:
log.Fatal("请指定 --prescription-id 或 --html-file")
}
pdf, err := hyfile.HtmlToPDF(html)
if err != nil {
log.Fatalf("HtmlToPDF 失败: %v", err)
}
log.Printf("pdf bytes=%d save_root=%s", len(pdf), hyfile.ProgramDir())
saved, err := hyfile.SavePDFLocal(pdf, pdfName)
if err != nil {
log.Fatalf("SavePDFLocal 失败: %v", err)
}
log.Printf("saved local pdf: %s", saved)
}
// runSync 执行一轮或多轮 step 后退出。
func runSync(args []string) {
applog.SetQuietConsole(true)
fs := flag.NewFlagSet("sync", flag.ExitOnError)
step := fs.String("step", "all", "consult|referral|recipe|verification|all")
date := fs.String("date", "", "anchor date Y-m-d")
_ = fs.Parse(args)
runner, cfg := loadRunner()
defer runner.StoreClose()
anchor := syncer.ResolveAnchorDate(cfg, *date)
applog.RunSeparator(fmt.Sprintf("BEGIN sync step=%s date=%s", *step, anchor))
applog.Appf("sync run start step=%s date=%s", *step, anchor)
applog.Consolef("[同步] 开始 step=%s date=%s", *step, anchor)
start := time.Now()
if err := runner.Run(*step, anchor); err != nil {
applog.Consolef("[同步] 失败 step=%s date=%s err=%v", *step, anchor, err)
applog.Appf("sync failed step=%s date=%s err=%v", *step, anchor, err)
applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=false", *step, anchor))
os.Exit(1)
}
applog.Consolef("[同步] 结束 step=%s date=%s 耗时=%s", *step, anchor, time.Since(start).Round(time.Second))
applog.Appf("sync done step=%s date=%s duration=%s", *step, anchor, time.Since(start).Round(time.Second))
applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=true", *step, anchor))
}
// runServe 启动 Cron阻塞进程直至被终止。
func runServe() {
applog.SetQuietConsole(true)
runner, cfg := loadRunner()
defer runner.StoreClose()
expr := resolveCronExpr(cfg)
c := cron.New()
_, err := c.AddFunc(expr, func() {
anchor := syncer.ResolveAnchorDate(cfg, "")
applog.RunSeparator(fmt.Sprintf("BEGIN cron step=all date=%s cron=%s", anchor, expr))
applog.Appf("cron trigger step=all date=%s cron=%s", anchor, expr)
applog.Consolef("[定时] 开始 step=all date=%s", anchor)
start := time.Now()
runErr := runner.Run("all", anchor)
if runErr != nil {
applog.Consolef("[定时] 失败 date=%s err=%v", anchor, runErr)
applog.Appf("cron failed date=%s err=%v", anchor, runErr)
} else {
applog.Consolef("[定时] 结束 date=%s 耗时=%s", anchor, time.Since(start).Round(time.Second))
applog.Appf("cron done date=%s duration=%s", anchor, time.Since(start).Round(time.Second))
}
applog.RunSeparator(fmt.Sprintf("END cron step=all date=%s ok=%v", anchor, runErr == nil))
})
if err != nil {
log.Fatalf("cron 表达式无效 %q: %v", expr, err)
}
c.Start()
applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%sCtrl+C 结束进程)", expr, applog.Dir())
select {}
}
// resolveCronExpr 解析定时表达式CRON_EXPR 优先,其次 SCHEDULE_TIME最后默认 22:00。
func resolveCronExpr(cfg config.Config) string {
if strings.TrimSpace(os.Getenv("CRON_EXPR")) != "" {
return strings.TrimSpace(cfg.CronExpr)
}
if t := strings.TrimSpace(cfg.ScheduleTime); t != "" {
parts := strings.Split(t, ":")
if len(parts) == 2 {
return fmt.Sprintf("0 %s %s * * *", parts[1], parts[0])
}
}
if strings.TrimSpace(cfg.CronExpr) != "" {
return strings.TrimSpace(cfg.CronExpr)
}
return "0 22 * * *"
}

210
docs/COMMANDS.md Normal file
View File

@@ -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)(路径以实际部署为准)。

22
go.mod Normal file
View File

@@ -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
)

31
go.sum Normal file
View File

@@ -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=

190
internal/applog/applog.go Normal file
View File

@@ -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...)
}

89
internal/config/config.go Normal file
View File

@@ -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_TOKENHeader: 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 // truePDF 上传经 forward /mng/file/auth/upload不直连政务云
MySQLDSN string // 本机中转库 DSNhy_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
}

View File

@@ -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 "."
}

122
internal/db/db.go Normal file
View File

@@ -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/recordBatchCreate/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
}

153
internal/hy/crypto.go Normal file
View File

@@ -0,0 +1,153 @@
// Package hy 监管平台上报AES 加密、HMAC 签名、经 forward-go 转发。
//
// 与 xk-api / PHP HyApiClient 算法一致,保证政务云可解密验签。
// BuildUpload 产出 UploadRequestPostForward 发往内网 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
}

View File

@@ -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")
}
}

21
internal/hy/paths.go Normal file
View File

@@ -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
}

110
internal/hy/response.go Normal file
View File

@@ -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)
}

75
internal/hy/upload.go Normal file
View File

@@ -0,0 +1,75 @@
// Package hy 监管平台上报sync 步骤 ③)。
//
// BuildUpload合并机构字段 → AES 加密 body → HMAC 签名头。
// PostForwardPOST 到 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 == "<nil>" {
recipe = "-"
}
return fmt.Sprintf("%s|%s|%s|%s", step, anchorDate, buss, recipe)
}

View File

@@ -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")
}
}

149
internal/hyfile/local.go Normal file
View File

@@ -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)
}

196
internal/hyfile/pdf.go Normal file
View File

@@ -0,0 +1,196 @@
// Package hyfile 处方监管 PDFchromedp 将打印 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
}

View File

@@ -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 := `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><p>test</p></body></html>`
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))
}
}

131
internal/hyfile/upload.go Normal file
View File

@@ -0,0 +1,131 @@
// Package hyfile 处方监管 PDF 生成与上传。
//
// HtmlToPDFchromedp 将 xk-api 打印 HTML 转为 PDF。
// UploadPDFmultipart POSTHeader 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。
//
// uploadURLFILE_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]
}

View File

@@ -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)
}
}

430
internal/sync/sync.go Normal file
View File

@@ -0,0 +1,430 @@
// Package syncer 编排监管中转主流程(与 xk-api HyTransitRecordService 配合)。
//
// 每个 stepconsult/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
}
// 步骤 2chromedp 将监管打印 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 }

256
internal/xkapi/client.go Normal file
View File

@@ -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、uploadTokenPDF 上传地址由 transit 改走 forward
// GetPrescriptionPrintDetail → GET /api/hy/transit/prescription/detailrecipe 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)
}

View File

@@ -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 ========

View File

@@ -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 ========

View File

@@ -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 ========

View File

@@ -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 ========

View File

@@ -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 ========

View File

@@ -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 ========

View File

@@ -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` 表示业务成功。