更新html

This commit is contained in:
李琦
2026-05-22 09:17:39 +08:00
parent 1ae21d83f1
commit 86da349ae1
36 changed files with 2280 additions and 28 deletions

View File

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

View File

@@ -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 路径以实际仓库为准)。

View File

@@ -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 日志目录=%sCtrl+C 结束进程)", expr, applog.Dir())
applog.Consolef("监管中转定时服务已启动 cron=%s 日志目录=%s", expr, applog.Dir())
applog.Consolef("Web 控制台 http://%s/ Ctrl+C 结束进程)", addr)
select {}
}

View File

@@ -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。
---
## 单次执行(跑完即退出)

View File

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

View File

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

278
internal/db/query.go Normal file
View File

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

19
internal/db/query_test.go Normal file
View File

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

View File

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

View File

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

103
internal/hy/labels.go Normal file
View File

@@ -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": "处方核销"},
}
}

View File

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

View File

View File

View File

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

202
internal/logweb/dbview.go Normal file
View File

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

6
internal/logweb/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package logweb
import "embed"
//go:embed web/*
var webFS embed.FS

118
internal/logweb/files.go Normal file
View File

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

View File

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

View File

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

View File

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

62
internal/logweb/parse.go Normal file
View File

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

228
internal/logweb/server.go Normal file
View File

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

View File

