From 86da349ae17a3da7823841d83c74682b59f9fbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 22 May 2026 09:17:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + README.md | 12 ++ cmd/transit/main.go | 114 +++++++++-- docs/COMMANDS.md | 19 +- internal/config/config.go | 4 + internal/db/db.go | 8 +- internal/db/query.go | 278 +++++++++++++++++++++++++++ internal/db/query_test.go | 19 ++ internal/hy/crypto.go | 7 +- internal/hy/crypto_parity_test.go | 25 +++ internal/hy/labels.go | 103 ++++++++++ internal/hy/labels_test.go | 32 +++ internal/hyfile/app-2026-05-22.log | 0 internal/hyfile/pull-2026-05-22.log | 0 internal/hyfile/push-2026-05-22.log | 6 + internal/logweb/dbview.go | 202 +++++++++++++++++++ internal/logweb/embed.go | 6 + internal/logweb/files.go | 118 ++++++++++++ internal/logweb/files_test.go | 55 ++++++ internal/logweb/jsonfmt.go | 62 ++++++ internal/logweb/labels_api.go | 70 +++++++ internal/logweb/parse.go | 62 ++++++ internal/logweb/server.go | 228 ++++++++++++++++++++++ internal/logweb/web/app.js | 61 ++++++ internal/logweb/web/index.html | 32 +++ internal/logweb/web/logs.html | 44 +++++ internal/logweb/web/logs_view.html | 60 ++++++ internal/logweb/web/runs.html | 89 +++++++++ internal/logweb/web/runs_record.html | 103 ++++++++++ internal/logweb/web/static.css | 145 ++++++++++++++ internal/logweb/web/test.html | 90 +++++++++ internal/sync/sync.go | 8 +- internal/syncgate/gate.go | 66 +++++++ log/transit/app-2026-05-22.log | 53 +++++ log/transit/pull-2026-05-22.log | 85 ++++++++ log/transit/push-2026-05-22.log | 38 ++++ 36 files changed, 2280 insertions(+), 28 deletions(-) create mode 100644 internal/db/query.go create mode 100644 internal/db/query_test.go create mode 100644 internal/hy/labels.go create mode 100644 internal/hy/labels_test.go create mode 100644 internal/hyfile/app-2026-05-22.log create mode 100644 internal/hyfile/pull-2026-05-22.log create mode 100644 internal/hyfile/push-2026-05-22.log create mode 100644 internal/logweb/dbview.go create mode 100644 internal/logweb/embed.go create mode 100644 internal/logweb/files.go create mode 100644 internal/logweb/files_test.go create mode 100644 internal/logweb/jsonfmt.go create mode 100644 internal/logweb/labels_api.go create mode 100644 internal/logweb/parse.go create mode 100644 internal/logweb/server.go create mode 100644 internal/logweb/web/app.js create mode 100644 internal/logweb/web/index.html create mode 100644 internal/logweb/web/logs.html create mode 100644 internal/logweb/web/logs_view.html create mode 100644 internal/logweb/web/runs.html create mode 100644 internal/logweb/web/runs_record.html create mode 100644 internal/logweb/web/static.css create mode 100644 internal/logweb/web/test.html create mode 100644 internal/syncgate/gate.go create mode 100644 log/transit/app-2026-05-22.log create mode 100644 log/transit/pull-2026-05-22.log create mode 100644 log/transit/push-2026-05-22.log diff --git a/.env.example b/.env.example index 4a603f5..abbd31c 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,7 @@ 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 + +# Web 控制台(transit serve 自动启动;浏览器打开查看日志/流水/测试同步) +# LOG_WEB_ADDR=127.0.0.1:8765 +# LOG_WEB_ALLOW_TEST=true diff --git a/README.md b/README.md index b995325..4805405 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ go run ./cmd/transit serve 应用日志默认目录:`log/transit/`(`pull` / `push` / `app` 按日滚动;控制台仅开始/结束/错误),详见 [docs/COMMANDS.md](docs/COMMANDS.md)。 +### Web 控制台 + +`transit serve` 启动后自动打开 **暗色 Web 控制台**(默认 `http://127.0.0.1:8765/`): + +- **文件日志**:app / pull / push 按日全文 +- **同步流水**:本机三表关联,JSON 详情(拉取 payload → **推送明文** → 请求头/密文 → 政务云响应 → 推断回调) +- **测试执行**:页面触发一次 `sync`(与 CLI 相同,需 `LOG_WEB_ALLOW_TEST=true`) + +无参数菜单选 **3) 查看日志** 可仅启动 Web(不跑 Cron)。环境变量:`LOG_WEB_ADDR`、`LOG_WEB_ALLOW_TEST`(见 `.env.example`)。 + +已有 MySQL 库需执行 [`sql/hy_push_log_plain_migration.sql`](../sql/hy_push_log_plain_migration.sql) 后,新同步记录才会落库推送明文。 + ## 接口文档 云端 API 说明:[`xk-api/docs/hy-transit-api.md`](../xk-api/docs/hy-transit-api.md)(WSL 路径以实际仓库为准)。 diff --git a/cmd/transit/main.go b/cmd/transit/main.go index 23c8daa..1ae35c0 100644 --- a/cmd/transit/main.go +++ b/cmd/transit/main.go @@ -24,7 +24,9 @@ import ( "xk-hy-transit-go/internal/config" "xk-hy-transit-go/internal/db" "xk-hy-transit-go/internal/hyfile" + "xk-hy-transit-go/internal/logweb" syncer "xk-hy-transit-go/internal/sync" + "xk-hy-transit-go/internal/syncgate" "xk-hy-transit-go/internal/xkapi" ) @@ -82,8 +84,9 @@ func runInteractiveMenu() { fmt.Println("======== 互联网医院监管中转 ========") fmt.Println(" 1) 单次执行(跑一轮后退出)") fmt.Println(" 2) 定时执行(常驻,按 .env 中 CRON 每天跑)") + fmt.Println(" 3) 查看日志(Web 控制台,不跑定时)") fmt.Println(" 0) 退出") - fmt.Print("请选择 [0-2]: ") + fmt.Print("请选择 [0-3]: ") choice, _ := reader.ReadString('\n') choice = strings.TrimSpace(choice) switch choice { @@ -93,6 +96,9 @@ func runInteractiveMenu() { case "2": runInteractiveServe(reader) return + case "3": + runInteractiveLogWeb() + return case "0", "": fmt.Println("已退出。") return @@ -160,7 +166,7 @@ func printUsage() { fmt.Println(`用法: transit 无参数进入交互菜单(运营推荐,需控制台) transit sync [--step=all] [--date=YYYY-MM-DD] 单次同步 - transit serve 定时常驻(默认每天 22:00) + transit serve 定时常驻 + Web 控制台(默认 :8765) transit pdf-test [--prescription-id=N] 单独测 HTML→PDF 落盘 transit upload-test --pdf=PATH 单独测监管文件上传 transit --step=all [--date=...] 兼容旧单次参数 @@ -310,36 +316,110 @@ func runSync(args []string) { applog.RunSeparator(fmt.Sprintf("END sync step=%s date=%s ok=true", *step, anchor)) } -// runServe 启动 Cron,阻塞进程直至被终止。 +func runInteractiveLogWeb() { + cfg := config.Load() + store, mysqlOK, mysqlErr := tryOpenStore(cfg) + if store != nil { + defer store.Close() + } + addr := cfg.LogWebAddr + fmt.Printf("Web 控制台: http://%s/\n", addr) + fmt.Println("(仅浏览日志与流水;测试同步请用「定时执行」启动 serve)") + if err := logweb.Run(addr, logweb.Deps{ + LogDir: applog.Dir(), + Store: store, + Cfg: cfg, + AllowTest: false, + MySQLOK: mysqlOK, + MySQLErr: mysqlErr, + }); err != nil { + log.Fatal(err) + } +} + +func tryOpenStore(cfg config.Config) (*db.Store, bool, string) { + if strings.TrimSpace(cfg.MySQLDSN) == "" { + return nil, false, "未配置 MYSQL_DSN" + } + store, err := db.Open(cfg.MySQLDSN) + if err != nil { + return nil, false, err.Error() + } + if err := store.Ping(); err != nil { + _ = store.Close() + return nil, false, err.Error() + } + return store, true, "" +} + +func buildLogWebDeps(runner *syncer.Runner, cfg config.Config, store *db.Store) logweb.Deps { + deps := logweb.Deps{ + LogDir: applog.Dir(), + Store: store, + Cfg: cfg, + AllowTest: cfg.LogWebAllowTest && runner != nil, + Runner: runner, + } + if store != nil { + if err := store.Ping(); err != nil { + deps.MySQLErr = err.Error() + } else { + deps.MySQLOK = true + } + } else { + deps.MySQLErr = "无数据库连接" + } + return deps +} + +// runServe 启动 Cron + Web 控制台,阻塞进程直至被终止。 func runServe() { applog.SetQuietConsole(true) runner, cfg := loadRunner() defer runner.StoreClose() + addr := cfg.LogWebAddr + deps := buildLogWebDeps(runner, cfg, runner.Store()) + go func() { + if err := logweb.Run(addr, deps); err != nil { + log.Printf("logweb 退出: %v", err) + } + }() + 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)) + if syncgate.IsRunning() { + applog.Appf("cron skip: sync already running") + return + } + anchor := syncer.ResolveAnchorDate(cfg, "") + if !syncgate.TryRun("all", anchor, func() error { + 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)) + return runErr + }) { + applog.Appf("cron skip: could not start sync") } - 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()) + applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%s", expr, applog.Dir()) + applog.Consolef("Web 控制台 http://%s/ (Ctrl+C 结束进程)", addr) select {} } diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index e98ab30..0fe9444 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -3,7 +3,7 @@ ## 前置 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)。 +2. 本机 MySQL 已执行 [`sql/hy_transit_schema.sql`](../../sql/hy_transit_schema.sql)。若库已存在且 Web 需展示**推送明文**,另执行 [`sql/hy_push_log_plain_migration.sql`](../../sql/hy_push_log_plain_migration.sql)。 3. 内网已启动 **xk-hy-forward-go**(`forward.exe` 或 `go run .`)。 4. 云端已部署 `xk_hy_transit_cloud.sql` 与 `routes/hy.php`。 @@ -29,14 +29,27 @@ go run ./cmd/transit ======== 互联网医院监管中转 ======== 1) 单次执行(跑一轮后退出) 2) 定时执行(常驻,按 .env 中 CRON 每天跑) + 3) 查看日志(Web 控制台,不跑定时) 0) 退出 -请选择 [0-2]: +请选择 [0-3]: ``` - 选 **1**:再输入 `step`(默认 all)、`date`(回车=按 ANCHOR_OFFSET_DAYS 推算,默认昨日)。 -- 选 **2**:显示当前 cron 配置,确认后进程常驻,到点自动 `step=all`。 +- 选 **2**:显示当前 cron 配置,确认后进程常驻,到点自动 `step=all`,并启动 Web 控制台(默认 `http://127.0.0.1:8765/`)。 +- 选 **3**:仅启动 Web,可浏览文件日志与本机同步流水(测试同步需选 2 用 serve)。 - 选 **0**:退出。 +### Web 控制台(serve 自动启动) + +| 页面 | 路径 | 说明 | +|------|------|------| +| 首页 | `/` | 导航 | +| 文件日志 | `/logs` | app / pull / push | +| 同步流水 | `/runs` | 三表关联 + JSON 详情 | +| 测试执行 | `/test` | POST 触发一次 sync | + +环境变量:`LOG_WEB_ADDR`(默认 `127.0.0.1:8765`)、`LOG_WEB_ALLOW_TEST`(默认 `true`)。Cron 与 Web 测试共用互斥锁,不会并行执行两次 sync。 + --- ## 单次执行(跑完即退出) diff --git a/internal/config/config.go b/internal/config/config.go index d84cab9..cd4ecc4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,8 @@ type Config struct { ScheduleTime string // HH:MM,仅当未设 CRON_EXPR 时转为 cron ChromePath string // 可选:Chrome/Edge 路径,未设则自动探测 FileUploadInsecureSkipVerify bool // 文件上传 HTTPS 跳过证书校验(政务网) + LogWebAddr string // Web 控制台监听,默认 127.0.0.1:8765 + LogWebAllowTest bool // 是否允许 Web 触发测试 sync } // Load 读取环境变量;未设置时使用默认值。 @@ -53,6 +55,8 @@ func Load() Config { ScheduleTime: env("SCHEDULE_TIME", ""), ChromePath: env("CHROME_PATH", ""), FileUploadInsecureSkipVerify: envBool("FILE_UPLOAD_INSECURE_SKIP_VERIFY", true), + LogWebAddr: env("LOG_WEB_ADDR", "127.0.0.1:8765"), + LogWebAllowTest: envBool("LOG_WEB_ALLOW_TEST", true), } } diff --git a/internal/db/db.go b/internal/db/db.go index 228434f..061b475 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -99,11 +99,11 @@ func (s *Store) IncRetry(bizKey string) error { } // SaveLog 写入单次 HTTP 上报日志 hy_push_log。 -func (s *Store) SaveLog(recordID int64, httpCode, msgCode int, msg, traceID string, bodyLen int, responseBody string, durationMs int) error { +func (s *Store) SaveLog(recordID int64, httpCode, msgCode int, msg, traceID string, bodyLen int, plainJSON, headersJSON, 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, +INSERT INTO hy_push_log (record_id, http_code, msg_code, msg, trace_id, request_body_len, request_plain_json, request_headers_json, response_body, duration_ms) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + recordID, httpCode, msgCode, msg, traceID, bodyLen, plainJSON, headersJSON, responseBody, durationMs, ) return err } diff --git a/internal/db/query.go b/internal/db/query.go new file mode 100644 index 0000000..d4106d4 --- /dev/null +++ b/internal/db/query.go @@ -0,0 +1,278 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" + "time" +) + +// SyncJobRow hy_sync_job 查询行。 +type SyncJobRow struct { + ID int64 + AnchorDate string + Step string + Status string + TotalCount int + SuccessCount int + FailedCount int + ErrorMessage sql.NullString + StartedAt sql.NullTime + FinishedAt sql.NullTime + UpdatedAt time.Time +} + +// PushLogRow hy_push_log 查询行。 +type PushLogRow struct { + ID int64 + RecordID int64 + HTTPCode int + MsgCode sql.NullInt64 + Msg string + TraceID string + RequestBodyLen int + RequestPlainJSON sql.NullString + RequestHeadersJSON sql.NullString + ResponseBody sql.NullString + DurationMs int + CreatedAt time.Time +} + +// RecordWithLog 业务记录 + 最新一条推送日志。 +type RecordWithLog struct { + ID int64 + JobID int64 + AnchorDate string + Method string + ServiceMethod string + BussID string + BizKey string + PayloadHash string + PayloadJSON string + ValidationErrors sql.NullString + PushStatus string + RetryCount int + LastError sql.NullString + UpdatedAt time.Time + LastLog *PushLogRow +} + +// RecordDetail 记录详情(含全部推送日志)。 +type RecordDetail struct { + Record RecordWithLog + Logs []PushLogRow +} + +// ListJobs 查询同步任务,按 started_at 降序。 +func (s *Store) ListJobs(limit int, anchorDate, step string) ([]SyncJobRow, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + q := `SELECT id, anchor_date, step, status, total_count, success_count, failed_count, + error_message, started_at, finished_at, updated_at + FROM hy_sync_job WHERE 1=1` + var args []any + if anchorDate != "" { + q += ` AND anchor_date = ?` + args = append(args, anchorDate) + } + if step != "" { + q += ` AND step = ?` + args = append(args, step) + } + q += ` ORDER BY COALESCE(started_at, updated_at) DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []SyncJobRow + for rows.Next() { + var j SyncJobRow + var ad time.Time + if err := rows.Scan(&j.ID, &ad, &j.Step, &j.Status, &j.TotalCount, &j.SuccessCount, &j.FailedCount, + &j.ErrorMessage, &j.StartedAt, &j.FinishedAt, &j.UpdatedAt); err != nil { + return nil, err + } + j.AnchorDate = ad.Format("2006-01-02") + out = append(out, j) + } + return out, rows.Err() +} + +// ListRecordsByAnchorMethod 按锚定日与监管 method 查业务记录及最新日志。 +func (s *Store) ListRecordsByAnchorMethod(anchorDate, method string, limit int) ([]RecordWithLog, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.db.Query(` +SELECT r.id, r.job_id, r.anchor_date, r.method, r.service_method, r.buss_id, r.biz_key, + r.payload_hash, r.payload_json, r.validation_errors, r.push_status, r.retry_count, r.last_error, r.updated_at, + l.id, l.record_id, l.http_code, l.msg_code, l.msg, l.trace_id, l.request_body_len, l.request_plain_json, l.request_headers_json, l.response_body, l.duration_ms, l.created_at +FROM hy_push_record r +LEFT JOIN hy_push_log l ON l.id = ( + SELECT id FROM hy_push_log WHERE record_id = r.id ORDER BY id DESC LIMIT 1 +) +WHERE r.anchor_date = ? AND r.method = ? +ORDER BY r.updated_at DESC +LIMIT ?`, anchorDate, method, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRecordsWithOptionalLog(rows) +} + +// ListRecordsRecent 最近更新的业务记录(跨 step),含最新日志。 +func (s *Store) ListRecordsRecent(limit int, anchorDate string) ([]RecordWithLog, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + q := ` +SELECT r.id, r.job_id, r.anchor_date, r.method, r.service_method, r.buss_id, r.biz_key, + r.payload_hash, r.payload_json, r.validation_errors, r.push_status, r.retry_count, r.last_error, r.updated_at, + l.id, l.record_id, l.http_code, l.msg_code, l.msg, l.trace_id, l.request_body_len, l.request_plain_json, l.request_headers_json, l.response_body, l.duration_ms, l.created_at +FROM hy_push_record r +LEFT JOIN hy_push_log l ON l.id = ( + SELECT id FROM hy_push_log WHERE record_id = r.id ORDER BY id DESC LIMIT 1 +) +WHERE 1=1` + var args []any + if anchorDate != "" { + q += ` AND r.anchor_date = ?` + args = append(args, anchorDate) + } + q += ` ORDER BY r.updated_at DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRecordsWithOptionalLog(rows) +} + +func scanRecordsWithOptionalLog(rows *sql.Rows) ([]RecordWithLog, error) { + var out []RecordWithLog + for rows.Next() { + var r RecordWithLog + var ad time.Time + var logID sql.NullInt64 + var logRecID sql.NullInt64 + var httpCode sql.NullInt64 + var msgCode sql.NullInt64 + var msg, traceID sql.NullString + var bodyLen sql.NullInt64 + var plainJSON, headersJSON sql.NullString + var respBody sql.NullString + var dur sql.NullInt64 + var logCreated sql.NullTime + + if err := rows.Scan( + &r.ID, &r.JobID, &ad, &r.Method, &r.ServiceMethod, &r.BussID, &r.BizKey, + &r.PayloadHash, &r.PayloadJSON, &r.ValidationErrors, &r.PushStatus, &r.RetryCount, &r.LastError, &r.UpdatedAt, + &logID, &logRecID, &httpCode, &msgCode, &msg, &traceID, &bodyLen, &plainJSON, &headersJSON, &respBody, &dur, &logCreated, + ); err != nil { + return nil, err + } + r.AnchorDate = ad.Format("2006-01-02") + if logID.Valid { + r.LastLog = &PushLogRow{ + ID: logID.Int64, + RecordID: logRecID.Int64, + HTTPCode: int(httpCode.Int64), + Msg: msg.String, + TraceID: traceID.String, + RequestBodyLen: int(bodyLen.Int64), + RequestPlainJSON: plainJSON, + RequestHeadersJSON: headersJSON, + ResponseBody: respBody, + DurationMs: int(dur.Int64), + } + if msgCode.Valid { + r.LastLog.MsgCode = sql.NullInt64{Int64: msgCode.Int64, Valid: true} + } + if logCreated.Valid { + r.LastLog.CreatedAt = logCreated.Time + } + } + out = append(out, r) + } + return out, rows.Err() +} + +// GetRecordDetail 按 id 查记录及全部推送日志。 +func (s *Store) GetRecordDetail(recordID int64) (*RecordDetail, error) { + var r RecordWithLog + var ad time.Time + err := s.db.QueryRow(` +SELECT id, job_id, anchor_date, method, service_method, buss_id, biz_key, + payload_hash, payload_json, validation_errors, push_status, retry_count, last_error, updated_at +FROM hy_push_record WHERE id = ?`, recordID).Scan( + &r.ID, &r.JobID, &ad, &r.Method, &r.ServiceMethod, &r.BussID, &r.BizKey, + &r.PayloadHash, &r.PayloadJSON, &r.ValidationErrors, &r.PushStatus, &r.RetryCount, &r.LastError, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + r.AnchorDate = ad.Format("2006-01-02") + + logRows, err := s.db.Query(` +SELECT id, record_id, http_code, msg_code, msg, trace_id, request_body_len, request_plain_json, request_headers_json, response_body, duration_ms, created_at +FROM hy_push_log WHERE record_id = ? ORDER BY id DESC`, recordID) + if err != nil { + return nil, err + } + defer logRows.Close() + + var logs []PushLogRow + for logRows.Next() { + var l PushLogRow + if err := logRows.Scan(&l.ID, &l.RecordID, &l.HTTPCode, &l.MsgCode, &l.Msg, &l.TraceID, + &l.RequestBodyLen, &l.RequestPlainJSON, &l.RequestHeadersJSON, &l.ResponseBody, &l.DurationMs, &l.CreatedAt); err != nil { + return nil, err + } + logs = append(logs, l) + } + if len(logs) > 0 { + r.LastLog = &logs[0] + } + return &RecordDetail{Record: r, Logs: logs}, logRows.Err() +} + +// Ping 检测数据库可用。 +func (s *Store) Ping() error { + return s.db.Ping() +} + +// StepForMethod 由 method 反查 step(用于 UI)。 +func StepForMethod(method string) string { + m := map[string]string{ + "uploadConsultIndicators": "consult", + "uploadReferralIndicators": "referral", + "uploadRecipeIndicators": "recipe", + "uploadRecipeVerificationIndicators": "verification", + } + if step, ok := m[method]; ok { + return step + } + return strings.TrimSpace(method) +} + +// MethodForStep 由 step 查 method。 +func MethodForStep(step string) (string, error) { + m := map[string]string{ + "consult": "uploadConsultIndicators", + "referral": "uploadReferralIndicators", + "recipe": "uploadRecipeIndicators", + "verification": "uploadRecipeVerificationIndicators", + } + if method, ok := m[step]; ok { + return method, nil + } + return "", fmt.Errorf("unknown step: %s", step) +} diff --git a/internal/db/query_test.go b/internal/db/query_test.go new file mode 100644 index 0000000..72b7c7f --- /dev/null +++ b/internal/db/query_test.go @@ -0,0 +1,19 @@ +package db + +import "testing" + +func TestMethodForStep(t *testing.T) { + m, err := MethodForStep("recipe") + if err != nil || m != "uploadRecipeIndicators" { + t.Fatalf("got %s %v", m, err) + } + if _, err := MethodForStep("nope"); err == nil { + t.Fatal("expected error") + } +} + +func TestStepForMethod(t *testing.T) { + if StepForMethod("uploadConsultIndicators") != "consult" { + t.Fatal() + } +} diff --git a/internal/hy/crypto.go b/internal/hy/crypto.go index ef83b47..0f40cbc 100644 --- a/internal/hy/crypto.go +++ b/internal/hy/crypto.go @@ -88,8 +88,9 @@ func StringToSign(headers map[string]string) string { } type UploadRequest struct { - Headers map[string]string - Body string + Headers map[string]string + Body string // AES 密文(requestBody) + PlainJSON string // 加密前组包 JSON,供审计/Web } // BuildUpload 组包监管上报请求:合并机构字段 → JSON → AES 加密 → HMAC 签名头。 @@ -133,7 +134,7 @@ func BuildUpload( headers["X-Ca-Signature"] = sig delete(headers, "secret") - return &UploadRequest{Headers: headers, Body: encrypted}, nil + return &UploadRequest{Headers: headers, Body: encrypted, PlainJSON: string(raw)}, nil } type OrganConfig struct { diff --git a/internal/hy/crypto_parity_test.go b/internal/hy/crypto_parity_test.go index 4b3aac4..1d086d9 100644 --- a/internal/hy/crypto_parity_test.go +++ b/internal/hy/crypto_parity_test.go @@ -128,6 +128,31 @@ func TestBuildUploadTimestampSeconds(t *testing.T) { } } +func TestBuildUploadPlainJSON(t *testing.T) { + req, err := BuildUpload( + "uploadConsultIndicators", + map[string]any{"bussID": "99", "platForm": "03"}, + OrganConfig{UnitID: "unit-1", OrganID: "org-1", OrganName: "测试机构"}, + parityAppKey, paritySecret, parityAESKey, + ) + if err != nil { + t.Fatal(err) + } + if req.PlainJSON == "" { + t.Fatal("PlainJSON empty") + } + var arr []map[string]any + if err := json.Unmarshal([]byte(req.PlainJSON), &arr); err != nil { + t.Fatalf("PlainJSON not valid JSON array: %v", err) + } + if len(arr) != 1 || arr[0]["unitID"] != "unit-1" { + t.Fatalf("merged organ fields missing: %v", arr) + } + if req.Headers["secret"] != "" { + t.Fatal("secret must be removed from Headers") + } +} + func resolvePHPScript() string { candidates := []string{ os.Getenv("XK_API_ROOT"), diff --git a/internal/hy/labels.go b/internal/hy/labels.go new file mode 100644 index 0000000..3d909dd --- /dev/null +++ b/internal/hy/labels.go @@ -0,0 +1,103 @@ +// Package hy 监管上报;labels 与 xk-api app/Enum/hy 中文文案一致。 +package hy + +// 与 HySuperviseStepEnum::description() 一致 +var stepLabels = map[string]string{ + "consult": "在线咨询", + "referral": "在线复诊", + "recipe": "在线处方", + "verification": "处方核销", + "all": "全部", +} + +// 与 HyTransitPushStatusEnum::description() 一致 +var pushStatusLabels = map[string]string{ + "pending": "待上报", + "success": "上报成功", + "failed": "上报失败", + "skipped": "已跳过", +} + +// 与 HyTransitCallbackStatusEnum::description() 一致 +var callbackStatusLabels = map[string]string{ + "waiting": "待回调", + "success": "回调成功", + "failed": "回调失败", +} + +// 本机 hy_sync_job.status +var syncJobStatusLabels = map[string]string{ + "pending": "待执行", + "running": "执行中", + "done": "已完成", + "failed": "失败", +} + +// 文件日志类型 +var logKindLabels = map[string]string{ + "app": "应用", + "pull": "拉取", + "push": "推送", +} + +// LabelStep 监管步骤中文名。 +func LabelStep(step string) string { + if s, ok := stepLabels[step]; ok { + return s + } + return step +} + +// LabelPushStatus 上报状态中文名。 +func LabelPushStatus(status string) string { + if s, ok := pushStatusLabels[status]; ok { + return s + } + return status +} + +// LabelCallbackStatus 回调状态中文名。 +func LabelCallbackStatus(status string) string { + if s, ok := callbackStatusLabels[status]; ok { + return s + } + return status +} + +// LabelSyncJobStatus 本机同步任务状态中文名。 +func LabelSyncJobStatus(status string) string { + if s, ok := syncJobStatusLabels[status]; ok { + return s + } + return status +} + +// LabelLogKind 日志文件类型中文名。 +func LabelLogKind(kind string) string { + if s, ok := logKindLabels[kind]; ok { + return s + } + return kind +} + +// StepSelectOptions 供 Web 筛选下拉。 +func StepSelectOptions() []map[string]string { + return []map[string]string{ + {"value": "", "label": "全部"}, + {"value": "consult", "label": "在线咨询"}, + {"value": "referral", "label": "在线复诊"}, + {"value": "recipe", "label": "在线处方"}, + {"value": "verification", "label": "处方核销"}, + } +} + +// TestStepSelectOptions 测试页 step 选项。 +func TestStepSelectOptions() []map[string]string { + return []map[string]string{ + {"value": "all", "label": "全部步骤"}, + {"value": "consult", "label": "在线咨询"}, + {"value": "referral", "label": "在线复诊"}, + {"value": "recipe", "label": "在线处方"}, + {"value": "verification", "label": "处方核销"}, + } +} diff --git a/internal/hy/labels_test.go b/internal/hy/labels_test.go new file mode 100644 index 0000000..31748a6 --- /dev/null +++ b/internal/hy/labels_test.go @@ -0,0 +1,32 @@ +package hy + +import "testing" + +func TestLabelStep_matchesXkApi(t *testing.T) { + cases := map[string]string{ + "consult": "在线咨询", + "referral": "在线复诊", + "recipe": "在线处方", + "verification": "处方核销", + } + for k, want := range cases { + if got := LabelStep(k); got != want { + t.Fatalf("%s: got %q want %q", k, got, want) + } + } +} + +func TestLabelPushStatus(t *testing.T) { + if LabelPushStatus("success") != "上报成功" { + t.Fatal() + } + if LabelPushStatus("skipped") != "已跳过" { + t.Fatal() + } +} + +func TestLabelCallbackStatus(t *testing.T) { + if LabelCallbackStatus("waiting") != "待回调" { + t.Fatal() + } +} diff --git a/internal/hyfile/app-2026-05-22.log b/internal/hyfile/app-2026-05-22.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/hyfile/pull-2026-05-22.log b/internal/hyfile/pull-2026-05-22.log new file mode 100644 index 0000000..e69de29 diff --git a/internal/hyfile/push-2026-05-22.log b/internal/hyfile/push-2026-05-22.log new file mode 100644 index 0000000..b012a23 --- /dev/null +++ b/internal/hyfile/push-2026-05-22.log @@ -0,0 +1,6 @@ +[push] 2026/05/22 08:51:02 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/22 08:51:02 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/22 08:51:03 chromedp: pdf bytes=5205 +[push] 2026/05/22 08:57:01 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/22 08:57:01 chromedp: html_len=87 data_url_len=152 +[push] 2026/05/22 08:57:02 chromedp: pdf bytes=5205 diff --git a/internal/logweb/dbview.go b/internal/logweb/dbview.go new file mode 100644 index 0000000..f12626e --- /dev/null +++ b/internal/logweb/dbview.go @@ -0,0 +1,202 @@ +package logweb + +import ( + "database/sql" + "encoding/json" + "fmt" + + "xk-hy-transit-go/internal/db" + "xk-hy-transit-go/internal/hy" +) + +// RunListItem 同步流水列表项(任务 + 业务 + 最新日志)。 +type RunListItem struct { + Job db.SyncJobRow `json:"job,omitempty"` + HasJob bool `json:"hasJob"` + Record db.RecordWithLog `json:"record"` + Step string `json:"step"` + StepLabel string `json:"stepLabel"` + PushStatusLabel string `json:"pushStatusLabel"` + CallbackStatusLabel string `json:"callbackStatusLabel"` + Callback CallbackView `json:"callback"` + Payload JSONBlock `json:"payload"` + Errors JSONBlock `json:"validationErrors"` + Response JSONBlock `json:"responseBody"` + PushPlain JSONBlock `json:"pushPlain"` + HasPushPlain bool `json:"hasPushPlain"` +} + +// RecordDetailView 记录详情 API 响应。 +type RecordDetailView struct { + Record db.RecordWithLog `json:"record"` + Step string `json:"step"` + StepLabel string `json:"stepLabel"` + PushStatusLabel string `json:"pushStatusLabel"` + CallbackStatusLabel string `json:"callbackStatusLabel"` + Logs []db.PushLogRow `json:"logs"` + Payload JSONBlock `json:"payload"` + Errors JSONBlock `json:"validationErrors"` + Callback CallbackView `json:"callback"` + LogViews []LogEntryView `json:"logViews"` + PushPlain JSONBlock `json:"pushPlain"` +} + +// LogEntryView 单条推送日志 + 格式化明文/头/响应。 +type LogEntryView struct { + Log db.PushLogRow `json:"log"` + PlainJSON JSONBlock `json:"plainJson"` + Headers JSONBlock `json:"headers"` + Response JSONBlock `json:"responseBody"` + CipherPreview string `json:"cipherPreview,omitempty"` +} + +// BuildCallbackView 从记录与最新日志合成回调视图。 +func BuildCallbackView(r db.RecordWithLog) CallbackView { + cbStatus := InferCallbackStatus(r.PushStatus) + cb := CallbackView{ + BizKey: r.BizKey, + PushStatus: r.PushStatus, + PushStatusLabel: hy.LabelPushStatus(r.PushStatus), + CallbackStatus: cbStatus, + CallbackStatusLabel: hy.LabelCallbackStatus(cbStatus), + Note: "本机未持久化云端 record_id;完整 callback_status 以 xk-api 为准", + } + if r.LastError.Valid && r.LastError.String != "" { + cb.ErrorMessage = r.LastError.String + } + if r.LastLog != nil { + cb.HTTPCode = r.LastLog.HTTPCode + if r.LastLog.MsgCode.Valid { + cb.MsgCode = int(r.LastLog.MsgCode.Int64) + } + cb.Msg = r.LastLog.Msg + cb.TraceID = r.LastLog.TraceID + if r.LastLog.ResponseBody.Valid { + cb.ResponseBody = r.LastLog.ResponseBody.String + } + } + return cb +} + +func nullStringJSON(ns sql.NullString) JSONBlock { + if !ns.Valid || ns.String == "" { + return JSONBlock{Raw: "", Valid: true} + } + return FormatJSON(ns.String) +} + +// ListRuns 组装流水列表:优先按记录查,附带匹配的 job。 +func ListRuns(store *db.Store, anchorDate, step string, limit int) ([]RunListItem, error) { + var records []db.RecordWithLog + var err error + if step != "" { + method, mErr := db.MethodForStep(step) + if mErr != nil { + return nil, mErr + } + if anchorDate == "" { + records, err = store.ListRecordsRecent(limit, "") + } else { + records, err = store.ListRecordsByAnchorMethod(anchorDate, method, limit) + } + } else { + records, err = store.ListRecordsRecent(limit, anchorDate) + } + if err != nil { + return nil, err + } + + jobs, _ := store.ListJobs(50, anchorDate, step) + jobIndex := make(map[string]db.SyncJobRow) + for _, j := range jobs { + key := j.AnchorDate + "|" + j.Step + jobIndex[key] = j + } + + out := make([]RunListItem, 0, len(records)) + for _, rec := range records { + st := db.StepForMethod(rec.Method) + cb := BuildCallbackView(rec) + item := RunListItem{ + Record: rec, + Step: st, + StepLabel: hy.LabelStep(st), + PushStatusLabel: hy.LabelPushStatus(rec.PushStatus), + CallbackStatusLabel: cb.CallbackStatusLabel, + Callback: cb, + Payload: FormatJSON(rec.PayloadJSON), + Errors: nullStringJSON(rec.ValidationErrors), + } + if rec.LastLog != nil { + if rec.LastLog.ResponseBody.Valid { + item.Response = FormatJSON(rec.LastLog.ResponseBody.String) + } + item.PushPlain = nullStringJSON(rec.LastLog.RequestPlainJSON) + item.HasPushPlain = rec.LastLog.RequestPlainJSON.Valid && rec.LastLog.RequestPlainJSON.String != "" + } + key := rec.AnchorDate + "|" + st + if j, ok := jobIndex[key]; ok { + item.Job = j + item.HasJob = true + } + out = append(out, item) + } + return out, nil +} + +// GetRecordDetail 记录详情。 +func GetRecordDetail(store *db.Store, recordID int64) (*RecordDetailView, error) { + d, err := store.GetRecordDetail(recordID) + if err != nil { + return nil, err + } + st := db.StepForMethod(d.Record.Method) + cb := BuildCallbackView(d.Record) + view := &RecordDetailView{ + Record: d.Record, + Step: st, + StepLabel: hy.LabelStep(st), + PushStatusLabel: hy.LabelPushStatus(d.Record.PushStatus), + CallbackStatusLabel: cb.CallbackStatusLabel, + Logs: d.Logs, + Payload: FormatJSON(d.Record.PayloadJSON), + Errors: nullStringJSON(d.Record.ValidationErrors), + Callback: cb, + } + if len(d.Logs) > 0 { + view.PushPlain = nullStringJSON(d.Logs[0].RequestPlainJSON) + } + for _, l := range d.Logs { + resp := JSONBlock{Raw: "", Valid: true} + if l.ResponseBody.Valid { + resp = FormatJSON(l.ResponseBody.String) + } + lv := LogEntryView{ + Log: l, + PlainJSON: nullStringJSON(l.RequestPlainJSON), + Headers: nullStringJSON(l.RequestHeadersJSON), + Response: resp, + } + if l.RequestBodyLen > 0 && l.RequestHeadersJSON.Valid { + // requestBody 在 headers JSON 的 requestBody 字段(密文) + lv.CipherPreview = cipherPreviewFromHeaders(l.RequestHeadersJSON.String, l.RequestBodyLen) + } + view.LogViews = append(view.LogViews, lv) + } + return view, nil +} + +func cipherPreviewFromHeaders(headersJSON string, bodyLen int) string { + var h map[string]string + if err := json.Unmarshal([]byte(headersJSON), &h); err != nil { + return fmt.Sprintf("密文长度 %d 字节", bodyLen) + } + rb := h["requestBody"] + if rb == "" { + return fmt.Sprintf("密文长度 %d 字节", bodyLen) + } + if len(rb) > 200 { + return rb[:200] + "…" + } + return rb +} diff --git a/internal/logweb/embed.go b/internal/logweb/embed.go new file mode 100644 index 0000000..adf7957 --- /dev/null +++ b/internal/logweb/embed.go @@ -0,0 +1,6 @@ +package logweb + +import "embed" + +//go:embed web/* +var webFS embed.FS diff --git a/internal/logweb/files.go b/internal/logweb/files.go new file mode 100644 index 0000000..f899127 --- /dev/null +++ b/internal/logweb/files.go @@ -0,0 +1,118 @@ +package logweb + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "xk-hy-transit-go/internal/hy" +) + +var dateRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +var validKinds = map[string]bool{"app": true, "pull": true, "push": true} + +// LogMeta 日志文件元信息。 +type LogMeta struct { + Kind string `json:"kind"` + KindLabel string `json:"kindLabel,omitempty"` + Date string `json:"date"` + Path string `json:"path"` + Size int64 `json:"size"` + Lines int `json:"lines"` + Modified string `json:"modified"` +} + +func logFilePath(logDir, kind, date string) (string, error) { + if !validKinds[kind] { + return "", fmt.Errorf("invalid kind") + } + if !dateRE.MatchString(date) { + return "", fmt.Errorf("invalid date") + } + root, err := filepath.Abs(logDir) + if err != nil { + return "", err + } + p := filepath.Join(root, kind+"-"+date+".log") + p, err = filepath.Abs(p) + if err != nil { + return "", err + } + if !strings.HasPrefix(p, root+string(os.PathSeparator)) && p != root { + return "", fmt.Errorf("path escape") + } + return p, nil +} + +// ListLogDates 列出某类型日志可用日期(降序)。 +func ListLogDates(logDir, kind string) ([]string, error) { + if !validKinds[kind] { + return nil, fmt.Errorf("invalid kind") + } + entries, err := os.ReadDir(logDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + prefix := kind + "-" + suffix := ".log" + var dates []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, suffix) { + d := strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix) + if dateRE.MatchString(d) { + dates = append(dates, d) + } + } + } + // simple sort desc + for i := 0; i < len(dates); i++ { + for j := i + 1; j < len(dates); j++ { + if dates[j] > dates[i] { + dates[i], dates[j] = dates[j], dates[i] + } + } + } + return dates, nil +} + +// ReadLogFile 读取日志全文。 +func ReadLogFile(logDir, kind, date string) (*LogMeta, []string, error) { + p, err := logFilePath(logDir, kind, date) + if err != nil { + return nil, nil, err + } + info, err := os.Stat(p) + if err != nil { + return nil, nil, err + } + b, err := os.ReadFile(p) + if err != nil { + return nil, nil, err + } + content := string(b) + var lines []string + if content != "" { + lines = strings.Split(strings.TrimRight(content, "\n"), "\n") + } + meta := &LogMeta{ + Kind: kind, + KindLabel: hy.LabelLogKind(kind), + Date: date, + Path: p, + Size: info.Size(), + Lines: len(lines), + Modified: info.ModTime().Format(time.RFC3339), + } + return meta, lines, nil +} diff --git a/internal/logweb/files_test.go b/internal/logweb/files_test.go new file mode 100644 index 0000000..ae419ba --- /dev/null +++ b/internal/logweb/files_test.go @@ -0,0 +1,55 @@ +package logweb + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLogFilePath_rejectsInvalid(t *testing.T) { + dir := t.TempDir() + for _, tc := range []struct{ kind, date string }{ + {"evil", "2026-05-22"}, + {"app", "2026/05/22"}, + {"app", "../../../etc/passwd"}, + } { + if _, err := logFilePath(dir, tc.kind, tc.date); err == nil { + t.Fatalf("expected error for %v", tc) + } + } +} + +func TestParseLogLines(t *testing.T) { + raw := []string{ + "======== BEGIN sync ========", + "[pull] 2026/05/22 08:28:44 batch created step=consult batch_id=1", + "[push] 2026/05/22 08:28:46 ok biz_key=x code=200", + "plain line fail something", + } + lines := ParseLogLines(raw) + if !lines[0].IsSeparator { + t.Fatal("sep") + } + if lines[1].Tag != "pull" || lines[1].Time == "" { + t.Fatalf("pull line: %+v", lines[1]) + } + if lines[2].Level != "ok" { + t.Fatalf("ok level: %s", lines[2].Level) + } + if lines[3].Level != "error" { + t.Fatalf("fail level: %s", lines[3].Level) + } +} + +func TestListLogDates(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "app-2026-05-20.log"), []byte("x"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "app-2026-05-22.log"), []byte("x"), 0o644) + dates, err := ListLogDates(dir, "app") + if err != nil { + t.Fatal(err) + } + if len(dates) != 2 || dates[0] != "2026-05-22" { + t.Fatalf("dates=%v", dates) + } +} diff --git a/internal/logweb/jsonfmt.go b/internal/logweb/jsonfmt.go new file mode 100644 index 0000000..b5b38f6 --- /dev/null +++ b/internal/logweb/jsonfmt.go @@ -0,0 +1,62 @@ +package logweb + +import ( + "encoding/json" + "strings" +) + +// JSONBlock 格式化 JSON 展示块。 +type JSONBlock struct { + Raw string `json:"raw"` + Formatted string `json:"formatted,omitempty"` + Valid bool `json:"valid"` + ParseError string `json:"parseError,omitempty"` +} + +// FormatJSON 尝试美化 JSON;失败则返回原文。 +func FormatJSON(s string) JSONBlock { + s = strings.TrimSpace(s) + if s == "" { + return JSONBlock{Raw: "", Valid: true} + } + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return JSONBlock{Raw: s, Valid: false, ParseError: err.Error()} + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return JSONBlock{Raw: s, Valid: false, ParseError: err.Error()} + } + return JSONBlock{Raw: s, Formatted: string(b), Valid: true} +} + +// CallbackView 推断的回调结构(与 xkapi.CallbackItem 对齐)。 +type CallbackView struct { + RecordID int `json:"record_id,omitempty"` + BizKey string `json:"biz_key"` + PushStatus string `json:"push_status"` + PushStatusLabel string `json:"push_status_label,omitempty"` + CallbackStatus string `json:"callback_status"` + CallbackStatusLabel string `json:"callback_status_label,omitempty"` + 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"` + Note string `json:"note,omitempty"` +} + +// InferCallbackStatus 由 push_status 推断 callback_status。 +func InferCallbackStatus(pushStatus string) string { + switch pushStatus { + case "success": + return "success" + case "skipped": + return "failed" + case "failed": + return "failed" + default: + return "waiting" + } +} diff --git a/internal/logweb/labels_api.go b/internal/logweb/labels_api.go new file mode 100644 index 0000000..d496d1e --- /dev/null +++ b/internal/logweb/labels_api.go @@ -0,0 +1,70 @@ +package logweb + +import ( + "xk-hy-transit-go/internal/db" + "xk-hy-transit-go/internal/hy" + "xk-hy-transit-go/internal/syncgate" +) + +// JobView 任务行(含中文标签)。 +type JobView struct { + ID int64 `json:"id"` + AnchorDate string `json:"anchorDate"` + Step string `json:"step"` + StepLabel string `json:"stepLabel"` + Status string `json:"status"` + StatusLabel string `json:"statusLabel"` + TotalCount int `json:"totalCount"` + SuccessCount int `json:"successCount"` + FailedCount int `json:"failedCount"` + StartedAt any `json:"startedAt,omitempty"` + FinishedAt any `json:"finishedAt,omitempty"` +} + +func enrichJobs(jobs []db.SyncJobRow) []JobView { + out := make([]JobView, 0, len(jobs)) + for _, j := range jobs { + v := JobView{ + ID: j.ID, + AnchorDate: j.AnchorDate, + Step: j.Step, + StepLabel: hy.LabelStep(j.Step), + Status: j.Status, + StatusLabel: hy.LabelSyncJobStatus(j.Status), + TotalCount: j.TotalCount, + SuccessCount: j.SuccessCount, + FailedCount: j.FailedCount, + } + if j.StartedAt.Valid { + v.StartedAt = j.StartedAt + } + if j.FinishedAt.Valid { + v.FinishedAt = j.FinishedAt + } + out = append(out, v) + } + return out +} + +// TestStatusView 测试同步状态(含中文标签)。 +type TestStatusView struct { + Running bool `json:"running"` + LastStep string `json:"last_step,omitempty"` + LastStepLabel string `json:"last_step_label,omitempty"` + LastDate string `json:"last_date,omitempty"` + LastError string `json:"last_error,omitempty"` + StartedAt any `json:"started_at,omitempty"` + FinishedAt any `json:"finished_at,omitempty"` +} + +func enrichTestStatus(s syncgate.Status) TestStatusView { + return TestStatusView{ + Running: s.Running, + LastStep: s.LastStep, + LastStepLabel: hy.LabelStep(s.LastStep), + LastDate: s.LastDate, + LastError: s.LastError, + StartedAt: s.StartedAt, + FinishedAt: s.FinishedAt, + } +} diff --git a/internal/logweb/parse.go b/internal/logweb/parse.go new file mode 100644 index 0000000..480a360 --- /dev/null +++ b/internal/logweb/parse.go @@ -0,0 +1,62 @@ +package logweb + +import ( + "regexp" + "strings" +) + +var ( + logLineRE = regexp.MustCompile(`^\[(app|pull|push)\]\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+(.*)$`) + sepRE = regexp.MustCompile(`^=+\s*.+\s*=+$`) +) + +// LogLine 解析后的日志行。 +type LogLine struct { + LineNo int `json:"lineNo"` + Tag string `json:"tag,omitempty"` + Time string `json:"time,omitempty"` + Message string `json:"message"` + Level string `json:"level"` + IsSeparator bool `json:"isSeparator"` + Raw string `json:"raw"` +} + +// ParseLogLines 解析原始行。 +func ParseLogLines(raw []string) []LogLine { + out := make([]LogLine, 0, len(raw)) + for i, line := range raw { + l := LogLine{LineNo: i + 1, Raw: line, Level: "info"} + if sepRE.MatchString(line) { + l.IsSeparator = true + l.Level = "sep" + l.Message = line + out = append(out, l) + continue + } + if m := logLineRE.FindStringSubmatch(line); len(m) == 4 { + l.Tag = m[1] + l.Time = m[2] + l.Message = m[3] + l.Level = classifyLevel(l.Message) + } else { + l.Message = line + l.Level = classifyLevel(line) + } + out = append(out, l) + } + return out +} + +func classifyLevel(msg string) string { + low := strings.ToLower(msg) + switch { + case strings.Contains(low, "fail") || strings.Contains(low, "err") || strings.Contains(low, "error"): + return "error" + case strings.Contains(low, "warning") || strings.Contains(low, "warn"): + return "warn" + case strings.Contains(low, " ok ") || strings.HasPrefix(low, "ok ") || strings.Contains(low, "ok=true"): + return "ok" + default: + return "info" + } +} diff --git a/internal/logweb/server.go b/internal/logweb/server.go new file mode 100644 index 0000000..5e5071c --- /dev/null +++ b/internal/logweb/server.go @@ -0,0 +1,228 @@ +package logweb + +import ( + "encoding/json" + "fmt" + "io/fs" + "log" + "net/http" + "strconv" + "strings" + + "xk-hy-transit-go/internal/applog" + "xk-hy-transit-go/internal/config" + "xk-hy-transit-go/internal/db" + "xk-hy-transit-go/internal/hy" + syncer "xk-hy-transit-go/internal/sync" + "xk-hy-transit-go/internal/syncgate" +) + +// Runner 同步执行器(与 serve 共用)。 +type Runner interface { + Run(step, anchorDate string) error +} + +// Deps Web 控制台依赖。 +type Deps struct { + LogDir string + Store *db.Store + Runner Runner + Cfg config.Config + AllowTest bool + MySQLOK bool + MySQLErr string +} + +// Server HTTP 控制台。 +type Server struct { + deps Deps + mux *http.ServeMux +} + +// NewServer 创建服务。 +func NewServer(deps Deps) *Server { + if deps.LogDir == "" { + deps.LogDir = applog.Dir() + } + s := &Server{deps: deps, mux: http.NewServeMux()} + s.routes() + return s +} + +func (s *Server) routes() { + sub, _ := fs.Sub(webFS, "web") + fileServer := http.FileServer(http.FS(sub)) + + s.mux.HandleFunc("/", s.handleIndex) + s.mux.HandleFunc("/logs", s.handlePage("logs.html")) + s.mux.HandleFunc("/logs/view", s.handlePage("logs_view.html")) + s.mux.HandleFunc("/runs", s.handlePage("runs.html")) + s.mux.HandleFunc("/runs/record", s.handlePage("runs_record.html")) + s.mux.HandleFunc("/test", s.handlePage("test.html")) + + s.mux.HandleFunc("/api/meta", s.handleAPIMeta) + s.mux.HandleFunc("/api/dates", s.handleAPIDates) + s.mux.HandleFunc("/api/log", s.handleAPILog) + s.mux.HandleFunc("/api/runs", s.handleAPIRuns) + s.mux.HandleFunc("/api/runs/record", s.handleAPIRecord) + s.mux.HandleFunc("/api/test/status", s.handleAPITestStatus) + s.mux.HandleFunc("/api/test/sync", s.handleAPITestSync) + + s.mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) +} + +func (s *Server) handlePage(name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + b, err := webFS.ReadFile("web/" + name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) + } +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + s.handlePage("index.html")(w, r) +} + +func (s *Server) handleAPIMeta(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{ + "logDir": s.deps.LogDir, + "mysqlOk": s.deps.MySQLOK, + "mysqlError": s.deps.MySQLErr, + "allowTest": s.deps.AllowTest && s.deps.Runner != nil, + "hasRunner": s.deps.Runner != nil, + "stepOptions": hy.StepSelectOptions(), + "testStepOptions": hy.TestStepSelectOptions(), + "logKindOptions": []map[string]string{{"value": "app", "label": "应用"}, {"value": "pull", "label": "拉取"}, {"value": "push", "label": "推送"}}, + }) +} + +func (s *Server) handleAPIDates(w http.ResponseWriter, r *http.Request) { + kind := r.URL.Query().Get("kind") + dates, err := ListLogDates(s.deps.LogDir, kind) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, dates) +} + +func (s *Server) handleAPILog(w http.ResponseWriter, r *http.Request) { + kind := r.URL.Query().Get("kind") + date := r.URL.Query().Get("date") + meta, lines, err := ReadLogFile(s.deps.LogDir, kind, date) + if err != nil { + writeErr(w, 404, err.Error()) + return + } + writeJSON(w, map[string]any{ + "meta": meta, + "lines": ParseLogLines(lines), + }) +} + +func (s *Server) handleAPIRuns(w http.ResponseWriter, r *http.Request) { + if s.deps.Store == nil { + writeErr(w, 503, "MySQL 未连接") + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := ListRuns(s.deps.Store, r.URL.Query().Get("anchor_date"), r.URL.Query().Get("step"), limit) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + jobs, _ := s.deps.Store.ListJobs(30, r.URL.Query().Get("anchor_date"), r.URL.Query().Get("step")) + writeJSON(w, map[string]any{"items": items, "jobs": enrichJobs(jobs)}) +} + +func (s *Server) handleAPIRecord(w http.ResponseWriter, r *http.Request) { + if s.deps.Store == nil { + writeErr(w, 503, "MySQL 未连接") + return + } + id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) + if id <= 0 { + writeErr(w, 400, "invalid id") + return + } + detail, err := GetRecordDetail(s.deps.Store, id) + if err != nil { + writeErr(w, 404, err.Error()) + return + } + writeJSON(w, detail) +} + +func (s *Server) handleAPITestStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, enrichTestStatus(syncgate.GetStatus())) +} + +func (s *Server) handleAPITestSync(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErr(w, 405, "POST only") + return + } + if !s.deps.AllowTest || s.deps.Runner == nil { + writeErr(w, 403, "测试同步未启用或未配置 Runner(请使用 transit serve)") + return + } + var body struct { + Step string `json:"step"` + Date string `json:"date"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid json") + return + } + step := strings.TrimSpace(body.Step) + if step == "" { + step = "all" + } + anchor := syncer.ResolveAnchorDate(s.deps.Cfg, strings.TrimSpace(body.Date)) + runner := s.deps.Runner + if !syncgate.TryRun(step, anchor, func() error { + applog.RunSeparator(fmt.Sprintf("BEGIN web-test step=%s date=%s", step, anchor)) + applog.Appf("web test sync start step=%s date=%s", step, anchor) + err := runner.Run(step, anchor) + if err != nil { + applog.Appf("web test sync failed step=%s date=%s err=%v", step, anchor, err) + applog.RunSeparator(fmt.Sprintf("END web-test step=%s date=%s ok=false", step, anchor)) + return err + } + applog.Appf("web test sync done step=%s date=%s", step, anchor) + applog.RunSeparator(fmt.Sprintf("END web-test step=%s date=%s ok=true", step, anchor)) + return nil + }) { + writeErr(w, 409, "已有同步任务在执行中") + return + } + writeJSON(w, map[string]any{"ok": true, "step": step, "date": anchor}) +} + +// Run 启动 HTTP 服务(阻塞)。 +func Run(addr string, deps Deps) error { + s := NewServer(deps) + log.Printf("logweb: 控制台 http://%s/", addr) + return http.ListenAndServe(addr, s.mux) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/logweb/web/app.js b/internal/logweb/web/app.js new file mode 100644 index 0000000..e5a1875 --- /dev/null +++ b/internal/logweb/web/app.js @@ -0,0 +1,61 @@ +async function api(path, opts) { + const r = await fetch(path, opts); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || r.statusText); + return j; +} + +function qs(name) { + return new URLSearchParams(location.search).get(name) || ''; +} + +/** 状态徽章:text 为中文标签,code 为原始值(用于配色) */ +function badge(code, text) { + const display = text != null && text !== '' ? text : (code || '-'); + const s = (code || display || '').toLowerCase(); + let cls = 'info'; + if (s === 'success' || s === 'done') cls = 'ok'; + else if (s === 'failed' || s === 'fail' || s === 'error') cls = 'err'; + else if (s === 'skipped' || s === 'warning' || s === 'waiting' || s === 'pending') cls = 'warn'; + else if (s === 'running' || s === 'pulling') cls = 'info'; + return `${esc(display)}`; +} + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; +} + +function fmtTime(nt) { + if (!nt || !nt.Valid || !nt.Time) return '-'; + try { return new Date(nt.Time).toLocaleString(); } catch { return nt.Time; } +} + +function fmtTimeAny(t) { + if (!t) return '-'; + if (typeof t === 'string') { + try { return new Date(t).toLocaleString(); } catch { return t; } + } + return fmtTime(t); +} + +async function loadMeta() { + try { + const m = await api('/api/meta'); + const el = document.getElementById('meta-footer'); + if (el) { + el.innerHTML = `日志目录: ${esc(m.logDir)} | MySQL: ${m.mysqlOk ? '已连接' : '' + esc(m.mysqlError || '未连接') + ''}`; + } + return m; + } catch (e) { + return null; + } +} + +function fillStepSelect(sel, options, includeAll) { + if (!sel || !options) return; + sel.innerHTML = options.map(o => + `` + ).join(''); +} diff --git a/internal/logweb/web/index.html b/internal/logweb/web/index.html new file mode 100644 index 0000000..1a00716 --- /dev/null +++ b/internal/logweb/web/index.html @@ -0,0 +1,32 @@ + + + + + Transit 控制台 + + + +
+
+

