更新功能(已使用,可行)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<div class="brand">
|
||||
<span class="logo">T</span>
|
||||
<span class="title">forward 政务云测试</span>
|
||||
<a href="/t/prescription" class="nav-link">处方记录</a>
|
||||
</div>
|
||||
<div class="connect-bar">
|
||||
<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;
|
||||
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