Files
2026-07-15 08:47:41 +08:00

667 lines
21 KiB
Go
Raw Permalink 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"
"sync"
"time"
"xk-hy-transit-go/internal/applog"
"xk-hy-transit-go/internal/config"
"xk-hy-transit-go/internal/db"
"xk-hy-transit-go/internal/fileauth"
"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 // 本地按 FileAuth.java 生成
cloudUploadToken string // xk-api 预生成值,仅诊断对比
fileBucket string
fileUploadViaForward bool
hyAppKey string
hyAppSecret string
hyAesKey string
lastCloud xkapi.ConfigResponse
lastCloudOK bool
lastConfigErr error
configRefreshedAt time.Time
configMu sync.RWMutex
}
// StoreClose 关闭本机 MySQL 连接。
func (r *Runner) StoreClose() {
if r.store != nil {
_ = r.store.Close()
}
}
// Store 返回本机审计库(供 Web 控制台只读查询)。
func (r *Runner) Store() *db.Store {
return r.store
}
// NewRunner 加载机构配置并创建 Runner含 forward 文件上传地址解析)。
func NewRunner(cfg config.Config, store *db.Store) (*Runner, error) {
fileauth.SetB64Mode(cfg.FileAuthB64)
if p := strings.TrimSpace(cfg.ChromePath); p != "" {
hyfile.SetChromePath(p)
applog.Appf("chrome path from config: %s", p)
}
hyfile.SetUploadInsecureSkipVerify(cfg.FileUploadInsecureSkipVerify)
hyfile.SetUploadMinInterval(cfg.FileUploadMinInterval)
if cfg.FileUploadMinInterval > 0 {
applog.Appf("file upload min interval: %s", cfg.FileUploadMinInterval)
}
xk := xkapi.New(cfg.XkAPIBaseURL, cfg.XkAPIToken, cfg.XkAPICallbackPath)
r := &Runner{
cfg: cfg,
xk: xk,
store: store,
fileUploadViaForward: cfg.FileUploadViaForward,
hyAppKey: cfg.HyAppKey,
hyAppSecret: cfg.HyAppSecret,
hyAesKey: cfg.HyAesKey,
}
if err := r.ReloadCloudConfig(); err != nil {
return nil, fmt.Errorf("load organ config: %w", err)
}
if strings.TrimSpace(r.uploadToken) == "" {
applog.Appf("warning: uploadToken empty, recipe file upload will fail (check xk-api HY_APP_KEY/HY_APP_SECRET/HY_FILE_BUCKET)")
}
if cfg.FileUploadViaForward {
applog.Appf("file upload via forward: %s (cloud fileUploadUrl ignored)", r.fileUploadURL)
}
return r, 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 {
_, err := r.executeStep(step, anchorDate, StepRunOptions{ItemLimit: 0, CollectTrace: false})
return err
}
// executeStep 单 step 完整链路;自动与联调共用,开头刷新云端配置与 uploadToken。
func (r *Runner) executeStep(step, anchorDate string, opts StepRunOptions) (*StepTestResult, error) {
method, ok := hy.StepMethods[step]
if !ok {
return nil, fmt.Errorf("unknown step: %s", step)
}
if err := r.ReloadCloudConfig(); err != nil {
return nil, fmt.Errorf("reload config: %w", err)
}
var result *StepTestResult
if opts.CollectTrace {
result = &StepTestResult{
Step: step,
StepLabel: hy.LabelStep(step),
AnchorDate: anchorDate,
}
}
applog.Appf("sync start step=%s date=%s", step, anchorDate)
if r.store != nil {
_, _ = r.store.UpsertJob(anchorDate, step, "running", 0, 0, 0, "")
}
batchID, err := r.xk.BatchCreate(anchorDate, step)
if err != nil {
if r.store != nil {
_ = r.store.FinishJob(anchorDate, step, "failed")
}
applog.Pullf("batch create failed step=%s date=%s err=%v", step, anchorDate, err)
return result, fmt.Errorf("batch create: %w", err)
}
if result != nil {
result.BatchID = batchID
}
applog.Pullf("batch created step=%s date=%s batch_id=%d", step, anchorDate, batchID)
pull, err := r.xk.Pull(step, anchorDate, batchID)
if err != nil {
_ = r.xk.BatchFinish(batchID, "failed")
if r.store != nil {
_ = r.store.FinishJob(anchorDate, step, "failed")
}
applog.Pullf("pull failed step=%s batch_id=%d err=%v", step, batchID, err)
return result, err
}
validationCount := 0
for _, item := range pull.Items {
if len(item.ValidationErrors) > 0 {
validationCount++
}
}
followUpCount := 0
if step == "verification" {
followUpCount = len(pull.FollowUpRecipeItems)
}
applog.Pullf("pull done step=%s batch_id=%d items=%d validation_failed=%d follow_up_recipe=%d", step, batchID, len(pull.Items), validationCount, followUpCount)
if result != nil {
result.PullSummary = PullSummary{
Total: len(pull.Items),
ValidationFailed: validationCount,
}
}
items := pull.Items
if opts.ItemLimit > 0 && len(items) > opts.ItemLimit {
items = items[:opts.ItemLimit]
}
if result != nil {
result.PullSummary.Processed = len(items)
}
var callbacks []xkapi.CallbackItem
success, failed, skipped := 0, 0, 0
appendProcessed := func(processStep, processMethod string, batchItems []xkapi.PullItem) {
for _, item := range batchItems {
var tr *itemTraceCtx
if opts.CollectTrace {
tr = newItemTraceCtx(item)
if item.BizKey == "" && item.Payload != nil {
tr.out.BizKey = hy.BizKey(processStep, anchorDate, item.Payload)
}
}
cb := r.processItem(processStep, anchorDate, processMethod, batchID, item, tr)
callbacks = append(callbacks, cb)
if tr != nil {
tr.finish(cb)
result.Items = append(result.Items, *tr.out)
}
switch cb.PushStatus {
case "success":
success++
case "skipped":
skipped++
default:
failed++
}
}
}
appendProcessed(step, method, items)
if step == "verification" && len(pull.FollowUpRecipeItems) > 0 {
followUpMethod, ok := hy.StepMethods["recipe"]
if !ok {
return result, fmt.Errorf("unknown follow-up step: recipe")
}
applog.Pullf("verification follow-up recipe items=%d", len(pull.FollowUpRecipeItems))
appendProcessed("recipe", followUpMethod, pull.FollowUpRecipeItems)
}
if err := r.xk.BatchCallback(batchID, callbacks); err != nil {
applog.Appf("batch callback failed step=%s batch_id=%d err=%v", step, batchID, err)
if r.store != nil {
_, _ = r.store.UpsertJob(anchorDate, step, "failed", success+failed+skipped, success, failed, err.Error())
_ = r.store.FinishJob(anchorDate, step, "failed")
}
if result != nil {
result.CallbackError = err.Error()
result.Summary = StepOutcomeSummary{Success: success, Failed: failed, Skipped: skipped}
}
_ = r.xk.BatchFinish(batchID, "failed")
return result, fmt.Errorf("batch callback: %w", err)
}
_ = r.xk.BatchFinish(batchID, "done")
total := success + failed + skipped
if r.store != nil {
_, _ = 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)
if result != nil {
result.Summary = StepOutcomeSummary{Success: success, Failed: failed, Skipped: skipped}
}
return result, nil
}
// processItem 处理单条:校验失败则 skipped否则 BuildUpload + PostForward组装 CallbackItem。
func (r *Runner) processItem(step, anchorDate, method string, batchID int, item xkapi.PullItem, tr *itemTraceCtx) 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)
if r.store != nil {
_ = 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
}
if tr != nil {
tr.setPayloadSnapshot(payload)
}
if step == "recipe" {
if metaBool(item.Meta, "needs_recipe_upload") {
if err := r.ensureRecipeFileID(item, payload, tr); err != nil {
if r.store != 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
}
} else if tr != nil {
tr.setRecipeSkipped("needs_recipe_upload=false")
}
}
payloadJSON, _ := json.Marshal(payload)
if r.store != nil {
_ = r.store.SaveRecord(int64(batchID), anchorDate, method, method, fmt.Sprintf("%v", payload["bussID"]), bizKey, "", string(payloadJSON), nil)
}
r.configMu.RLock()
appKey, appSecret, aesKey := r.hyAppKey, r.hyAppSecret, r.hyAesKey
r.configMu.RUnlock()
req, err := hy.BuildUpload(method, payload, r.organ, appKey, appSecret, aesKey)
if err != nil {
if r.store != 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
}
if tr != nil {
tr.setPushReq(req)
}
start := time.Now()
result, err := hy.PostForward(r.cfg.ForwardBaseURL, req, r.cfg.ForwardSharedSecret)
duration := int(time.Since(start).Milliseconds())
if err != nil {
if r.store != 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
}
if r.store != nil {
recordLocalID, _ := r.store.GetRecordID(bizKey)
headersJSON, _ := json.Marshal(req.Headers)
_ = r.store.SaveLog(recordLocalID, result.HTTPCode, result.MsgCode, result.Msg, result.TraceID, len(req.Body), req.PlainJSON, string(headersJSON), 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) {
if r.store != nil {
_ = 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)
if r.store != nil {
_ = 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, tr *itemTraceCtx) 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§2.3.4 multipart 字段 file
filename := hyfile.BuildPDFFilename(pdfName)
token, err := r.uploadTokenForFile(filename)
if err != nil {
return err
}
r.configMu.RLock()
url := r.fileUploadURL
r.configMu.RUnlock()
upRes, err := hyfile.UploadPDFDetail(pdf, filename, url, token, r.cfg.ForwardSharedSecret)
if tr != nil {
tr.setRecipeUpload(upRes, localPath, err)
}
if err != nil {
applog.Pushf("upload pdf fail prescription_no=%s local=%s err=%v", pdfName.PrescriptionNo, localPath, err)
return err
}
fileID := upRes.FileID
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 }
// ForwardSharedSecret 与 forward-go FORWARD_SHARED_SECRET 一致(可选)。
func (r *Runner) ForwardSharedSecret() string { return r.cfg.ForwardSharedSecret }
// Cfg 返回本地环境配置(只读副本)。
func (r *Runner) Cfg() config.Config { return r.cfg }
// FileBucket 返回生效的文件 bucketuploadToken scope
func (r *Runner) FileBucket() string {
r.configMu.RLock()
defer r.configMu.RUnlock()
return r.fileBucket
}
// CloudUploadToken 返回 xk-api 预生成的 uploadToken仅诊断对比
func (r *Runner) CloudUploadToken() string {
r.configMu.RLock()
defer r.configMu.RUnlock()
return r.cloudUploadToken
}
// UploadTokenSource 说明 uploadToken 生成方式。
func (r *Runner) UploadTokenSource() string { return "local-fileauth-java" }
// RegenerateUploadToken 供诊断:用当前凭证重新生成 token与 refreshUploadToken 相同)。
func (r *Runner) RegenerateUploadToken() error {
return r.refreshUploadToken()
}
// RetryRecord 对本机 hy_push_record 整条重试:刷新配置 → 处理 → 回调云端。
func (r *Runner) RetryRecord(localRecordID int64) error {
if r.store == nil {
return fmt.Errorf("no local store")
}
if err := r.ReloadCloudConfig(); err != nil {
return fmt.Errorf("reload config: %w", err)
}
detail, err := r.store.GetRecordDetail(localRecordID)
if err != nil {
return err
}
rec := detail.Record
step := db.StepForMethod(rec.Method)
if step == "" {
return fmt.Errorf("unknown method: %s", rec.Method)
}
method := rec.Method
batchID := int(rec.JobID)
if batchID <= 0 {
return fmt.Errorf("invalid batch_id (job_id) on record %d", localRecordID)
}
var payload map[string]any
if rec.PayloadJSON != "" {
if err := json.Unmarshal([]byte(rec.PayloadJSON), &payload); err != nil {
return fmt.Errorf("parse payload_json: %w", err)
}
}
var validationErrors []string
if rec.ValidationErrors.Valid && rec.ValidationErrors.String != "" {
_ = json.Unmarshal([]byte(rec.ValidationErrors.String), &validationErrors)
}
meta := buildRetryMeta(step, payload)
item := xkapi.PullItem{
RecordID: 0,
BizKey: rec.BizKey,
Payload: payload,
Meta: meta,
ValidationErrors: validationErrors,
}
cb := r.processItem(step, rec.AnchorDate, method, batchID, item, nil)
if err := r.xk.BatchCallback(batchID, []xkapi.CallbackItem{cb}); err != nil {
applog.Appf("retry batch callback failed record_id=%d err=%v", localRecordID, err)
return fmt.Errorf("batch callback: %w", err)
}
_ = r.store.IncRetry(rec.BizKey)
if cb.PushStatus != "success" {
if cb.ErrorMessage != "" {
return fmt.Errorf("%s", cb.ErrorMessage)
}
return fmt.Errorf("retry failed push_status=%s", cb.PushStatus)
}
return nil
}
func buildRetryMeta(step string, payload map[string]any) map[string]any {
meta := map[string]any{}
if step != "recipe" || payload == nil {
return meta
}
rid := metaIntFromPayload(payload, "recipeID")
if rid > 0 {
meta["prescription_id"] = rid
}
if v, ok := payload["recipeFileId"]; !ok || v == nil || fmt.Sprintf("%v", v) == "" {
meta["needs_recipe_upload"] = true
}
return meta
}