互联网医院监管中转 · Web 控制台

+
+ + +
+ + + + diff --git a/internal/logweb/web/logs.html b/internal/logweb/web/logs.html new file mode 100644 index 0000000..663e742 --- /dev/null +++ b/internal/logweb/web/logs.html @@ -0,0 +1,44 @@ + + + + + 文件日志 + + + +
+
+

文件日志

+ +
+ + +
+ + + + diff --git a/internal/logweb/web/logs_view.html b/internal/logweb/web/logs_view.html new file mode 100644 index 0000000..756b866 --- /dev/null +++ b/internal/logweb/web/logs_view.html @@ -0,0 +1,60 @@ + + + + + 日志详情 + + + +
+
+

日志详情

+ +
+
+ + + + +
+
+
+ + + + diff --git a/internal/logweb/web/runs.html b/internal/logweb/web/runs.html new file mode 100644 index 0000000..3b377be --- /dev/null +++ b/internal/logweb/web/runs.html @@ -0,0 +1,89 @@ + + + + + 同步流水 + + + +
+
+

同步流水

+ +
+
+ + + +
+

同步任务

+
+ + +
日期类型状态总数成功失败开始结束
+
+

业务记录(拉取 → 上报 → 回调)

+
+ + + +
ID日期类型批次biz_key拉取校验上报推送明文HTTP回调trace
+
+
+ + + + diff --git a/internal/logweb/web/runs_record.html b/internal/logweb/web/runs_record.html new file mode 100644 index 0000000..399ff54 --- /dev/null +++ b/internal/logweb/web/runs_record.html @@ -0,0 +1,103 @@ + + + + + 记录详情 + + + +
+
+

