From fb1c9356caa94404e742c47a79c0246ac888e4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Tue, 21 Jul 2026 16:35:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E5=B7=B2=E4=BD=BF=E7=94=A8=EF=BC=8C=E5=8F=AF=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 + internal/forward/apilog.go | 11 +- internal/forward/testweb/apilog_list.go | 47 ++- .../forward/testweb/prescription_handlers.go | 56 +++ .../forward/testweb/prescription_index.go | 365 ++++++++++++++++++ .../testweb/prescription_index_test.go | 143 +++++++ internal/forward/testweb/server.go | 6 + internal/forward/testweb/web/index.html | 1 + .../forward/testweb/web/prescription.html | 69 ++++ internal/forward/testweb/web/prescription.js | 132 +++++++ internal/forward/testweb/web/t.css | 74 ++++ 11 files changed, 912 insertions(+), 11 deletions(-) create mode 100644 internal/forward/testweb/prescription_handlers.go create mode 100644 internal/forward/testweb/prescription_index.go create mode 100644 internal/forward/testweb/prescription_index_test.go create mode 100644 internal/forward/testweb/web/prescription.html create mode 100644 internal/forward/testweb/web/prescription.js diff --git a/README.md b/README.md index df55f07..b802a48 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ | `POST /mng/file/auth/upload` | `FILE_TARGET_URL`(28211) | 处方 PDF multipart 上传 | | `GET /health` | 本地 | 健康检查 | | `GET /t` | 本地 | 政务云测试页(读 file api-logs、连通测试、回放、导出 ApiPost) | +| `GET /t/prescription` | 本地 | 处方业务记录(PDF / 在线处方 / 处方核销,支持详情与单条重试) | 转发规则: @@ -108,6 +109,24 @@ IDE 调试:Working Directory 设为项目根,运行文件选 `main.go` 即 日志含 `X-Authorization` 等敏感信息,请勿将导出的集合或 `/t` 暴露到公网。 +## 处方业务记录 `/t/prescription` + +访问:`http://:16001/t/prescription` + +每次写 api-log 后,若为处方相关业务,会追加一行到 `{api-logs}/prescription-audit.jsonl`: + +| bizType | 含义 | +|---------|------| +| `pdf` | 28211 处方 PDF 上传 | +| `recipe` | 28212 `uploadRecipeIndicators` 在线处方 | +| `verification` | 28212 `uploadRecipeVerificationIndicators` 处方核销 | + +页面三栏切换查看记录,支持 **详情**(api-log 三段 JSON)与 **重试**(出站块 2 回放政务云,不更新 transit/xk-api 状态)。 + +历史日志可点 **重建索引**(`POST /t/api/prescription/rebuild`)全量扫描 `*_file_*.log` 与 `*_supervise_*.log`。 + +`/t/api/logs/detail` 与 `/t/api/test/send` 已支持 supervise 日志 `rel` 路径。 + 本地联调 mock 上游时,在 `.env` 中设置: ```env diff --git a/internal/forward/apilog.go b/internal/forward/apilog.go index f5dff72..14f4bbd 100644 --- a/internal/forward/apilog.go +++ b/internal/forward/apilog.go @@ -15,6 +15,8 @@ import ( "strings" "sync" "time" + + "xk-hy-forward-go/internal/forward/testweb" ) const apiLogsDirName = "api-logs" @@ -240,7 +242,14 @@ func writeAPILogFile(r *http.Request, col *apiLogCollector, cw *captureResponseW apiLogMu.Lock() defer apiLogMu.Unlock() - return os.WriteFile(path, buf.Bytes(), 0o644) + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + return err + } + rel := filepath.ToSlash(filepath.Join(hourDir, name)) + if err := testweb.AppendPrescriptionFromLogContent(apiLogRoot(), rel, buf.Bytes()); err != nil { + appLogf("%s | prescription-index append error | %v", time.Now().Format(time.RFC3339), err) + } + return nil } func writeInboundBlock(buf *bytes.Buffer, r *http.Request, kind string, body []byte) { diff --git a/internal/forward/testweb/apilog_list.go b/internal/forward/testweb/apilog_list.go index 961f043..2190dab 100644 --- a/internal/forward/testweb/apilog_list.go +++ b/internal/forward/testweb/apilog_list.go @@ -10,24 +10,30 @@ import ( "time" ) -var logRelRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}/\d{8,14}_file_[a-f0-9]+\.log$`) +// logRelRE 允许 file 与 supervise 通道 api-log 相对路径。 +var logRelRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}/\d{8,14}_(file|supervise)_[a-f0-9]+\.log$`) -// FileLogEntry api-logs 中的 file 通道日志条目。 -type FileLogEntry struct { +// APILogEntry api-logs 中的日志条目。 +type APILogEntry struct { Rel string `json:"rel"` Name string `json:"name"` HourDir string `json:"hourDir"` + Kind string `json:"kind"` Modified string `json:"modified"` Size int64 `json:"size"` } -// ListFileLogs 列出 api-logs 下所有 *_file_*.log(按修改时间倒序)。 -func ListFileLogs(root string) ([]FileLogEntry, error) { +// FileLogEntry 兼容旧 API(file 通道)。 +type FileLogEntry = APILogEntry + +// ListAPILogs 列出 api-logs;kind 为空则 file+supervise,否则 file 或 supervise。 +func ListAPILogs(root, kind string) ([]APILogEntry, error) { root, err := filepath.Abs(root) if err != nil { return nil, err } - var entries []FileLogEntry + kind = strings.TrimSpace(strings.ToLower(kind)) + var entries []APILogEntry err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -36,7 +42,18 @@ func ListFileLogs(root string) ([]FileLogEntry, error) { return nil } name := d.Name() - if !strings.Contains(name, "_file_") || !strings.HasSuffix(name, ".log") { + if !strings.HasSuffix(name, ".log") { + return nil + } + entryKind := "" + if strings.Contains(name, "_file_") { + entryKind = "file" + } else if strings.Contains(name, "_supervise_") { + entryKind = "supervise" + } else { + return nil + } + if kind != "" && entryKind != kind { return nil } rel, err := filepath.Rel(root, path) @@ -51,11 +68,11 @@ func ListFileLogs(root string) ([]FileLogEntry, error) { if err != nil { return err } - hourDir := filepath.ToSlash(filepath.Dir(rel)) - entries = append(entries, FileLogEntry{ + entries = append(entries, APILogEntry{ Rel: rel, Name: name, - HourDir: hourDir, + HourDir: filepath.ToSlash(filepath.Dir(rel)), + Kind: entryKind, Modified: info.ModTime().Format(time.RFC3339), Size: info.Size(), }) @@ -73,6 +90,16 @@ func ListFileLogs(root string) ([]FileLogEntry, error) { return entries, nil } +// ListFileLogs 列出 api-logs 下所有 *_file_*.log(按修改时间倒序)。 +func ListFileLogs(root string) ([]FileLogEntry, error) { + return ListAPILogs(root, "file") +} + +// ListSuperviseLogs 列出 supervise 通道日志。 +func ListSuperviseLogs(root string) ([]APILogEntry, error) { + return ListAPILogs(root, "supervise") +} + // resolveLogRelPath 校验 rel 并返回绝对路径。 func resolveLogRelPath(root, rel string) (string, error) { rel = filepath.ToSlash(strings.TrimSpace(rel)) diff --git a/internal/forward/testweb/prescription_handlers.go b/internal/forward/testweb/prescription_handlers.go new file mode 100644 index 0000000..e5b1b00 --- /dev/null +++ b/internal/forward/testweb/prescription_handlers.go @@ -0,0 +1,56 @@ +package testweb + +import ( + "net/http" + "strconv" + "strings" +) + +// handlePrescriptionPage 处方业务记录 HTML 页。 +func (s *server) handlePrescriptionPage(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/t/prescription" && r.URL.Path != "/t/prescription/" { + http.NotFound(w, r) + return + } + b, err := webFS.ReadFile("web/prescription.html") + if err != nil { + http.Error(w, "page not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) +} + +// handlePrescriptionRecords 返回 prescription-audit.jsonl 列表。 +func (s *server) handlePrescriptionRecords(w http.ResponseWriter, r *http.Request) { + bizType := strings.TrimSpace(r.URL.Query().Get("bizType")) + limit := 100 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + list, err := ListPrescriptionRecords(s.deps.APILogRoot, bizType, limit) + if err != nil { + writeErr(w, err.Error(), http.StatusInternalServerError) + return + } + if list == nil { + list = []PrescriptionRecord{} + } + writeJSON(w, map[string]any{"items": list, "bizType": bizType, "limit": limit}) +} + +// handlePrescriptionRebuild 全量重建处方业务索引。 +func (s *server) handlePrescriptionRebuild(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodGet { + writeErr(w, "GET or POST only", http.StatusMethodNotAllowed) + return + } + n, err := RebuildPrescriptionIndex(s.deps.APILogRoot) + if err != nil { + writeErr(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true, "count": n}) +} diff --git a/internal/forward/testweb/prescription_index.go b/internal/forward/testweb/prescription_index.go new file mode 100644 index 0000000..9717fa1 --- /dev/null +++ b/internal/forward/testweb/prescription_index.go @@ -0,0 +1,365 @@ +package testweb + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" +) + +const prescriptionIndexFile = "prescription-audit.jsonl" + +// 监管 method(与 transit hy.StepMethods 一致)。 +const ( + MethodRecipe = "uploadRecipeIndicators" + MethodVerification = "uploadRecipeVerificationIndicators" +) + +var ( + prescriptionIndexMu sync.Mutex + filenameRE = regexp.MustCompile(`filename="([^"]+)"`) +) + +// PrescriptionRecord 处方相关业务索引行。 +type PrescriptionRecord struct { + Rel string `json:"rel"` + BizType string `json:"bizType"` + Channel string `json:"channel"` + ServiceMethod string `json:"serviceMethod,omitempty"` + PrescriptionHint string `json:"prescriptionHint,omitempty"` + HTTPStatus int `json:"httpStatus"` + MsgCode int `json:"msgCode,omitempty"` + TraceID string `json:"traceId,omitempty"` + FileID string `json:"fileId,omitempty"` + OK bool `json:"ok"` + DurationMs int64 `json:"durationMs"` + At string `json:"at"` + ErrorSummary string `json:"errorSummary,omitempty"` +} + +// AppendPrescriptionFromLogContent 写 api-log 后追加处方业务索引。 +func AppendPrescriptionFromLogContent(root, rel string, raw []byte) error { + rec, ok := classifyPrescriptionLog(rel, raw) + if !ok { + return nil + } + return appendPrescriptionRecord(root, rec) +} + +func classifyPrescriptionLog(rel string, raw []byte) (PrescriptionRecord, bool) { + parsed, err := ParseAPILogBytes(rel, raw) + if err != nil { + return PrescriptionRecord{}, false + } + path := parsed.Meta["Path"] + kind := parsed.Meta["Kind"] + if path == "" && kind == "file" { + path = "/mng/file/auth/upload" + } + if path == "" && kind == "supervise" { + path = "/province/supervise/data" + } + + var rec PrescriptionRecord + rec.Rel = rel + rec.At = parsed.Meta["Time"] + if d := parsed.Meta["DurationMs"]; d != "" { + if n, err := strconv.ParseInt(d, 10, 64); err == nil { + rec.DurationMs = n + } + } + + switch { + case path == "/mng/file/auth/upload" || kind == "file": + rec.BizType = "pdf" + rec.Channel = "file" + case path == "/province/supervise/data" || kind == "supervise": + method := headerVal(parsed.Inbound.Headers, "X-Service-Method") + if method == "" { + method = headerVal(parsed.Outbound.Headers, "X-Service-Method") + } + rec.ServiceMethod = method + rec.Channel = "supervise" + switch method { + case MethodRecipe: + rec.BizType = "recipe" + case MethodVerification: + rec.BizType = "verification" + default: + return PrescriptionRecord{}, false + } + default: + return PrescriptionRecord{}, false + } + + fillPrescriptionResponse(&rec, parsed) + if rec.BizType == "pdf" { + rec.PrescriptionHint = extractPrescriptionHint(parsed.Inbound.BodyBytes) + if rec.PrescriptionHint == "" { + rec.PrescriptionHint = extractPrescriptionHint(parsed.Outbound.BodyBytes) + } + } + return rec, true +} + +func fillPrescriptionResponse(rec *PrescriptionRecord, p *ParsedAPILog) { + resp := p.Response + rec.HTTPStatus = resp.Status + if rec.HTTPStatus == 0 { + if st := p.Meta["Status"]; st != "" { + if n, err := strconv.Atoi(st); err == nil { + rec.HTTPStatus = n + } + } + } + rec.TraceID = headerVal(resp.Headers, "X-Ca-Request-Id") + if rec.TraceID == "" { + rec.TraceID = headerVal(resp.Headers, "x-ca-request-id") + } + + body := resp.BodyJSON + if body == "" { + body = resp.Body + } + if body == "" { + rec.OK = rec.HTTPStatus >= 200 && rec.HTTPStatus < 300 + return + } + + var v map[string]any + if err := json.Unmarshal([]byte(body), &v); err != nil { + rec.OK = rec.HTTPStatus >= 200 && rec.HTTPStatus < 300 + return + } + if rec.BizType == "pdf" { + fillPDFFromJSON(rec, v) + return + } + fillSuperviseFromJSON(rec, v) +} + +func fillPDFFromJSON(rec *PrescriptionRecord, v map[string]any) { + if success, ok := v["success"].(bool); ok && success { + rec.OK = true + } else if rec.HTTPStatus >= 200 && rec.HTTPStatus < 300 { + rec.OK = true + } + if row, ok := v["record"].(map[string]any); ok { + if fid, ok := row["fileId"].(string); ok { + rec.FileID = fid + } + } + if !rec.OK { + if msg, ok := v["message"].(string); ok { + rec.ErrorSummary = msg + } + } +} + +func fillSuperviseFromJSON(rec *PrescriptionRecord, v map[string]any) { + if mc, ok := jsonInt(v["msgCode"]); ok { + rec.MsgCode = mc + } + if code, ok := jsonInt(v["code"]); ok && rec.MsgCode == 0 { + rec.MsgCode = code + } + gwOK := rec.HTTPStatus >= 200 && rec.HTTPStatus < 300 + bizOK := rec.MsgCode == 0 || rec.MsgCode == 200 + if data, ok := v["data"].(map[string]any); ok { + if mc, ok := jsonInt(data["msgCode"]); ok { + rec.MsgCode = mc + bizOK = mc == 0 || mc == 200 + } + } + rec.OK = gwOK && bizOK + if !rec.OK { + rec.ErrorSummary = firstJSONString(v, "msg", "message", "errorMessage") + } +} + +func firstJSONString(v map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := v[k].(string); ok && s != "" { + return s + } + } + return "" +} + +func jsonInt(v any) (int, bool) { + switch x := v.(type) { + case float64: + return int(x), true + case int: + return x, true + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + return n, err == nil + default: + return 0, false + } +} + +func headerVal(h map[string]string, key string) string { + if h == nil { + return "" + } + for k, v := range h { + if strings.EqualFold(k, key) { + return v + } + } + return "" +} + +func extractPrescriptionHint(body []byte) string { + if len(body) == 0 { + return "" + } + text := string(body) + if m := filenameRE.FindStringSubmatch(text); len(m) > 1 { + name := m[1] + if idx := strings.LastIndex(name, "_"); idx >= 0 && idx < len(name)-1 { + suffix := strings.TrimSuffix(name[idx+1:], ".pdf") + if suffix != "" && suffix != "prescription" { + return suffix + } + } + return name + } + return "" +} + +func appendPrescriptionRecord(root string, rec PrescriptionRecord) error { + line, err := json.Marshal(rec) + if err != nil { + return err + } + prescriptionIndexMu.Lock() + defer prescriptionIndexMu.Unlock() + path := filepath.Join(root, prescriptionIndexFile) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(line, '\n')) + return err +} + +// RebuildPrescriptionIndex 扫描 api-logs 重建 prescription-audit.jsonl。 +func RebuildPrescriptionIndex(root string) (int, error) { + root, err := filepath.Abs(root) + if err != nil { + return 0, err + } + var rels []string + err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + name := d.Name() + if (!strings.Contains(name, "_file_") && !strings.Contains(name, "_supervise_")) || !strings.HasSuffix(name, ".log") { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if !logRelRE.MatchString(rel) { + return nil + } + rels = append(rels, rel) + return nil + }) + if err != nil && !os.IsNotExist(err) { + return 0, err + } + + indexPath := filepath.Join(root, prescriptionIndexFile) + tmpPath := indexPath + ".tmp" + out, err := os.Create(tmpPath) + if err != nil { + return 0, err + } + count := 0 + for _, rel := range rels { + full := filepath.Join(root, filepath.FromSlash(rel)) + raw, err := os.ReadFile(full) + if err != nil { + continue + } + rec, ok := classifyPrescriptionLog(rel, raw) + if !ok { + continue + } + line, err := json.Marshal(rec) + if err != nil { + continue + } + if _, err := out.Write(append(line, '\n')); err != nil { + _ = out.Close() + _ = os.Remove(tmpPath) + return count, err + } + count++ + } + if err := out.Close(); err != nil { + return count, err + } + if err := os.Rename(tmpPath, indexPath); err != nil { + return count, err + } + return count, nil +} + +// ListPrescriptionRecords 读取 jsonl 列表。 +func ListPrescriptionRecords(root, bizType string, limit int) ([]PrescriptionRecord, error) { + if limit <= 0 { + limit = 100 + } + path := filepath.Join(root, prescriptionIndexFile) + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + raw, err := io.ReadAll(f) + if err != nil { + return nil, err + } + lines := strings.Split(string(raw), "\n") + var all []PrescriptionRecord + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var rec PrescriptionRecord + if err := json.Unmarshal([]byte(line), &rec); err != nil { + continue + } + if bizType != "" && rec.BizType != bizType { + continue + } + all = append(all, rec) + } + for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { + all[i], all[j] = all[j], all[i] + } + if len(all) > limit { + all = all[:limit] + } + return all, nil +} diff --git a/internal/forward/testweb/prescription_index_test.go b/internal/forward/testweb/prescription_index_test.go new file mode 100644 index 0000000..3c2f008 --- /dev/null +++ b/internal/forward/testweb/prescription_index_test.go @@ -0,0 +1,143 @@ +package testweb + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const sampleRecipeLog = `=== META === +Time: 2026-07-13T08:00:00+08:00 +ClientIP: 127.0.0.1 +Method: POST +Path: /province/supervise/data +Kind: supervise +DurationMs: 250 +Status: 200 + +=== 1. 入站请求(transit → forward)=== +URL: POST 127.0.0.1:16001/province/supervise/data + +Headers: +Content-Type: application/json +X-Service-Method: uploadRecipeIndicators +X-Forward-Token: secret + +Body: +{"encrypted":"x"} + +=== 2. 出站转发(forward → 政务云)=== +TargetURL: https://59.202.52.129:28212/province/supervise/data +Method: POST + +Headers: +Content-Type: application/json +X-Service-Method: uploadRecipeIndicators + +Body: +{"encrypted":"x"} + +=== 3. 回复(政务云 → forward → transit)=== +UpstreamStatus: 200 + +UpstreamHeaders: +Content-Type: application/json +X-Ca-Request-Id: trace-recipe-1 + +Body: +{"msgCode":0,"msg":"ok"} + +BodyJSON: +{ + "msgCode": 0, + "msg": "ok" +} +` + +func verificationLogBytes() []byte { + return []byte(strings.ReplaceAll(sampleRecipeLog, "uploadRecipeIndicators", "uploadRecipeVerificationIndicators")) +} + +func TestClassifyPrescriptionLog_recipe(t *testing.T) { + rec, ok := classifyPrescriptionLog("2026-07-13 08/20260713080000_supervise_abcd.log", []byte(sampleRecipeLog)) + if !ok { + t.Fatal("expected classified") + } + if rec.BizType != "recipe" || rec.Channel != "supervise" { + t.Fatalf("rec=%+v", rec) + } + if !rec.OK || rec.MsgCode != 0 || rec.TraceID != "trace-recipe-1" { + t.Fatalf("response fields=%+v", rec) + } +} + +func TestClassifyPrescriptionLog_verification(t *testing.T) { + rec, ok := classifyPrescriptionLog("2026-07-13 08/20260713080000_supervise_abcd.log", verificationLogBytes()) + if !ok || rec.BizType != "verification" { + t.Fatalf("rec=%+v ok=%v", rec, ok) + } +} + +func TestClassifyPrescriptionLog_pdf(t *testing.T) { + rec, ok := classifyPrescriptionLog("2026-07-13 08/20260713080000_file_abcd.log", []byte(sampleLog)) + if !ok || rec.BizType != "pdf" { + t.Fatalf("rec=%+v", rec) + } + if rec.FileID != "id1" || !rec.OK { + t.Fatalf("pdf response=%+v", rec) + } +} + +func TestRebuildPrescriptionIndex(t *testing.T) { + tmp := t.TempDir() + hour := filepath.Join(tmp, "2026-07-13 08") + if err := os.MkdirAll(hour, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hour, "20260713080000_file_abcd.log"), []byte(sampleLog), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hour, "20260713080100_supervise_abcd.log"), []byte(sampleRecipeLog), 0o644); err != nil { + t.Fatal(err) + } + n, err := RebuildPrescriptionIndex(tmp) + if err != nil || n != 2 { + t.Fatalf("count=%d err=%v", n, err) + } + pdfList, err := ListPrescriptionRecords(tmp, "pdf", 10) + if err != nil || len(pdfList) != 1 { + t.Fatalf("pdf=%v err=%v", pdfList, err) + } + recipeList, err := ListPrescriptionRecords(tmp, "recipe", 10) + if err != nil || len(recipeList) != 1 { + t.Fatalf("recipe=%v err=%v", recipeList, err) + } +} + +func TestListSuperviseLogs_and_resolve(t *testing.T) { + tmp := t.TempDir() + hour := filepath.Join(tmp, "2026-07-13 08") + if err := os.MkdirAll(hour, 0o755); err != nil { + t.Fatal(err) + } + name := "20260713080100_supervise_abcd.log" + if err := os.WriteFile(filepath.Join(hour, name), []byte(sampleRecipeLog), 0o644); err != nil { + t.Fatal(err) + } + list, err := ListSuperviseLogs(tmp) + if err != nil || len(list) != 1 || list[0].Kind != "supervise" { + t.Fatalf("list=%v err=%v", list, err) + } + rel := list[0].Rel + if _, err := resolveLogRelPath(tmp, rel); err != nil { + t.Fatal(err) + } + p, err := ParseAPILogFile(tmp, rel) + if err != nil { + t.Fatal(err) + } + if p.Inbound.Headers["X-Service-Method"] != MethodRecipe { + t.Fatalf("method=%q", p.Inbound.Headers["X-Service-Method"]) + } +} diff --git a/internal/forward/testweb/server.go b/internal/forward/testweb/server.go index 0d67892..a2ec7ba 100644 --- a/internal/forward/testweb/server.go +++ b/internal/forward/testweb/server.go @@ -32,6 +32,10 @@ func Register(mux *http.ServeMux, deps Deps) { s.handlePage(w, r) return } + if r.URL.Path == "/t/prescription" || r.URL.Path == "/t/prescription/" { + s.handlePrescriptionPage(w, r) + return + } http.NotFound(w, r) }) @@ -39,6 +43,8 @@ func Register(mux *http.ServeMux, deps Deps) { mux.HandleFunc("/t/api/logs", s.handleLogs) mux.HandleFunc("/t/api/logs/detail", s.handleLogDetail) mux.HandleFunc("/t/api/logs/export", s.handleLogExport) + mux.HandleFunc("/t/api/prescription/records", s.handlePrescriptionRecords) + mux.HandleFunc("/t/api/prescription/rebuild", s.handlePrescriptionRebuild) mux.HandleFunc("/t/api/test/connect", s.handleTestConnect) mux.HandleFunc("/t/api/test/send", s.handleTestSend) } diff --git a/internal/forward/testweb/web/index.html b/internal/forward/testweb/web/index.html index f921cb4..d59aedc 100644 --- a/internal/forward/testweb/web/index.html +++ b/internal/forward/testweb/web/index.html @@ -12,6 +12,7 @@
forward 政务云测试 + 处方记录
连通测试 diff --git a/internal/forward/testweb/web/prescription.html b/internal/forward/testweb/web/prescription.html new file mode 100644 index 0000000..4b67739 --- /dev/null +++ b/internal/forward/testweb/web/prescription.html @@ -0,0 +1,69 @@ + + + + + + 处方业务记录 · forward-go + + + +
+
+
+ + 处方 / 核销记录 + 政务云测试 +
+
+ + + +
+
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + + +
时间通道HTTPmsgCodetracefileId / 处方提示状态耗时操作
+

