更新功能(已使用,可行)
This commit is contained in:
19
README.md
19
README.md
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 兼容旧 API(file 通道)。
|
||||||
func ListFileLogs(root string) ([]FileLogEntry, error) {
|
type FileLogEntry = APILogEntry
|
||||||
|
|
||||||
|
// ListAPILogs 列出 api-logs;kind 为空则 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))
|
||||||
|
|||||||
56
internal/forward/testweb/prescription_handlers.go
Normal file
56
internal/forward/testweb/prescription_handlers.go
Normal 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})
|
||||||
|
}
|
||||||
365
internal/forward/testweb/prescription_index.go
Normal file
365
internal/forward/testweb/prescription_index.go
Normal 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
|
||||||
|
}
|
||||||
143
internal/forward/testweb/prescription_index_test.go
Normal file
143
internal/forward/testweb/prescription_index_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
69
internal/forward/testweb/web/prescription.html
Normal file
69
internal/forward/testweb/web/prescription.html
Normal 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>
|
||||||
132
internal/forward/testweb/web/prescription.js
Normal file
132
internal/forward/testweb/web/prescription.js
Normal 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));
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user