记录详情 #

+ +
+
+
+

① 拉取(云端组包 payload_json)

+

云端拉下的原始明文,可能尚未含本机机构字段。

+

+  
+
+

校验 validation_errors

+

+  
+
+

② 推送明文(加密前组包 JSON)

+

BuildUpload 合并 unitID/organID/organName 后、AES 加密前的业务 JSON。

+

+  
+
+

③ 推送请求(转发头 + 密文摘要)

+
+
+
+

④ 政务云响应

+
+
+
+

⑤ 回调(推断 CallbackItem)

+

+  
+
+ + + + diff --git a/internal/logweb/web/static.css b/internal/logweb/web/static.css new file mode 100644 index 0000000..e33ab25 --- /dev/null +++ b/internal/logweb/web/static.css @@ -0,0 +1,145 @@ +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2d333b; + --text: #e6edf3; + --muted: #8b949e; + --accent: #58a6ff; + --ok: #3fb950; + --err: #f85149; + --warn: #d29922; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.wrap { max-width: 1400px; margin: 0 auto; padding: 1.25rem; } +header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} +header h1 { margin: 0; font-size: 1.35rem; font-weight: 600; } +.nav { display: flex; gap: 0.75rem; flex-wrap: wrap; } +.nav a { + padding: 0.4rem 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; +} +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; } +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.25rem; + transition: border-color 0.15s; +} +.card:hover { border-color: var(--accent); } +.card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; } +.card p { margin: 0; color: var(--muted); font-size: 0.9rem; } +.toolbar { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + margin-bottom: 1rem; +} +input, select, button { + font: inherit; + padding: 0.45rem 0.65rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} +button { + cursor: pointer; + background: #21262d; +} +button.primary { background: #238636; border-color: #2ea043; } +button:disabled { opacity: 0.5; cursor: not-allowed; } +table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +th, td { + border-bottom: 1px solid var(--border); + padding: 0.5rem 0.65rem; + text-align: left; + vertical-align: top; + word-break: break-all; +} +th { background: #161b22; color: var(--muted); font-weight: 600; white-space: nowrap; } +tr:hover td { background: #1c2128; } +.badge { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} +.badge.ok { background: rgba(63,185,80,0.2); color: var(--ok); } +.badge.err { background: rgba(248,81,73,0.2); color: var(--err); } +.badge.warn { background: rgba(210,153,34,0.2); color: var(--warn); } +.badge.info { background: rgba(88,166,255,0.15); color: var(--accent); } +.log-lines { + font-family: ui-monospace, Consolas, monospace; + font-size: 0.8rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + max-height: 75vh; + overflow: auto; +} +.log-line { + display: grid; + grid-template-columns: 3rem 10rem 4rem 1fr; + gap: 0.5rem; + padding: 0.35rem 0.65rem; + border-bottom: 1px solid var(--border); +} +.log-line.sep { grid-template-columns: 1fr; color: var(--accent); background: #161b22; } +.log-line.error { background: rgba(248,81,73,0.08); } +.log-line.warn { background: rgba(210,153,34,0.06); } +.log-line.ok { background: rgba(63,185,80,0.06); } +.log-line .ln { color: var(--muted); } +.log-line .msg { white-space: pre-wrap; word-break: break-all; } +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 1rem; +} +.panel h3 { + margin: 0; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} +.panel pre { + margin: 0; + padding: 1rem; + overflow: auto; + max-height: 50vh; + font-family: ui-monospace, Consolas, monospace; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-all; +} +.footer { margin-top: 2rem; color: var(--muted); font-size: 0.85rem; } +.hidden { display: none; } +.status-msg { color: var(--muted); font-size: 0.9rem; } diff --git a/internal/logweb/web/test.html b/internal/logweb/web/test.html new file mode 100644 index 0000000..4e6b01e --- /dev/null +++ b/internal/logweb/web/test.html @@ -0,0 +1,90 @@ + + + + + 测试执行 + + + +
+
+

