Files
xk-hy-transit-go/internal/sync/sync.go
2026-05-22 08:06:07 +08:00

431 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package syncer 编排监管中转主流程(与 xk-api HyTransitRecordService 配合)。
//
// 每个 stepconsult/referral/recipe/verification在 runStep 中顺序执行:
//
// ① xkapi.BatchCreate → 云端 xk_hy_transit_batch 建批次
// ② xkapi.Pull → 云端组包并写入 xk_hy_transit_record返回 record_id
// ③ hy.PostForward逐条 → 经 forward-go 报政务云;本地 hy_push_* 留痕
// ④ xkapi.BatchCallback → 整批回写 callback_status非逐条 HTTP
// ⑤ xkapi.BatchFinish → 标记批次 pull 结束
//
// 运营可见状态在云端表;本机表仅用于外网机审计与排错。
package syncer
import (
"encoding/json"
"fmt"
"strings"
"time"
"xk-hy-transit-go/internal/applog"
"xk-hy-transit-go/internal/config"
"xk-hy-transit-go/internal/db"
"xk-hy-transit-go/internal/hy"
"xk-hy-transit-go/internal/hyfile"
"xk-hy-transit-go/internal/xkapi"
)
// Runner 单次或定时同步执行器。
type Runner struct {
cfg config.Config
xk *xkapi.Client
store *db.Store
organ hy.OrganConfig
fileUploadURL string // 实际上传地址(经 forward 或直连政务云)
uploadToken string // 由 xk-api config 接口生成的 uploadToken
fileUploadViaForward bool
}
// StoreClose 关闭本机 MySQL 连接。
func (r *Runner) StoreClose() {
if r.store != nil {
_ = r.store.Close()
}
}
// NewRunner 加载机构配置并创建 Runner含 forward 文件上传地址解析)。
func NewRunner(cfg config.Config, store *db.Store) (*Runner, error) {
if p := strings.TrimSpace(cfg.ChromePath); p != "" {
hyfile.SetChromePath(p)
applog.Appf("chrome path from config: %s", p)
}
hyfile.SetUploadInsecureSkipVerify(cfg.FileUploadInsecureSkipVerify)
xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath)
conf, err := xk.Config()
if err != nil {
return nil, fmt.Errorf("load organ config: %w", err)
}
if strings.TrimSpace(conf.UploadToken) == "" {
applog.Appf("warning: uploadToken empty, recipe file upload will fail (check xk-api HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET)")
}
uploadURL := resolveFileUploadURL(cfg, conf.FileUploadURL)
if cfg.FileUploadViaForward {
applog.Appf("file upload via forward: %s (cloud fileUploadUrl ignored)", uploadURL)
}
return &Runner{
cfg: cfg,
xk: xk,
store: store,
organ: hy.OrganConfig{
UnitID: conf.UnitID,
OrganID: conf.OrganID,
OrganName: conf.OrganName,
},
fileUploadURL: uploadURL,
uploadToken: conf.UploadToken,
fileUploadViaForward: cfg.FileUploadViaForward,
}, nil
}
// resolveFileUploadURL 决定 PDF 实际上传地址:默认经 forward-go避免外网机直连政务云 28211。
func resolveFileUploadURL(cfg config.Config, cloudFileUploadURL string) string {
if cfg.FileUploadViaForward {
return hy.ForwardFileUploadURL(cfg.ForwardBaseURL)
}
return strings.TrimSpace(cloudFileUploadURL)
}
// Run 执行一个或多个 step。
func (r *Runner) Run(step, anchorDate string) error {
if step == "all" {
for _, s := range []string{"consult", "referral", "recipe", "verification"} {
if err := r.runStep(s, anchorDate); err != nil {
return err
}
}
return nil
}
return r.runStep(step, anchorDate)
}
// runStep 执行单个监管类型的完整同步(见包注释 ①~⑤)。
func (r *Runner) runStep(step, anchorDate string) error {
method, ok := hy.StepMethods[step]
if !ok {
return fmt.Errorf("unknown step: %s", step)
}
applog.Appf("sync start step=%s date=%s", step, anchorDate)
_, _ = r.store.UpsertJob(anchorDate, step, "running", 0, 0, 0, "")
// ① 云端建批次
batchID, err := r.xk.BatchCreate(anchorDate, step)
if err != nil {
_ = r.store.FinishJob(anchorDate, step, "failed")
applog.Pullf("batch create failed step=%s date=%s err=%v", step, anchorDate, err)
return fmt.Errorf("batch create: %w", err)
}
applog.Pullf("batch created step=%s date=%s batch_id=%d", step, anchorDate, batchID)
// ② 云端组包拉取(含 validation_errors、meta.needs_recipe_upload 等)
pull, err := r.xk.Pull(step, anchorDate, batchID)
if err != nil {
_ = r.xk.BatchFinish(batchID, "failed")
_ = r.store.FinishJob(anchorDate, step, "failed")
applog.Pullf("pull failed step=%s batch_id=%d err=%v", step, batchID, err)
return err
}
validationCount := 0
for _, item := range pull.Items {
if len(item.ValidationErrors) > 0 {
validationCount++
}
}
applog.Pullf("pull done step=%s batch_id=%d items=%d validation_failed=%d", step, batchID, len(pull.Items), validationCount)
var callbacks []xkapi.CallbackItem
success, failed, skipped := 0, 0, 0
// ③ 逐条recipe 可能先 PDF 上传,再加密经 forward 报政务云
for _, item := range pull.Items {
cb := r.processItem(step, anchorDate, method, batchID, item)
callbacks = append(callbacks, cb)
switch cb.PushStatus {
case "success":
success++
case "skipped":
skipped++
default:
failed++
}
}
// ④ 整批回调云端(写 push_status / callback_status
if err := r.xk.BatchCallback(batchID, callbacks); err != nil {
applog.Appf("batch callback warning batch_id=%d err=%v", batchID, err)
}
// ⑤ 标记批次结束
_ = r.xk.BatchFinish(batchID, "done")
total := success + failed + skipped
_, _ = r.store.UpsertJob(anchorDate, step, "done", total, success, failed, "")
_ = r.store.FinishJob(anchorDate, step, "done")
applog.Pullf("step done step=%s batch_id=%d total=%d success=%d failed=%d skipped=%d", step, batchID, total, success, failed, skipped)
return nil
}
// processItem 处理单条:校验失败则 skipped否则 BuildUpload + PostForward组装 CallbackItem。
func (r *Runner) processItem(step, anchorDate, method string, batchID int, item xkapi.PullItem) xkapi.CallbackItem {
payload := item.Payload
bizKey := item.BizKey
if bizKey == "" && payload != nil {
bizKey = hy.BizKey(step, anchorDate, payload)
}
recordID := item.RecordID
cb := xkapi.CallbackItem{
RecordID: recordID,
BizKey: bizKey,
CallbackStatus: "waiting",
}
if len(item.ValidationErrors) > 0 || payload == nil {
bussID := ""
if payload != nil {
bussID = fmt.Sprintf("%v", payload["bussID"])
}
payloadJSON, _ := json.Marshal(payload)
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, bussID, bizKey, "", string(payloadJSON), item.ValidationErrors)
cb.PushStatus = "skipped"
cb.CallbackStatus = "failed"
cb.ErrorMessage = "validation failed"
if len(item.ValidationErrors) > 0 {
cb.ErrorMessage = item.ValidationErrors[0]
}
applog.Pullf("skip validation biz_key=%s errors=%v", bizKey, item.ValidationErrors)
return cb
}
// recipe 且需上传处方 PDF先经 forward 拿到 recipeFileId再加密上报监管
if step == "recipe" && metaBool(item.Meta, "needs_recipe_upload") {
if err := r.ensureRecipeFileID(item, payload); err != nil {
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", mustJSON(payload), nil)
cb.PushStatus = "failed"
cb.CallbackStatus = "failed"
cb.ErrorMessage = "recipe file upload: " + err.Error()
applog.Pushf("recipe upload fail biz_key=%s err=%v", bizKey, err)
return cb
}
}
payloadJSON, _ := json.Marshal(payload)
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", string(payloadJSON), nil)
req, err := hy.BuildUpload(method, payload, r.organ, r.cfg.HyAppKey, r.cfg.HyAppSecret, r.cfg.HyAesKey)
if err != nil {
_ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error())
cb.PushStatus = "failed"
cb.CallbackStatus = "failed"
cb.ErrorMessage = err.Error()
applog.Pushf("build upload fail biz_key=%s err=%v", bizKey, err)
return cb
}
start := time.Now()
result, err := hy.PostForward(r.cfg.ForwardBaseURL, req)
duration := int(time.Since(start).Milliseconds())
if err != nil {
_ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error())
cb.PushStatus = "failed"
cb.CallbackStatus = "failed"
cb.ErrorMessage = err.Error()
applog.Pushf("post forward fail biz_key=%s method=%s err=%v", bizKey, method, err)
return cb
}
recordLocalID, _ := r.store.GetRecordID(bizKey)
_ = r.store.SaveLog(recordLocalID, result.HTTPCode, result.MsgCode, result.Msg, result.TraceID, len(req.Body), result.Body, duration)
cb.HTTPCode = result.HTTPCode
cb.MsgCode = result.MsgCode
cb.Msg = result.Msg
cb.TraceID = result.TraceID
cb.ResponseBody = result.Body
if hy.IsSuperviseOK(result) {
_ = r.store.UpdateRecordStatus(bizKey, "success", "")
cb.PushStatus = "success"
cb.CallbackStatus = "success"
applog.Pushf("ok biz_key=%s method=%s code=%d msgCode=%d trace=%s %dms", bizKey, method, result.GatewayCode, result.MsgCode, result.TraceID, duration)
} else {
errText := hy.SuperviseErrorSummary(result)
_ = r.store.UpdateRecordStatus(bizKey, "failed", errText)
cb.PushStatus = "failed"
cb.CallbackStatus = "failed"
cb.ErrorMessage = errText
applog.Pushf("fail biz_key=%s method=%s %s %dms", bizKey, method, errText, duration)
}
return cb
}
// ResolveAnchorDate 解析锚定日:命令行优先,否则按配置向前推 N 天。
func ResolveAnchorDate(cfg config.Config, dateArg string) string {
if dateArg != "" {
return dateArg
}
return time.Now().AddDate(0, 0, -cfg.AnchorOffsetDays).Format("2006-01-02")
}
// ensureRecipeFileID 处方 PDF 子流程HTML→PDF→本地落盘→经 forward 上传→写回 recipeFileId。
func (r *Runner) ensureRecipeFileID(item xkapi.PullItem, payload map[string]any) error {
// 步骤 1解析处方 ID必要时从云端拉取打印 HTML
prescriptionID := metaInt(item.Meta, "prescription_id")
if prescriptionID <= 0 {
prescriptionID = metaIntFromPayload(payload, "recipeID")
}
html, _ := item.Meta["recipe_file_html"].(string)
if strings.TrimSpace(html) == "" {
if prescriptionID <= 0 {
return fmt.Errorf("recipe_file_html missing and no prescription_id")
}
applog.Pushf("fetch recipe html prescription_id=%d", prescriptionID)
detail, err := r.xk.GetPrescriptionPrintDetail(prescriptionID)
if err != nil {
return fmt.Errorf("fetch recipe html: %w", err)
}
html = detail.RecipeFileHTML
}
// 步骤 2chromedp 将监管打印 HTML 转为 PDF 字节
applog.Pushf("recipe pdf: prescription_id=%d html_len=%d save_root=%s", prescriptionID, len(html), hyfile.ProgramDir())
pdf, err := hyfile.HtmlToPDF(html)
if err != nil {
return err
}
// 步骤 3落盘到本机 pdf/ 目录,便于运营核对
pdfName := buildPDFNameInput(item.Meta, payload)
var localPath string
if saved, err := hyfile.SavePDFLocal(pdf, pdfName); err != nil {
applog.Pushf("save local pdf warning prescription_id=%d err=%v", prescriptionID, err)
} else {
localPath = saved
applog.Pushf("saved local pdf: %s", saved)
}
// 步骤 4经 forward-go 上传至政务云文件服务,得到 recipeFileId
filename := hyfile.BuildPDFFilename(pdfName)
fileID, err := hyfile.UploadPDF(pdf, filename, r.fileUploadURL, r.uploadToken)
if err != nil {
applog.Pushf("upload pdf fail prescription_no=%s local=%s err=%v", pdfName.PrescriptionNo, localPath, err)
return err
}
applog.Pushf("upload pdf ok prescription_no=%s fileId=%s local=%s url=%s", pdfName.PrescriptionNo, fileID, localPath, r.fileUploadURL)
// 步骤 5写入组包 payload并回写云端处方表
payload["recipeFileId"] = fileID
if prescriptionID > 0 {
if err := r.xk.SaveRecipeFile(prescriptionID, fileID, item.RecordID); err != nil {
applog.Pushf("save recipe file cloud warning prescription_id=%d err=%v", prescriptionID, err)
}
}
return nil
}
func metaBool(meta map[string]any, key string) bool {
if meta == nil {
return false
}
switch v := meta[key].(type) {
case bool:
return v
case float64:
return v != 0
case int:
return v != 0
case string:
s := strings.TrimSpace(strings.ToLower(v))
return s == "true" || s == "1" || s == "yes"
default:
return false
}
}
func metaString(meta map[string]any, key string) string {
if meta == nil {
return ""
}
if s, ok := meta[key].(string); ok {
return s
}
return fmt.Sprintf("%v", meta[key])
}
func metaInt(meta map[string]any, key string) int {
if meta == nil {
return 0
}
switch v := meta[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
var n int
fmt.Sscanf(v, "%d", &n)
return n
default:
return 0
}
}
func buildPDFNameInput(meta map[string]any, payload map[string]any) hyfile.PDFNameInput {
in := hyfile.PDFNameInput{At: parseMetaTime(meta, payload)}
in.PatientName = metaString(meta, "patient_name")
if in.PatientName == "" {
if s, ok := payload["patientName"].(string); ok {
in.PatientName = s
}
}
in.StoreName = metaString(meta, "store_name")
in.PrescriptionNo = metaString(meta, "prescription_no")
if in.PrescriptionNo == "" {
in.PrescriptionNo = fmt.Sprintf("%v", payload["recipeUniqueID"])
}
return in
}
func parseMetaTime(meta map[string]any, payload map[string]any) time.Time {
if meta != nil {
if t := hyfile.ParseTimeString(metaString(meta, "created_at")); !t.IsZero() {
return t
}
}
if s, ok := payload["datein"].(string); ok {
if t := hyfile.ParseTimeString(s); !t.IsZero() {
return t
}
}
return time.Now()
}
func metaIntFromPayload(payload map[string]any, key string) int {
switch v := payload[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
var n int
fmt.Sscanf(v, "%d", &n)
return n
default:
return 0
}
}
func mustJSON(payload map[string]any) string {
b, _ := json.Marshal(payload)
return string(b)
}
// FileUploadURL 返回实际使用的文件上传地址upload-test 与排错用)。
func (r *Runner) FileUploadURL() string { return r.fileUploadURL }
// FileUploadViaForward 是否经内网 forward 上传 PDF。
func (r *Runner) FileUploadViaForward() bool { return r.fileUploadViaForward }
// UploadToken 返回监管文件上传凭证。
func (r *Runner) UploadToken() string { return r.uploadToken }