+
+
+ + + + + + diff --git a/internal/forward/testweb/web/prescription.js b/internal/forward/testweb/web/prescription.js new file mode 100644 index 0000000..a9be098 --- /dev/null +++ b/internal/forward/testweb/web/prescription.js @@ -0,0 +1,132 @@ +(function () { + const state = { bizType: 'pdf', items: [], currentRel: null }; + + function $(id) { return document.getElementById(id); } + + async function api(path, opts) { + const r = await fetch(path, opts); + const t = await r.text(); + let j; + try { j = JSON.parse(t); } catch { j = { error: t }; } + if (!r.ok) throw new Error(j.error || r.statusText); + return j; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function showToast(msg, ok) { + const el = $('toast'); + el.textContent = msg; + el.className = 'rx-toast ' + (ok ? 'ok' : 'err'); + setTimeout(() => { el.textContent = ''; el.className = 'rx-toast'; }, 4000); + } + + function statusBadge(rec) { + if (rec.ok) return '成功'; + return '失败'; + } + + function fmtTime(at) { + if (!at) return '-'; + try { + return new Date(at).toLocaleString('zh-CN'); + } catch { + return esc(at); + } + } + + async function load() { + const data = await api('/t/api/prescription/records?bizType=' + encodeURIComponent(state.bizType) + '&limit=200'); + state.items = data.items || []; + const tb = $('tbody'); + if (!state.items.length) { + tb.innerHTML = '暂无记录(可先触发同步或点「重建索引」)'; + $('hint').textContent = '索引文件:api-logs/prescription-audit.jsonl'; + return; + } + tb.innerHTML = state.items.map((rec) => { + const hint = rec.fileId || rec.prescriptionHint || '-'; + const rowClass = rec.ok ? '' : ' class="rx-fail"'; + return '' + + '' + fmtTime(rec.at) + '' + + '' + esc(rec.channel) + '' + + '' + esc(rec.httpStatus) + '' + + '' + esc(rec.msgCode != null ? rec.msgCode : '-') + '' + + '' + esc(rec.traceId || '-') + '' + + '' + esc(hint) + '' + + '' + statusBadge(rec) + (rec.errorSummary ? ' ' + esc(rec.errorSummary) : '') + '' + + '' + esc(rec.durationMs) + 'ms' + + '' + + ' ' + + '' + + ''; + }).join(''); + $('hint').textContent = '共 ' + state.items.length + ' 条 · 类型 ' + state.bizType; + tb.querySelectorAll('button[data-act]').forEach((btn) => { + btn.onclick = () => { + const rel = btn.dataset.rel; + if (btn.dataset.act === 'detail') openDetail(rel); + else retryRecord(rel); + }; + }); + } + + async function openDetail(rel) { + state.currentRel = rel; + const d = await api('/t/api/logs/detail?rel=' + encodeURIComponent(rel)); + $('detail-view').textContent = JSON.stringify(d, null, 2); + $('replay-view').style.display = 'none'; + $('replay-view').textContent = ''; + $('drawer').hidden = false; + } + + async function retryRecord(rel) { + if (!confirm('向政务云回放该条出站请求(块2)?\n\n注意:仅内网运维重放,不会更新 transit/xk-api 状态。')) return; + state.currentRel = rel; + try { + const result = await api('/t/api/test/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rel: rel, useOutbound: true }), + }); + $('replay-view').style.display = 'block'; + $('replay-view').textContent = '回放结果:\n' + JSON.stringify(result, null, 2); + $('drawer').hidden = false; + $('detail-view').textContent = '(重试完成,见下方回放结果;可点详情查看完整 api-log)'; + showToast('回放完成 HTTP ' + (result.httpStatus || '-'), result.ok !== false); + } catch (e) { + showToast(e.message, false); + } + } + + document.querySelectorAll('.rx-tabs .tab').forEach((tab) => { + tab.onclick = () => { + document.querySelectorAll('.rx-tabs .tab').forEach((t) => t.classList.remove('active')); + tab.classList.add('active'); + state.bizType = tab.dataset.biz; + load().catch((e) => showToast(e.message, false)); + }; + }); + + $('btn-refresh').onclick = () => load().catch((e) => showToast(e.message, false)); + $('btn-rebuild').onclick = async () => { + if (!confirm('扫描 api-logs 全量重建 prescription-audit.jsonl?')) return; + try { + const r = await api('/t/api/prescription/rebuild', { method: 'POST' }); + showToast('已索引 ' + r.count + ' 条', true); + await load(); + } catch (e) { + showToast(e.message, false); + } + }; + $('btn-close-drawer').onclick = () => { $('drawer').hidden = true; }; + $('btn-retry').onclick = () => { + if (state.currentRel) retryRecord(state.currentRel); + }; + + load().catch((e) => showToast(e.message, false)); +})(); diff --git a/internal/forward/testweb/web/t.css b/internal/forward/testweb/web/t.css index 3ccf8ed..68d0176 100644 --- a/internal/forward/testweb/web/t.css +++ b/internal/forward/testweb/web/t.css @@ -222,3 +222,77 @@ body { white-space: pre-wrap; word-break: break-all; } +.nav-link { + margin-left: 1rem; + color: var(--accent); + text-decoration: none; + font-size: 12px; +} +.nav-link:hover { text-decoration: underline; } +.rx-app { height: 100vh; } +.rx-tabs { + display: flex; + gap: 0; + padding: 0 1rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.rx-main { + flex: 1; + overflow: auto; + padding: 0.75rem 1rem; +} +.rx-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.rx-table th, .rx-table td { + border: 1px solid var(--border); + padding: 0.4rem 0.5rem; + text-align: left; + vertical-align: top; +} +.rx-table th { background: var(--surface2); color: var(--muted); } +.rx-table tr.rx-fail { background: rgba(248, 113, 113, 0.08); } +.rx-table .trace { font-size: 10px; word-break: break-all; } +.rx-actions { white-space: nowrap; } +.rx-hint { color: var(--muted); font-size: 11px; margin-top: 0.75rem; } +.rx-toast { font-size: 12px; margin-left: 0.5rem; } +.rx-toast.ok { color: var(--ok); } +.rx-toast.err { color: var(--err); } +.rx-drawer { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 100; + display: flex; + align-items: stretch; + justify-content: flex-end; +} +.rx-drawer[hidden] { display: none !important; } +.rx-drawer-inner { + width: min(720px, 95vw); + background: var(--sidebar); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + max-height: 100vh; +} +.rx-drawer-head, .rx-drawer-foot { + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} +.rx-drawer-foot { border-bottom: none; border-top: 1px solid var(--border); } +.rx-drawer-head h3 { margin: 0; font-size: 14px; } +.rx-drawer-body { + flex: 1; + overflow: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + min-height: 0; +}