测试:执行一次同步

+ +
+

+
+ + + +
+
+

执行状态

+
加载中…
+
+
+ + + + diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 45ee27d..7d36b30 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -43,6 +43,11 @@ func (r *Runner) StoreClose() { } } +// Store 返回本机审计库(供 Web 控制台只读查询)。 +func (r *Runner) Store() *db.Store { + return r.store +} + // NewRunner 加载机构配置并创建 Runner(含 forward 文件上传地址解析)。 func NewRunner(cfg config.Config, store *db.Store) (*Runner, error) { if p := strings.TrimSpace(cfg.ChromePath); p != "" { @@ -235,7 +240,8 @@ func (r *Runner) processItem(step, anchorDate, method string, batchID int, item } recordLocalID, _ := r.store.GetRecordID(bizKey) - _ = r.store.SaveLog(recordLocalID, result.HTTPCode, result.MsgCode, result.Msg, result.TraceID, len(req.Body), result.Body, duration) + 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 diff --git a/internal/syncgate/gate.go b/internal/syncgate/gate.go new file mode 100644 index 0000000..4c73e7a --- /dev/null +++ b/internal/syncgate/gate.go @@ -0,0 +1,66 @@ +// Package syncgate 保证全局同一时间仅有一次 sync(Cron 与 Web 测试互斥)。 +package syncgate + +import ( + "sync" + "time" +) + +// Status 当前同步执行状态。 +type Status struct { + Running bool `json:"running"` + LastStep string `json:"last_step,omitempty"` + LastDate string `json:"last_date,omitempty"` + LastError string `json:"last_error,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` +} + +var ( + mu sync.Mutex + status Status +) + +// TryRun 若已有任务在执行则返回 false;否则在 goroutine 中执行 fn 并更新状态。 +func TryRun(step, date string, fn func() error) bool { + mu.Lock() + if status.Running { + mu.Unlock() + return false + } + status.Running = true + status.LastStep = step + status.LastDate = date + status.LastError = "" + status.StartedAt = time.Now() + status.FinishedAt = time.Time{} + mu.Unlock() + + go func() { + err := fn() + mu.Lock() + status.Running = false + status.FinishedAt = time.Now() + if err != nil { + status.LastError = err.Error() + } else { + status.LastError = "" + } + mu.Unlock() + }() + return true +} + +// GetStatus 返回状态快照。 +func GetStatus() Status { + mu.Lock() + defer mu.Unlock() + return status +} + +// IsRunning 是否正在执行 sync。 +func IsRunning() bool { + mu.Lock() + defer mu.Unlock() + return status.Running +} diff --git a/log/transit/app-2026-05-22.log b/log/transit/app-2026-05-22.log new file mode 100644 index 0000000..709c7d4 --- /dev/null +++ b/log/transit/app-2026-05-22.log @@ -0,0 +1,53 @@ +[app] 2026/05/22 08:28:43 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-22 ======== +[app] 2026/05/22 08:28:43 sync run start step=all date=2026-05-22 +[app] 2026/05/22 08:28:43 sync start step=consult date=2026-05-22 +[app] 2026/05/22 08:28:44 sync start step=referral date=2026-05-22 +[app] 2026/05/22 08:28:44 sync start step=recipe date=2026-05-22 +[app] 2026/05/22 08:28:46 sync start step=verification date=2026-05-22 +[app] 2026/05/22 08:28:46 sync done step=all date=2026-05-22 duration=3s +======== END sync step=all date=2026-05-22 ok=true ======== +[app] 2026/05/22 08:39:27 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN sync step=all date=2026-05-22 ======== +[app] 2026/05/22 08:39:27 sync run start step=all date=2026-05-22 +[app] 2026/05/22 08:39:27 sync start step=consult date=2026-05-22 +[app] 2026/05/22 08:39:27 sync start step=referral date=2026-05-22 +[app] 2026/05/22 08:39:28 sync start step=recipe date=2026-05-22 +[app] 2026/05/22 08:39:28 sync start step=verification date=2026-05-22 +[app] 2026/05/22 08:39:29 sync done step=all date=2026-05-22 duration=1s +======== END sync step=all date=2026-05-22 ok=true ======== +[app] 2026/05/22 08:52:10 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN web-test step=all date=2026-05-22 ======== +[app] 2026/05/22 08:53:14 web test sync start step=all date=2026-05-22 +[app] 2026/05/22 08:53:14 sync start step=consult date=2026-05-22 +[app] 2026/05/22 08:53:14 sync start step=referral date=2026-05-22 +[app] 2026/05/22 08:53:14 sync start step=recipe date=2026-05-22 +[app] 2026/05/22 08:53:15 sync start step=verification date=2026-05-22 +[app] 2026/05/22 08:53:15 web test sync done step=all date=2026-05-22 +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[app] 2026/05/22 08:54:01 web test sync start step=all date=2026-05-22 +[app] 2026/05/22 08:54:01 sync start step=consult date=2026-05-22 +[app] 2026/05/22 08:54:01 sync start step=referral date=2026-05-22 +[app] 2026/05/22 08:54:01 sync start step=recipe date=2026-05-22 +[app] 2026/05/22 08:54:02 sync start step=verification date=2026-05-22 +[app] 2026/05/22 08:54:02 web test sync done step=all date=2026-05-22 +======== END web-test step=all date=2026-05-22 ok=true ======== +[app] 2026/05/22 08:59:26 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) +======== BEGIN web-test step=all date=2026-05-22 ======== +[app] 2026/05/22 08:59:35 web test sync start step=all date=2026-05-22 +[app] 2026/05/22 08:59:35 sync start step=consult date=2026-05-22 +[app] 2026/05/22 08:59:35 sync start step=referral date=2026-05-22 +[app] 2026/05/22 08:59:35 sync start step=recipe date=2026-05-22 +[app] 2026/05/22 08:59:36 sync start step=verification date=2026-05-22 +[app] 2026/05/22 08:59:36 web test sync done step=all date=2026-05-22 +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[app] 2026/05/22 09:04:42 web test sync start step=all date=2026-05-22 +[app] 2026/05/22 09:04:42 sync start step=consult date=2026-05-22 +[app] 2026/05/22 09:04:42 sync start step=referral date=2026-05-22 +[app] 2026/05/22 09:04:42 sync start step=recipe date=2026-05-22 +[app] 2026/05/22 09:04:48 sync start step=verification date=2026-05-22 +[app] 2026/05/22 09:04:48 web test sync done step=all date=2026-05-22 +======== END web-test step=all date=2026-05-22 ok=true ======== +[app] 2026/05/22 09:12:46 file upload via forward: http://127.0.0.1:16001/mng/file/auth/upload (cloud fileUploadUrl ignored) diff --git a/log/transit/pull-2026-05-22.log b/log/transit/pull-2026-05-22.log new file mode 100644 index 0000000..7601802 --- /dev/null +++ b/log/transit/pull-2026-05-22.log @@ -0,0 +1,85 @@ +======== BEGIN sync step=all date=2026-05-22 ======== +[pull] 2026/05/22 08:28:44 batch created step=consult date=2026-05-22 batch_id=1 +[pull] 2026/05/22 08:28:44 pull done step=consult batch_id=1 items=0 validation_failed=0 +[pull] 2026/05/22 08:28:44 step done step=consult batch_id=1 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:28:44 batch created step=referral date=2026-05-22 batch_id=2 +[pull] 2026/05/22 08:28:44 pull done step=referral batch_id=2 items=0 validation_failed=0 +[pull] 2026/05/22 08:28:44 step done step=referral batch_id=2 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:28:44 batch created step=recipe date=2026-05-22 batch_id=3 +[pull] 2026/05/22 08:28:45 pull done step=recipe batch_id=3 items=1 validation_failed=0 +[pull] 2026/05/22 08:28:46 step done step=recipe batch_id=3 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/22 08:28:46 batch created step=verification date=2026-05-22 batch_id=4 +[pull] 2026/05/22 08:28:46 pull done step=verification batch_id=4 items=0 validation_failed=0 +[pull] 2026/05/22 08:28:46 step done step=verification batch_id=4 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-22 ok=true ======== +======== BEGIN sync step=all date=2026-05-22 ======== +[pull] 2026/05/22 08:39:27 batch created step=consult date=2026-05-22 batch_id=9 +[pull] 2026/05/22 08:39:27 pull done step=consult batch_id=9 items=0 validation_failed=0 +[pull] 2026/05/22 08:39:27 step done step=consult batch_id=9 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:39:28 batch created step=referral date=2026-05-22 batch_id=10 +[pull] 2026/05/22 08:39:28 pull done step=referral batch_id=10 items=0 validation_failed=0 +[pull] 2026/05/22 08:39:28 step done step=referral batch_id=10 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:39:28 batch created step=recipe date=2026-05-22 batch_id=11 +[pull] 2026/05/22 08:39:28 pull done step=recipe batch_id=11 items=1 validation_failed=0 +[pull] 2026/05/22 08:39:28 step done step=recipe batch_id=11 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/22 08:39:29 batch created step=verification date=2026-05-22 batch_id=12 +[pull] 2026/05/22 08:39:29 pull done step=verification batch_id=12 items=0 validation_failed=0 +[pull] 2026/05/22 08:39:29 step done step=verification batch_id=12 total=0 success=0 failed=0 skipped=0 +======== END sync step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[pull] 2026/05/22 08:53:14 batch created step=consult date=2026-05-22 batch_id=13 +[pull] 2026/05/22 08:53:14 pull done step=consult batch_id=13 items=0 validation_failed=0 +[pull] 2026/05/22 08:53:14 step done step=consult batch_id=13 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:53:14 batch created step=referral date=2026-05-22 batch_id=14 +[pull] 2026/05/22 08:53:14 pull done step=referral batch_id=14 items=0 validation_failed=0 +[pull] 2026/05/22 08:53:14 step done step=referral batch_id=14 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:53:14 batch created step=recipe date=2026-05-22 batch_id=15 +[pull] 2026/05/22 08:53:15 pull done step=recipe batch_id=15 items=1 validation_failed=0 +[pull] 2026/05/22 08:53:15 step done step=recipe batch_id=15 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/22 08:53:15 batch created step=verification date=2026-05-22 batch_id=16 +[pull] 2026/05/22 08:53:15 pull done step=verification batch_id=16 items=0 validation_failed=0 +[pull] 2026/05/22 08:53:15 step done step=verification batch_id=16 total=0 success=0 failed=0 skipped=0 +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[pull] 2026/05/22 08:54:01 batch created step=consult date=2026-05-22 batch_id=1 +[pull] 2026/05/22 08:54:01 pull done step=consult batch_id=1 items=0 validation_failed=0 +[pull] 2026/05/22 08:54:01 step done step=consult batch_id=1 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:54:01 batch created step=referral date=2026-05-22 batch_id=2 +[pull] 2026/05/22 08:54:01 pull done step=referral batch_id=2 items=0 validation_failed=0 +[pull] 2026/05/22 08:54:01 step done step=referral batch_id=2 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:54:01 batch created step=recipe date=2026-05-22 batch_id=3 +[pull] 2026/05/22 08:54:02 pull done step=recipe batch_id=3 items=1 validation_failed=0 +[pull] 2026/05/22 08:54:02 step done step=recipe batch_id=3 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/22 08:54:02 batch created step=verification date=2026-05-22 batch_id=4 +[pull] 2026/05/22 08:54:02 pull done step=verification batch_id=4 items=0 validation_failed=0 +[pull] 2026/05/22 08:54:02 step done step=verification batch_id=4 total=0 success=0 failed=0 skipped=0 +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[pull] 2026/05/22 08:59:35 batch created step=consult date=2026-05-22 batch_id=1 +[pull] 2026/05/22 08:59:35 pull done step=consult batch_id=1 items=0 validation_failed=0 +[pull] 2026/05/22 08:59:35 step done step=consult batch_id=1 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:59:35 batch created step=referral date=2026-05-22 batch_id=2 +[pull] 2026/05/22 08:59:35 pull done step=referral batch_id=2 items=0 validation_failed=0 +[pull] 2026/05/22 08:59:35 step done step=referral batch_id=2 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 08:59:35 batch created step=recipe date=2026-05-22 batch_id=3 +[pull] 2026/05/22 08:59:36 pull done step=recipe batch_id=3 items=1 validation_failed=0 +[pull] 2026/05/22 08:59:36 step done step=recipe batch_id=3 total=1 success=1 failed=0 skipped=0 +[pull] 2026/05/22 08:59:36 batch created step=verification date=2026-05-22 batch_id=4 +[pull] 2026/05/22 08:59:36 pull done step=verification batch_id=4 items=0 validation_failed=0 +[pull] 2026/05/22 08:59:36 step done step=verification batch_id=4 total=0 success=0 failed=0 skipped=0 +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[pull] 2026/05/22 09:04:42 batch created step=consult date=2026-05-22 batch_id=1 +[pull] 2026/05/22 09:04:42 pull done step=consult batch_id=1 items=0 validation_failed=0 +[pull] 2026/05/22 09:04:42 step done step=consult batch_id=1 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 09:04:42 batch created step=referral date=2026-05-22 batch_id=2 +[pull] 2026/05/22 09:04:42 pull done step=referral batch_id=2 items=0 validation_failed=0 +[pull] 2026/05/22 09:04:42 step done step=referral batch_id=2 total=0 success=0 failed=0 skipped=0 +[pull] 2026/05/22 09:04:43 batch created step=recipe date=2026-05-22 batch_id=3 +[pull] 2026/05/22 09:04:45 pull done step=recipe batch_id=3 items=4 validation_failed=1 +[pull] 2026/05/22 09:04:46 skip validation biz_key=recipe|2026-05-22|261|ZY2224671779411833 errors=[recipeChHerbalIndicatorsReq.organDiseaseId recipeChHerbalIndicatorsReq.organDiseaseName recipeChHerbalIndicatorsReq.symptomId recipeChHerbalIndicatorsReq.symptomName recipeChHerbalIndicatorsReq.tcmTherapyCode recipeChHerbalIndicatorsReq.tcmTherapyName] +[pull] 2026/05/22 09:04:48 step done step=recipe batch_id=3 total=4 success=3 failed=0 skipped=1 +[pull] 2026/05/22 09:04:48 batch created step=verification date=2026-05-22 batch_id=4 +[pull] 2026/05/22 09:04:48 pull done step=verification batch_id=4 items=0 validation_failed=0 +[pull] 2026/05/22 09:04:48 step done step=verification batch_id=4 total=0 success=0 failed=0 skipped=0 +======== END web-test step=all date=2026-05-22 ok=true ======== diff --git a/log/transit/push-2026-05-22.log b/log/transit/push-2026-05-22.log new file mode 100644 index 0000000..9378c28 --- /dev/null +++ b/log/transit/push-2026-05-22.log @@ -0,0 +1,38 @@ +======== BEGIN sync step=all date=2026-05-22 ======== +[push] 2026/05/22 08:28:45 recipe pdf: prescription_id=400 html_len=143641 save_root=D:\worker\code\xk-hy-transit-go +[push] 2026/05/22 08:28:45 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/22 08:28:45 chromedp: html_len=143639 data_url_len=191556 +[push] 2026/05/22 08:28:46 chromedp: pdf bytes=93431 +[push] 2026/05/22 08:28:46 saved local pdf: D:\worker\code\xk-hy-transit-go\pdf\2026-05-22\李二狗浙江萧康医药有限公司萧山俊良诊所 XY2947851779409669 2026-05-22 08-27-49.pdf +[push] 2026/05/22 08:28:46 upload pdf ok prescription_no=XY2947851779409669 fileId=e9fc5fcb6d5c4f7a8b09c7f5c5c local=D:\worker\code\xk-hy-transit-go\pdf\2026-05-22\李二狗浙江萧康医药有限公司萧山俊良诊所 XY2947851779409669 2026-05-22 08-27-49.pdf url=http://127.0.0.1:16001/mng/file/auth/upload +[push] 2026/05/22 08:28:46 ok biz_key=recipe|2026-05-22|259|XY2947851779409669 method=uploadRecipeIndicators code=200 msgCode=0 trace= 14ms +======== END sync step=all date=2026-05-22 ok=true ======== +======== BEGIN sync step=all date=2026-05-22 ======== +[push] 2026/05/22 08:39:28 ok biz_key=recipe|2026-05-22|259|XY2947851779409669 method=uploadRecipeIndicators code=200 msgCode=0 trace= 49ms +======== END sync step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[push] 2026/05/22 08:53:15 ok biz_key=recipe|2026-05-22|259|XY2947851779409669 method=uploadRecipeIndicators code=200 msgCode=0 trace= 12ms +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[push] 2026/05/22 08:54:02 ok biz_key=recipe|2026-05-22|259|XY2947851779409669 method=uploadRecipeIndicators code=200 msgCode=0 trace= 55ms +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[push] 2026/05/22 08:59:36 ok biz_key=recipe|2026-05-22|259|XY2947851779409669 method=uploadRecipeIndicators code=200 msgCode=0 trace= 12ms +======== END web-test step=all date=2026-05-22 ok=true ======== +======== BEGIN web-test step=all date=2026-05-22 ======== +[push] 2026/05/22 09:04:45 recipe pdf: prescription_id=403 html_len=145929 save_root=D:\worker\code\xk-hy-transit-go +[push] 2026/05/22 09:04:45 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/22 09:04:45 chromedp: html_len=145927 data_url_len=194608 +[push] 2026/05/22 09:04:46 chromedp: pdf bytes=103571 +[push] 2026/05/22 09:04:46 saved local pdf: D:\worker\code\xk-hy-transit-go\pdf\2026-05-22\李二狗浙江萧康医药有限公司萧山俊良诊所 ZY1638911779411850 2026-05-22 09-04-10.pdf +[push] 2026/05/22 09:04:46 upload pdf ok prescription_no=ZY1638911779411850 fileId=e9fc5fcb6d5c4f7a8b09c7f5c5c local=D:\worker\code\xk-hy-transit-go\pdf\2026-05-22\李二狗浙江萧康医药有限公司萧山俊良诊所 ZY1638911779411850 2026-05-22 09-04-10.pdf url=http://127.0.0.1:16001/mng/file/auth/upload +[push] 2026/05/22 09:04:46 ok biz_key=recipe|2026-05-22|261|ZY1638911779411850 method=uploadRecipeIndicators code=200 msgCode=0 trace= 12ms +[push] 2026/05/22 09:04:46 recipe pdf: prescription_id=401 html_len=143634 save_root=D:\worker\code\xk-hy-transit-go +[push] 2026/05/22 09:04:46 chromedp: using browser C:\Program Files\Google\Chrome\Application\chrome.exe +[push] 2026/05/22 09:04:46 chromedp: html_len=143632 data_url_len=191548 +[push] 2026/05/22 09:04:47 chromedp: pdf bytes=92532 +[push] 2026/05/22 09:04:47 saved local pdf: D:\worker\code\xk-hy-transit-go\pdf\2026-05-22\李二狗浙江萧康医药有限公司萧山俊良诊所 XY5688971779411826 2026-05-22 09-03-46.pdf +[push] 2026/05/22 09:04:47 upload pdf ok prescription_no=XY5688971779411826 fileId=e9fc5fcb6d5c4f7a8b09c7f5c5c local=D:\worker\code\xk-hy-transit-go\pdf\2026-05-22\李二狗浙江萧康医药有限公司萧山俊良诊所 XY5688971779411826 2026-05-22 09-03-46.pdf url=http://127.0.0.1:16001/mng/file/auth/upload +[push] 2026/05/22 09:04:48 ok biz_key=recipe|2026-05-22|261|XY5688971779411826 method=uploadRecipeIndicators code=200 msgCode=0 trace= 54ms +[push] 2026/05/22 09:04:48 ok biz_key=recipe|2026-05-22|259|XY2947851779409669 method=uploadRecipeIndicators code=200 msgCode=0 trace= 54ms +======== END web-test step=all date=2026-05-22 ok=true ========