app.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. """Ai-DOP 第三方对接模拟器 · 本地控制服务(任务书 P1-B / P1-G)。
  2. - 只绑定 127.0.0.1,页面与 API 仅本机可用;
  3. - Secret/密码/Token 只从环境变量读取,任何 API 不回显;
  4. - 所有对外目标 host 走白名单硬阻断(默认拒绝);
  5. - 运行记录(RunStore)写入前统一脱敏;
  6. - 内嵌管理 Mock API(providers.mock_api),默认端口 8018,可启停/切鉴权/注入错误;
  7. - 三层报告:代码层 SUPPORTED/UNSUPPORTED、配置层 READY/NOT_*、数据层 NOT_RUN/ACCEPTED/TRANSFORM_PENDING/RUN_FAILED。
  8. 启动:python tools/integration-simulator/app.py (详见 README.md)
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import os
  13. import socket
  14. import sys
  15. import time
  16. import uuid
  17. from pathlib import Path
  18. SIM_ROOT = Path(__file__).resolve().parent
  19. if str(SIM_ROOT) not in sys.path:
  20. sys.path.insert(0, str(SIM_ROOT))
  21. import uvicorn
  22. from fastapi import FastAPI, HTTPException
  23. from fastapi.responses import FileResponse, Response
  24. from fastapi.staticfiles import StaticFiles
  25. from pydantic import BaseModel
  26. from catalog import sample_adapters as sa
  27. from clients import aidop_probe
  28. from clients.inbound_client import InboundClient
  29. from clients.redaction import mask_access_key, redact_obj
  30. from providers import mock_api, mock_db
  31. DEFAULT_CONTROL_PORT = 8017
  32. MAX_RUNS = 200
  33. # 状态枚举(任务书 §4)
  34. CONFIG_STATES = ("READY", "CONFIG_NOT_REGISTERED", "CONFIG_NOT_ENABLED",
  35. "GRANT_MISSING", "SOURCE_UNREACHABLE", "RUN_FAILED")
  36. def _env(name: str) -> str:
  37. return os.environ.get(name, "") or ""
  38. def _secrets() -> list[str]:
  39. return [v for v in (_env("AIDOP_SIM_INBOUND_SECRET"), _env("AIDOP_SIM_MYSQL_PASSWORD"),
  40. _env("AIDOP_SIM_ADMIN_TOKEN")) if v]
  41. def _access_keys() -> list[str]:
  42. """已知 AccessKey 值:自由文本中出现时只保留后四位(任务书 §24)。"""
  43. ak = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
  44. return [ak] if ak else []
  45. def load_config() -> dict:
  46. cfg = {
  47. "aidopBaseUrl": "http://127.0.0.1:5005",
  48. "mockApiPort": 8018,
  49. "mysqlDatabase": mock_db.SIM_DB_DEFAULT,
  50. "allowedTargetHosts": ["127.0.0.1", "localhost"],
  51. }
  52. cfg_file = SIM_ROOT / "config.json"
  53. if cfg_file.exists():
  54. import json
  55. with cfg_file.open("r", encoding="utf-8") as f:
  56. cfg.update(json.load(f))
  57. return cfg
  58. CONFIG = load_config()
  59. MOCK_STATE = mock_api.MockApiState()
  60. MOCK_APP = mock_api.create_app(MOCK_STATE)
  61. class RunStore:
  62. def __init__(self, limit: int = MAX_RUNS):
  63. self.limit = limit
  64. self.runs: list[dict] = []
  65. def add(self, run_id: str, object_code: str, channel: str, request_summary: dict,
  66. response: object, layers: dict, followup_sql: list[str] | None = None,
  67. gaps: list[str] | None = None, scenario_id: str | None = None,
  68. nodes: list[dict] | None = None) -> dict:
  69. entry = {
  70. "runId": run_id,
  71. "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
  72. "objectCode": object_code,
  73. "channel": channel,
  74. "scenarioId": scenario_id,
  75. "nodes": nodes,
  76. "request": redact_obj(request_summary, _secrets(), _access_keys()),
  77. "response": redact_obj(response, _secrets(), _access_keys()),
  78. "layers": layers,
  79. "followupSql": followup_sql or [],
  80. "knownGaps": gaps or [],
  81. }
  82. self.runs.append(entry)
  83. if len(self.runs) > self.limit:
  84. del self.runs[: len(self.runs) - self.limit]
  85. return entry
  86. def recent(self) -> list[dict]:
  87. return list(reversed(self.runs[-50:]))
  88. RUNS = RunStore()
  89. app = FastAPI(title="Ai-DOP Integration Simulator", version="1.0.0")
  90. # ---------------------------------------------------------------------------
  91. # Mock API 内嵌启停
  92. # ---------------------------------------------------------------------------
  93. class _MockServerHandle:
  94. def __init__(self):
  95. self.server: uvicorn.Server | None = None
  96. self.task: asyncio.Task | None = None
  97. self.last_error: str = ""
  98. MOCK_HANDLE = _MockServerHandle()
  99. def _port_in_use(port: int) -> bool:
  100. """真实探测本机端口是否已被占用。
  101. 不依赖 uvicorn 内部属性:0.30+ 的 Server 已无 startup_error,
  102. 读它会抛 AttributeError 并把已启动的服务误报成 500。
  103. """
  104. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
  105. probe.settimeout(0.3)
  106. return probe.connect_ex(("127.0.0.1", int(port))) == 0
  107. async def _start_mock_api() -> dict:
  108. port = int(CONFIG["mockApiPort"])
  109. if MOCK_HANDLE.task and not MOCK_HANDLE.task.done():
  110. return {"running": True, "port": port, "alreadyRunning": True}
  111. # 端口预检必须在建 task 之前:否则失败路径会留下一个已赋值的句柄,
  112. # 使 _mock_running() 与实际监听状态不一致。
  113. if _port_in_use(port):
  114. MOCK_HANDLE.last_error = f"端口 {port} 已被占用(可能是另一个模拟器实例)"
  115. return {"running": False, "error": MOCK_HANDLE.last_error}
  116. config = uvicorn.Config(MOCK_APP, host="127.0.0.1", port=port, log_level="warning")
  117. server = uvicorn.Server(config)
  118. MOCK_HANDLE.server = server
  119. MOCK_HANDLE.task = asyncio.get_running_loop().create_task(server.serve())
  120. # 轮询库的公开状态位,上限 ~2s:比固定 sleep 既不会过早返回未就绪,也不会白等。
  121. for _ in range(40):
  122. await asyncio.sleep(0.05)
  123. if server.started:
  124. MOCK_HANDLE.last_error = ""
  125. return {"running": True, "port": port}
  126. if server.should_exit or MOCK_HANDLE.task.done():
  127. break
  128. MOCK_HANDLE.last_error = f"Mock API 在端口 {port} 上启动失败"
  129. failed_task = MOCK_HANDLE.task
  130. MOCK_HANDLE.server = None
  131. MOCK_HANDLE.task = None
  132. if failed_task and not failed_task.done():
  133. failed_task.cancel()
  134. return {"running": False, "error": MOCK_HANDLE.last_error}
  135. async def _stop_mock_api() -> dict:
  136. if MOCK_HANDLE.server and MOCK_HANDLE.task and not MOCK_HANDLE.task.done():
  137. MOCK_HANDLE.server.should_exit = True
  138. try:
  139. await asyncio.wait_for(MOCK_HANDLE.task, timeout=5)
  140. except asyncio.TimeoutError:
  141. MOCK_HANDLE.task.cancel()
  142. return {"running": False}
  143. return {"running": False, "alreadyStopped": True}
  144. def _mock_running() -> bool:
  145. return bool(MOCK_HANDLE.task and not MOCK_HANDLE.task.done() and
  146. MOCK_HANDLE.server and MOCK_HANDLE.server.started)
  147. # ---------------------------------------------------------------------------
  148. # 基础模型
  149. # ---------------------------------------------------------------------------
  150. class RowsPayload(BaseModel):
  151. objectCode: str
  152. runId: str | None = None
  153. rows: list[dict] | None = None
  154. class InboundExecPayload(BaseModel):
  155. objectCode: str | None = None
  156. entityCode: str | None = None
  157. operation: str = "push" # schema|push|receipt|bulk|snapshot_open|snapshot_commit|digest
  158. runId: str | None = None
  159. rows: list[dict] | None = None
  160. contractVersion: str | None = None
  161. # 用例与操作参数
  162. case: str | None = None # happy|missing-idem|tamper-body|expired-ts|nonce-replay|same-key-replay|same-key-diff-body|bad-signature
  163. syncBatchId: str | None = None
  164. snapshotId: str | None = None
  165. force: bool = False
  166. bizDate: str | None = None
  167. class ScenarioPayload(BaseModel):
  168. scenarioId: str
  169. runId: str | None = None
  170. channelOverride: dict[str, str] | None = None
  171. class ReportPayload(BaseModel):
  172. """全链路报告草稿(任务书 F5 §10):只保存测试人员填写的摘要。"""
  173. runId: str
  174. channel: str # DB_SYNC | API_PULL | API_INBOUND
  175. environment: str = "" # 环境与测试租户
  176. objectCode: str = "" # 对象
  177. simPrep: str = "" # 模拟端准备结果
  178. manualSteps: str = "" # 人工执行步骤
  179. simEvidence: str = "" # 模拟端证据
  180. aidopConn: str = "" # Ai-DOP 测连结果
  181. flowIds: str = "" # 同步/入站流水编号
  182. stagingCheck: str = "" # 贴源核验结果
  183. targetResult: str = "" # 目标业务结果
  184. conclusion: str = "" # 结论
  185. gaps: str = "" # 未完成项
  186. cleanup: str = "" # 清理提示
  187. CHANNELS = ("DB_SYNC", "API_PULL", "API_INBOUND")
  188. CHANNEL_LABELS = {
  189. "DB_SYNC": "方式甲 DB_SYNC(数据库同步)",
  190. "API_PULL": "方式乙 API_PULL(接口拉取)",
  191. "API_INBOUND": "方式丙 API_INBOUND(接口推送)",
  192. }
  193. # 报告章节:与任务书 §10 的字段清单一一对应
  194. REPORT_FIELDS = [
  195. ("runId", "运行编号 runId"),
  196. ("channel", "集成方式"),
  197. ("environment", "环境与测试租户"),
  198. ("objectCode", "对象"),
  199. ("simPrep", "模拟端准备结果"),
  200. ("manualSteps", "人工执行步骤"),
  201. ("simEvidence", "模拟端证据"),
  202. ("aidopConn", "Ai-DOP 测连结果"),
  203. ("flowIds", "同步/入站流水编号"),
  204. ("stagingCheck", "贴源核验结果"),
  205. ("targetResult", "目标业务结果"),
  206. ("conclusion", "结论"),
  207. ("gaps", "未完成项"),
  208. ("cleanup", "清理提示"),
  209. ]
  210. class ReportStore:
  211. """全链路报告存储(任务书 F5)。
  212. - 只保存测试人员填写的摘要字段,不保存数据库密码、Secret、JWT、连接串或完整敏感 payload;
  213. - 保存前统一过 redact_obj,导出内容与内存一致;
  214. - 内存态、不落盘:控制台重启即清空,避免凭据残留。
  215. """
  216. def __init__(self, limit: int = 50):
  217. self.limit = limit
  218. self.items: list[dict] = []
  219. def save(self, fields: dict, secrets: list[str] | None = None,
  220. access_keys: list[str] | None = None) -> dict:
  221. entry = {
  222. "id": uuid.uuid4().hex[:12],
  223. "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
  224. }
  225. entry.update(redact_obj(dict(fields), secrets, access_keys))
  226. self.items.append(entry)
  227. if len(self.items) > self.limit:
  228. del self.items[: len(self.items) - self.limit]
  229. return entry
  230. def list(self) -> list[dict]:
  231. return list(reversed(self.items[-20:]))
  232. def get(self, report_id: str) -> dict | None:
  233. return next((r for r in self.items if r["id"] == report_id), None)
  234. def delete(self, report_id: str) -> bool:
  235. before = len(self.items)
  236. self.items = [r for r in self.items if r["id"] != report_id]
  237. return len(self.items) < before
  238. def build_report_markdown(entry: dict) -> str:
  239. """报告导出 Markdown(任务书 F5 §10 字段 + 脱敏声明)。"""
  240. lines = ["# Ai-DOP 第三方系统模拟器 · 全链路测试报告", ""]
  241. for key, label in REPORT_FIELDS:
  242. value = str(entry.get(key) or "").strip()
  243. if key == "channel":
  244. value = CHANNEL_LABELS.get(value, value)
  245. lines.append(f"## {label}")
  246. lines.append(value or "(未填写)")
  247. lines.append("")
  248. lines.append("---")
  249. lines.append(f"报告 ID:{entry.get('id', '')} · 创建时间:{entry.get('createdAt', '')}")
  250. lines.append("")
  251. lines.append("> 脱敏说明:本报告已自动脱敏,AccessKey 仅保留后四位;"
  252. "数据库密码、Secret、JWT、完整 Authorization 与连接串已遮蔽。")
  253. return "\n".join(lines)
  254. REPORTS = ReportStore()
  255. # ---------------------------------------------------------------------------
  256. # 公共辅助
  257. # ---------------------------------------------------------------------------
  258. def _require_obj(object_code: str) -> dict:
  259. obj = sa.find_object(object_code)
  260. if not obj:
  261. raise HTTPException(status_code=404, detail=f"unknown objectCode: {object_code}")
  262. return obj
  263. def _build_inbound_client() -> InboundClient:
  264. base = CONFIG["aidopBaseUrl"]
  265. try:
  266. aidop_probe.assert_host_allowed(base, CONFIG["allowedTargetHosts"])
  267. except aidop_probe.HostNotAllowedError as ex:
  268. raise HTTPException(status_code=400, detail=str(ex))
  269. access_key = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
  270. secret = _env("AIDOP_SIM_INBOUND_SECRET")
  271. missing = [n for n, v in (("AIDOP_SIM_INBOUND_ACCESS_KEY", access_key),
  272. ("AIDOP_SIM_INBOUND_SECRET", secret)) if not v]
  273. if missing:
  274. raise HTTPException(status_code=400,
  275. detail=f"缺少环境变量: {', '.join(missing)}(Secret 只从环境变量读取,不落盘)")
  276. return InboundClient(base, access_key, secret)
  277. def _resolve_entity(payload: InboundExecPayload) -> tuple[dict | None, str]:
  278. """解析本次执行的对象与 entityCode。仅给 entityCode 时允许无目录对象直发(rows 必填)。"""
  279. if payload.objectCode:
  280. obj = _require_obj(payload.objectCode)
  281. else:
  282. obj = _require_obj_by_entity(payload.entityCode or "")
  283. if payload.entityCode:
  284. entity = payload.entityCode.upper()
  285. else:
  286. if not obj:
  287. raise HTTPException(status_code=400, detail="need objectCode or entityCode")
  288. inbound = obj.get("apiInbound") or {}
  289. if not inbound.get("supported"):
  290. raise HTTPException(status_code=400,
  291. detail=f"{obj['objectCode']} 不支持 API_INBOUND: {inbound.get('reason', '无契约')}")
  292. entity = inbound["entityCode"]
  293. return obj, entity
  294. def _require_obj_by_entity(entity_code: str) -> dict | None:
  295. for obj in sa.load_catalog()["objects"]:
  296. if (obj.get("apiInbound") or {}).get("entityCode") == entity_code.upper():
  297. return obj
  298. return None
  299. def _layers(code: str, config: str, data: str) -> dict:
  300. return {"code": code, "config": config, "data": data}
  301. def _followup_sql(obj: dict, channel: str, run_id: str) -> list[str]:
  302. prefix = sa.biz_prefix(run_id)
  303. db = (obj.get("dbSync") or {}).get("table")
  304. sqls = [
  305. "SELECT source_code, status, COUNT(*) cnt, MAX(end_time) latest FROM mdp_sync_log "
  306. "WHERE source_code LIKE 'SIM_MODE3%' GROUP BY source_code, status;",
  307. "SELECT COUNT(*) FROM mdp_inbound_request WHERE create_time >= CURDATE();",
  308. ]
  309. if channel == "DB_SYNC" and db:
  310. sqls.insert(0, f"-- 模拟库核对:\nSELECT COUNT(*), MAX(sourceUpdatedAt) FROM `{db}` WHERE bizKey LIKE '{prefix}%';")
  311. sqls.append(f"SELECT * FROM `{db.replace('sim_', 'mdp_stg_sim_')}` WHERE raw_data LIKE '%{prefix}%' LIMIT 20;")
  312. if channel == "API_INBOUND":
  313. entity = (obj.get("apiInbound") or {}).get("entityCode")
  314. sqls.append(f"SELECT status, accepted_rows, rejected_rows, sync_batch_id FROM mdp_inbound_request "
  315. f"WHERE entity_code='{entity}' ORDER BY id DESC LIMIT 10;")
  316. sqls.append("-- 标准层/KPI 下游核验属第二阶段自动对账范围;第一阶段报告 TRANSFORM_PENDING,不猜测成功。")
  317. return sqls
  318. # ---------------------------------------------------------------------------
  319. # 状态与目录
  320. # ---------------------------------------------------------------------------
  321. @app.get("/api/status")
  322. def status():
  323. inbound_key = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
  324. return {
  325. "controlPort": DEFAULT_CONTROL_PORT,
  326. "aidopBaseUrl": CONFIG["aidopBaseUrl"],
  327. "allowedTargetHosts": CONFIG["allowedTargetHosts"],
  328. "aidopReachable": None, # 由 /api/precheck 实测,避免每次状态查询打真实请求
  329. "mockApi": {"running": _mock_running(), "port": CONFIG["mockApiPort"],
  330. "authMode": MOCK_STATE.settings.auth_mode,
  331. "forceStatus": MOCK_STATE.settings.force_status,
  332. "lastError": MOCK_HANDLE.last_error},
  333. "mysql": {
  334. "database": _env("AIDOP_SIM_MYSQL_DATABASE") or mock_db.SIM_DB_DEFAULT,
  335. "passwordLoaded": bool(_env("AIDOP_SIM_MYSQL_PASSWORD")),
  336. },
  337. "inbound": {
  338. "accessKeyLoaded": bool(inbound_key),
  339. "accessKeyMasked": mask_access_key(inbound_key),
  340. "secretLoaded": bool(_env("AIDOP_SIM_INBOUND_SECRET")),
  341. },
  342. "adminTokenLoaded": bool(_env("AIDOP_SIM_ADMIN_TOKEN")),
  343. "objectCounts": _channel_counts(),
  344. }
  345. def _channel_counts() -> dict:
  346. counts = {"DB_SYNC": 0, "API_PULL": 0, "API_INBOUND": 0, "total": 0}
  347. for obj in sa.load_catalog()["objects"]:
  348. counts["total"] += 1
  349. if (obj.get("dbSync") or {}).get("supported"):
  350. counts["DB_SYNC"] += 1
  351. if (obj.get("apiPull") or {}).get("supported"):
  352. counts["API_PULL"] += 1
  353. if (obj.get("apiInbound") or {}).get("supported"):
  354. counts["API_INBOUND"] += 1
  355. return counts
  356. @app.get("/api/catalog")
  357. def catalog():
  358. return sa.load_catalog()
  359. @app.get("/api/scenarios")
  360. def scenarios():
  361. return sa.load_scenarios()
  362. @app.get("/api/sample/{object_code}")
  363. def get_sample(object_code: str, runId: str | None = None):
  364. obj = _require_obj(object_code)
  365. run_id = runId or sa.new_run_id()
  366. inbound = obj.get("apiInbound") or {}
  367. result: dict = {"objectCode": obj["objectCode"], "runId": run_id,
  368. "canonical": sa.load_canonical_rows(obj),
  369. "dbSync": None, "apiPull": None, "apiInbound": None}
  370. if (obj.get("dbSync") or {}).get("supported"):
  371. result["dbSync"] = sa.adapt_db_rows(obj, run_id)
  372. if (obj.get("apiPull") or {}).get("supported"):
  373. result["apiPull"] = sa.adapt_api_rows(obj, run_id)
  374. if inbound.get("supported"):
  375. adapted = sa.adapt_inbound_rows(obj, run_id)
  376. result["apiInbound"] = {
  377. "entityCode": inbound["entityCode"],
  378. "contractVersion": inbound.get("contractVersion", "v1"),
  379. "rows": adapted["rows"],
  380. "missingRequired": adapted["missing_required"],
  381. "envelope": sa.inbound_envelope(adapted["rows"]),
  382. }
  383. return result
  384. # ---------------------------------------------------------------------------
  385. # Mock API 控制
  386. # ---------------------------------------------------------------------------
  387. @app.post("/api/mock-api/start")
  388. async def mock_api_start():
  389. return await _start_mock_api()
  390. @app.post("/api/mock-api/stop")
  391. async def mock_api_stop():
  392. return await _stop_mock_api()
  393. @app.get("/api/mock-api/settings")
  394. def mock_api_settings():
  395. s = MOCK_STATE.settings
  396. return {"running": _mock_running(), "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
  397. "forceStatus": s.force_status, "responsePath": s.response_path,
  398. "apiKeyHeader": s.api_key_header, "overrides": sorted(MOCK_STATE.overrides.keys())}
  399. @app.post("/api/mock-api/control")
  400. def mock_api_control(payload: dict):
  401. """统一控制入口:auth_mode/token/basic/apikey/delay/force_status/response_path/override。"""
  402. s = MOCK_STATE.settings
  403. if "auth_mode" in payload:
  404. mode = str(payload["auth_mode"]).upper()
  405. if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
  406. raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
  407. s.auth_mode = mode
  408. for key, attr in (("token", "token"), ("basic_user", "basic_user"),
  409. ("basic_password", "basic_password"), ("api_key_header", "api_key_header"),
  410. ("api_key_value", "api_key_value"), ("response_path", "response_path")):
  411. if payload.get(key):
  412. setattr(s, attr, str(payload[key]))
  413. if "delay_seconds" in payload:
  414. s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
  415. if "force_status" in payload:
  416. s.force_status = int(payload["force_status"]) if payload["force_status"] else None
  417. if payload.get("override_path") and isinstance(payload.get("override_rows"), list):
  418. MOCK_STATE.overrides[payload["override_path"]] = payload["override_rows"]
  419. if payload.get("clear_overrides"):
  420. MOCK_STATE.overrides.clear()
  421. return {"ok": True, "settings": mock_api_settings()}
  422. @app.get("/api/mock-api/audit")
  423. def mock_api_audit():
  424. return {"entries": redact_obj(MOCK_STATE.audit[-50:], _secrets())}
  425. # ---------------------------------------------------------------------------
  426. # 模拟 MySQL 控制
  427. # ---------------------------------------------------------------------------
  428. @app.post("/api/db/ensure")
  429. def db_ensure(payload: RowsPayload):
  430. """初始化模拟 schema 并确保对象源表存在(DDL 由样例推断)。"""
  431. obj = _require_obj(payload.objectCode)
  432. db_spec = obj.get("dbSync") or {}
  433. if not db_spec.get("supported"):
  434. raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
  435. if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
  436. raise HTTPException(status_code=400, detail="缺少环境变量 AIDOP_SIM_MYSQL_PASSWORD")
  437. run_id = payload.runId or sa.new_run_id()
  438. rows = payload.rows if payload.rows is not None else sa.adapt_db_rows(obj, run_id)
  439. try:
  440. result = mock_db.init_schema({db_spec["table"]: {
  441. "rows": rows, "businessKey": db_spec.get("businessKey"),
  442. "incrementColumn": db_spec.get("incrementColumn", "sourceUpdatedAt")}})
  443. return {"ok": True, "runId": run_id, **result}
  444. except mock_db.BlockedDatabaseError as ex:
  445. raise HTTPException(status_code=400, detail=str(ex))
  446. except Exception as ex:
  447. raise HTTPException(status_code=502, detail=f"MySQL 不可达或执行失败: {type(ex).__name__}: {ex}")
  448. @app.post("/api/db/load")
  449. def db_load(payload: RowsPayload):
  450. obj = _require_obj(payload.objectCode)
  451. db_spec = obj.get("dbSync") or {}
  452. if not db_spec.get("supported"):
  453. raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
  454. run_id = payload.runId or sa.new_run_id()
  455. rows = payload.rows if payload.rows is not None else sa.adapt_db_rows(obj, run_id)
  456. try:
  457. count = mock_db.upsert_rows(db_spec["table"], rows)
  458. stats = mock_db.table_stats(db_spec["table"], db_spec.get("incrementColumn", "sourceUpdatedAt"))
  459. except Exception as ex:
  460. raise HTTPException(status_code=502, detail=f"装载失败(先执行 /api/db/ensure?): {type(ex).__name__}: {ex}")
  461. layers = _layers("SUPPORTED", "READY", "ACCEPTED")
  462. RUNS.add(run_id, obj["objectCode"], "DB_SYNC",
  463. {"action": "load", "table": db_spec["table"], "rowCount": count},
  464. stats, layers, _followup_sql(obj, "DB_SYNC", run_id),
  465. gaps=["Ai-DOP 侧同步须由 MdpDbPullExecutor 真实执行;本操作只装载第三方模拟源库"])
  466. return {"ok": True, "runId": run_id, "loaded": count, "stats": stats, "layers": layers}
  467. @app.post("/api/db/reset")
  468. def db_reset(payload: RowsPayload):
  469. obj = _require_obj(payload.objectCode)
  470. table = (obj.get("dbSync") or {}).get("table")
  471. if not table:
  472. raise HTTPException(status_code=400, detail="object has no dbSync table")
  473. try:
  474. mock_db.reset_table(table)
  475. return {"ok": True, "table": table}
  476. except Exception as ex:
  477. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  478. @app.post("/api/db/update")
  479. def db_update(payload: dict):
  480. obj = _require_obj(payload.get("objectCode", ""))
  481. db_spec = obj.get("dbSync") or {}
  482. table = db_spec.get("table")
  483. updates = payload.get("updates") or {}
  484. where = payload.get("where") or {}
  485. if not table or not updates or not where:
  486. raise HTTPException(status_code=400, detail="need objectCode/updates/where")
  487. try:
  488. affected = mock_db.update_rows(table, updates, where,
  489. db_spec.get("incrementColumn", "sourceUpdatedAt"),
  490. payload.get("newIncrement"))
  491. return {"ok": True, "table": table, "affected": affected}
  492. except Exception as ex:
  493. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  494. @app.get("/api/db/stats/{object_code}")
  495. def db_stats(object_code: str):
  496. obj = _require_obj(object_code)
  497. db_spec = obj.get("dbSync") or {}
  498. if not db_spec.get("table"):
  499. raise HTTPException(status_code=400, detail="object has no dbSync table")
  500. try:
  501. return mock_db.table_stats(db_spec["table"], db_spec.get("incrementColumn", "sourceUpdatedAt"))
  502. except Exception as ex:
  503. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  504. @app.get("/api/db/aidop-config/{object_code}")
  505. def db_aidop_config(object_code: str):
  506. obj = _require_obj(object_code)
  507. base = f"http://127.0.0.1:{CONFIG['mockApiPort']}"
  508. return mock_db.generate_aidop_config(obj, base)
  509. # ---------------------------------------------------------------------------
  510. # 预检与三层报告(P1-G)
  511. # ---------------------------------------------------------------------------
  512. @app.get("/api/precheck")
  513. def precheck():
  514. base = CONFIG["aidopBaseUrl"]
  515. reach = aidop_probe.check_reachable(base, CONFIG["allowedTargetHosts"])
  516. inbound_ready = bool(_env("AIDOP_SIM_INBOUND_ACCESS_KEY") and _env("AIDOP_SIM_INBOUND_SECRET"))
  517. env_missing = [n for n in ("AIDOP_SIM_INBOUND_ACCESS_KEY", "AIDOP_SIM_INBOUND_SECRET",
  518. "AIDOP_SIM_MYSQL_PASSWORD") if not _env(n)]
  519. return {
  520. "aidopBaseUrl": base,
  521. "aidop": reach,
  522. "mockApi": {"running": _mock_running(), "port": CONFIG["mockApiPort"]},
  523. "mysql": {"database": _env("AIDOP_SIM_MYSQL_DATABASE") or mock_db.SIM_DB_DEFAULT,
  524. "passwordLoaded": bool(_env("AIDOP_SIM_MYSQL_PASSWORD"))},
  525. "inboundCredentialsLoaded": inbound_ready,
  526. "envMissing": env_missing,
  527. "testSources": mock_db.SIM_SOURCES,
  528. "note": "DB_SYNC/API_PULL 的来源、实体、租户登记须人工在 Ai-DOP 配置页核对(生成清单见 /api/db/aidop-config/{objectCode})",
  529. }
  530. @app.get("/api/precheck/inbound")
  531. def precheck_inbound():
  532. """26 个代码契约逐一 schema 预检(真实签名,只读)。"""
  533. client = _build_inbound_client()
  534. codes = sorted({(o.get("apiInbound") or {}).get("entityCode")
  535. for o in sa.load_catalog()["objects"]
  536. if (o.get("apiInbound") or {}).get("supported")})
  537. results = aidop_probe.precheck_all_inbound(client, codes, CONFIG["allowedTargetHosts"])
  538. return {"accessKey": mask_access_key(client.key), "count": len(results), "results": results}
  539. @app.get("/api/precheck/inbound/{entity_code}")
  540. def precheck_inbound_one(entity_code: str):
  541. client = _build_inbound_client()
  542. return aidop_probe.precheck_inbound_entity(client, entity_code.upper(), CONFIG["allowedTargetHosts"])
  543. # ---------------------------------------------------------------------------
  544. # API_INBOUND 执行(P1-E)
  545. # ---------------------------------------------------------------------------
  546. @app.post("/api/inbound/exec")
  547. def inbound_exec(payload: InboundExecPayload):
  548. obj, entity = _resolve_entity(payload)
  549. client = _build_inbound_client()
  550. run_id = payload.runId or sa.new_run_id()
  551. contract_version = payload.contractVersion
  552. if obj is None:
  553. # 仅按 entityCode 直发(无目录样例),rows 必填
  554. rows = payload.rows
  555. if rows is None and payload.operation in ("push", "bulk"):
  556. raise HTTPException(status_code=400, detail="entityCode-only exec requires rows")
  557. envelope = sa.inbound_envelope(rows or [])
  558. else:
  559. inbound = obj.get("apiInbound") or {}
  560. contract_version = contract_version or inbound.get("contractVersion")
  561. rows = payload.rows if payload.rows is not None else sa.adapt_inbound_rows(obj, run_id)["rows"]
  562. envelope = sa.inbound_envelope(rows)
  563. object_code = obj["objectCode"] if obj else entity
  564. op = payload.operation
  565. result: dict = {"runId": run_id, "entityCode": entity, "operation": op, "case": payload.case}
  566. def record(status: int, body, headers, layers: dict, extra: dict | None = None):
  567. result.update({"httpStatus": status, "response": body, "requestHeaders": headers, **(extra or {})})
  568. RUNS.add(run_id, object_code, "API_INBOUND",
  569. {"operation": op, "case": payload.case, "entityCode": entity,
  570. "rowCount": len(rows or []), "contractVersion": contract_version},
  571. {"httpStatus": status, "body": body, "headers": headers},
  572. layers, _followup_sql(obj or {"objectCode": entity, "apiInbound": {"entityCode": entity}},
  573. "API_INBOUND", run_id))
  574. return result
  575. # 预检(配置层)
  576. pre = aidop_probe.precheck_inbound_entity(client, entity, CONFIG["allowedTargetHosts"], contract_version)
  577. config_state = pre["configState"]
  578. result["precheck"] = {"configState": config_state, "httpStatus": pre["httpStatus"]}
  579. if op == "schema":
  580. status, body, headers = client.op_schema(entity, contract_version)
  581. layers = _layers("SUPPORTED", aidop_probe.classify_inbound_precheck(status, body), "NOT_RUN")
  582. return record(status, body, headers, layers)
  583. if config_state != "READY" and op in ("push", "bulk", "snapshot_open"):
  584. layers = _layers("SUPPORTED", config_state, "NOT_RUN")
  585. raise HTTPException(status_code=409, detail={
  586. "message": f"配置层 {config_state}:普通推数只对 READY 实体执行(不伪造成功)",
  587. "precheck": pre, "layers": layers})
  588. if op == "push":
  589. case = payload.case or "happy"
  590. if case == "happy":
  591. status, body, headers = client.op_push(entity, envelope, contract_version=contract_version)
  592. elif case == "missing-idem":
  593. status, body, headers = client.case_missing_idem(entity, envelope)
  594. elif case == "tamper-body":
  595. status, body, headers = client.case_tamper_body(entity, envelope)
  596. elif case == "expired-ts":
  597. status, body, headers = client.case_expired_ts(entity, envelope)
  598. elif case == "bad-signature":
  599. status, body, headers = client.case_bad_signature(entity, envelope)
  600. elif case == "nonce-replay":
  601. (s1, d1), (s2, d2) = client.case_nonce_replay(entity, envelope)
  602. status, body, headers = s2, {"first": [s1, d1], "replay": [s2, d2]}, {}
  603. elif case == "same-key-replay":
  604. (s1, d1), (s2, d2) = client.case_same_key_replay(entity, envelope)
  605. status, body, headers = s2, {"first": [s1, d1], "replay": [s2, d2]}, {}
  606. elif case == "same-key-diff-body":
  607. env2 = sa.inbound_envelope([{**r, "sourceUpdatedAt": sa.now_iso()} for r in (rows or [])])
  608. (s1, d1), (s2, d2) = client.case_same_key_diff_body(entity, envelope, env2)
  609. status, body, headers = s2, {"first": [s1, d1], "conflict": [s2, d2]}, {}
  610. else:
  611. raise HTTPException(status_code=400, detail=f"unknown case: {case}")
  612. data_state = "ACCEPTED" if status == 202 else ("RUN_FAILED" if status >= 400 else "NOT_RUN")
  613. layers = _layers("SUPPORTED", config_state, data_state)
  614. return record(status, body, headers, layers,
  615. extra={"gaps": ["HTTP 202 仅代表摄取受理;贴源/标准层核验见 followupSql(TRANSFORM_PENDING)"]})
  616. if op == "receipt":
  617. if not payload.syncBatchId:
  618. raise HTTPException(status_code=400, detail="receipt requires syncBatchId")
  619. status, body, headers = client.op_receipt(payload.syncBatchId)
  620. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 200 else "RUN_FAILED")
  621. return record(status, body, headers, layers)
  622. if op == "bulk":
  623. # 后端 NDJSON 分块:每行须为完整信封(data.list)+ 根级 idempotencyKey(MdpInboundReceiveService.BulkAsync)
  624. bulk_rows = [{"idempotencyKey": f"{run_id}-L{i + 1}",
  625. "data": {"list": [r]}} for i, r in enumerate(rows or [])]
  626. status, body, headers = client.op_bulk(entity, bulk_rows, contract_version=contract_version)
  627. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 202 else "RUN_FAILED")
  628. return record(status, body, headers, layers)
  629. if op == "snapshot_open":
  630. status, body, headers = client.op_snapshot_open(entity)
  631. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status in (200, 201) else "RUN_FAILED")
  632. return record(status, body, headers, layers)
  633. if op == "snapshot_commit":
  634. if not payload.snapshotId:
  635. raise HTTPException(status_code=400, detail="snapshot_commit requires snapshotId")
  636. status, body, headers = client.op_snapshot_commit(entity, payload.snapshotId, payload.force)
  637. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status in (200, 202) else "RUN_FAILED")
  638. return record(status, body, headers, layers)
  639. if op == "digest":
  640. biz_date = payload.bizDate or time.strftime("%Y-%m-%d")
  641. status, body, headers = client.op_digest(entity, biz_date)
  642. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 200 else "RUN_FAILED")
  643. return record(status, body, headers, layers)
  644. raise HTTPException(status_code=400, detail=f"unknown operation: {op}")
  645. # ---------------------------------------------------------------------------
  646. # 场景执行(P1-F)
  647. # ---------------------------------------------------------------------------
  648. @app.post("/api/scenarios/run")
  649. def scenario_run(payload: ScenarioPayload):
  650. scenario = next((s for s in sa.load_scenarios()["scenarios"]
  651. if s["scenarioId"] == payload.scenarioId), None)
  652. if not scenario:
  653. raise HTTPException(status_code=404, detail=f"unknown scenario: {payload.scenarioId}")
  654. run_id = payload.runId or sa.new_run_id()
  655. overrides = payload.channelOverride or {}
  656. nodes_out = []
  657. for node in scenario["nodes"]:
  658. obj = sa.find_object(node["objectCode"])
  659. channel = overrides.get(node["objectCode"], node["channel"])
  660. node_result = {"objectCode": node["objectCode"], "channel": channel,
  661. "relationKey": node.get("relationKey")}
  662. if not obj:
  663. node_result.update({"state": "CODE_UNSUPPORTED", "message": "目录中无此对象"})
  664. nodes_out.append(node_result)
  665. continue
  666. spec = {"DB_SYNC": obj.get("dbSync"), "API_PULL": obj.get("apiPull"),
  667. "API_INBOUND": obj.get("apiInbound")}.get(channel)
  668. if not spec or not spec.get("supported"):
  669. reason = (spec or {}).get("reason") or node.get("inboundNote") or "该通道不支持"
  670. node_result.update({"state": "CODE_UNSUPPORTED", "message": reason})
  671. nodes_out.append(node_result)
  672. continue
  673. try:
  674. if channel == "DB_SYNC":
  675. rows = sa.adapt_db_rows(obj, run_id)
  676. if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
  677. raise RuntimeError("缺少 AIDOP_SIM_MYSQL_PASSWORD")
  678. mock_db.init_schema({spec["table"]: {"rows": rows, "businessKey": spec.get("businessKey"),
  679. "incrementColumn": spec.get("incrementColumn")}})
  680. count = mock_db.upsert_rows(spec["table"], rows)
  681. node_result.update({"state": "ACCEPTED", "loaded": count, "table": spec["table"]})
  682. elif channel == "API_PULL":
  683. if not _mock_running():
  684. raise RuntimeError("Mock API 未启动(先 POST /api/mock-api/start)")
  685. rows = sa.adapt_api_rows(obj, run_id)
  686. MOCK_STATE.overrides[spec["path"]] = rows
  687. node_result.update({"state": "ACCEPTED", "overridePath": spec["path"], "rowCount": len(rows),
  688. "note": "已在 Mock API 挂载适配样例;由 Ai-DOP MdpApiPullExecutor 真实拉取"})
  689. else:
  690. client = _build_inbound_client()
  691. entity = spec["entityCode"]
  692. version = node.get("contractVersion") or spec.get("contractVersion")
  693. pre = aidop_probe.precheck_inbound_entity(client, entity, CONFIG["allowedTargetHosts"], version)
  694. if pre["configState"] != "READY":
  695. node_result.update({"state": pre["configState"],
  696. "message": f"配置层 {pre['configState']},未推数(不伪造成功)"})
  697. else:
  698. rows = sa.adapt_inbound_rows(obj, run_id)["rows"]
  699. status, body, _ = client.op_push(entity, sa.inbound_envelope(rows),
  700. contract_version=version)
  701. node_result.update({"state": "ACCEPTED" if status == 202 else "RUN_FAILED",
  702. "httpStatus": status, "response": body})
  703. except HTTPException:
  704. raise
  705. except aidop_probe.HostNotAllowedError as ex:
  706. node_result.update({"state": "SOURCE_UNREACHABLE", "message": str(ex)})
  707. except Exception as ex:
  708. node_result.update({"state": "RUN_FAILED", "message": f"{type(ex).__name__}: {ex}"})
  709. nodes_out.append(node_result)
  710. unsupported = [n["objectCode"] for n in nodes_out if n["state"] == "CODE_UNSUPPORTED"]
  711. layers = _layers("SUPPORTED",
  712. "READY" if all(n["state"] not in ("SOURCE_UNREACHABLE",) for n in nodes_out) else "SOURCE_UNREACHABLE",
  713. "ACCEPTED" if all(n["state"] in ("ACCEPTED", "CODE_UNSUPPORTED") for n in nodes_out) else "RUN_FAILED")
  714. entry = RUNS.add(run_id, ",".join(n["objectCode"] for n in nodes_out), f"SCENARIO:{payload.scenarioId}",
  715. {"scenario": scenario["name"]}, {"nodes": nodes_out}, layers,
  716. _followup_sql({"objectCode": "SCENARIO", "dbSync": {}}, "SCENARIO", run_id),
  717. gaps=([f"以下节点该通道不支持:{', '.join(unsupported)}(链路不完整,不得宣称全链路跑通)"]
  718. if unsupported else []),
  719. scenario_id=payload.scenarioId, nodes=nodes_out)
  720. return entry
  721. # ---------------------------------------------------------------------------
  722. # 运行记录
  723. # ---------------------------------------------------------------------------
  724. @app.get("/api/runs")
  725. def runs():
  726. return {"runs": RUNS.recent()}
  727. # ---------------------------------------------------------------------------
  728. # 全链路报告(F5)
  729. # ---------------------------------------------------------------------------
  730. @app.post("/api/report/save")
  731. def report_save(payload: ReportPayload):
  732. if payload.channel not in CHANNELS:
  733. raise HTTPException(status_code=400, detail=f"unknown channel: {payload.channel}")
  734. if not payload.runId.strip():
  735. raise HTTPException(status_code=400, detail="runId required")
  736. entry = REPORTS.save(payload.model_dump(), _secrets(), _access_keys())
  737. return {"ok": True, "report": entry}
  738. @app.get("/api/report/list")
  739. def report_list():
  740. return {"reports": REPORTS.list()}
  741. @app.get("/api/report/evidence/{channel}")
  742. def report_evidence(channel: str):
  743. """按方式取最近运行记录(已脱敏),供测试人员一键导入「模拟端证据」。"""
  744. if channel not in CHANNELS:
  745. raise HTTPException(status_code=404, detail="unknown channel")
  746. entries = [r for r in RUNS.runs if r["channel"] == channel][-5:]
  747. return {"entries": list(reversed(entries))}
  748. @app.get("/api/report/export/{report_id}")
  749. def report_export(report_id: str):
  750. entry = REPORTS.get(report_id)
  751. if not entry:
  752. raise HTTPException(status_code=404, detail="report not found")
  753. md = build_report_markdown(entry)
  754. filename = f"aidop-sim-report-{entry['runId']}.md"
  755. return Response(content=md, media_type="text/markdown; charset=utf-8",
  756. headers={"Content-Disposition": f'attachment; filename="{filename}"'})
  757. @app.delete("/api/report/{report_id}")
  758. def report_delete(report_id: str):
  759. if not REPORTS.delete(report_id):
  760. raise HTTPException(status_code=404, detail="report not found")
  761. return {"ok": True}
  762. # ---------------------------------------------------------------------------
  763. # 静态页面
  764. # ---------------------------------------------------------------------------
  765. @app.get("/")
  766. def index():
  767. return FileResponse(SIM_ROOT / "static" / "index.html")
  768. app.mount("/static", StaticFiles(directory=SIM_ROOT / "static"), name="static")
  769. def main() -> None:
  770. print(f"[simulator] control panel: http://127.0.0.1:{DEFAULT_CONTROL_PORT}/")
  771. print(f"[simulator] aidop target : {CONFIG['aidopBaseUrl']} (allowed hosts: {CONFIG['allowedTargetHosts']})")
  772. uvicorn.run(app, host="127.0.0.1", port=DEFAULT_CONTROL_PORT, log_level="info")
  773. if __name__ == "__main__":
  774. main()