|
|
@@ -25,7 +25,7 @@ if str(SIM_ROOT) not in sys.path:
|
|
|
|
|
|
import uvicorn
|
|
|
from fastapi import FastAPI, HTTPException
|
|
|
-from fastapi.responses import FileResponse
|
|
|
+from fastapi.responses import FileResponse, Response
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
@@ -52,6 +52,12 @@ def _secrets() -> list[str]:
|
|
|
_env("AIDOP_SIM_ADMIN_TOKEN")) if v]
|
|
|
|
|
|
|
|
|
+def _access_keys() -> list[str]:
|
|
|
+ """已知 AccessKey 值:自由文本中出现时只保留后四位(任务书 §24)。"""
|
|
|
+ ak = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
|
|
|
+ return [ak] if ak else []
|
|
|
+
|
|
|
+
|
|
|
def load_config() -> dict:
|
|
|
cfg = {
|
|
|
"aidopBaseUrl": "http://127.0.0.1:5005",
|
|
|
@@ -88,8 +94,8 @@ class RunStore:
|
|
|
"channel": channel,
|
|
|
"scenarioId": scenario_id,
|
|
|
"nodes": nodes,
|
|
|
- "request": redact_obj(request_summary, _secrets()),
|
|
|
- "response": redact_obj(response, _secrets()),
|
|
|
+ "request": redact_obj(request_summary, _secrets(), _access_keys()),
|
|
|
+ "response": redact_obj(response, _secrets(), _access_keys()),
|
|
|
"layers": layers,
|
|
|
"followupSql": followup_sql or [],
|
|
|
"knownGaps": gaps or [],
|
|
|
@@ -214,6 +220,107 @@ class ScenarioPayload(BaseModel):
|
|
|
channelOverride: dict[str, str] | None = None
|
|
|
|
|
|
|
|
|
+class ReportPayload(BaseModel):
|
|
|
+ """全链路报告草稿(任务书 F5 §10):只保存测试人员填写的摘要。"""
|
|
|
+ runId: str
|
|
|
+ channel: str # DB_SYNC | API_PULL | API_INBOUND
|
|
|
+ environment: str = "" # 环境与测试租户
|
|
|
+ objectCode: str = "" # 对象
|
|
|
+ simPrep: str = "" # 模拟端准备结果
|
|
|
+ manualSteps: str = "" # 人工执行步骤
|
|
|
+ simEvidence: str = "" # 模拟端证据
|
|
|
+ aidopConn: str = "" # Ai-DOP 测连结果
|
|
|
+ flowIds: str = "" # 同步/入站流水编号
|
|
|
+ stagingCheck: str = "" # 贴源核验结果
|
|
|
+ targetResult: str = "" # 目标业务结果
|
|
|
+ conclusion: str = "" # 结论
|
|
|
+ gaps: str = "" # 未完成项
|
|
|
+ cleanup: str = "" # 清理提示
|
|
|
+
|
|
|
+
|
|
|
+CHANNELS = ("DB_SYNC", "API_PULL", "API_INBOUND")
|
|
|
+CHANNEL_LABELS = {
|
|
|
+ "DB_SYNC": "方式甲 DB_SYNC(数据库同步)",
|
|
|
+ "API_PULL": "方式乙 API_PULL(接口拉取)",
|
|
|
+ "API_INBOUND": "方式丙 API_INBOUND(接口推送)",
|
|
|
+}
|
|
|
+
|
|
|
+# 报告章节:与任务书 §10 的字段清单一一对应
|
|
|
+REPORT_FIELDS = [
|
|
|
+ ("runId", "运行编号 runId"),
|
|
|
+ ("channel", "集成方式"),
|
|
|
+ ("environment", "环境与测试租户"),
|
|
|
+ ("objectCode", "对象"),
|
|
|
+ ("simPrep", "模拟端准备结果"),
|
|
|
+ ("manualSteps", "人工执行步骤"),
|
|
|
+ ("simEvidence", "模拟端证据"),
|
|
|
+ ("aidopConn", "Ai-DOP 测连结果"),
|
|
|
+ ("flowIds", "同步/入站流水编号"),
|
|
|
+ ("stagingCheck", "贴源核验结果"),
|
|
|
+ ("targetResult", "目标业务结果"),
|
|
|
+ ("conclusion", "结论"),
|
|
|
+ ("gaps", "未完成项"),
|
|
|
+ ("cleanup", "清理提示"),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+class ReportStore:
|
|
|
+ """全链路报告存储(任务书 F5)。
|
|
|
+
|
|
|
+ - 只保存测试人员填写的摘要字段,不保存数据库密码、Secret、JWT、连接串或完整敏感 payload;
|
|
|
+ - 保存前统一过 redact_obj,导出内容与内存一致;
|
|
|
+ - 内存态、不落盘:控制台重启即清空,避免凭据残留。
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self, limit: int = 50):
|
|
|
+ self.limit = limit
|
|
|
+ self.items: list[dict] = []
|
|
|
+
|
|
|
+ def save(self, fields: dict, secrets: list[str] | None = None,
|
|
|
+ access_keys: list[str] | None = None) -> dict:
|
|
|
+ entry = {
|
|
|
+ "id": uuid.uuid4().hex[:12],
|
|
|
+ "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
|
+ }
|
|
|
+ entry.update(redact_obj(dict(fields), secrets, access_keys))
|
|
|
+ self.items.append(entry)
|
|
|
+ if len(self.items) > self.limit:
|
|
|
+ del self.items[: len(self.items) - self.limit]
|
|
|
+ return entry
|
|
|
+
|
|
|
+ def list(self) -> list[dict]:
|
|
|
+ return list(reversed(self.items[-20:]))
|
|
|
+
|
|
|
+ def get(self, report_id: str) -> dict | None:
|
|
|
+ return next((r for r in self.items if r["id"] == report_id), None)
|
|
|
+
|
|
|
+ def delete(self, report_id: str) -> bool:
|
|
|
+ before = len(self.items)
|
|
|
+ self.items = [r for r in self.items if r["id"] != report_id]
|
|
|
+ return len(self.items) < before
|
|
|
+
|
|
|
+
|
|
|
+def build_report_markdown(entry: dict) -> str:
|
|
|
+ """报告导出 Markdown(任务书 F5 §10 字段 + 脱敏声明)。"""
|
|
|
+ lines = ["# Ai-DOP 第三方系统模拟器 · 全链路测试报告", ""]
|
|
|
+ for key, label in REPORT_FIELDS:
|
|
|
+ value = str(entry.get(key) or "").strip()
|
|
|
+ if key == "channel":
|
|
|
+ value = CHANNEL_LABELS.get(value, value)
|
|
|
+ lines.append(f"## {label}")
|
|
|
+ lines.append(value or "(未填写)")
|
|
|
+ lines.append("")
|
|
|
+ lines.append("---")
|
|
|
+ lines.append(f"报告 ID:{entry.get('id', '')} · 创建时间:{entry.get('createdAt', '')}")
|
|
|
+ lines.append("")
|
|
|
+ lines.append("> 脱敏说明:本报告已自动脱敏,AccessKey 仅保留后四位;"
|
|
|
+ "数据库密码、Secret、JWT、完整 Authorization 与连接串已遮蔽。")
|
|
|
+ return "\n".join(lines)
|
|
|
+
|
|
|
+
|
|
|
+REPORTS = ReportStore()
|
|
|
+
|
|
|
+
|
|
|
# ---------------------------------------------------------------------------
|
|
|
# 公共辅助
|
|
|
# ---------------------------------------------------------------------------
|
|
|
@@ -647,7 +754,10 @@ def inbound_exec(payload: InboundExecPayload):
|
|
|
return record(status, body, headers, layers)
|
|
|
|
|
|
if op == "bulk":
|
|
|
- status, body, headers = client.op_bulk(entity, rows or [], contract_version=contract_version)
|
|
|
+ # 后端 NDJSON 分块:每行须为完整信封(data.list)+ 根级 idempotencyKey(MdpInboundReceiveService.BulkAsync)
|
|
|
+ bulk_rows = [{"idempotencyKey": f"{run_id}-L{i + 1}",
|
|
|
+ "data": {"list": [r]}} for i, r in enumerate(rows or [])]
|
|
|
+ status, body, headers = client.op_bulk(entity, bulk_rows, contract_version=contract_version)
|
|
|
layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 202 else "RUN_FAILED")
|
|
|
return record(status, body, headers, layers)
|
|
|
|
|
|
@@ -761,6 +871,52 @@ def runs():
|
|
|
return {"runs": RUNS.recent()}
|
|
|
|
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 全链路报告(F5)
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+@app.post("/api/report/save")
|
|
|
+def report_save(payload: ReportPayload):
|
|
|
+ if payload.channel not in CHANNELS:
|
|
|
+ raise HTTPException(status_code=400, detail=f"unknown channel: {payload.channel}")
|
|
|
+ if not payload.runId.strip():
|
|
|
+ raise HTTPException(status_code=400, detail="runId required")
|
|
|
+ entry = REPORTS.save(payload.model_dump(), _secrets(), _access_keys())
|
|
|
+ return {"ok": True, "report": entry}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/report/list")
|
|
|
+def report_list():
|
|
|
+ return {"reports": REPORTS.list()}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/report/evidence/{channel}")
|
|
|
+def report_evidence(channel: str):
|
|
|
+ """按方式取最近运行记录(已脱敏),供测试人员一键导入「模拟端证据」。"""
|
|
|
+ if channel not in CHANNELS:
|
|
|
+ raise HTTPException(status_code=404, detail="unknown channel")
|
|
|
+ entries = [r for r in RUNS.runs if r["channel"] == channel][-5:]
|
|
|
+ return {"entries": list(reversed(entries))}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/report/export/{report_id}")
|
|
|
+def report_export(report_id: str):
|
|
|
+ entry = REPORTS.get(report_id)
|
|
|
+ if not entry:
|
|
|
+ raise HTTPException(status_code=404, detail="report not found")
|
|
|
+ md = build_report_markdown(entry)
|
|
|
+ filename = f"aidop-sim-report-{entry['runId']}.md"
|
|
|
+ return Response(content=md, media_type="text/markdown; charset=utf-8",
|
|
|
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
|
|
|
+
|
|
|
+
|
|
|
+@app.delete("/api/report/{report_id}")
|
|
|
+def report_delete(report_id: str):
|
|
|
+ if not REPORTS.delete(report_id):
|
|
|
+ raise HTTPException(status_code=404, detail="report not found")
|
|
|
+ return {"ok": True}
|
|
|
+
|
|
|
+
|
|
|
# ---------------------------------------------------------------------------
|
|
|
# 静态页面
|
|
|
# ---------------------------------------------------------------------------
|