初始化
This commit is contained in:
20
.env
Normal file
20
.env
Normal 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
42
.env.example
Normal 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
|
||||
|
||||
# true:PDF 上传走 FORWARD_BASE_URL/mng/file/auth/upload,不直连政务云 28211
|
||||
FILE_UPLOAD_VIA_FORWARD=true
|
||||
|
||||
# 本机中转库(先执行 sql/hy_transit_schema.sql);含 & 或 ? 时请加双引号
|
||||
MYSQL_DSN="root:password@tcp(127.0.0.1:3306)/xk_hy_transit?parseTime=true&charset=utf8mb4"
|
||||
|
||||
# 锚定日:默认推前 N 天(1=昨日)
|
||||
ANCHOR_OFFSET_DAYS=1
|
||||
|
||||
# 批量回调路径(一般无需改)
|
||||
XK_API_CALLBACK_PATH=/api/hy/transit/batch/callback
|
||||
|
||||
# 定时任务(serve 子命令):CRON_EXPR 优先于 SCHEDULE_TIME;含空格须加双引号
|
||||
CRON_EXPR="0 22 * * *"
|
||||
# SCHEDULE_TIME=22:00
|
||||
|
||||
# 处方 HTML 转 PDF(chromedp / headless Chrome,可选指定路径)
|
||||
# CHROME_PATH=C:\Program Files\Google\Chrome\Application\chrome.exe
|
||||
|
||||
# 本地 PDF 落盘:{程序目录}/pdf/{YYYY-MM-DD}/{患者姓名}{诊所名} {处方号} {YYYY-MM-DD HH-mm-ss}.pdf
|
||||
# 打包后为 exe 同级 pdf/;go run 时为当前工作目录下的 pdf/(请在 xk-hy-transit-go 下执行)
|
||||
|
||||
# 监管文件上传 HTTPS(政务网自签证书,默认 true)
|
||||
FILE_UPLOAD_INSECURE_SKIP_VERIFY=true
|
||||
|
||||
# 应用日志(默认 {程序目录}/log/transit/,含 pull/push/app 按日文件;每次执行有 BEGIN/END 分隔符)
|
||||
# LOG_DIR 可覆盖根目录,实际路径为 {LOG_DIR}/transit/
|
||||
# LOG_DIR=D:\worker\code\xk-hy-transit-go\log
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.exe
|
||||
dist/
|
||||
pdf/
|
||||
../log/
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
11
.idea/go.imports.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
9
.idea/xk-hy-transit-go.iml
generated
Normal 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
48
README.md
Normal 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
361
cmd/transit/main.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// 监管中转 CLI 入口。
|
||||
//
|
||||
// 三种启动方式:
|
||||
// 1. 无参数 transit.exe → 交互菜单(运营推荐)
|
||||
// 2. transit sync [...] → 单次同步后退出(脚本/计划任务)
|
||||
// 3. transit serve → 常驻 + Cron 定时(默认每天 22:00)
|
||||
//
|
||||
// 单次同步主流程见 internal/sync/sync.go 的 runStep(①建批次→②拉取→③转发→④回调→⑤finish)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"xk-hy-transit-go/internal/applog"
|
||||
"xk-hy-transit-go/internal/config"
|
||||
"xk-hy-transit-go/internal/db"
|
||||
"xk-hy-transit-go/internal/hyfile"
|
||||
syncer "xk-hy-transit-go/internal/sync"
|
||||
"xk-hy-transit-go/internal/xkapi"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config.LoadEnvFiles()
|
||||
config.WarnIfRequiredEnvMissing()
|
||||
if err := applog.Init("transit"); err != nil {
|
||||
log.Printf("applog init warning: %v", err)
|
||||
}
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
if !isInteractiveTerminal() {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
runInteractiveMenu()
|
||||
return
|
||||
}
|
||||
|
||||
cmd := os.Args[1]
|
||||
switch cmd {
|
||||
case "sync":
|
||||
runSync(os.Args[2:])
|
||||
case "serve":
|
||||
runServe()
|
||||
case "pdf-test":
|
||||
runPDFTest(os.Args[2:])
|
||||
case "upload-test":
|
||||
runUploadTest(os.Args[2:])
|
||||
default:
|
||||
// 兼容旧用法:transit --step=all
|
||||
if strings.HasPrefix(cmd, "-") {
|
||||
runSync(os.Args[1:])
|
||||
return
|
||||
}
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// isInteractiveTerminal 判断 stdin 是否为控制台(避免 CI 卡在菜单)。
|
||||
func isInteractiveTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// runInteractiveMenu 无参数时展示中文菜单,供运营选择单次或定时。
|
||||
func runInteractiveMenu() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Println()
|
||||
fmt.Println("======== 互联网医院监管中转 ========")
|
||||
fmt.Println(" 1) 单次执行(跑一轮后退出)")
|
||||
fmt.Println(" 2) 定时执行(常驻,按 .env 中 CRON 每天跑)")
|
||||
fmt.Println(" 0) 退出")
|
||||
fmt.Print("请选择 [0-2]: ")
|
||||
choice, _ := reader.ReadString('\n')
|
||||
choice = strings.TrimSpace(choice)
|
||||
switch choice {
|
||||
case "1":
|
||||
runInteractiveSync(reader)
|
||||
return
|
||||
case "2":
|
||||
runInteractiveServe(reader)
|
||||
return
|
||||
case "0", "":
|
||||
fmt.Println("已退出。")
|
||||
return
|
||||
default:
|
||||
fmt.Println("无效选项,请重新输入。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runInteractiveSync(reader *bufio.Reader) {
|
||||
fmt.Print("step [consult|referral|recipe|verification|all,默认 all]: ")
|
||||
stepLine, _ := reader.ReadString('\n')
|
||||
step := strings.TrimSpace(stepLine)
|
||||
if step == "" {
|
||||
step = "all"
|
||||
}
|
||||
fmt.Print("锚定日 date [YYYY-MM-DD,回车=按 ANCHOR_OFFSET_DAYS 推算]: ")
|
||||
dateLine, _ := reader.ReadString('\n')
|
||||
date := strings.TrimSpace(dateLine)
|
||||
fmt.Printf("即将执行单次同步 step=%s date=%s\n", step, displayDate(date))
|
||||
fmt.Print("确认执行? [Y/n]: ")
|
||||
confirm, _ := reader.ReadString('\n')
|
||||
confirm = strings.TrimSpace(strings.ToLower(confirm))
|
||||
if confirm == "n" || confirm == "no" {
|
||||
fmt.Println("已取消。")
|
||||
return
|
||||
}
|
||||
args := []string{"--step=" + step}
|
||||
if date != "" {
|
||||
args = append(args, "--date="+date)
|
||||
}
|
||||
runSync(args)
|
||||
}
|
||||
|
||||
func runInteractiveServe(reader *bufio.Reader) {
|
||||
cfg := config.Load()
|
||||
expr := resolveCronExpr(cfg)
|
||||
fmt.Println("当前定时配置:")
|
||||
if strings.TrimSpace(os.Getenv("CRON_EXPR")) != "" {
|
||||
fmt.Printf(" CRON_EXPR=%s\n", expr)
|
||||
} else if strings.TrimSpace(cfg.ScheduleTime) != "" {
|
||||
fmt.Printf(" SCHEDULE_TIME=%s → cron: %s\n", cfg.ScheduleTime, expr)
|
||||
} else {
|
||||
fmt.Printf(" 默认 cron: %s(每天 22:00)\n", expr)
|
||||
}
|
||||
fmt.Println("启动后进程将常驻,到点自动执行 step=all。")
|
||||
fmt.Print("确认启动定时服务? [Y/n]: ")
|
||||
confirm, _ := reader.ReadString('\n')
|
||||
confirm = strings.TrimSpace(strings.ToLower(confirm))
|
||||
if confirm == "n" || confirm == "no" {
|
||||
fmt.Println("已取消。")
|
||||
return
|
||||
}
|
||||
runServe()
|
||||
}
|
||||
|
||||
func displayDate(date string) string {
|
||||
if date == "" {
|
||||
return "(自动:默认昨日)"
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`用法:
|
||||
transit 无参数进入交互菜单(运营推荐,需控制台)
|
||||
transit sync [--step=all] [--date=YYYY-MM-DD] 单次同步
|
||||
transit serve 定时常驻(默认每天 22:00)
|
||||
transit pdf-test [--prescription-id=N] 单独测 HTML→PDF 落盘
|
||||
transit upload-test --pdf=PATH 单独测监管文件上传
|
||||
transit --step=all [--date=...] 兼容旧单次参数
|
||||
|
||||
详见 docs/COMMANDS.md`)
|
||||
}
|
||||
|
||||
// loadRunner 连接本机库并初始化同步执行器(会拉取云端机构配置)。
|
||||
func loadRunner() (*syncer.Runner, config.Config) {
|
||||
cfg := config.Load()
|
||||
if cfg.XkAPIToken == "" || cfg.HyAppKey == "" || cfg.HyAppSecret == "" || cfg.HyAesKey == "" {
|
||||
log.Fatal("缺少环境变量: XK_API_TOKEN, HY_APP_KEY, HY_APP_SECRET, HY_AES_KEY")
|
||||
}
|
||||
store, err := db.Open(cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库连接失败: %v", err)
|
||||
}
|
||||
runner, err := syncer.NewRunner(cfg, store)
|
||||
if err != nil {
|
||||
store.Close()
|
||||
log.Fatalf("初始化失败: %v", err)
|
||||
}
|
||||
return runner, cfg
|
||||
}
|
||||
|
||||
// runUploadTest 单独测试监管 Ver2.0 文件上传(与 recipe 同步使用相同 forward 路径)。
|
||||
func runUploadTest(args []string) {
|
||||
fs := flag.NewFlagSet("upload-test", flag.ExitOnError)
|
||||
pdfPath := fs.String("pdf", "", "本地 PDF 文件路径")
|
||||
_ = fs.Parse(args)
|
||||
if strings.TrimSpace(*pdfPath) == "" {
|
||||
log.Fatal("请指定 --pdf=本地PDF路径")
|
||||
}
|
||||
pdf, err := os.ReadFile(strings.TrimSpace(*pdfPath))
|
||||
if err != nil {
|
||||
log.Fatalf("读取 PDF 失败: %v", err)
|
||||
}
|
||||
runner, _ := loadRunner()
|
||||
defer runner.StoreClose()
|
||||
if runner.UploadToken() == "" {
|
||||
log.Fatal("uploadToken 为空,请在 xk-api 配置 HY_APP_KEY / HY_APP_SECRET / HY_FILE_BUCKET")
|
||||
}
|
||||
uploadURL := runner.FileUploadURL()
|
||||
if runner.FileUploadViaForward() {
|
||||
log.Printf("upload-test: 经 forward 上传 url=%s", uploadURL)
|
||||
}
|
||||
fileID, err := hyfile.UploadPDF(pdf, filepath.Base(*pdfPath), uploadURL, runner.UploadToken())
|
||||
if err != nil {
|
||||
log.Fatalf("上传失败: %v", err)
|
||||
}
|
||||
log.Printf("upload ok fileId=%s url=%s via_forward=%v", fileID, uploadURL, runner.FileUploadViaForward())
|
||||
}
|
||||
|
||||
// runPDFTest 不走上报链路,仅验证 chromedp 转 PDF 与本地落盘。
|
||||
func runPDFTest(args []string) {
|
||||
fs := flag.NewFlagSet("pdf-test", flag.ExitOnError)
|
||||
prescriptionID := fs.Int("prescription-id", 0, "从 xk-api 拉取打印 HTML")
|
||||
htmlFile := fs.String("html-file", "", "本地 HTML 文件路径")
|
||||
patient := fs.String("patient", "测试患者", "落盘文件名中的患者名")
|
||||
store := fs.String("store", "", "落盘文件名中的诊所名")
|
||||
prescriptionNo := fs.String("prescription-no", "", "落盘文件名中的处方号")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
cfg := config.Load()
|
||||
if cfg.XkAPIToken == "" {
|
||||
log.Fatal("缺少环境变量 XK_API_TOKEN")
|
||||
}
|
||||
if p := strings.TrimSpace(cfg.ChromePath); p != "" {
|
||||
hyfile.SetChromePath(p)
|
||||
}
|
||||
|
||||
var html string
|
||||
pdfName := hyfile.PDFNameInput{
|
||||
PatientName: *patient,
|
||||
StoreName: *store,
|
||||
PrescriptionNo: *prescriptionNo,
|
||||
}
|
||||
switch {
|
||||
case *prescriptionID > 0:
|
||||
xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath)
|
||||
detail, err := xk.GetPrescriptionPrintDetail(*prescriptionID)
|
||||
if err != nil {
|
||||
log.Fatalf("拉取处方 HTML 失败: %v", err)
|
||||
}
|
||||
html = detail.RecipeFileHTML
|
||||
if detail.PatientName != "" {
|
||||
pdfName.PatientName = detail.PatientName
|
||||
}
|
||||
if detail.StoreName != "" {
|
||||
pdfName.StoreName = detail.StoreName
|
||||
}
|
||||
if detail.PrescriptionNo != "" {
|
||||
pdfName.PrescriptionNo = detail.PrescriptionNo
|
||||
}
|
||||
if t := hyfile.ParseTimeString(detail.CreatedAt); !t.IsZero() {
|
||||
pdfName.At = t
|
||||
}
|
||||
log.Printf("prescription_id=%d html_len=%d", *prescriptionID, len(html))
|
||||
case strings.TrimSpace(*htmlFile) != "":
|
||||
b, err := os.ReadFile(strings.TrimSpace(*htmlFile))
|
||||
if err != nil {
|
||||
log.Fatalf("读取 HTML 文件失败: %v", err)
|
||||
}
|
||||
html = string(b)
|
||||
log.Printf("html_file=%s html_len=%d", *htmlFile, len(html))
|
||||
default:
|
||||
log.Fatal("请指定 --prescription-id 或 --html-file")
|
||||
}
|
||||
|
||||
pdf, err := hyfile.HtmlToPDF(html)
|
||||
if err != nil {
|
||||
log.Fatalf("HtmlToPDF 失败: %v", err)
|
||||
}
|
||||
log.Printf("pdf bytes=%d save_root=%s", len(pdf), hyfile.ProgramDir())
|
||||
saved, err := hyfile.SavePDFLocal(pdf, pdfName)
|
||||
if err != nil {
|
||||
log.Fatalf("SavePDFLocal 失败: %v", err)
|
||||
}
|
||||
log.Printf("saved local pdf: %s", saved)
|
||||
}
|
||||
|
||||
// runSync 执行一轮或多轮 step 后退出。
|
||||
func runSync(args []string) {
|
||||
applog.SetQuietConsole(true)
|
||||
|
||||
fs := flag.NewFlagSet("sync", flag.ExitOnError)
|
||||
step := fs.String("step", "all", "consult|referral|recipe|verification|all")
|
||||
date := fs.String("date", "", "anchor date Y-m-d")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
runner, cfg := loadRunner()
|
||||
defer runner.StoreClose()
|
||||
|
||||
anchor := syncer.ResolveAnchorDate(cfg, *date)
|
||||
applog.RunSeparator(fmt.Sprintf("BEGIN sync step=%s date=%s", *step, anchor))
|
||||
applog.Appf("sync run start step=%s date=%s", *step, anchor)
|
||||
applog.Consolef("[同步] 开始 step=%s date=%s", *step, anchor)
|
||||
start := time.Now()
|
||||
if err := runner.Run(*step, anchor); err != nil {
|
||||
applog.Consolef("[同步] 失败 step=%s date=%s err=%v", *step, anchor, err)
|
||||
applog.Appf("sync failed step=%s date=%s err=%v", *step, anchor, err)
|
||||
applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=false", *step, anchor))
|
||||
os.Exit(1)
|
||||
}
|
||||
applog.Consolef("[同步] 结束 step=%s date=%s 耗时=%s", *step, anchor, time.Since(start).Round(time.Second))
|
||||
applog.Appf("sync done step=%s date=%s duration=%s", *step, anchor, time.Since(start).Round(time.Second))
|
||||
applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=true", *step, anchor))
|
||||
}
|
||||
|
||||
// runServe 启动 Cron,阻塞进程直至被终止。
|
||||
func runServe() {
|
||||
applog.SetQuietConsole(true)
|
||||
|
||||
runner, cfg := loadRunner()
|
||||
defer runner.StoreClose()
|
||||
|
||||
expr := resolveCronExpr(cfg)
|
||||
c := cron.New()
|
||||
_, err := c.AddFunc(expr, func() {
|
||||
anchor := syncer.ResolveAnchorDate(cfg, "")
|
||||
applog.RunSeparator(fmt.Sprintf("BEGIN cron step=all date=%s cron=%s", anchor, expr))
|
||||
applog.Appf("cron trigger step=all date=%s cron=%s", anchor, expr)
|
||||
applog.Consolef("[定时] 开始 step=all date=%s", anchor)
|
||||
start := time.Now()
|
||||
runErr := runner.Run("all", anchor)
|
||||
if runErr != nil {
|
||||
applog.Consolef("[定时] 失败 date=%s err=%v", anchor, runErr)
|
||||
applog.Appf("cron failed date=%s err=%v", anchor, runErr)
|
||||
} else {
|
||||
applog.Consolef("[定时] 结束 date=%s 耗时=%s", anchor, time.Since(start).Round(time.Second))
|
||||
applog.Appf("cron done date=%s duration=%s", anchor, time.Since(start).Round(time.Second))
|
||||
}
|
||||
applog.RunSeparator(fmt.Sprintf("END cron step=all date=%s ok=%v", anchor, runErr == nil))
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("cron 表达式无效 %q: %v", expr, err)
|
||||
}
|
||||
c.Start()
|
||||
applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%s(Ctrl+C 结束进程)", expr, applog.Dir())
|
||||
select {}
|
||||
}
|
||||
|
||||
// resolveCronExpr 解析定时表达式:CRON_EXPR 优先,其次 SCHEDULE_TIME,最后默认 22:00。
|
||||
func resolveCronExpr(cfg config.Config) string {
|
||||
if strings.TrimSpace(os.Getenv("CRON_EXPR")) != "" {
|
||||
return strings.TrimSpace(cfg.CronExpr)
|
||||
}
|
||||
if t := strings.TrimSpace(cfg.ScheduleTime); t != "" {
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) == 2 {
|
||||
return fmt.Sprintf("0 %s %s * * *", parts[1], parts[0])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.CronExpr) != "" {
|
||||
return strings.TrimSpace(cfg.CronExpr)
|
||||
}
|
||||
return "0 22 * * *"
|
||||
}
|
||||
210
docs/COMMANDS.md
Normal file
210
docs/COMMANDS.md
Normal 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
22
go.mod
Normal 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
31
go.sum
Normal 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
190
internal/applog/applog.go
Normal 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
89
internal/config/config.go
Normal 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_TOKEN,Header: X-Hy-Transit-Token
|
||||
HyAppKey string // 监管平台 AppKey(加密上报)
|
||||
HyAppSecret string // 监管平台 Secret(签名)
|
||||
HyAesKey string // 监管平台 AES 密钥(body 加密)
|
||||
ForwardBaseURL string // xk-hy-forward-go 地址,如 http://192.168.1.10:8080
|
||||
FileUploadViaForward bool // true:PDF 上传经 forward /mng/file/auth/upload,不直连政务云
|
||||
MySQLDSN string // 本机中转库 DSN(hy_transit_schema.sql)
|
||||
AnchorOffsetDays int // 锚定日 = 今天 - N 天,默认 1
|
||||
XkAPICallbackPath string // 批量回调路径,默认 /api/hy/transit/batch/callback
|
||||
CronExpr string // serve 用,默认 0 22 * * *
|
||||
ScheduleTime string // HH:MM,仅当未设 CRON_EXPR 时转为 cron
|
||||
ChromePath string // 可选:Chrome/Edge 路径,未设则自动探测
|
||||
FileUploadInsecureSkipVerify bool // 文件上传 HTTPS 跳过证书校验(政务网)
|
||||
}
|
||||
|
||||
// Load 读取环境变量;未设置时使用默认值。
|
||||
func Load() Config {
|
||||
return Config{
|
||||
XkAPIBaseURL: env("XK_API_BASE_URL", "https://api.xiaokang88.com"),
|
||||
XkAPIToken: env("XK_API_TOKEN", ""),
|
||||
HyAppKey: env("HY_APP_KEY", ""),
|
||||
HyAppSecret: env("HY_APP_SECRET", ""),
|
||||
HyAesKey: env("HY_AES_KEY", ""),
|
||||
ForwardBaseURL: strings.TrimRight(env("FORWARD_BASE_URL", "http://127.0.0.1:8080"), "/"),
|
||||
FileUploadViaForward: envBool("FILE_UPLOAD_VIA_FORWARD", true),
|
||||
MySQLDSN: env("MYSQL_DSN", "root:password@tcp(127.0.0.1:3306)/xk_hy_transit?parseTime=true&charset=utf8mb4"),
|
||||
AnchorOffsetDays: envInt("ANCHOR_OFFSET_DAYS", 1),
|
||||
XkAPICallbackPath: env("XK_API_CALLBACK_PATH", "/api/hy/transit/batch/callback"),
|
||||
CronExpr: env("CRON_EXPR", "*/2 * * * *"),
|
||||
ScheduleTime: env("SCHEDULE_TIME", ""),
|
||||
ChromePath: env("CHROME_PATH", ""),
|
||||
FileUploadInsecureSkipVerify: envBool("FILE_UPLOAD_INSECURE_SKIP_VERIFY", true),
|
||||
}
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
switch v {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
81
internal/config/envfile.go
Normal file
81
internal/config/envfile.go
Normal 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
122
internal/db/db.go
Normal 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/record;BatchCreate/Pull/Callback 仍以 xk-api 为准。
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// Store 本机中转库连接。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open 连接 MySQL 并 Ping。
|
||||
func Open(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(10)
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close 关闭连接。
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// UpsertJob 写入/更新 hy_sync_job(按 anchor_date + step 维度汇总一次任务)。
|
||||
func (s *Store) UpsertJob(anchorDate, step, status string, total, success, failed int, errMsg string) (int64, error) {
|
||||
res, err := s.db.Exec(`
|
||||
INSERT INTO hy_sync_job (anchor_date, step, status, total_count, success_count, failed_count, error_message, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status=VALUES(status),
|
||||
total_count=VALUES(total_count),
|
||||
success_count=VALUES(success_count),
|
||||
failed_count=VALUES(failed_count),
|
||||
error_message=VALUES(error_message),
|
||||
updated_at=NOW()`,
|
||||
anchorDate, step, status, total, success, failed, errMsg,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// PushRecord 本机推送记录行(查询用)。
|
||||
type PushRecord struct {
|
||||
ID int64
|
||||
BizKey string
|
||||
PushStatus string
|
||||
ValidationErrors []string
|
||||
}
|
||||
|
||||
// SaveRecord 写入 hy_push_record;有 validationErrors 时 push_status=skipped。
|
||||
func (s *Store) SaveRecord(jobID int64, anchorDate, method, serviceMethod, bussID, bizKey, payloadHash, payloadJSON string, validationErrors []string) error {
|
||||
ve, _ := json.Marshal(validationErrors)
|
||||
status := "pending"
|
||||
if len(validationErrors) > 0 {
|
||||
status = "skipped"
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hy_push_record (job_id, anchor_date, method, service_method, buss_id, biz_key, payload_hash, payload_json, validation_errors, push_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
payload_json=VALUES(payload_json),
|
||||
validation_errors=VALUES(validation_errors),
|
||||
push_status=IF(VALUES(validation_errors) IS NOT NULL AND JSON_LENGTH(VALUES(validation_errors))>0, 'skipped', push_status),
|
||||
updated_at=NOW()`,
|
||||
jobID, anchorDate, method, serviceMethod, bussID, bizKey, payloadHash, payloadJSON, string(ve), status,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateRecordStatus 更新本机推送结果。
|
||||
func (s *Store) UpdateRecordStatus(bizKey, status, lastError string) error {
|
||||
_, err := s.db.Exec(`UPDATE hy_push_record SET push_status=?, last_error=?, updated_at=NOW() WHERE biz_key=?`, status, lastError, bizKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// IncRetry 增加重试计数。
|
||||
func (s *Store) IncRetry(bizKey string) error {
|
||||
_, err := s.db.Exec(`UPDATE hy_push_record SET retry_count=retry_count+1, updated_at=NOW() WHERE biz_key=?`, bizKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveLog 写入单次 HTTP 上报日志 hy_push_log。
|
||||
func (s *Store) SaveLog(recordID int64, httpCode, msgCode int, msg, traceID string, bodyLen int, responseBody string, durationMs int) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hy_push_log (record_id, http_code, msg_code, msg, trace_id, request_body_len, response_body, duration_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
recordID, httpCode, msgCode, msg, traceID, bodyLen, responseBody, durationMs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRecordID 按 biz_key 查本机记录 id。
|
||||
func (s *Store) GetRecordID(bizKey string) (int64, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRow(`SELECT id FROM hy_push_record WHERE biz_key=?`, bizKey).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// FinishJob 标记 hy_sync_job 结束。
|
||||
func (s *Store) FinishJob(anchorDate, step, status string) error {
|
||||
_, err := s.db.Exec(`UPDATE hy_sync_job SET status=?, finished_at=? WHERE anchor_date=? AND step=?`, status, time.Now(), anchorDate, step)
|
||||
return err
|
||||
}
|
||||
153
internal/hy/crypto.go
Normal file
153
internal/hy/crypto.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package hy 监管平台上报:AES 加密、HMAC 签名、经 forward-go 转发。
|
||||
//
|
||||
// 与 xk-api / PHP HyApiClient 算法一致,保证政务云可解密验签。
|
||||
// BuildUpload 产出 UploadRequest;PostForward 发往内网 forward /province/supervise/data。
|
||||
package hy
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var signHeaderOrder = []string{
|
||||
"requestBody",
|
||||
"secret",
|
||||
"X-Ca-Appkey",
|
||||
"X-Ca-Encryption",
|
||||
"X-Ca-Nonce",
|
||||
"X-Ca-Timestamp",
|
||||
"X-Service-Id",
|
||||
"X-Service-Method",
|
||||
}
|
||||
|
||||
// EncryptBody AES-128-ECB + PKCS7,与 PHP AesUtilService 一致
|
||||
func EncryptBody(plain string, key string) (string, error) {
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := pkcs7Pad([]byte(plain), block.BlockSize())
|
||||
out := make([]byte, len(data))
|
||||
ecb := newECBEncrypter(block)
|
||||
ecb.CryptBlocks(out, data)
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - (len(data) % blockSize)
|
||||
padding := make([]byte, pad)
|
||||
for i := range padding {
|
||||
padding[i] = byte(pad)
|
||||
}
|
||||
return append(data, padding...)
|
||||
}
|
||||
|
||||
type ecbEncrypter struct {
|
||||
b cipher.Block
|
||||
blockSize int
|
||||
}
|
||||
|
||||
func newECBEncrypter(b cipher.Block) *ecbEncrypter {
|
||||
return &ecbEncrypter{b: b, blockSize: b.BlockSize()}
|
||||
}
|
||||
|
||||
func (x *ecbEncrypter) CryptBlocks(dst, src []byte) {
|
||||
if len(src)%x.blockSize != 0 {
|
||||
panic("invalid padding")
|
||||
}
|
||||
for len(src) > 0 {
|
||||
x.b.Encrypt(dst, src[:x.blockSize])
|
||||
src = src[x.blockSize:]
|
||||
dst = dst[x.blockSize:]
|
||||
}
|
||||
}
|
||||
|
||||
func Sign(secret string, headers map[string]string) string {
|
||||
parts := make([]string, 0, len(signHeaderOrder))
|
||||
for _, k := range signHeaderOrder {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, headers[k]))
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(strings.Join(parts, "&")))
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
Headers map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
// BuildUpload 组包监管上报请求:合并机构字段 → JSON → AES 加密 → HMAC 签名头。
|
||||
func BuildUpload(
|
||||
serviceMethod string,
|
||||
payload map[string]any,
|
||||
organ OrganConfig,
|
||||
appKey, appSecret, aesKey string,
|
||||
) (*UploadRequest, error) {
|
||||
merged := make(map[string]any, len(payload)+4)
|
||||
for k, v := range payload {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["unitID"] = organ.UnitID
|
||||
merged["organID"] = organ.OrganID
|
||||
merged["organName"] = organ.OrganName
|
||||
|
||||
raw, err := json.Marshal([]map[string]any{merged})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted, err := EncryptBody(string(raw), aesKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
nonce := randomNonce()
|
||||
headers := map[string]string{
|
||||
"secret": appSecret,
|
||||
"X-Ca-Appkey": appKey,
|
||||
"X-Ca-Encryption": "AES",
|
||||
"X-Ca-Nonce": nonce,
|
||||
"X-Ca-Timestamp": ts,
|
||||
"X-Service-Id": "his.provinceDataUploadService",
|
||||
"X-Service-Method": serviceMethod,
|
||||
"requestBody": encrypted,
|
||||
}
|
||||
sig := Sign(appSecret, headers)
|
||||
headers["X-Ca-Signature"] = sig
|
||||
delete(headers, "secret")
|
||||
|
||||
return &UploadRequest{Headers: headers, Body: encrypted}, nil
|
||||
}
|
||||
|
||||
type OrganConfig struct {
|
||||
UnitID string `json:"unitID"`
|
||||
OrganID string `json:"organID"`
|
||||
OrganName string `json:"organName"`
|
||||
}
|
||||
|
||||
func randomNonce() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// SortedHeaderKeys for debug only
|
||||
func SortedHeaderKeys(h map[string]string) []string {
|
||||
keys := make([]string, 0, len(h))
|
||||
for k := range h {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
40
internal/hy/crypto_test.go
Normal file
40
internal/hy/crypto_test.go
Normal 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
21
internal/hy/paths.go
Normal 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
110
internal/hy/response.go
Normal 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
75
internal/hy/upload.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Package hy 监管平台上报(sync 步骤 ③)。
|
||||
//
|
||||
// BuildUpload:合并机构字段 → AES 加密 body → HMAC 签名头。
|
||||
// PostForward:POST 到 forward-go 的 /province/supervise/data,由 forward-go 透明转发政务云。
|
||||
// BizKey:与云端 HyTransitRecordService::makeBizKey 规则一致,用于幂等。
|
||||
package hy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PostForward 将加密后的监管报文 POST 到内网 forward-go,由 forward 透明转发至政务云 28212。
|
||||
func PostForward(forwardBaseURL string, req *UploadRequest) (*UploadResult, error) {
|
||||
url := ForwardSuperviseURL(forwardBaseURL)
|
||||
httpReq, err := http.NewRequest(http.MethodPost, url, strings.NewReader(req.Body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 经内网 forward 访问
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
body := string(bodyBytes)
|
||||
|
||||
result := &UploadResult{
|
||||
HTTPCode: resp.StatusCode,
|
||||
Body: body,
|
||||
TraceID: resp.Header.Get("X-Ca-Request-Id"),
|
||||
}
|
||||
if result.TraceID == "" {
|
||||
result.TraceID = resp.Header.Get("x-ca-request-id")
|
||||
}
|
||||
|
||||
gw, mc, msg, hasMC := ParseSuperviseResponse(bodyBytes)
|
||||
result.GatewayCode = gw
|
||||
result.MsgCode = mc
|
||||
result.Msg = msg
|
||||
result.HasMsgCode = hasMC
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var StepMethods = map[string]string{
|
||||
"consult": "uploadConsultIndicators",
|
||||
"referral": "uploadReferralIndicators",
|
||||
"recipe": "uploadRecipeIndicators",
|
||||
"verification": "uploadRecipeVerificationIndicators",
|
||||
}
|
||||
|
||||
func BizKey(step, anchorDate string, payload map[string]any) string {
|
||||
buss := fmt.Sprintf("%v", payload["bussID"])
|
||||
recipe := fmt.Sprintf("%v", payload["recipeUniqueID"])
|
||||
if recipe == "" || recipe == "<nil>" {
|
||||
recipe = "-"
|
||||
}
|
||||
return fmt.Sprintf("%s|%s|%s|%s", step, anchorDate, buss, recipe)
|
||||
}
|
||||
64
internal/hy/upload_test.go
Normal file
64
internal/hy/upload_test.go
Normal 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
149
internal/hyfile/local.go
Normal 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
196
internal/hyfile/pdf.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Package hyfile 处方监管 PDF:chromedp 将打印 HTML 转为 PDF,并经 forward 上传政务云。
|
||||
package hyfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"encoding/base64"
|
||||
|
||||
"fmt"
|
||||
|
||||
"os"
|
||||
|
||||
"strings"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"xk-hy-transit-go/internal/applog"
|
||||
)
|
||||
|
||||
var pdfChromePath string
|
||||
|
||||
// SetChromePath 可选:指定 Chrome/Edge 可执行文件路径(环境变量 CHROME_PATH 亦可)。
|
||||
|
||||
func SetChromePath(path string) {
|
||||
|
||||
pdfChromePath = strings.TrimSpace(path)
|
||||
|
||||
}
|
||||
|
||||
func envChromePath() string {
|
||||
|
||||
if pdfChromePath != "" {
|
||||
|
||||
return pdfChromePath
|
||||
|
||||
}
|
||||
|
||||
return strings.TrimSpace(os.Getenv("CHROME_PATH"))
|
||||
|
||||
}
|
||||
|
||||
func resolveChromePath() string {
|
||||
|
||||
if p := envChromePath(); p != "" {
|
||||
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
|
||||
return p
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
|
||||
`C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
||||
|
||||
`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
||||
|
||||
`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
||||
|
||||
`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
|
||||
return c
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return ""
|
||||
|
||||
}
|
||||
|
||||
func chromedpAllocatorOpts() []chromedp.ExecAllocatorOption {
|
||||
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
|
||||
chromedp.Flag("headless", true),
|
||||
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
|
||||
chromedp.Flag("allow-file-access-from-files", true),
|
||||
)
|
||||
|
||||
if p := resolveChromePath(); p != "" {
|
||||
|
||||
opts = append(opts, chromedp.ExecPath(p))
|
||||
|
||||
}
|
||||
|
||||
return opts
|
||||
|
||||
}
|
||||
|
||||
func htmlDataURL(html string) string {
|
||||
|
||||
return "data:text/html;charset=utf-8;base64," + base64.StdEncoding.EncodeToString([]byte(html))
|
||||
|
||||
}
|
||||
|
||||
// HtmlToPDF 使用 headless Chrome 将完整 HTML 文档转为 PDF。
|
||||
|
||||
// 每次调用独立启动浏览器,避免复用 context 导致 context canceled。
|
||||
|
||||
func HtmlToPDF(html string) ([]byte, error) {
|
||||
|
||||
html = strings.TrimSpace(html)
|
||||
|
||||
if html == "" {
|
||||
|
||||
return nil, fmt.Errorf("empty html")
|
||||
|
||||
}
|
||||
|
||||
opts := chromedpAllocatorOpts()
|
||||
|
||||
if p := resolveChromePath(); p != "" {
|
||||
|
||||
applog.Pushf("chromedp: using browser %s", p)
|
||||
|
||||
} else {
|
||||
|
||||
applog.Pushf("chromedp: CHROME_PATH unset, using chromedp default browser lookup")
|
||||
|
||||
}
|
||||
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
|
||||
defer allocCancel()
|
||||
|
||||
browserCtx, browserCancel := chromedp.NewContext(allocCtx)
|
||||
|
||||
defer browserCancel()
|
||||
|
||||
runCtx, runCancel := context.WithTimeout(browserCtx, 120*time.Second)
|
||||
|
||||
defer runCancel()
|
||||
|
||||
dataURL := htmlDataURL(html)
|
||||
|
||||
applog.Pushf("chromedp: html_len=%d data_url_len=%d", len(html), len(dataURL))
|
||||
|
||||
var pdf []byte
|
||||
|
||||
err := chromedp.Run(runCtx,
|
||||
|
||||
chromedp.Navigate(dataURL),
|
||||
|
||||
chromedp.WaitReady("body"),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
|
||||
var printErr error
|
||||
|
||||
pdf, _, printErr = page.PrintToPDF().
|
||||
WithPrintBackground(true).
|
||||
WithPreferCSSPageSize(true).
|
||||
Do(ctx)
|
||||
|
||||
return printErr
|
||||
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
||||
return nil, fmt.Errorf("chromedp: %w", err)
|
||||
|
||||
}
|
||||
|
||||
if len(pdf) == 0 {
|
||||
|
||||
return nil, fmt.Errorf("chromedp: empty pdf output")
|
||||
|
||||
}
|
||||
|
||||
applog.Pushf("chromedp: pdf bytes=%d", len(pdf))
|
||||
|
||||
return pdf, nil
|
||||
|
||||
}
|
||||
21
internal/hyfile/pdf_test.go
Normal file
21
internal/hyfile/pdf_test.go
Normal 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
131
internal/hyfile/upload.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package hyfile 处方监管 PDF 生成与上传。
|
||||
//
|
||||
// HtmlToPDF:chromedp 将 xk-api 打印 HTML 转为 PDF。
|
||||
// UploadPDF:multipart POST,Header X-Authorization 为 uploadToken;
|
||||
// 默认请求地址为 forward-go /mng/file/auth/upload,由 forward 转发至政务云 28211。
|
||||
package hyfile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
uploadInsecureSkipVerify bool
|
||||
uploadMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetUploadInsecureSkipVerify 政务网 HTTPS 证书校验(与 forward-go 一致,由配置注入)。
|
||||
func SetUploadInsecureSkipVerify(skip bool) {
|
||||
uploadMu.Lock()
|
||||
uploadInsecureSkipVerify = skip
|
||||
defer uploadMu.Unlock()
|
||||
}
|
||||
|
||||
func uploadTLSConfig() *tls.Config {
|
||||
uploadMu.RLock()
|
||||
skip := uploadInsecureSkipVerify
|
||||
uploadMu.RUnlock()
|
||||
if !skip {
|
||||
return nil
|
||||
}
|
||||
return &tls.Config{InsecureSkipVerify: true} //nolint:gosec // 政务网自签证书
|
||||
}
|
||||
|
||||
func uploadHTTPClient() *http.Client {
|
||||
transport := &http.Transport{}
|
||||
if cfg := uploadTLSConfig(); cfg != nil {
|
||||
transport.TLSClientConfig = cfg
|
||||
}
|
||||
return &http.Client{Timeout: 120 * time.Second, Transport: transport}
|
||||
}
|
||||
|
||||
// UploadPDF 上传 PDF 至监管文件服务(通常经 forward-go),成功时返回 record.fileId。
|
||||
//
|
||||
// uploadURL:FILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。
|
||||
// uploadToken:由 xk-api HY_APP_KEY/HY_APP_SECRET + HY_FILE_BUCKET 签名生成,放在 X-Authorization 请求头。
|
||||
func UploadPDF(pdf []byte, filename, uploadURL, uploadToken string) (string, error) {
|
||||
if len(pdf) == 0 {
|
||||
return "", fmt.Errorf("empty pdf")
|
||||
}
|
||||
if uploadURL == "" {
|
||||
return "", fmt.Errorf("file upload url empty")
|
||||
}
|
||||
if uploadToken == "" {
|
||||
return "", fmt.Errorf("upload token empty")
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "prescription.pdf"
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(pdf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, uploadURL, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-Authorization", uploadToken)
|
||||
|
||||
resp, err := uploadHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return "", fmt.Errorf("上传凭证无效或过期 (http 403)")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("upload http %d: %s", resp.StatusCode, truncate(raw, 512))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Success bool `json:"success"`
|
||||
Record struct {
|
||||
FileID string `json:"fileId"`
|
||||
} `json:"record"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return "", fmt.Errorf("parse upload response: %w", err)
|
||||
}
|
||||
if !parsed.Success {
|
||||
msg := parsed.Message
|
||||
if msg == "" {
|
||||
msg = string(truncate(raw, 512))
|
||||
}
|
||||
return "", fmt.Errorf("upload failed: %s", msg)
|
||||
}
|
||||
fileID := parsed.Record.FileID
|
||||
if fileID == "" {
|
||||
return "", fmt.Errorf("upload response missing record.fileId: %s", truncate(raw, 512))
|
||||
}
|
||||
return fileID, nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) []byte {
|
||||
if len(b) <= n {
|
||||
return b
|
||||
}
|
||||
return b[:n]
|
||||
}
|
||||
58
internal/hyfile/upload_test.go
Normal file
58
internal/hyfile/upload_test.go
Normal 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
430
internal/sync/sync.go
Normal file
@@ -0,0 +1,430 @@
|
||||
// Package syncer 编排监管中转主流程(与 xk-api HyTransitRecordService 配合)。
|
||||
//
|
||||
// 每个 step(consult/referral/recipe/verification)在 runStep 中顺序执行:
|
||||
//
|
||||
// ① xkapi.BatchCreate → 云端 xk_hy_transit_batch 建批次
|
||||
// ② xkapi.Pull → 云端组包并写入 xk_hy_transit_record,返回 record_id
|
||||
// ③ hy.PostForward(逐条) → 经 forward-go 报政务云;本地 hy_push_* 留痕
|
||||
// ④ xkapi.BatchCallback → 整批回写 callback_status(非逐条 HTTP)
|
||||
// ⑤ xkapi.BatchFinish → 标记批次 pull 结束
|
||||
//
|
||||
// 运营可见状态在云端表;本机表仅用于外网机审计与排错。
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xk-hy-transit-go/internal/applog"
|
||||
"xk-hy-transit-go/internal/config"
|
||||
"xk-hy-transit-go/internal/db"
|
||||
"xk-hy-transit-go/internal/hy"
|
||||
"xk-hy-transit-go/internal/hyfile"
|
||||
"xk-hy-transit-go/internal/xkapi"
|
||||
)
|
||||
|
||||
// Runner 单次或定时同步执行器。
|
||||
type Runner struct {
|
||||
cfg config.Config
|
||||
xk *xkapi.Client
|
||||
store *db.Store
|
||||
organ hy.OrganConfig
|
||||
fileUploadURL string // 实际上传地址(经 forward 或直连政务云)
|
||||
uploadToken string // 由 xk-api config 接口生成的 uploadToken
|
||||
fileUploadViaForward bool
|
||||
}
|
||||
|
||||
// StoreClose 关闭本机 MySQL 连接。
|
||||
func (r *Runner) StoreClose() {
|
||||
if r.store != nil {
|
||||
_ = r.store.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// NewRunner 加载机构配置并创建 Runner(含 forward 文件上传地址解析)。
|
||||
func NewRunner(cfg config.Config, store *db.Store) (*Runner, error) {
|
||||
if p := strings.TrimSpace(cfg.ChromePath); p != "" {
|
||||
hyfile.SetChromePath(p)
|
||||
applog.Appf("chrome path from config: %s", p)
|
||||
}
|
||||
hyfile.SetUploadInsecureSkipVerify(cfg.FileUploadInsecureSkipVerify)
|
||||
xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath)
|
||||
conf, err := xk.Config()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load organ config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(conf.UploadToken) == "" {
|
||||
applog.Appf("warning: uploadToken empty, recipe file upload will fail (check xk-api HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET)")
|
||||
}
|
||||
uploadURL := resolveFileUploadURL(cfg, conf.FileUploadURL)
|
||||
if cfg.FileUploadViaForward {
|
||||
applog.Appf("file upload via forward: %s (cloud fileUploadUrl ignored)", uploadURL)
|
||||
}
|
||||
return &Runner{
|
||||
cfg: cfg,
|
||||
xk: xk,
|
||||
store: store,
|
||||
organ: hy.OrganConfig{
|
||||
UnitID: conf.UnitID,
|
||||
OrganID: conf.OrganID,
|
||||
OrganName: conf.OrganName,
|
||||
},
|
||||
fileUploadURL: uploadURL,
|
||||
uploadToken: conf.UploadToken,
|
||||
fileUploadViaForward: cfg.FileUploadViaForward,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveFileUploadURL 决定 PDF 实际上传地址:默认经 forward-go,避免外网机直连政务云 28211。
|
||||
func resolveFileUploadURL(cfg config.Config, cloudFileUploadURL string) string {
|
||||
if cfg.FileUploadViaForward {
|
||||
return hy.ForwardFileUploadURL(cfg.ForwardBaseURL)
|
||||
}
|
||||
return strings.TrimSpace(cloudFileUploadURL)
|
||||
}
|
||||
|
||||
// Run 执行一个或多个 step。
|
||||
func (r *Runner) Run(step, anchorDate string) error {
|
||||
if step == "all" {
|
||||
for _, s := range []string{"consult", "referral", "recipe", "verification"} {
|
||||
if err := r.runStep(s, anchorDate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return r.runStep(step, anchorDate)
|
||||
}
|
||||
|
||||
// runStep 执行单个监管类型的完整同步(见包注释 ①~⑤)。
|
||||
func (r *Runner) runStep(step, anchorDate string) error {
|
||||
method, ok := hy.StepMethods[step]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown step: %s", step)
|
||||
}
|
||||
|
||||
applog.Appf("sync start step=%s date=%s", step, anchorDate)
|
||||
_, _ = r.store.UpsertJob(anchorDate, step, "running", 0, 0, 0, "")
|
||||
|
||||
// ① 云端建批次
|
||||
batchID, err := r.xk.BatchCreate(anchorDate, step)
|
||||
if err != nil {
|
||||
_ = r.store.FinishJob(anchorDate, step, "failed")
|
||||
applog.Pullf("batch create failed step=%s date=%s err=%v", step, anchorDate, err)
|
||||
return fmt.Errorf("batch create: %w", err)
|
||||
}
|
||||
applog.Pullf("batch created step=%s date=%s batch_id=%d", step, anchorDate, batchID)
|
||||
|
||||
// ② 云端组包拉取(含 validation_errors、meta.needs_recipe_upload 等)
|
||||
pull, err := r.xk.Pull(step, anchorDate, batchID)
|
||||
if err != nil {
|
||||
_ = r.xk.BatchFinish(batchID, "failed")
|
||||
_ = r.store.FinishJob(anchorDate, step, "failed")
|
||||
applog.Pullf("pull failed step=%s batch_id=%d err=%v", step, batchID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
validationCount := 0
|
||||
for _, item := range pull.Items {
|
||||
if len(item.ValidationErrors) > 0 {
|
||||
validationCount++
|
||||
}
|
||||
}
|
||||
applog.Pullf("pull done step=%s batch_id=%d items=%d validation_failed=%d", step, batchID, len(pull.Items), validationCount)
|
||||
|
||||
var callbacks []xkapi.CallbackItem
|
||||
success, failed, skipped := 0, 0, 0
|
||||
|
||||
// ③ 逐条:recipe 可能先 PDF 上传,再加密经 forward 报政务云
|
||||
for _, item := range pull.Items {
|
||||
cb := r.processItem(step, anchorDate, method, batchID, item)
|
||||
callbacks = append(callbacks, cb)
|
||||
switch cb.PushStatus {
|
||||
case "success":
|
||||
success++
|
||||
case "skipped":
|
||||
skipped++
|
||||
default:
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 整批回调云端(写 push_status / callback_status)
|
||||
if err := r.xk.BatchCallback(batchID, callbacks); err != nil {
|
||||
applog.Appf("batch callback warning batch_id=%d err=%v", batchID, err)
|
||||
}
|
||||
// ⑤ 标记批次结束
|
||||
_ = r.xk.BatchFinish(batchID, "done")
|
||||
|
||||
total := success + failed + skipped
|
||||
_, _ = r.store.UpsertJob(anchorDate, step, "done", total, success, failed, "")
|
||||
_ = r.store.FinishJob(anchorDate, step, "done")
|
||||
applog.Pullf("step done step=%s batch_id=%d total=%d success=%d failed=%d skipped=%d", step, batchID, total, success, failed, skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
// processItem 处理单条:校验失败则 skipped;否则 BuildUpload + PostForward,组装 CallbackItem。
|
||||
func (r *Runner) processItem(step, anchorDate, method string, batchID int, item xkapi.PullItem) xkapi.CallbackItem {
|
||||
payload := item.Payload
|
||||
bizKey := item.BizKey
|
||||
if bizKey == "" && payload != nil {
|
||||
bizKey = hy.BizKey(step, anchorDate, payload)
|
||||
}
|
||||
recordID := item.RecordID
|
||||
|
||||
cb := xkapi.CallbackItem{
|
||||
RecordID: recordID,
|
||||
BizKey: bizKey,
|
||||
CallbackStatus: "waiting",
|
||||
}
|
||||
|
||||
if len(item.ValidationErrors) > 0 || payload == nil {
|
||||
bussID := ""
|
||||
if payload != nil {
|
||||
bussID = fmt.Sprintf("%v", payload["bussID"])
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, bussID, bizKey, "", string(payloadJSON), item.ValidationErrors)
|
||||
cb.PushStatus = "skipped"
|
||||
cb.CallbackStatus = "failed"
|
||||
cb.ErrorMessage = "validation failed"
|
||||
if len(item.ValidationErrors) > 0 {
|
||||
cb.ErrorMessage = item.ValidationErrors[0]
|
||||
}
|
||||
applog.Pullf("skip validation biz_key=%s errors=%v", bizKey, item.ValidationErrors)
|
||||
return cb
|
||||
}
|
||||
|
||||
// recipe 且需上传处方 PDF:先经 forward 拿到 recipeFileId,再加密上报监管
|
||||
if step == "recipe" && metaBool(item.Meta, "needs_recipe_upload") {
|
||||
if err := r.ensureRecipeFileID(item, payload); err != nil {
|
||||
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", mustJSON(payload), nil)
|
||||
cb.PushStatus = "failed"
|
||||
cb.CallbackStatus = "failed"
|
||||
cb.ErrorMessage = "recipe file upload: " + err.Error()
|
||||
applog.Pushf("recipe upload fail biz_key=%s err=%v", bizKey, err)
|
||||
return cb
|
||||
}
|
||||
}
|
||||
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", string(payloadJSON), nil)
|
||||
|
||||
req, err := hy.BuildUpload(method, payload, r.organ, r.cfg.HyAppKey, r.cfg.HyAppSecret, r.cfg.HyAesKey)
|
||||
if err != nil {
|
||||
_ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error())
|
||||
cb.PushStatus = "failed"
|
||||
cb.CallbackStatus = "failed"
|
||||
cb.ErrorMessage = err.Error()
|
||||
applog.Pushf("build upload fail biz_key=%s err=%v", bizKey, err)
|
||||
return cb
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, err := hy.PostForward(r.cfg.ForwardBaseURL, req)
|
||||
duration := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
_ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error())
|
||||
cb.PushStatus = "failed"
|
||||
cb.CallbackStatus = "failed"
|
||||
cb.ErrorMessage = err.Error()
|
||||
applog.Pushf("post forward fail biz_key=%s method=%s err=%v", bizKey, method, err)
|
||||
return cb
|
||||
}
|
||||
|
||||
recordLocalID, _ := r.store.GetRecordID(bizKey)
|
||||
_ = r.store.SaveLog(recordLocalID, result.HTTPCode, result.MsgCode, result.Msg, result.TraceID, len(req.Body), result.Body, duration)
|
||||
|
||||
cb.HTTPCode = result.HTTPCode
|
||||
cb.MsgCode = result.MsgCode
|
||||
cb.Msg = result.Msg
|
||||
cb.TraceID = result.TraceID
|
||||
cb.ResponseBody = result.Body
|
||||
|
||||
if hy.IsSuperviseOK(result) {
|
||||
_ = r.store.UpdateRecordStatus(bizKey, "success", "")
|
||||
cb.PushStatus = "success"
|
||||
cb.CallbackStatus = "success"
|
||||
applog.Pushf("ok biz_key=%s method=%s code=%d msgCode=%d trace=%s %dms", bizKey, method, result.GatewayCode, result.MsgCode, result.TraceID, duration)
|
||||
} else {
|
||||
errText := hy.SuperviseErrorSummary(result)
|
||||
_ = r.store.UpdateRecordStatus(bizKey, "failed", errText)
|
||||
cb.PushStatus = "failed"
|
||||
cb.CallbackStatus = "failed"
|
||||
cb.ErrorMessage = errText
|
||||
applog.Pushf("fail biz_key=%s method=%s %s %dms", bizKey, method, errText, duration)
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
// ResolveAnchorDate 解析锚定日:命令行优先,否则按配置向前推 N 天。
|
||||
func ResolveAnchorDate(cfg config.Config, dateArg string) string {
|
||||
if dateArg != "" {
|
||||
return dateArg
|
||||
}
|
||||
return time.Now().AddDate(0, 0, -cfg.AnchorOffsetDays).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ensureRecipeFileID 处方 PDF 子流程:HTML→PDF→本地落盘→经 forward 上传→写回 recipeFileId。
|
||||
func (r *Runner) ensureRecipeFileID(item xkapi.PullItem, payload map[string]any) error {
|
||||
// 步骤 1:解析处方 ID,必要时从云端拉取打印 HTML
|
||||
prescriptionID := metaInt(item.Meta, "prescription_id")
|
||||
if prescriptionID <= 0 {
|
||||
prescriptionID = metaIntFromPayload(payload, "recipeID")
|
||||
}
|
||||
html, _ := item.Meta["recipe_file_html"].(string)
|
||||
if strings.TrimSpace(html) == "" {
|
||||
if prescriptionID <= 0 {
|
||||
return fmt.Errorf("recipe_file_html missing and no prescription_id")
|
||||
}
|
||||
applog.Pushf("fetch recipe html prescription_id=%d", prescriptionID)
|
||||
detail, err := r.xk.GetPrescriptionPrintDetail(prescriptionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch recipe html: %w", err)
|
||||
}
|
||||
html = detail.RecipeFileHTML
|
||||
}
|
||||
|
||||
// 步骤 2:chromedp 将监管打印 HTML 转为 PDF 字节
|
||||
applog.Pushf("recipe pdf: prescription_id=%d html_len=%d save_root=%s", prescriptionID, len(html), hyfile.ProgramDir())
|
||||
pdf, err := hyfile.HtmlToPDF(html)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 步骤 3:落盘到本机 pdf/ 目录,便于运营核对
|
||||
pdfName := buildPDFNameInput(item.Meta, payload)
|
||||
var localPath string
|
||||
if saved, err := hyfile.SavePDFLocal(pdf, pdfName); err != nil {
|
||||
applog.Pushf("save local pdf warning prescription_id=%d err=%v", prescriptionID, err)
|
||||
} else {
|
||||
localPath = saved
|
||||
applog.Pushf("saved local pdf: %s", saved)
|
||||
}
|
||||
|
||||
// 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId
|
||||
filename := hyfile.BuildPDFFilename(pdfName)
|
||||
fileID, err := hyfile.UploadPDF(pdf, filename, r.fileUploadURL, r.uploadToken)
|
||||
if err != nil {
|
||||
applog.Pushf("upload pdf fail prescription_no=%s local=%s err=%v", pdfName.PrescriptionNo, localPath, err)
|
||||
return err
|
||||
}
|
||||
applog.Pushf("upload pdf ok prescription_no=%s fileId=%s local=%s url=%s", pdfName.PrescriptionNo, fileID, localPath, r.fileUploadURL)
|
||||
|
||||
// 步骤 5:写入组包 payload,并回写云端处方表
|
||||
payload["recipeFileId"] = fileID
|
||||
if prescriptionID > 0 {
|
||||
if err := r.xk.SaveRecipeFile(prescriptionID, fileID, item.RecordID); err != nil {
|
||||
applog.Pushf("save recipe file cloud warning prescription_id=%d err=%v", prescriptionID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaBool(meta map[string]any, key string) bool {
|
||||
if meta == nil {
|
||||
return false
|
||||
}
|
||||
switch v := meta[key].(type) {
|
||||
case bool:
|
||||
return v
|
||||
case float64:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
case string:
|
||||
s := strings.TrimSpace(strings.ToLower(v))
|
||||
return s == "true" || s == "1" || s == "yes"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func metaString(meta map[string]any, key string) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := meta[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", meta[key])
|
||||
}
|
||||
|
||||
func metaInt(meta map[string]any, key string) int {
|
||||
if meta == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := meta[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
case string:
|
||||
var n int
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func buildPDFNameInput(meta map[string]any, payload map[string]any) hyfile.PDFNameInput {
|
||||
in := hyfile.PDFNameInput{At: parseMetaTime(meta, payload)}
|
||||
in.PatientName = metaString(meta, "patient_name")
|
||||
if in.PatientName == "" {
|
||||
if s, ok := payload["patientName"].(string); ok {
|
||||
in.PatientName = s
|
||||
}
|
||||
}
|
||||
in.StoreName = metaString(meta, "store_name")
|
||||
in.PrescriptionNo = metaString(meta, "prescription_no")
|
||||
if in.PrescriptionNo == "" {
|
||||
in.PrescriptionNo = fmt.Sprintf("%v", payload["recipeUniqueID"])
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func parseMetaTime(meta map[string]any, payload map[string]any) time.Time {
|
||||
if meta != nil {
|
||||
if t := hyfile.ParseTimeString(metaString(meta, "created_at")); !t.IsZero() {
|
||||
return t
|
||||
}
|
||||
}
|
||||
if s, ok := payload["datein"].(string); ok {
|
||||
if t := hyfile.ParseTimeString(s); !t.IsZero() {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func metaIntFromPayload(payload map[string]any, key string) int {
|
||||
switch v := payload[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
case string:
|
||||
var n int
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(payload map[string]any) string {
|
||||
b, _ := json.Marshal(payload)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// FileUploadURL 返回实际使用的文件上传地址(upload-test 与排错用)。
|
||||
func (r *Runner) FileUploadURL() string { return r.fileUploadURL }
|
||||
|
||||
// FileUploadViaForward 是否经内网 forward 上传 PDF。
|
||||
func (r *Runner) FileUploadViaForward() bool { return r.fileUploadViaForward }
|
||||
|
||||
// UploadToken 返回监管文件上传凭证。
|
||||
func (r *Runner) UploadToken() string { return r.uploadToken }
|
||||
256
internal/xkapi/client.go
Normal file
256
internal/xkapi/client.go
Normal 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、uploadToken;PDF 上传地址由 transit 改走 forward)
|
||||
// GetPrescriptionPrintDetail → GET /api/hy/transit/prescription/detail(recipe PDF HTML)
|
||||
// SaveRecipeFile → POST /api/hy/transit/prescription/recipe-file(回写 recipeFileId)
|
||||
//
|
||||
// 响应统一为 code=0 时 result 字段;见 docs/hy-transit-api.md。
|
||||
package xkapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client 云端 API 客户端。
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
callbackPath string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New 创建客户端;callbackPath 为批量回调路径,如 /api/hy/transit/batch/callback。
|
||||
func New(baseURL, token, callbackPath string) *Client {
|
||||
if callbackPath == "" {
|
||||
callbackPath = "/api/hy/transit/batch/callback"
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
callbackPath: callbackPath,
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// PullResponse 单次拉取响应。
|
||||
type PullResponse struct {
|
||||
AnchorDate string `json:"anchor_date"`
|
||||
BatchID int `json:"batch_id"`
|
||||
Items []PullItem `json:"items"`
|
||||
}
|
||||
|
||||
// PullItem 单条组包记录(含云端 record_id)。
|
||||
type PullItem struct {
|
||||
RecordID int `json:"record_id"`
|
||||
BizKey string `json:"biz_key"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
ValidationErrors []string `json:"validation_errors"`
|
||||
}
|
||||
|
||||
// ConfigResponse 机构公开配置(Runner 启动时拉取一次)。
|
||||
// FileUploadURL 在 FILE_UPLOAD_VIA_FORWARD=true 时由 transit 忽略,改走 forward 路径。
|
||||
type ConfigResponse struct {
|
||||
OrganID string `json:"organID"`
|
||||
UnitID string `json:"unitID"`
|
||||
OrganName string `json:"organName"`
|
||||
HosCode string `json:"hosCode"`
|
||||
HosName string `json:"hosName"`
|
||||
FileUploadURL string `json:"fileUploadUrl"`
|
||||
FileBucket string `json:"fileBucket"`
|
||||
UploadToken string `json:"uploadToken"`
|
||||
}
|
||||
|
||||
// CallbackItem 批量回调单条结果。
|
||||
type CallbackItem struct {
|
||||
RecordID int `json:"record_id"`
|
||||
BizKey string `json:"biz_key,omitempty"`
|
||||
PushStatus string `json:"push_status"`
|
||||
CallbackStatus string `json:"callback_status"`
|
||||
HTTPCode int `json:"http_code,omitempty"`
|
||||
MsgCode int `json:"msg_code,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
ResponseBody string `json:"response_body,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) setAuth(req *http.Request) {
|
||||
req.Header.Set("X-Hy-Transit-Token", c.token)
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(req *http.Request, out any) error {
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("http %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return fmt.Errorf("api code %d: %s", envelope.Code, envelope.Message)
|
||||
}
|
||||
if out != nil && len(envelope.Result) > 0 {
|
||||
return json.Unmarshal(envelope.Result, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchCreate 创建同步批次,返回 batch_id。
|
||||
func (c *Client) BatchCreate(anchorDate, step string) (int, error) {
|
||||
payload := map[string]string{
|
||||
"anchor_date": anchorDate,
|
||||
"step": step,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/hy/transit/batch/create", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var result struct {
|
||||
BatchID int `json:"batch_id"`
|
||||
}
|
||||
if err := c.doJSON(req, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.BatchID, nil
|
||||
}
|
||||
|
||||
// BatchCallback 整批回写上报结果。
|
||||
func (c *Client) BatchCallback(batchID int, items []CallbackItem) error {
|
||||
payload := map[string]any{
|
||||
"batch_id": batchID,
|
||||
"items": items,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+c.callbackPath, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.doJSON(req, nil)
|
||||
}
|
||||
|
||||
// BatchFinish 标记批次拉取结束。
|
||||
func (c *Client) BatchFinish(batchID int, pullStatus string) error {
|
||||
payload := map[string]any{
|
||||
"batch_id": batchID,
|
||||
"pull_status": pullStatus,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/hy/transit/batch/finish", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.doJSON(req, nil)
|
||||
}
|
||||
|
||||
// Pull 拉取指定 step 的组包列表(需 batch_id)。
|
||||
func (c *Client) Pull(step, date string, batchID int) (*PullResponse, error) {
|
||||
u, err := url.Parse(c.baseURL + "/api/hy/supervise/" + step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("date", date)
|
||||
q.Set("batch_id", fmt.Sprintf("%d", batchID))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result PullResponse
|
||||
if err := c.doJSON(req, &result); err != nil {
|
||||
return nil, fmt.Errorf("pull %s: %w", step, err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Config 获取机构配置(加密上报用)。
|
||||
func (c *Client) Config() (*ConfigResponse, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/hy/supervise/config", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result ConfigResponse
|
||||
if err := c.doJSON(req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// PrescriptionPrintDetail transit 处方详情(含打印 HTML)。
|
||||
type PrescriptionPrintDetail struct {
|
||||
RecipeFileHTML string `json:"recipe_file_html"`
|
||||
PatientName string `json:"patient_name"`
|
||||
StoreName string `json:"store_name"`
|
||||
PrescriptionNo string `json:"prescription_no"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetPrescriptionPrintDetail 拉取与 PC detail 一致的处方数据及 recipe_file_html。
|
||||
func (c *Client) GetPrescriptionPrintDetail(prescriptionID int) (*PrescriptionPrintDetail, error) {
|
||||
u, err := url.Parse(c.baseURL + "/api/hy/transit/prescription/detail")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("prescription_id", fmt.Sprintf("%d", prescriptionID))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result PrescriptionPrintDetail
|
||||
if err := c.doJSON(req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(result.RecipeFileHTML) == "" {
|
||||
return nil, fmt.Errorf("empty recipe_file_html for prescription %d", prescriptionID)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SaveRecipeFile 回写处方监管文件 ID。
|
||||
func (c *Client) SaveRecipeFile(prescriptionID int, fileID string, recordID int) error {
|
||||
payload := map[string]any{
|
||||
"prescription_id": prescriptionID,
|
||||
"recipe_file_id": fileID,
|
||||
"record_id": recordID,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/hy/transit/prescription/recipe-file", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.doJSON(req, nil)
|
||||
}
|
||||
23
log/transit/app-2026-05-20.log
Normal file
23
log/transit/app-2026-05-20.log
Normal 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 ========
|
||||
51
log/transit/app-2026-05-21.log
Normal file
51
log/transit/app-2026-05-21.log
Normal 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 ========
|
||||
33
log/transit/pull-2026-05-20.log
Normal file
33
log/transit/pull-2026-05-20.log
Normal 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 ========
|
||||
73
log/transit/pull-2026-05-21.log
Normal file
73
log/transit/pull-2026-05-21.log
Normal 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 ========
|
||||
8
log/transit/push-2026-05-20.log
Normal file
8
log/transit/push-2026-05-20.log
Normal 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 ========
|
||||
26
log/transit/push-2026-05-21.log
Normal file
26
log/transit/push-2026-05-21.log
Normal 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 ========
|
||||
37
scripts/integration-check.md
Normal file
37
scripts/integration-check.md
Normal 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` 表示业务成功。
|
||||
Reference in New Issue
Block a user