更新html
This commit is contained in:
@@ -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
|
||||
|
||||
12
README.md
12
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 路径以实际仓库为准)。
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
---
|
||||
|
||||
## 单次执行(跑完即退出)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
278
internal/db/query.go
Normal 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
19
internal/db/query_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
103
internal/hy/labels.go
Normal 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": "处方核销"},
|
||||
}
|
||||
}
|
||||
32
internal/hy/labels_test.go
Normal file
32
internal/hy/labels_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
0
internal/hyfile/app-2026-05-22.log
Normal file
0
internal/hyfile/app-2026-05-22.log
Normal file
0
internal/hyfile/pull-2026-05-22.log
Normal file
0
internal/hyfile/pull-2026-05-22.log
Normal file
6
internal/hyfile/push-2026-05-22.log
Normal file
6
internal/hyfile/push-2026-05-22.log
Normal 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
202
internal/logweb/dbview.go
Normal 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
6
internal/logweb/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package logweb
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
118
internal/logweb/files.go
Normal file
118
internal/logweb/files.go
Normal 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
|
||||
}
|
||||
55
internal/logweb/files_test.go
Normal file
55
internal/logweb/files_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
62
internal/logweb/jsonfmt.go
Normal file
62
internal/logweb/jsonfmt.go
Normal 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"
|
||||
}
|
||||
}
|
||||
70
internal/logweb/labels_api.go
Normal file
70
internal/logweb/labels_api.go
Normal 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
62
internal/logweb/parse.go
Normal 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
228
internal/logweb/server.go
Normal 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})
|
||||
}
|
||||
61
internal/logweb/web/app.js
Normal file
61
internal/logweb/web/app.js
Normal 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('');
|
||||
}
|
||||
32
internal/logweb/web/index.html
Normal file
32
internal/logweb/web/index.html
Normal 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>
|
||||
44
internal/logweb/web/logs.html
Normal file
44
internal/logweb/web/logs.html
Normal 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>
|
||||
60
internal/logweb/web/logs_view.html
Normal file
60
internal/logweb/web/logs_view.html
Normal 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>
|
||||
89
internal/logweb/web/runs.html
Normal file
89
internal/logweb/web/runs.html
Normal 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>
|
||||
103
internal/logweb/web/runs_record.html
Normal file
103
internal/logweb/web/runs_record.html
Normal 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>
|
||||
145
internal/logweb/web/static.css
Normal file
145
internal/logweb/web/static.css
Normal 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; }
|
||||
90
internal/logweb/web/test.html
Normal file
90
internal/logweb/web/test.html
Normal 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>
|
||||
@@ -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
66
internal/syncgate/gate.go
Normal file
@@ -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
|
||||
}
|
||||
53
log/transit/app-2026-05-22.log
Normal file
53
log/transit/app-2026-05-22.log
Normal 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)
|
||||
85
log/transit/pull-2026-05-22.log
Normal file
85
log/transit/pull-2026-05-22.log
Normal 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 ========
|
||||
38
log/transit/push-2026-05-22.log
Normal file
38
log/transit/push-2026-05-22.log
Normal 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 ========
|
||||
Reference in New Issue
Block a user