更新功能(已使用,可行)

This commit is contained in:
李琦
2026-07-21 16:35:46 +08:00
parent 0c268fb2c5
commit fb1c9356ca
11 changed files with 912 additions and 11 deletions

View File

@@ -10,6 +10,7 @@
| `POST /mng/file/auth/upload` | `FILE_TARGET_URL`28211 | 处方 PDF multipart 上传 | | `POST /mng/file/auth/upload` | `FILE_TARGET_URL`28211 | 处方 PDF multipart 上传 |
| `GET /health` | 本地 | 健康检查 | | `GET /health` | 本地 | 健康检查 |
| `GET /t` | 本地 | 政务云测试页(读 file api-logs、连通测试、回放、导出 ApiPost | | `GET /t` | 本地 | 政务云测试页(读 file api-logs、连通测试、回放、导出 ApiPost |
| `GET /t/prescription` | 本地 | 处方业务记录PDF / 在线处方 / 处方核销,支持详情与单条重试) |
转发规则: 转发规则:
@@ -108,6 +109,24 @@ IDE 调试Working Directory 设为项目根,运行文件选 `main.go` 即
日志含 `X-Authorization` 等敏感信息,请勿将导出的集合或 `/t` 暴露到公网。 日志含 `X-Authorization` 等敏感信息,请勿将导出的集合或 `/t` 暴露到公网。
## 处方业务记录 `/t/prescription`
访问:`http://<forward-host>: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` 中设置: 本地联调 mock 上游时,在 `.env` 中设置:
```env ```env

View File

@@ -15,6 +15,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"xk-hy-forward-go/internal/forward/testweb"
) )
const apiLogsDirName = "api-logs" const apiLogsDirName = "api-logs"
@@ -240,7 +242,14 @@ func writeAPILogFile(r *http.Request, col *apiLogCollector, cw *captureResponseW
apiLogMu.Lock() apiLogMu.Lock()
defer apiLogMu.Unlock() 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) { func writeInboundBlock(buf *bytes.Buffer, r *http.Request, kind string, body []byte) {

View File

@@ -10,24 +10,30 @@ import (
"time" "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 通道日志条目。 // APILogEntry api-logs 中的日志条目。
type FileLogEntry struct { type APILogEntry struct {
Rel string `json:"rel"` Rel string `json:"rel"`
Name string `json:"name"` Name string `json:"name"`
HourDir string `json:"hourDir"` HourDir string `json:"hourDir"`
Kind string `json:"kind"`
Modified string `json:"modified"` Modified string `json:"modified"`
Size int64 `json:"size"` Size int64 `json:"size"`
} }
// ListFileLogs 列出 api-logs 下所有 *_file_*.log按修改时间倒序)。 // FileLogEntry 兼容旧 APIfile 通道)。
func ListFileLogs(root string) ([]FileLogEntry, error) { type FileLogEntry = APILogEntry
// ListAPILogs 列出 api-logskind 为空则 file+supervise否则 file 或 supervise。
func ListAPILogs(root, kind string) ([]APILogEntry, error) {
root, err := filepath.Abs(root) root, err := filepath.Abs(root)
if err != nil { if err != nil {
return nil, err 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 { err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
@@ -36,7 +42,18 @@ func ListFileLogs(root string) ([]FileLogEntry, error) {
return nil return nil
} }
name := d.Name() 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 return nil
} }
rel, err := filepath.Rel(root, path) rel, err := filepath.Rel(root, path)
@@ -51,11 +68,11 @@ func ListFileLogs(root string) ([]FileLogEntry, error) {
if err != nil { if err != nil {
return err return err
} }
hourDir := filepath.ToSlash(filepath.Dir(rel)) entries = append(entries, APILogEntry{
entries = append(entries, FileLogEntry{
Rel: rel, Rel: rel,
Name: name, Name: name,
HourDir: hourDir, HourDir: filepath.ToSlash(filepath.Dir(rel)),
Kind: entryKind,
Modified: info.ModTime().Format(time.RFC3339), Modified: info.ModTime().Format(time.RFC3339),
Size: info.Size(), Size: info.Size(),
}) })
@@ -73,6 +90,16 @@ func ListFileLogs(root string) ([]FileLogEntry, error) {
return entries, nil 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 并返回绝对路径。 // resolveLogRelPath 校验 rel 并返回绝对路径。
func resolveLogRelPath(root, rel string) (string, error) { func resolveLogRelPath(root, rel string) (string, error) {
rel = filepath.ToSlash(strings.TrimSpace(rel)) rel = filepath.ToSlash(strings.TrimSpace(rel))

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,10 @@ func Register(mux *http.ServeMux, deps Deps) {
s.handlePage(w, r) s.handlePage(w, r)
return return
} }
if r.URL.Path == "/t/prescription" || r.URL.Path == "/t/prescription/" {
s.handlePrescriptionPage(w, r)
return
}
http.NotFound(w, r) 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", s.handleLogs)
mux.HandleFunc("/t/api/logs/detail", s.handleLogDetail) mux.HandleFunc("/t/api/logs/detail", s.handleLogDetail)
mux.HandleFunc("/t/api/logs/export", s.handleLogExport) 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/connect", s.handleTestConnect)
mux.HandleFunc("/t/api/test/send", s.handleTestSend) mux.HandleFunc("/t/api/test/send", s.handleTestSend)
} }

View File

@@ -12,6 +12,7 @@
<div class="brand"> <div class="brand">
<span class="logo">T</span> <span class="logo">T</span>
<span class="title">forward 政务云测试</span> <span class="title">forward 政务云测试</span>
<a href="/t/prescription" class="nav-link">处方记录</a>
</div> </div>
<div class="connect-bar"> <div class="connect-bar">
<span class="label">连通测试</span> <span class="label">连通测试</span>

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>处方业务记录 · forward-go</title>
<link rel="stylesheet" href="/t/static/t.css">
</head>
<body>
<div class="app rx-app">
<header class="topbar">
<div class="brand">
<span class="logo">Rx</span>
<span class="title">处方 / 核销记录</span>
<a href="/t" class="nav-link">政务云测试</a>
</div>
<div class="connect-bar">
<button type="button" class="btn sm" id="btn-refresh">刷新</button>
<button type="button" class="btn sm" id="btn-rebuild">重建索引</button>
<span id="toast" class="rx-toast"></span>
</div>
</header>
<div class="rx-tabs">
<button type="button" class="tab active" data-biz="pdf">PDF 上传</button>
<button type="button" class="tab" data-biz="recipe">在线处方</button>
<button type="button" class="tab" data-biz="verification">处方核销</button>
</div>
<main class="rx-main">
<table class="rx-table" id="records">
<thead>
<tr>
<th>时间</th>
<th>通道</th>
<th>HTTP</th>
<th>msgCode</th>
<th>trace</th>
<th>fileId / 处方提示</th>
<th>状态</th>
<th>耗时</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<p class="rx-hint" id="hint"></p>
</main>
</div>
<div class="rx-drawer" id="drawer" hidden>
<div class="rx-drawer-inner">
<header class="rx-drawer-head">
<h3>api-log 详情</h3>
<button type="button" class="btn sm" id="btn-close-drawer">关闭</button>
</header>
<div class="rx-drawer-body">
<pre id="detail-view" class="code-block"></pre>
<pre id="replay-view" class="code-block" style="margin-top:0.5rem;display:none"></pre>
</div>
<footer class="rx-drawer-foot">
<button type="button" class="btn send primary" id="btn-retry">重试出站块2</button>
</footer>
</div>
</div>
<script src="/t/static/prescription.js"></script>
</body>
</html>

View File

@@ -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 '<span class="badge ok">成功</span>';
return '<span class="badge err">失败</span>';
}
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 = '<tr><td colspan="9" style="text-align:center;color:var(--muted)">暂无记录(可先触发同步或点「重建索引」)</td></tr>';
$('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 '<tr' + rowClass + '>' +
'<td>' + fmtTime(rec.at) + '</td>' +
'<td><code>' + esc(rec.channel) + '</code></td>' +
'<td>' + esc(rec.httpStatus) + '</td>' +
'<td>' + esc(rec.msgCode != null ? rec.msgCode : '-') + '</td>' +
'<td><code class="trace">' + esc(rec.traceId || '-') + '</code></td>' +
'<td>' + esc(hint) + '</td>' +
'<td>' + statusBadge(rec) + (rec.errorSummary ? ' ' + esc(rec.errorSummary) : '') + '</td>' +
'<td>' + esc(rec.durationMs) + 'ms</td>' +
'<td class="rx-actions">' +
'<button type="button" class="btn sm" data-act="detail" data-rel="' + esc(rec.rel) + '">详情</button> ' +
'<button type="button" class="btn sm primary" data-act="retry" data-rel="' + esc(rec.rel) + '">重试</button>' +
'</td></tr>';
}).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));
})();

View File

@@ -222,3 +222,77 @@ body {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; 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;
}