@@ -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 `<span class="badge ${cls}">${esc(display)}</span>`;
}
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 ? '<span class="badge ok">已连接</span>' : '<span class="badge err">' + esc(m.mysqlError || '未连接') + '</span>'}`;
}
return m;
} catch (e) {
return null;
}
}
function fillStepSelect(sel, options, includeAll) {
if (!sel || !options) return;
sel.innerHTML = options.map(o =>
`<option value="${esc(o.value)}">${esc(o.label)}</option>`
).join('');
}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Transit 控制台</title>
<link rel="stylesheet" href="/static/static.css">
</head>
<body>
<div class="wrap">
<header>
<h1>互联网医院监管中转 · Web 控制台</h1>
</header>
<div class="cards">
<a class="card" href="/logs">
<h2>文件日志</h2>
<p>查看 app / pull / push 按日滚动日志,支持搜索与高亮。</p>
</a>
<a class="card" href="/runs">
<h2>同步流水</h2>
<p>三表关联:任务、拉取 payload、上传 HTTP、推断回调。</p>
</a>
<a class="card" href="/test">
<h2>测试执行</h2>
<p>触发一次 sync拉取 → 上传 → 回调),用于联调测试。</p>
</a>
</div>
<p class="footer" id="meta-footer">加载中…</p>
</div>
<script src="/static/app.js"></script>
<script>loadMeta();</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>文件日志</title>
<link rel="stylesheet" href="/static/static.css">
</head>
<body>
<div class="wrap">
<header>
<h1>文件日志</h1>
<nav class="nav"><a href="/">首页</a><a href="/runs">同步流水</a><a href="/test">测试</a></nav>
</header>
<div class="cards">
<a class="card" href="#" data-kind="app"><h2>应用 app</h2><p>进程、定时、配置告警</p></a>
<a class="card" href="#" data-kind="pull"><h2>拉取 pull</h2><p>建批次、云端拉取</p></a>
<a class="card" href="#" data-kind="push"><h2>推送 push</h2><p>转发政务云、PDF 上传</p></a>
</div>
<div class="toolbar hidden" id="date-bar">
<label>日期 <select id="date-select"></select></label>
<button class="primary" id="view-btn">查看</button>
</div>
</div>
<script src="/static/app.js"></script>
<script>
const kinds = { app: '应用', pull: '拉取', push: '推送' };
let currentKind = '';
document.querySelectorAll('[data-kind]').forEach(el => {
el.addEventListener('click', async e => {
e.preventDefault();
currentKind = el.dataset.kind;
const dates = await api('/api/dates?kind=' + currentKind);
const sel = document.getElementById('date-select');
sel.innerHTML = dates.map(d => `<option value="${d}">${d}</option>`).join('');
document.getElementById('date-bar').classList.remove('hidden');
});
});
document.getElementById('view-btn').onclick = () => {
const d = document.getElementById('date-select').value;
if (currentKind && d) location.href = '/logs/view?kind=' + currentKind + '&date=' + d;
};
</script>
</body>
</html>

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>日志详情</title>
<link rel="stylesheet" href="/static/static.css">
</head>
<body>
<div class="wrap">
<header>
<h1 id="title">日志详情</h1>
<nav class="nav"><a href="/logs">返回</a><a href="/">首页</a></nav>
</header>
<div class="toolbar">
<input type="search" id="search" placeholder="过滤内容…" style="min-width:200px">
<label><input type="checkbox" id="auto"> 每 5 秒刷新</label>
<button id="refresh">刷新</button>
<span class="status-msg" id="meta"></span>
</div>
<div class="log-lines" id="lines"></div>
</div>
<script src="/static/app.js"></script>
<script>
const kind = qs('kind');
const date = qs('date');
document.getElementById('title').textContent = kind + ' / ' + date;
function renderLines(lines, filter) {
const f = (filter || '').toLowerCase();
const box = document.getElementById('lines');
box.innerHTML = lines.filter(l => !f || (l.message + l.raw).toLowerCase().includes(f))
.map(l => {
if (l.isSeparator) return `<div class="log-line sep">${esc(l.raw)}</div>`;
return `<div class="log-line ${l.level}">
<span class="ln">${l.lineNo}</span>
<span>${esc(l.time)}</span>
<span>${esc(l.tag)}</span>
<span class="msg">${esc(l.message || l.raw)}</span>
</div>`;
}).join('');
}
async function load() {
const data = await api('/api/log?kind=' + encodeURIComponent(kind) + '&date=' + encodeURIComponent(date));
document.getElementById('meta').textContent =
`${data.meta.path} | ${data.meta.lines} 行 | ${data.meta.size} B | ${data.meta.modified}`;
renderLines(data.lines, document.getElementById('search').value);
}
document.getElementById('search').oninput = () => load().catch(() => {});
document.getElementById('refresh').onclick = () => load();
let timer;
document.getElementById('auto').onchange = e => {
clearInterval(timer);
if (e.target.checked) timer = setInterval(() => load().catch(() => {}), 5000);
};
load().catch(e => alert(e.message));
</script>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>同步流水</title>
<link rel="stylesheet" href="/static/static.css">
</head>
<body>
<div class="wrap">
<header>
<h1>同步流水</h1>
<nav class="nav"><a href="/">首页</a><a href="/logs">文件日志</a><a href="/test">测试</a></nav>
</header>
<div class="toolbar">
<label>锚定日 <input type="date" id="anchor"></label>
<label>类型
<select id="step"></select>
</label>
<button class="primary" id="load">刷新</button>
</div>
<h3 style="color:var(--muted);font-size:0.95rem">同步任务</h3>
<div style="overflow:auto;margin-bottom:1.5rem">
<table><thead><tr>
<th>日期</th><th>类型</th><th>状态</th><th>总数</th><th>成功</th><th>失败</th><th>开始</th><th>结束</th>
</tr></thead><tbody id="jobs"></tbody></table>
</div>
<h3 style="color:var(--muted);font-size:0.95rem">业务记录(拉取 → 上报 → 回调)</h3>
<div style="overflow:auto">
<table><thead><tr>
<th>ID</th><th>日期</th><th>类型</th><th>批次</th><th>biz_key</th><th>拉取校验</th>
<th>上报</th><th>推送明文</th><th>HTTP</th><th>回调</th><th>trace</th><th></th>
</tr></thead><tbody id="items"></tbody></table>
</div>
</div>
<script src="/static/app.js"></script>
<script>
const anchorFromUrl = qs('anchor_date');
if (anchorFromUrl) document.getElementById('anchor').value = anchorFromUrl;
(async () => {
const m = await loadMeta();
if (m && m.stepOptions) fillStepSelect(document.getElementById('step'), m.stepOptions);
})();
async function load() {
const anchor = document.getElementById('anchor').value;
const step = document.getElementById('step').value;
let url = '/api/runs?limit=100';
if (anchor) url += '&anchor_date=' + anchor;
if (step) url += '&step=' + step;
const data = await api(url);
document.getElementById('jobs').innerHTML = (data.jobs || []).map(j => `<tr>
<td>${esc(j.anchorDate)}</td>
<td>${esc(j.stepLabel || j.step)}</td>
<td>${badge(j.status, j.statusLabel)}</td>
<td>${j.totalCount}</td><td>${j.successCount}</td><td>${j.failedCount}</td>
<td>${fmtTimeAny(j.startedAt)}</td>
<td>${fmtTimeAny(j.finishedAt)}</td>
</tr>`).join('') || '<tr><td colspan="8">无任务</td></tr>';
document.getElementById('items').innerHTML = (data.items || []).map(it => {
const r = it.record;
const cb = it.callback;
const ve = it.errors && it.errors.valid ? (it.errors.formatted || it.errors.raw) : (it.errors && it.errors.raw) || '';
const veShort = ve.length > 80 ? ve.slice(0, 80) + '…' : ve;
const ll = r.LastLog;
return `<tr>
<td>${r.ID}</td>
<td>${esc(r.AnchorDate)}</td>
<td>${esc(it.stepLabel || it.step)}</td>
<td>${r.JobID}</td>
<td><code>${esc(r.BizKey)}</code></td>
<td>${veShort ? '<span class="badge warn">有</span> ' + esc(veShort) : badge('success', '通过')}</td>
<td>${badge(r.PushStatus, it.pushStatusLabel)}</td>
<td>${it.hasPushPlain ? '<span class="badge ok">有</span>' : '<span class="badge warn">无</span>'}</td>
<td>${ll ? ll.HTTPCode + ' / ' + (ll.MsgCode && ll.MsgCode.Valid ? ll.MsgCode.Int64 : '-') + ' / ' + ll.DurationMs + 'ms' : '-'}</td>
<td>${badge(cb.callback_status, it.callbackStatusLabel)} ${esc(cb.error_message || '')}</td>
<td>${ll ? esc(ll.TraceID) : '-'}</td>
<td><a href="/runs/record?id=${r.ID}">详情</a></td>
</tr>`;
}).join('') || '<tr><td colspan="12">无记录</td></tr>';
}
document.getElementById('load').onclick = () => load().catch(e => alert(e.message));
load().catch(e => alert(e.message));
</script>
</body>
</html>

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>记录详情</title>
<link rel="stylesheet" href="/static/static.css">
</head>
<body>
<div class="wrap">
<header>
<h1>记录详情 #<span id="rid"></span></h1>
<nav class="nav"><a href="/runs">返回流水</a><a href="/">首页</a></nav>
</header>
<div class="toolbar" id="summary"></div>
<div class="panel">
<h3>① 拉取(云端组包 payload_json</h3>
<p class="status-msg">云端拉下的原始明文,可能尚未含本机机构字段。</p>
<pre id="payload"></pre>
</div>
<div class="panel">
<h3>校验 validation_errors</h3>
<pre id="errors"></pre>
</div>
<div class="panel">
<h3>② 推送明文(加密前组包 JSON</h3>
<p class="status-msg">BuildUpload 合并 unitID/organID/organName 后、AES 加密前的业务 JSON。</p>
<pre id="push-plain"></pre>
</div>
<div class="panel">
<h3>③ 推送请求(转发头 + 密文摘要)</h3>
<div id="push-req"></div>
</div>
<div class="panel">
<h3>④ 政务云响应</h3>
<div id="responses"></div>
</div>
<div class="panel">
<h3>⑤ 回调(推断 CallbackItem</h3>
<pre id="callback"></pre>
</div>
</div>
<script src="/static/app.js"></script>
<script>
const id = qs('id');
document.getElementById('rid').textContent = id;
function showJSON(el, block, emptyHint) {
if (!block || (!block.raw && !block.formatted)) {
el.textContent = emptyHint || '(空)';
return;
}
el.textContent = block.valid && block.formatted ? block.formatted : (block.raw || '(空)');
}
async function load() {
const d = await api('/api/runs/record?id=' + id);
const r = d.record;
document.getElementById('summary').innerHTML =
`锚定日 <b>${esc(r.AnchorDate)}</b> | step <b>${esc(d.step)}</b> | batch_id <b>${r.JobID}</b> | ` +
`biz_key <code>${esc(r.BizKey)}</code> | 上传 ${badge(r.PushStatus)} | 回调 ${badge(d.callback.CallbackStatus)}`;
showJSON(document.getElementById('payload'), d.payload);
showJSON(document.getElementById('errors'), d.errors);
const plainEl = document.getElementById('push-plain');
if (d.pushPlain && d.pushPlain.raw) {
showJSON(plainEl, d.pushPlain);
} else {
plainEl.textContent = '(历史记录未采集推送明文,请重新执行同步后查看)';
}
const reqEl = document.getElementById('push-req');
if (!d.logViews || !d.logViews.length) {
reqEl.innerHTML = '<pre>(无推送日志)</pre>';
} else {
reqEl.innerHTML = d.logViews.map((lv, i) => {
const hdr = lv.headers && lv.headers.valid && lv.headers.formatted ? lv.headers.formatted : (lv.headers && lv.headers.raw) || '(无请求头)';
const cipher = lv.cipherPreview ? `<p class="status-msg">requestBody 密文摘要(前 200 字符):</p><pre>${esc(lv.cipherPreview)}</pre>` : '';
return `<div style="margin-bottom:0.75rem"><b>请求 #${i + 1}</b> 密文 body 长度 ${lv.log.RequestBodyLen} 字节${cipher}<p class="status-msg">转发请求头(不含 secret</p><pre>${esc(hdr)}</pre></div>`;
}).join('');
}
const respEl = document.getElementById('responses');
if (!d.logViews || !d.logViews.length) {
respEl.innerHTML = '<pre>(无)</pre>';
} else {
respEl.innerHTML = d.logViews.map((lv, i) => {
const l = lv.log;
const respText = lv.response && lv.response.valid && lv.response.formatted ? lv.response.formatted : (lv.response && lv.response.raw) || l.Msg || '';
return `<div class="panel" style="margin:0.5rem 0;border:none">
<h3>HTTP ${l.HTTPCode} msgCode ${l.MsgCode && l.MsgCode.Valid ? l.MsgCode.Int64 : '-'} ${l.DurationMs}ms trace ${esc(l.TraceID)}</h3>
<pre>${esc(respText)}</pre>
</div>`;
}).join('');
}
document.getElementById('callback').textContent = JSON.stringify(d.callback, null, 2);
}
load().catch(e => alert(e.message));
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>测试执行</title>
<link rel="stylesheet" href="/static/static.css">
</head>
<body>
<div class="wrap">
<header>
<h1>测试:执行一次同步</h1>
<nav class="nav"><a href="/">首页</a><a href="/runs">同步流水</a></nav>
</header>
<p class="status-msg" id="hint"></p>
<div class="toolbar">
<label>类型
<select id="step"></select>
</label>
<label>锚定日 <input type="date" id="date" placeholder="空=按配置推算"></label>
<button class="primary" id="run">执行一次</button>
</div>
<div class="panel">
<h3>执行状态</h3>
<pre id="status">加载中…</pre>
</div>
</div>
<script src="/static/app.js"></script>
<script>
let pollTimer;
function formatStatus(s) {
const lines = [
'运行中: ' + (s.running ? '是' : '否'),
'类型: ' + (s.last_step_label || s.last_step || '-'),
'锚定日: ' + (s.last_date || '-'),
'开始: ' + fmtTimeAny(s.started_at),
'结束: ' + fmtTimeAny(s.finished_at),
];
if (s.last_error) lines.push('错误: ' + s.last_error);
return lines.join('\n');
}
async function refreshStatus() {
const s = await api('/api/test/status');
document.getElementById('status').textContent = formatStatus(s);
document.getElementById('run').disabled = s.running;
if (!s.running && s.finishedAt && s.finishedAt !== '0001-01-01T00:00:00Z') {
const d = s.last_date;
if (d && !pollTimer) {
const go = confirm('执行已结束。是否打开同步流水?');
if (go) location.href = '/runs?anchor_date=' + d;
}
}
}
async function init() {
const m = await loadMeta();
if (m && m.testStepOptions) fillStepSelect(document.getElementById('step'), m.testStepOptions);
const hint = document.getElementById('hint');
if (!m || !m.allowTest) {
hint.innerHTML = '<span class="badge err">测试同步不可用</span> 请使用 <code>transit serve</code> 启动,并设置 LOG_WEB_ALLOW_TEST=true';
document.getElementById('run').disabled = true;
} else {
hint.textContent = '将执行完整链路:建批次 → 拉取 → 上报 → 批量回调。与 CLI transit sync 相同。';
}
await refreshStatus();
setInterval(refreshStatus, 2000);
}
document.getElementById('run').onclick = async () => {
const step = document.getElementById('step').value;
const date = document.getElementById('date').value;
try {
const r = await api('/api/test/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step, date }),
});
alert('已启动: ' + (r.step_label || r.step) + ' 锚定日=' + r.date);
pollTimer = null;
refreshStatus();
} catch (e) {
alert(e.message);
}
};
init();
</script>
</body>
</html>

View File

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

66
internal/syncgate/gate.go Normal file
View File

@@ -0,0 +1,66 @@
// Package syncgate 保证全局同一时间仅有一次 syncCron 与 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
}

View File

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

View File

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

View File

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