|
|
@@ -16,6 +16,7 @@ import json
|
|
|
import os
|
|
|
import socket
|
|
|
import sys
|
|
|
+import threading
|
|
|
import time
|
|
|
import uuid
|
|
|
from pathlib import Path
|
|
|
@@ -34,6 +35,9 @@ from catalog import sample_adapters as sa
|
|
|
from clients import aidop_probe
|
|
|
from clients.inbound_client import InboundClient
|
|
|
from clients.redaction import mask_access_key, redact_obj
|
|
|
+from load import controls as load_controls
|
|
|
+from load import metrics as load_metrics
|
|
|
+from load import runner_p3
|
|
|
from providers import fault_catalog, mock_api, mock_db
|
|
|
|
|
|
DEFAULT_CONTROL_PORT = 8017
|
|
|
@@ -43,6 +47,10 @@ MAX_RUNS = 200
|
|
|
CONFIG_STATES = ("READY", "CONFIG_NOT_REGISTERED", "CONFIG_NOT_ENABLED",
|
|
|
"GRANT_MISSING", "SOURCE_UNREACHABLE", "RUN_FAILED")
|
|
|
|
|
|
+# 入站实体授权探测缓存(60s TTL;探测用 GET schema,无副作用)
|
|
|
+_AUTH_PROBE_CACHE: dict = {"at": 0.0, "data": None}
|
|
|
+_AUTH_PROBE_TTL = 60.0
|
|
|
+
|
|
|
|
|
|
def _env(name: str) -> str:
|
|
|
return os.environ.get(name, "") or ""
|
|
|
@@ -78,6 +86,13 @@ CONFIG = load_config()
|
|
|
MOCK_STATE = mock_api.MockApiState()
|
|
|
MOCK_APP = mock_api.create_app(MOCK_STATE)
|
|
|
|
|
|
+# ---- 第三阶段压力测试全局(默认关闭,重启即清)----
|
|
|
+LOAD_GUARD = load_controls.LoadGuard()
|
|
|
+LOAD_METRICS = load_metrics.LoadMetrics()
|
|
|
+LOAD_RUNNER: runner_p3.P3Runner | None = None
|
|
|
+LOAD_REPORTS = None # 在 ReportStore 定义后创建
|
|
|
+DB_GEN = {"thread": None, "result": None, "error": ""}
|
|
|
+
|
|
|
|
|
|
class RunStore:
|
|
|
def __init__(self, limit: int = MAX_RUNS):
|
|
|
@@ -257,6 +272,65 @@ class FaultReportPayload(BaseModel):
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
+class LoadPlanPayload(BaseModel):
|
|
|
+ """压力测试计划(§16 十项 + §17 参数 + 通道专属参数,§20 安全限制)。
|
|
|
+
|
|
|
+ 校验由 load.controls.validate_plan 执行;本模型只负责字段承接。
|
|
|
+ """
|
|
|
+ channel: str # DB_SYNC | API_PULL | API_INBOUND
|
|
|
+ objectCode: str = ""
|
|
|
+ entityCode: str = ""
|
|
|
+ runId: str | None = None
|
|
|
+ # §16 执行前必填十项
|
|
|
+ environment: str = ""
|
|
|
+ tenant: str = ""
|
|
|
+ owner: str = ""
|
|
|
+ timeWindow: str = ""
|
|
|
+ maxDataRows: int = 0
|
|
|
+ maxConcurrency: int = 0
|
|
|
+ maxQps: int = 0
|
|
|
+ maxDurationSeconds: int = 0
|
|
|
+ stopThreshold: str = ""
|
|
|
+ cleanupPlan: str = ""
|
|
|
+ # 安全
|
|
|
+ confirmed: bool = False # 共享环境负责人确认
|
|
|
+ escalationConfirm: bool = False # 提高上限二次确认
|
|
|
+ # §17 通用参数
|
|
|
+ warmupSeconds: int = 0
|
|
|
+ batchSize: int = 1
|
|
|
+ requestIntervalMs: int = 0
|
|
|
+ timeoutSeconds: int = 30
|
|
|
+ maxRetries: int = 0
|
|
|
+ sampleMode: str = "fixed" # fixed | random
|
|
|
+ maxConsecutiveErrors: int = 0
|
|
|
+ maxTimeouts: int = 0
|
|
|
+ maxRateLimited: int = 0
|
|
|
+ errorRate: float = 0.0 # P2 Mock 随机 500 比例 / P3 错误数据比例
|
|
|
+ # P1 专属(§18)
|
|
|
+ size: str = "" # S | M | L | XL(记录档位,行数以 maxDataRows 为准)
|
|
|
+ incrementRatio: float = 1.0
|
|
|
+ fieldWidth: int = 0
|
|
|
+ # P2 专属(§19)
|
|
|
+ pageSize: int = 0
|
|
|
+ totalRowsLimit: int = 0
|
|
|
+ randomDelay: float = 0.0
|
|
|
+ rateLimit: float = 0.0
|
|
|
+ # P3 专属(§20)
|
|
|
+ mode: str = "push" # push | bulk
|
|
|
+ rampSteps: list[dict] | None = None # 阶梯升压 [{duration,qps,concurrency}]
|
|
|
+ idemReplay: bool = False # 幂等重复
|
|
|
+
|
|
|
+
|
|
|
+class LoadReportPayload(BaseModel):
|
|
|
+ """压力报告人工观察部分(自动指标与计划由后端快照填充,§18 验收/§17 末句)。"""
|
|
|
+ runId: str = ""
|
|
|
+ channel: str = ""
|
|
|
+ aidopResource: str = "" # Ai-DOP CPU/内存/队列/数据库人工观察结果
|
|
|
+ aidopFlow: str = "" # Ai-DOP 同步/入站流水核对结果
|
|
|
+ conclusion: str = ""
|
|
|
+ notes: str = ""
|
|
|
+
|
|
|
+
|
|
|
CHANNELS = ("DB_SYNC", "API_PULL", "API_INBOUND")
|
|
|
CHANNEL_LABELS = {
|
|
|
"DB_SYNC": "方式甲 DB_SYNC(数据库同步)",
|
|
|
@@ -339,6 +413,9 @@ def build_report_markdown(entry: dict) -> str:
|
|
|
|
|
|
REPORTS = ReportStore()
|
|
|
|
|
|
+# 压力报告复用 ReportStore(保存前脱敏、内存态不落盘)
|
|
|
+LOAD_REPORTS = ReportStore(limit=20)
|
|
|
+
|
|
|
|
|
|
class FaultReportStore(ReportStore):
|
|
|
"""异常测试报告存储(任务书 §15 N4)。复用脱敏与内存态约定。
|
|
|
@@ -743,6 +820,52 @@ def precheck_inbound_one(entity_code: str):
|
|
|
# API_INBOUND 执行(P1-E)
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
+@app.get("/api/inbound/auth-probe")
|
|
|
+def inbound_auth_probe(refresh: int = 0):
|
|
|
+ """探测全部入站实体在 Ai-DOP 侧的授权状态(GET schema,只读无副作用)。
|
|
|
+
|
|
|
+ Secret 不离开后端进程;结果缓存 60 秒,refresh=1 强制重测。
|
|
|
+ 前端实体下拉据此只展示已授权实体,避免选到 403 未授权实体。
|
|
|
+ """
|
|
|
+ global _AUTH_PROBE_CACHE
|
|
|
+ now = time.monotonic()
|
|
|
+ if not refresh and _AUTH_PROBE_CACHE["data"] is not None \
|
|
|
+ and now - _AUTH_PROBE_CACHE["at"] < _AUTH_PROBE_TTL:
|
|
|
+ return {"ok": True, "cached": True, **_AUTH_PROBE_CACHE["data"]}
|
|
|
+ reach = aidop_probe.check_reachable(CONFIG["aidopBaseUrl"], CONFIG["allowedTargetHosts"],
|
|
|
+ timeout=5)
|
|
|
+ if not reach.get("reachable"):
|
|
|
+ # Ai-DOP 不可达:不逐个探测(避免 26 次连接等待),全部如实标记为不可用
|
|
|
+ entities = {inbound["entityCode"]: False
|
|
|
+ for obj in sa.load_catalog()["objects"]
|
|
|
+ if (inbound := ((obj or {}).get("apiInbound") or {})) and inbound.get("supported")}
|
|
|
+ data = {"entities": entities,
|
|
|
+ "probedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ "probeErrors": [f"Ai-DOP 不可达: {reach.get('error')}"]}
|
|
|
+ _AUTH_PROBE_CACHE.update(at=time.monotonic(), data=data)
|
|
|
+ return {"ok": True, "cached": False, **data}
|
|
|
+ client = _build_inbound_client()
|
|
|
+ entities: dict[str, bool] = {}
|
|
|
+ probe_errors: list[str] = []
|
|
|
+ for obj in sa.load_catalog()["objects"]:
|
|
|
+ inbound = (obj or {}).get("apiInbound") or {}
|
|
|
+ if not inbound.get("supported"):
|
|
|
+ continue
|
|
|
+ entity = inbound["entityCode"]
|
|
|
+ try:
|
|
|
+ status, _, _ = client.op_schema(entity,
|
|
|
+ contract_version=inbound.get("contractVersion"))
|
|
|
+ entities[entity] = status == 200
|
|
|
+ if status not in (200, 403):
|
|
|
+ probe_errors.append(f"{entity}: HTTP {status}")
|
|
|
+ except Exception as ex: # noqa: BLE001 —— 单实体失败不阻断整体探测
|
|
|
+ probe_errors.append(f"{entity}: {type(ex).__name__}")
|
|
|
+ data = {"entities": entities, "probedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ "probeErrors": probe_errors}
|
|
|
+ _AUTH_PROBE_CACHE.update(at=time.monotonic(), data=data)
|
|
|
+ return {"ok": True, "cached": False, **data}
|
|
|
+
|
|
|
+
|
|
|
@app.post("/api/inbound/exec")
|
|
|
def inbound_exec(payload: InboundExecPayload):
|
|
|
obj, entity = _resolve_entity(payload)
|
|
|
@@ -1209,6 +1332,307 @@ def fault_report_delete(report_id: str):
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+# 压力测试(第三阶段 §16-§21)
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+def _apply_p2_load(plan: dict) -> dict:
|
|
|
+ """P2:把压力参数注入 Mock API(§19 配置每次返回行数/总量/延迟/错误比例/限流阈值)。"""
|
|
|
+ s = MOCK_STATE.settings
|
|
|
+ s.load_mode = True
|
|
|
+ s.page_size = int(plan.get("pageSize") or 0)
|
|
|
+ s.total_rows_limit = int(plan.get("totalRowsLimit") or 0)
|
|
|
+ s.random_delay = float(plan.get("randomDelay") or 0)
|
|
|
+ s.error_rate = float(plan.get("errorRate") or 0)
|
|
|
+ s.rate_limit = float(plan.get("rateLimit") or 0)
|
|
|
+ MOCK_STATE.load_stats.reset()
|
|
|
+ return {"ok": True, "settings": mock_api_settings(), "mockRunning": _mock_running(),
|
|
|
+ "note": "" if _mock_running() else "提示:Mock API 未启动,先点「启动 Mock API」"}
|
|
|
+
|
|
|
+
|
|
|
+def _stop_p2_load() -> dict:
|
|
|
+ """P2:实时停止压力模式,恢复正常模式(§19)。"""
|
|
|
+ s = MOCK_STATE.settings
|
|
|
+ s.load_mode = False
|
|
|
+ s.page_size = 0
|
|
|
+ s.total_rows_limit = 0
|
|
|
+ s.random_delay = 0.0
|
|
|
+ s.error_rate = 0.0
|
|
|
+ s.rate_limit = 0.0
|
|
|
+ return {"ok": True, "note": "Mock 压力模式已停止,恢复正常模式"}
|
|
|
+
|
|
|
+
|
|
|
+def _run_p1_generation(obj: dict, plan: dict, run_id: str) -> None:
|
|
|
+ """P1 后台线程:批量生成档位数据(§18 批量生成/耗时行数/增量比例/字段宽度/一键停止)。"""
|
|
|
+ DB_GEN.update({"result": None, "error": ""})
|
|
|
+ table = (obj.get("dbSync") or {}).get("table")
|
|
|
+ rows = sa.adapt_db_rows(obj, run_id)
|
|
|
+ started = time.monotonic()
|
|
|
+ try:
|
|
|
+ result = mock_db.bulk_generate(
|
|
|
+ table, rows, int(plan["maxDataRows"]),
|
|
|
+ increment_ratio=float(plan.get("incrementRatio") or 1.0),
|
|
|
+ field_width=int(plan.get("fieldWidth") or 0),
|
|
|
+ batch_size=1000, stop_event=LOAD_GUARD.stop)
|
|
|
+ LOAD_METRICS.mark_start()
|
|
|
+ LOAD_METRICS.record(200, time.monotonic() - started,
|
|
|
+ rows=result["loaded"], batches=result["batches"])
|
|
|
+ LOAD_METRICS.mark_finish()
|
|
|
+ DB_GEN["result"] = result
|
|
|
+ except Exception as ex: # noqa: BLE001
|
|
|
+ LOAD_METRICS.mark_start()
|
|
|
+ LOAD_METRICS.record(500, time.monotonic() - started)
|
|
|
+ LOAD_METRICS.mark_finish()
|
|
|
+ DB_GEN["error"] = f"{type(ex).__name__}: {ex}"
|
|
|
+ finally:
|
|
|
+ LOAD_GUARD.finish()
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/load/limits")
|
|
|
+def load_limits():
|
|
|
+ """上限与必填项(前端展示与预校验)。"""
|
|
|
+ return {"requiredFields": list(load_controls.REQUIRED_PLAN_FIELDS),
|
|
|
+ "defaultLimits": load_controls.DEFAULT_LIMITS,
|
|
|
+ "hardLimits": load_controls.HARD_LIMITS,
|
|
|
+ "sizes": mock_db.LOAD_SIZES,
|
|
|
+ "guard": LOAD_GUARD.snapshot()}
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/api/load/plan")
|
|
|
+def load_plan(payload: LoadPlanPayload):
|
|
|
+ """提交压力计划:§16 十项必填 + 上限校验 + 负责人确认(默认不改变运行状态)。"""
|
|
|
+ if LOAD_GUARD.state in ("running", "stopping"):
|
|
|
+ raise HTTPException(status_code=409,
|
|
|
+ detail=f"当前状态 {LOAD_GUARD.state}:先停止本轮压测再提交新计划")
|
|
|
+ result = LOAD_GUARD.submit_plan(payload.model_dump())
|
|
|
+ if not result["ok"]:
|
|
|
+ raise HTTPException(status_code=400, detail={"errors": result["errors"]})
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/api/load/start")
|
|
|
+def load_start(payload: dict):
|
|
|
+ """显式开启压测(§16:必须由测试人员显式开启)。按通道启动对应执行器。"""
|
|
|
+ if LOAD_GUARD.state != "ready":
|
|
|
+ raise HTTPException(status_code=409,
|
|
|
+ detail=f"当前状态 {LOAD_GUARD.state}:须先提交并通过压力计划校验")
|
|
|
+ plan = LOAD_GUARD.plan or {}
|
|
|
+ channel = plan["channel"]
|
|
|
+ run_id = LOAD_GUARD.start(plan.get("runId") or None)
|
|
|
+ try:
|
|
|
+ if channel == "API_INBOUND":
|
|
|
+ client = _build_inbound_client()
|
|
|
+ entity = (plan.get("entityCode") or payload.get("entityCode") or "").upper()
|
|
|
+ obj = _require_obj_by_entity(entity) or _require_obj(plan.get("objectCode") or "")
|
|
|
+ inbound = (obj or {}).get("apiInbound") or {}
|
|
|
+ if not inbound.get("supported"):
|
|
|
+ raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 API_INBOUND")
|
|
|
+ entity = entity or inbound["entityCode"]
|
|
|
+ rows = sa.adapt_inbound_rows(obj, run_id)["rows"]
|
|
|
+ global LOAD_RUNNER
|
|
|
+ LOAD_RUNNER = runner_p3.P3Runner(
|
|
|
+ client, entity, rows, plan, LOAD_GUARD, LOAD_METRICS,
|
|
|
+ contract_version=inbound.get("contractVersion"))
|
|
|
+ LOAD_RUNNER.start()
|
|
|
+ elif channel == "DB_SYNC":
|
|
|
+ obj = _require_obj(plan.get("objectCode") or "")
|
|
|
+ db_spec = obj.get("dbSync") or {}
|
|
|
+ if not db_spec.get("supported") or not db_spec.get("table"):
|
|
|
+ raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
|
|
|
+ if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
|
|
|
+ raise HTTPException(status_code=400, detail="缺少环境变量 AIDOP_SIM_MYSQL_PASSWORD")
|
|
|
+ thread = threading.Thread(target=_run_p1_generation, args=(obj, plan, run_id),
|
|
|
+ name=f"p1-gen-{run_id}", daemon=True)
|
|
|
+ DB_GEN["thread"] = thread
|
|
|
+ LOAD_GUARD.register_worker(thread)
|
|
|
+ thread.start()
|
|
|
+ elif channel == "API_PULL":
|
|
|
+ if not _mock_running():
|
|
|
+ raise HTTPException(status_code=400, detail="Mock API 未启动(先 POST /api/mock-api/start)")
|
|
|
+ _apply_p2_load(plan)
|
|
|
+ # P2 无后台负载:由 Ai-DOP 侧发压,压力模式由 /api/load/stop 实时停止
|
|
|
+ else:
|
|
|
+ raise HTTPException(status_code=400, detail=f"unknown channel: {channel}")
|
|
|
+ except HTTPException:
|
|
|
+ LOAD_GUARD.finish("启动失败")
|
|
|
+ raise
|
|
|
+ return {"ok": True, "runId": run_id, "channel": channel, "state": LOAD_GUARD.state}
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/api/load/stop")
|
|
|
+def load_stop(payload: dict):
|
|
|
+ """立即停止(§21):P1/P3 置停止信号等待在途收尾;P2 恢复 Mock 正常模式。"""
|
|
|
+ plan = LOAD_GUARD.plan or {}
|
|
|
+ reason = str(payload.get("reason") or "manual")
|
|
|
+ p2_recovered = False
|
|
|
+ if plan.get("channel") == "API_PULL":
|
|
|
+ _stop_p2_load()
|
|
|
+ p2_recovered = True
|
|
|
+ result = LOAD_GUARD.request_stop(reason)
|
|
|
+ if plan.get("channel") == "API_PULL":
|
|
|
+ # P2 无后台线程:直接收尾(含无残留断言)
|
|
|
+ result.update(LOAD_GUARD.finish(reason))
|
|
|
+ return {"ok": True, "p2Recovered": p2_recovered, **result}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/load/status")
|
|
|
+def load_status():
|
|
|
+ """实时状态(§21 实时基础指标 + 错误摘要)。"""
|
|
|
+ guard = LOAD_GUARD.snapshot()
|
|
|
+ out = {"guard": guard, "metrics": LOAD_METRICS.snapshot()}
|
|
|
+ channel = guard.get("planChannel")
|
|
|
+ if channel == "API_PULL":
|
|
|
+ out["p2"] = {"loadMode": MOCK_STATE.settings.load_mode,
|
|
|
+ "stats": MOCK_STATE.load_stats.snapshot()}
|
|
|
+ if channel == "DB_SYNC":
|
|
|
+ out["p1"] = {"result": DB_GEN.get("result"), "error": DB_GEN.get("error")}
|
|
|
+ try:
|
|
|
+ out["p1"]["db"] = mock_db.db_status()
|
|
|
+ except Exception: # noqa: BLE001 —— 模拟库不可达时只影响统计展示
|
|
|
+ out["p1"]["db"] = None
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/load/aidop-checklist/{channel}")
|
|
|
+def load_aidop_checklist(channel: str):
|
|
|
+ """Ai-DOP 人工执行和观察清单(§18 末项;§17 末句资源观察)。"""
|
|
|
+ common = [
|
|
|
+ "确认压力计划已获负责人审批,在当前时间窗口内执行",
|
|
|
+ "打开 Ai-DOP 监控工具,观察 CPU、内存、队列深度、数据库连接数",
|
|
|
+ "记录 mdp_sync_log / mdp_inbound_request 流水号与状态",
|
|
|
+ ]
|
|
|
+ if channel == "DB_SYNC":
|
|
|
+ steps = [
|
|
|
+ "在 Ai-DOP 登记 SIM_MODE3_DB 数据源并测连(连接指向模拟库)",
|
|
|
+ "确认实体 source_table_name 指向目标表,增量列与模拟库一致",
|
|
|
+ "人工触发首次全量同步,观察分批拉取与耗时",
|
|
|
+ "全量完成后核对 Ai-DOP 贴源表行数与模拟库源端行数(分页无丢行、无重复)",
|
|
|
+ "修改模拟库数据后触发增量,核对增量值与批次",
|
|
|
+ "同步中断后恢复,观察断点续拉状态是否可解释",
|
|
|
+ ]
|
|
|
+ elif channel == "API_PULL":
|
|
|
+ steps = [
|
|
|
+ "在 Ai-DOP 登记 SIM_MODE3_API 数据源并测连(指向 Mock API :8018)",
|
|
|
+ "人工触发拉取,观察多批 cursor 推进与批次耗时",
|
|
|
+ "核对请求量与模拟器统计(请求量/QPS/429/5xx 可对应)",
|
|
|
+ "观察间歇 429/5xx/延迟下 Ai-DOP 的重试与错误提示",
|
|
|
+ "压力结束后停止 Mock 压力模式,确认可恢复正常拉取",
|
|
|
+ ]
|
|
|
+ elif channel == "API_INBOUND":
|
|
|
+ steps = [
|
|
|
+ "观察 mdp_inbound_request 流水:受理数、拒收数与原因分类",
|
|
|
+ "核对贴源表 mdp_stg_* 新增行数与发送行数",
|
|
|
+ "观察 429 限流响应与恢复后继续受理的情况",
|
|
|
+ "压测结束后确认无持续入站请求(模拟器停止后 Ai-DOP 侧连接回落)",
|
|
|
+ ]
|
|
|
+ else:
|
|
|
+ raise HTTPException(status_code=404, detail="unknown channel")
|
|
|
+ return {"channel": channel, "steps": common + steps}
|
|
|
+
|
|
|
+
|
|
|
+LOAD_REPORT_FIELDS = [
|
|
|
+ ("runId", "运行编号 runId"),
|
|
|
+ ("channel", "集成方式"),
|
|
|
+ ("environment", "环境"),
|
|
|
+ ("tenant", "测试租户"),
|
|
|
+ ("owner", "负责人"),
|
|
|
+ ("timeWindow", "时间窗口"),
|
|
|
+ ("maxDataRows", "最大数据量"),
|
|
|
+ ("maxConcurrency", "最大并发"),
|
|
|
+ ("maxQps", "最大 QPS"),
|
|
|
+ ("maxDurationSeconds", "最大持续时间"),
|
|
|
+ ("stopThreshold", "停止阈值"),
|
|
|
+ ("cleanupPlan", "清理方案"),
|
|
|
+ ("startedAt", "实际开始时间"),
|
|
|
+ ("finishedAt", "实际结束时间"),
|
|
|
+ ("stopReason", "停止原因"),
|
|
|
+ ("aidopResource", "Ai-DOP 资源人工观察"),
|
|
|
+ ("aidopFlow", "Ai-DOP 流水核对"),
|
|
|
+ ("conclusion", "结论"),
|
|
|
+ ("notes", "备注"),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def build_load_report_markdown(entry: dict) -> str:
|
|
|
+ """压力报告导出 Markdown(§17 指标全列 + §16 计划 + 人工观察 + 脱敏声明)。"""
|
|
|
+ lines = ["# Ai-DOP 第三方系统模拟器 · 压力测试报告", ""]
|
|
|
+ metrics = entry.get("metrics") or {}
|
|
|
+ guard = entry.get("guard") or {}
|
|
|
+ lines.append(f"- 集成方式:{CHANNEL_LABELS.get(entry.get('channel', ''), entry.get('channel', ''))}")
|
|
|
+ lines.append(f"- runId:{entry.get('runId', '')}")
|
|
|
+ lines.append("")
|
|
|
+ lines.append("## 测试指标(§17)")
|
|
|
+ lines.append("| 指标 | 值 |")
|
|
|
+ lines.append("|---|---|")
|
|
|
+ for key, label in load_metrics.METRIC_FIELDS:
|
|
|
+ value = metrics.get(key)
|
|
|
+ if isinstance(value, dict):
|
|
|
+ value = ", ".join(f"{k}:{v}" for k, v in value.items()) or "—"
|
|
|
+ lines.append(f"| {label} | {value if value not in (None, '') else '—'} |")
|
|
|
+ lines.append("")
|
|
|
+ lines.append("## 计划与确认(§16)")
|
|
|
+ lines.append("| 项目 | 内容 |")
|
|
|
+ lines.append("|---|---|")
|
|
|
+ for key, label in LOAD_REPORT_FIELDS:
|
|
|
+ value = entry.get(key)
|
|
|
+ if key in ("startedAt", "finishedAt", "stopReason"):
|
|
|
+ value = guard.get(key, value)
|
|
|
+ value = str(value or "").strip() or "(未填写)"
|
|
|
+ lines.append(f"| {label} | {value.replace(chr(10), '<br>')} |")
|
|
|
+ lines.append("")
|
|
|
+ lines.append(f"报告 ID:{entry.get('id', '')} · 创建时间:{entry.get('createdAt', '')}")
|
|
|
+ lines.append("")
|
|
|
+ lines.append("> 脱敏说明:本报告已自动脱敏,AccessKey 仅保留后四位;"
|
|
|
+ "数据库密码、Secret、JWT、完整 Authorization 与连接串已遮蔽。")
|
|
|
+ return "\n".join(lines)
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/api/load/report")
|
|
|
+def load_report_save(payload: LoadReportPayload):
|
|
|
+ """保存压力报告:自动快照计划与指标 + 人工观察字段,保存前脱敏。"""
|
|
|
+ guard = LOAD_GUARD.snapshot()
|
|
|
+ fields = dict(payload.model_dump())
|
|
|
+ fields.update({
|
|
|
+ "metrics": LOAD_METRICS.snapshot(),
|
|
|
+ "guard": guard,
|
|
|
+ "environment": (LOAD_GUARD.plan or {}).get("environment", ""),
|
|
|
+ "tenant": (LOAD_GUARD.plan or {}).get("tenant", ""),
|
|
|
+ "owner": (LOAD_GUARD.plan or {}).get("owner", ""),
|
|
|
+ "timeWindow": (LOAD_GUARD.plan or {}).get("timeWindow", ""),
|
|
|
+ "maxDataRows": (LOAD_GUARD.plan or {}).get("maxDataRows", ""),
|
|
|
+ "maxConcurrency": (LOAD_GUARD.plan or {}).get("maxConcurrency", ""),
|
|
|
+ "maxQps": (LOAD_GUARD.plan or {}).get("maxQps", ""),
|
|
|
+ "maxDurationSeconds": (LOAD_GUARD.plan or {}).get("maxDurationSeconds", ""),
|
|
|
+ "stopThreshold": (LOAD_GUARD.plan or {}).get("stopThreshold", ""),
|
|
|
+ "cleanupPlan": (LOAD_GUARD.plan or {}).get("cleanupPlan", ""),
|
|
|
+ })
|
|
|
+ entry = LOAD_REPORTS.save(fields, _secrets(), _access_keys())
|
|
|
+ return {"ok": True, "report": entry}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/load/report/list")
|
|
|
+def load_report_list():
|
|
|
+ return {"reports": LOAD_REPORTS.list()}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/api/load/report/export/{report_id}")
|
|
|
+def load_report_export(report_id: str):
|
|
|
+ entry = LOAD_REPORTS.get(report_id)
|
|
|
+ if not entry:
|
|
|
+ raise HTTPException(status_code=404, detail="report not found")
|
|
|
+ md = build_load_report_markdown(entry)
|
|
|
+ filename = f"aidop-sim-load-report-{report_id}.md"
|
|
|
+ return Response(content=md, media_type="text/markdown; charset=utf-8",
|
|
|
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
|
|
|
+
|
|
|
+
|
|
|
+@app.delete("/api/load/report/{report_id}")
|
|
|
+def load_report_delete(report_id: str):
|
|
|
+ if not LOAD_REPORTS.delete(report_id):
|
|
|
+ raise HTTPException(status_code=404, detail="report not found")
|
|
|
+ return {"ok": True}
|
|
|
+
|
|
|
+
|
|
|
# ---------------------------------------------------------------------------
|
|
|
# 全链路报告(F5)
|
|
|
# ---------------------------------------------------------------------------
|