app.py 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277
  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 json
  13. import os
  14. import socket
  15. import sys
  16. import time
  17. import uuid
  18. from pathlib import Path
  19. SIM_ROOT = Path(__file__).resolve().parent
  20. if str(SIM_ROOT) not in sys.path:
  21. sys.path.insert(0, str(SIM_ROOT))
  22. import uvicorn
  23. from fastapi import FastAPI, HTTPException
  24. from fastapi.responses import FileResponse, Response
  25. from fastapi.staticfiles import StaticFiles
  26. from pydantic import BaseModel, Field
  27. from catalog import sample_adapters as sa
  28. from clients import aidop_probe
  29. from clients.inbound_client import InboundClient
  30. from clients.redaction import mask_access_key, redact_obj
  31. from providers import fault_catalog, mock_api, mock_db
  32. DEFAULT_CONTROL_PORT = 8017
  33. MAX_RUNS = 200
  34. # 状态枚举(任务书 §4)
  35. CONFIG_STATES = ("READY", "CONFIG_NOT_REGISTERED", "CONFIG_NOT_ENABLED",
  36. "GRANT_MISSING", "SOURCE_UNREACHABLE", "RUN_FAILED")
  37. def _env(name: str) -> str:
  38. return os.environ.get(name, "") or ""
  39. def _secrets() -> list[str]:
  40. return [v for v in (_env("AIDOP_SIM_INBOUND_SECRET"), _env("AIDOP_SIM_MYSQL_PASSWORD"),
  41. _env("AIDOP_SIM_ADMIN_TOKEN")) if v]
  42. def _access_keys() -> list[str]:
  43. """已知 AccessKey 值:自由文本中出现时只保留后四位(任务书 §24)。"""
  44. ak = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
  45. return [ak] if ak else []
  46. def load_config() -> dict:
  47. cfg = {
  48. "aidopBaseUrl": "http://127.0.0.1:5005",
  49. "mockApiPort": 8018,
  50. "mysqlDatabase": mock_db.SIM_DB_DEFAULT,
  51. "allowedTargetHosts": ["127.0.0.1", "localhost"],
  52. }
  53. cfg_file = SIM_ROOT / "config.json"
  54. if cfg_file.exists():
  55. import json
  56. with cfg_file.open("r", encoding="utf-8") as f:
  57. cfg.update(json.load(f))
  58. return cfg
  59. CONFIG = load_config()
  60. MOCK_STATE = mock_api.MockApiState()
  61. MOCK_APP = mock_api.create_app(MOCK_STATE)
  62. class RunStore:
  63. def __init__(self, limit: int = MAX_RUNS):
  64. self.limit = limit
  65. self.runs: list[dict] = []
  66. def add(self, run_id: str, object_code: str, channel: str, request_summary: dict,
  67. response: object, layers: dict, followup_sql: list[str] | None = None,
  68. gaps: list[str] | None = None, scenario_id: str | None = None,
  69. nodes: list[dict] | None = None) -> dict:
  70. entry = {
  71. "runId": run_id,
  72. "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
  73. "objectCode": object_code,
  74. "channel": channel,
  75. "scenarioId": scenario_id,
  76. "nodes": nodes,
  77. "request": redact_obj(request_summary, _secrets(), _access_keys()),
  78. "response": redact_obj(response, _secrets(), _access_keys()),
  79. "layers": layers,
  80. "followupSql": followup_sql or [],
  81. "knownGaps": gaps or [],
  82. }
  83. self.runs.append(entry)
  84. if len(self.runs) > self.limit:
  85. del self.runs[: len(self.runs) - self.limit]
  86. return entry
  87. def recent(self) -> list[dict]:
  88. return list(reversed(self.runs[-50:]))
  89. RUNS = RunStore()
  90. app = FastAPI(title="Ai-DOP Integration Simulator", version="1.0.0")
  91. # ---------------------------------------------------------------------------
  92. # Mock API 内嵌启停
  93. # ---------------------------------------------------------------------------
  94. class _MockServerHandle:
  95. def __init__(self):
  96. self.server: uvicorn.Server | None = None
  97. self.task: asyncio.Task | None = None
  98. self.last_error: str = ""
  99. MOCK_HANDLE = _MockServerHandle()
  100. def _port_in_use(port: int) -> bool:
  101. """真实探测本机端口是否已被占用。
  102. 不依赖 uvicorn 内部属性:0.30+ 的 Server 已无 startup_error,
  103. 读它会抛 AttributeError 并把已启动的服务误报成 500。
  104. """
  105. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
  106. probe.settimeout(0.3)
  107. return probe.connect_ex(("127.0.0.1", int(port))) == 0
  108. async def _start_mock_api() -> dict:
  109. port = int(CONFIG["mockApiPort"])
  110. if MOCK_HANDLE.task and not MOCK_HANDLE.task.done():
  111. return {"running": True, "port": port, "alreadyRunning": True}
  112. # 端口预检必须在建 task 之前:否则失败路径会留下一个已赋值的句柄,
  113. # 使 _mock_running() 与实际监听状态不一致。
  114. if _port_in_use(port):
  115. MOCK_HANDLE.last_error = f"端口 {port} 已被占用(可能是另一个模拟器实例)"
  116. return {"running": False, "error": MOCK_HANDLE.last_error}
  117. config = uvicorn.Config(MOCK_APP, host="127.0.0.1", port=port, log_level="warning")
  118. server = uvicorn.Server(config)
  119. MOCK_HANDLE.server = server
  120. MOCK_HANDLE.task = asyncio.get_running_loop().create_task(server.serve())
  121. # 轮询库的公开状态位,上限 ~2s:比固定 sleep 既不会过早返回未就绪,也不会白等。
  122. for _ in range(40):
  123. await asyncio.sleep(0.05)
  124. if server.started:
  125. MOCK_HANDLE.last_error = ""
  126. return {"running": True, "port": port}
  127. if server.should_exit or MOCK_HANDLE.task.done():
  128. break
  129. MOCK_HANDLE.last_error = f"Mock API 在端口 {port} 上启动失败"
  130. failed_task = MOCK_HANDLE.task
  131. MOCK_HANDLE.server = None
  132. MOCK_HANDLE.task = None
  133. if failed_task and not failed_task.done():
  134. failed_task.cancel()
  135. return {"running": False, "error": MOCK_HANDLE.last_error}
  136. async def _stop_mock_api() -> dict:
  137. if MOCK_HANDLE.server and MOCK_HANDLE.task and not MOCK_HANDLE.task.done():
  138. MOCK_HANDLE.server.should_exit = True
  139. try:
  140. await asyncio.wait_for(MOCK_HANDLE.task, timeout=5)
  141. except asyncio.TimeoutError:
  142. MOCK_HANDLE.task.cancel()
  143. return {"running": False}
  144. return {"running": False, "alreadyStopped": True}
  145. def _mock_running() -> bool:
  146. return bool(MOCK_HANDLE.task and not MOCK_HANDLE.task.done() and
  147. MOCK_HANDLE.server and MOCK_HANDLE.server.started)
  148. # ---------------------------------------------------------------------------
  149. # 基础模型
  150. # ---------------------------------------------------------------------------
  151. class RowsPayload(BaseModel):
  152. objectCode: str
  153. runId: str | None = None
  154. rows: list[dict] | None = None
  155. class InboundExecPayload(BaseModel):
  156. objectCode: str | None = None
  157. entityCode: str | None = None
  158. operation: str = "push" # schema|push|receipt|bulk|snapshot_open|snapshot_commit|digest
  159. runId: str | None = None
  160. rows: list[dict] | None = None
  161. contractVersion: str | None = None
  162. # 用例与操作参数
  163. case: str | None = None # happy|missing-idem|tamper-body|expired-ts|nonce-replay|same-key-replay|same-key-diff-body|bad-signature
  164. syncBatchId: str | None = None
  165. snapshotId: str | None = None
  166. force: bool = False
  167. bizDate: str | None = None
  168. class ScenarioPayload(BaseModel):
  169. scenarioId: str
  170. runId: str | None = None
  171. channelOverride: dict[str, str] | None = None
  172. class ReportPayload(BaseModel):
  173. """全链路报告草稿(任务书 F5 §10):只保存测试人员填写的摘要。"""
  174. runId: str
  175. channel: str # DB_SYNC | API_PULL | API_INBOUND
  176. environment: str = "" # 环境与测试租户
  177. objectCode: str = "" # 对象
  178. simPrep: str = "" # 模拟端准备结果
  179. manualSteps: str = "" # 人工执行步骤
  180. simEvidence: str = "" # 模拟端证据
  181. aidopConn: str = "" # Ai-DOP 测连结果
  182. flowIds: str = "" # 同步/入站流水编号
  183. stagingCheck: str = "" # 贴源核验结果
  184. targetResult: str = "" # 目标业务结果
  185. conclusion: str = "" # 结论
  186. gaps: str = "" # 未完成项
  187. cleanup: str = "" # 清理提示
  188. class FaultApplyPayload(BaseModel):
  189. scenarioId: str
  190. objectCode: str | None = None # DB/API 场景默认 ITEM
  191. entityCode: str | None = None # N3-15 未启用 entity 场景的目标实体码
  192. class FaultReportPayload(BaseModel):
  193. """异常测试报告(任务书 §15 八列)。「是否符合预期」JSON 键为 pass。"""
  194. scenarioId: str
  195. simBehavior: str = ""
  196. expected: str = ""
  197. actualResponse: str = ""
  198. aidopFlowStatus: str = ""
  199. targetChanged: str = ""
  200. conform: str = Field(default="", alias="pass")
  201. note: str = ""
  202. CHANNELS = ("DB_SYNC", "API_PULL", "API_INBOUND")
  203. CHANNEL_LABELS = {
  204. "DB_SYNC": "方式甲 DB_SYNC(数据库同步)",
  205. "API_PULL": "方式乙 API_PULL(接口拉取)",
  206. "API_INBOUND": "方式丙 API_INBOUND(接口推送)",
  207. }
  208. # 报告章节:与任务书 §10 的字段清单一一对应
  209. REPORT_FIELDS = [
  210. ("runId", "运行编号 runId"),
  211. ("channel", "集成方式"),
  212. ("environment", "环境与测试租户"),
  213. ("objectCode", "对象"),
  214. ("simPrep", "模拟端准备结果"),
  215. ("manualSteps", "人工执行步骤"),
  216. ("simEvidence", "模拟端证据"),
  217. ("aidopConn", "Ai-DOP 测连结果"),
  218. ("flowIds", "同步/入站流水编号"),
  219. ("stagingCheck", "贴源核验结果"),
  220. ("targetResult", "目标业务结果"),
  221. ("conclusion", "结论"),
  222. ("gaps", "未完成项"),
  223. ("cleanup", "清理提示"),
  224. ]
  225. class ReportStore:
  226. """全链路报告存储(任务书 F5)。
  227. - 只保存测试人员填写的摘要字段,不保存数据库密码、Secret、JWT、连接串或完整敏感 payload;
  228. - 保存前统一过 redact_obj,导出内容与内存一致;
  229. - 内存态、不落盘:控制台重启即清空,避免凭据残留。
  230. """
  231. def __init__(self, limit: int = 50):
  232. self.limit = limit
  233. self.items: list[dict] = []
  234. def save(self, fields: dict, secrets: list[str] | None = None,
  235. access_keys: list[str] | None = None) -> dict:
  236. entry = {
  237. "id": uuid.uuid4().hex[:12],
  238. "createdAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
  239. }
  240. entry.update(redact_obj(dict(fields), secrets, access_keys))
  241. self.items.append(entry)
  242. if len(self.items) > self.limit:
  243. del self.items[: len(self.items) - self.limit]
  244. return entry
  245. def list(self) -> list[dict]:
  246. return list(reversed(self.items[-20:]))
  247. def get(self, report_id: str) -> dict | None:
  248. return next((r for r in self.items if r["id"] == report_id), None)
  249. def delete(self, report_id: str) -> bool:
  250. before = len(self.items)
  251. self.items = [r for r in self.items if r["id"] != report_id]
  252. return len(self.items) < before
  253. def build_report_markdown(entry: dict) -> str:
  254. """报告导出 Markdown(任务书 F5 §10 字段 + 脱敏声明)。"""
  255. lines = ["# Ai-DOP 第三方系统模拟器 · 全链路测试报告", ""]
  256. for key, label in REPORT_FIELDS:
  257. value = str(entry.get(key) or "").strip()
  258. if key == "channel":
  259. value = CHANNEL_LABELS.get(value, value)
  260. lines.append(f"## {label}")
  261. lines.append(value or "(未填写)")
  262. lines.append("")
  263. lines.append("---")
  264. lines.append(f"报告 ID:{entry.get('id', '')} · 创建时间:{entry.get('createdAt', '')}")
  265. lines.append("")
  266. lines.append("> 脱敏说明:本报告已自动脱敏,AccessKey 仅保留后四位;"
  267. "数据库密码、Secret、JWT、完整 Authorization 与连接串已遮蔽。")
  268. return "\n".join(lines)
  269. REPORTS = ReportStore()
  270. class FaultReportStore(ReportStore):
  271. """异常测试报告存储(任务书 §15 N4)。复用脱敏与内存态约定。
  272. 每场景一行最终结论:同 scenarioId 重复保存 = 原位更新(保留原 id),
  273. 避免误点/重复点保存产生重复行。
  274. """
  275. def save(self, fields: dict, secrets: list[str] | None = None,
  276. access_keys: list[str] | None = None) -> dict:
  277. scenario_id = fields.get("scenarioId")
  278. existing = next((r for r in self.items if r.get("scenarioId") == scenario_id), None)
  279. if existing is None:
  280. entry = super().save(fields, secrets, access_keys)
  281. replaced = False
  282. else:
  283. entry = existing
  284. entry.update(redact_obj(dict(fields), secrets, access_keys))
  285. entry["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%S")
  286. replaced = True
  287. scenario = fault_catalog.find_scenario(entry.get("scenarioId", ""))
  288. entry["scenarioTitle"] = scenario["title"] if scenario else ""
  289. entry["channel"] = scenario["channel"] if scenario else ""
  290. entry["replaced"] = replaced
  291. return entry
  292. FAULT_REPORTS = FaultReportStore()
  293. def build_fault_report_markdown(entry: dict) -> str:
  294. """异常报告导出 Markdown(任务书 §15 八列 + 脱敏声明)。"""
  295. lines = ["# Ai-DOP 第三方系统模拟器 · 异常测试报告", ""]
  296. lines.append(f"- 场景:{entry.get('scenarioId', '')} {entry.get('scenarioTitle', '')}")
  297. lines.append(f"- 集成方式:{fault_catalog.FAULT_CHANNEL_LABELS.get(entry.get('channel', ''), entry.get('channel', ''))}")
  298. lines.append(f"- 创建时间:{entry.get('createdAt', '')}")
  299. lines.append("")
  300. lines.append("| 项目 | 内容 |")
  301. lines.append("|---|---|")
  302. for key, label in fault_catalog.FAULT_REPORT_FIELDS:
  303. value = str(entry.get(key) or "").strip() or "(未填写)"
  304. lines.append(f"| {label} | {value.replace(chr(10), '<br>')} |")
  305. lines.append("")
  306. lines.append("> 脱敏说明:本报告已自动脱敏,AccessKey 仅保留后四位;"
  307. "数据库密码、Secret、JWT、完整 Authorization 与连接串已遮蔽。")
  308. return "\n".join(lines)
  309. # ---------------------------------------------------------------------------
  310. # 公共辅助
  311. # ---------------------------------------------------------------------------
  312. def _require_obj(object_code: str) -> dict:
  313. obj = sa.find_object(object_code)
  314. if not obj:
  315. raise HTTPException(status_code=404, detail=f"unknown objectCode: {object_code}")
  316. return obj
  317. def _build_inbound_client() -> InboundClient:
  318. base = CONFIG["aidopBaseUrl"]
  319. try:
  320. aidop_probe.assert_host_allowed(base, CONFIG["allowedTargetHosts"])
  321. except aidop_probe.HostNotAllowedError as ex:
  322. raise HTTPException(status_code=400, detail=str(ex))
  323. access_key = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
  324. secret = _env("AIDOP_SIM_INBOUND_SECRET")
  325. missing = [n for n, v in (("AIDOP_SIM_INBOUND_ACCESS_KEY", access_key),
  326. ("AIDOP_SIM_INBOUND_SECRET", secret)) if not v]
  327. if missing:
  328. raise HTTPException(status_code=400,
  329. detail=f"缺少环境变量: {', '.join(missing)}(Secret 只从环境变量读取,不落盘)")
  330. return InboundClient(base, access_key, secret)
  331. def _resolve_entity(payload: InboundExecPayload) -> tuple[dict | None, str]:
  332. """解析本次执行的对象与 entityCode。仅给 entityCode 时允许无目录对象直发(rows 必填)。"""
  333. if payload.objectCode:
  334. obj = _require_obj(payload.objectCode)
  335. else:
  336. obj = _require_obj_by_entity(payload.entityCode or "")
  337. if payload.entityCode:
  338. entity = payload.entityCode.upper()
  339. else:
  340. if not obj:
  341. raise HTTPException(status_code=400, detail="need objectCode or entityCode")
  342. inbound = obj.get("apiInbound") or {}
  343. if not inbound.get("supported"):
  344. raise HTTPException(status_code=400,
  345. detail=f"{obj['objectCode']} 不支持 API_INBOUND: {inbound.get('reason', '无契约')}")
  346. entity = inbound["entityCode"]
  347. return obj, entity
  348. def _require_obj_by_entity(entity_code: str) -> dict | None:
  349. for obj in sa.load_catalog()["objects"]:
  350. if (obj.get("apiInbound") or {}).get("entityCode") == entity_code.upper():
  351. return obj
  352. return None
  353. def _first_required_field(entity: str) -> str:
  354. """契约实体第一个必填字段(N3 缺必填字段/类型错误/部分行拒绝场景用)。"""
  355. spec = sa.INBOUND_SPECS.get(entity) or []
  356. for field, required, _ in spec:
  357. if required:
  358. return field
  359. return "Descr"
  360. def _layers(code: str, config: str, data: str) -> dict:
  361. return {"code": code, "config": config, "data": data}
  362. def _followup_sql(obj: dict, channel: str, run_id: str) -> list[str]:
  363. prefix = sa.biz_prefix(run_id)
  364. db = (obj.get("dbSync") or {}).get("table")
  365. sqls = [
  366. "SELECT source_code, status, COUNT(*) cnt, MAX(end_time) latest FROM mdp_sync_log "
  367. "WHERE source_code LIKE 'SIM_MODE3%' GROUP BY source_code, status;",
  368. "SELECT COUNT(*) FROM mdp_inbound_request WHERE create_time >= CURDATE();",
  369. ]
  370. if channel == "DB_SYNC" and db:
  371. sqls.insert(0, f"-- 模拟库核对:\nSELECT COUNT(*), MAX(sourceUpdatedAt) FROM `{db}` WHERE bizKey LIKE '{prefix}%';")
  372. sqls.append(f"SELECT * FROM `{db.replace('sim_', 'mdp_stg_sim_')}` WHERE raw_data LIKE '%{prefix}%' LIMIT 20;")
  373. if channel == "API_INBOUND":
  374. entity = (obj.get("apiInbound") or {}).get("entityCode")
  375. sqls.append(f"SELECT status, accepted_rows, rejected_rows, sync_batch_id FROM mdp_inbound_request "
  376. f"WHERE entity_code='{entity}' ORDER BY id DESC LIMIT 10;")
  377. sqls.append("-- 标准层/KPI 下游核验属第二阶段自动对账范围;第一阶段报告 TRANSFORM_PENDING,不猜测成功。")
  378. return sqls
  379. # ---------------------------------------------------------------------------
  380. # 状态与目录
  381. # ---------------------------------------------------------------------------
  382. @app.get("/api/status")
  383. def status():
  384. inbound_key = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
  385. return {
  386. "controlPort": DEFAULT_CONTROL_PORT,
  387. "aidopBaseUrl": CONFIG["aidopBaseUrl"],
  388. "allowedTargetHosts": CONFIG["allowedTargetHosts"],
  389. "aidopReachable": None, # 由 /api/precheck 实测,避免每次状态查询打真实请求
  390. "mockApi": {"running": _mock_running(), "port": CONFIG["mockApiPort"],
  391. "authMode": MOCK_STATE.settings.auth_mode,
  392. "forceStatus": MOCK_STATE.settings.force_status,
  393. "lastError": MOCK_HANDLE.last_error},
  394. "mysql": {
  395. "database": _env("AIDOP_SIM_MYSQL_DATABASE") or mock_db.SIM_DB_DEFAULT,
  396. "passwordLoaded": bool(_env("AIDOP_SIM_MYSQL_PASSWORD")),
  397. },
  398. "inbound": {
  399. "accessKeyLoaded": bool(inbound_key),
  400. "accessKeyMasked": mask_access_key(inbound_key),
  401. "secretLoaded": bool(_env("AIDOP_SIM_INBOUND_SECRET")),
  402. },
  403. "adminTokenLoaded": bool(_env("AIDOP_SIM_ADMIN_TOKEN")),
  404. "objectCounts": _channel_counts(),
  405. }
  406. def _channel_counts() -> dict:
  407. counts = {"DB_SYNC": 0, "API_PULL": 0, "API_INBOUND": 0, "total": 0}
  408. for obj in sa.load_catalog()["objects"]:
  409. counts["total"] += 1
  410. if (obj.get("dbSync") or {}).get("supported"):
  411. counts["DB_SYNC"] += 1
  412. if (obj.get("apiPull") or {}).get("supported"):
  413. counts["API_PULL"] += 1
  414. if (obj.get("apiInbound") or {}).get("supported"):
  415. counts["API_INBOUND"] += 1
  416. return counts
  417. @app.get("/api/catalog")
  418. def catalog():
  419. return sa.load_catalog()
  420. @app.get("/api/scenarios")
  421. def scenarios():
  422. return sa.load_scenarios()
  423. @app.get("/api/sample/{object_code}")
  424. def get_sample(object_code: str, runId: str | None = None):
  425. obj = _require_obj(object_code)
  426. run_id = runId or sa.new_run_id()
  427. inbound = obj.get("apiInbound") or {}
  428. result: dict = {"objectCode": obj["objectCode"], "runId": run_id,
  429. "canonical": sa.load_canonical_rows(obj),
  430. "dbSync": None, "apiPull": None, "apiInbound": None}
  431. if (obj.get("dbSync") or {}).get("supported"):
  432. result["dbSync"] = sa.adapt_db_rows(obj, run_id)
  433. if (obj.get("apiPull") or {}).get("supported"):
  434. result["apiPull"] = sa.adapt_api_rows(obj, run_id)
  435. if inbound.get("supported"):
  436. adapted = sa.adapt_inbound_rows(obj, run_id)
  437. result["apiInbound"] = {
  438. "entityCode": inbound["entityCode"],
  439. "contractVersion": inbound.get("contractVersion", "v1"),
  440. "rows": adapted["rows"],
  441. "missingRequired": adapted["missing_required"],
  442. "envelope": sa.inbound_envelope(adapted["rows"]),
  443. }
  444. return result
  445. # ---------------------------------------------------------------------------
  446. # Mock API 控制
  447. # ---------------------------------------------------------------------------
  448. @app.post("/api/mock-api/start")
  449. async def mock_api_start():
  450. return await _start_mock_api()
  451. @app.post("/api/mock-api/stop")
  452. async def mock_api_stop():
  453. return await _stop_mock_api()
  454. @app.get("/api/mock-api/settings")
  455. def mock_api_settings():
  456. s = MOCK_STATE.settings
  457. return {"running": _mock_running(), "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
  458. "forceStatus": s.force_status, "responsePath": s.response_path,
  459. "apiKeyHeader": s.api_key_header, "overrides": sorted(MOCK_STATE.overrides.keys())}
  460. @app.post("/api/mock-api/control")
  461. def mock_api_control(payload: dict):
  462. """统一控制入口:auth_mode/token/basic/apikey/delay/force_status/response_path/override。"""
  463. s = MOCK_STATE.settings
  464. if "auth_mode" in payload:
  465. mode = str(payload["auth_mode"]).upper()
  466. if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
  467. raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
  468. s.auth_mode = mode
  469. for key, attr in (("token", "token"), ("basic_user", "basic_user"),
  470. ("basic_password", "basic_password"), ("api_key_header", "api_key_header"),
  471. ("api_key_value", "api_key_value"), ("response_path", "response_path")):
  472. if payload.get(key):
  473. setattr(s, attr, str(payload[key]))
  474. if "delay_seconds" in payload:
  475. s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
  476. if "force_status" in payload:
  477. s.force_status = int(payload["force_status"]) if payload["force_status"] else None
  478. if payload.get("override_path") and isinstance(payload.get("override_rows"), list):
  479. MOCK_STATE.overrides[payload["override_path"]] = payload["override_rows"]
  480. if payload.get("clear_overrides"):
  481. MOCK_STATE.overrides.clear()
  482. return {"ok": True, "settings": mock_api_settings()}
  483. @app.get("/api/mock-api/audit")
  484. def mock_api_audit():
  485. return {"entries": redact_obj(MOCK_STATE.audit[-50:], _secrets())}
  486. # ---------------------------------------------------------------------------
  487. # 模拟 MySQL 控制
  488. # ---------------------------------------------------------------------------
  489. @app.post("/api/db/ensure")
  490. def db_ensure(payload: RowsPayload):
  491. """初始化模拟 schema 并确保对象源表存在(DDL 由样例推断)。"""
  492. obj = _require_obj(payload.objectCode)
  493. db_spec = obj.get("dbSync") or {}
  494. if not db_spec.get("supported"):
  495. raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
  496. if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
  497. raise HTTPException(status_code=400, detail="缺少环境变量 AIDOP_SIM_MYSQL_PASSWORD")
  498. run_id = payload.runId or sa.new_run_id()
  499. rows = payload.rows if payload.rows is not None else sa.adapt_db_rows(obj, run_id)
  500. try:
  501. result = mock_db.init_schema({db_spec["table"]: {
  502. "rows": rows, "businessKey": db_spec.get("businessKey"),
  503. "incrementColumn": db_spec.get("incrementColumn", "sourceUpdatedAt")}})
  504. return {"ok": True, "runId": run_id, **result}
  505. except mock_db.BlockedDatabaseError as ex:
  506. raise HTTPException(status_code=400, detail=str(ex))
  507. except Exception as ex:
  508. raise HTTPException(status_code=502, detail=f"MySQL 不可达或执行失败: {type(ex).__name__}: {ex}")
  509. @app.post("/api/db/load")
  510. def db_load(payload: RowsPayload):
  511. obj = _require_obj(payload.objectCode)
  512. db_spec = obj.get("dbSync") or {}
  513. if not db_spec.get("supported"):
  514. raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
  515. run_id = payload.runId or sa.new_run_id()
  516. rows = payload.rows if payload.rows is not None else sa.adapt_db_rows(obj, run_id)
  517. try:
  518. count = mock_db.upsert_rows(db_spec["table"], rows)
  519. stats = mock_db.table_stats(db_spec["table"], db_spec.get("incrementColumn", "sourceUpdatedAt"))
  520. except Exception as ex:
  521. raise HTTPException(status_code=502, detail=f"装载失败(先执行 /api/db/ensure?): {type(ex).__name__}: {ex}")
  522. layers = _layers("SUPPORTED", "READY", "ACCEPTED")
  523. RUNS.add(run_id, obj["objectCode"], "DB_SYNC",
  524. {"action": "load", "table": db_spec["table"], "rowCount": count},
  525. stats, layers, _followup_sql(obj, "DB_SYNC", run_id),
  526. gaps=["Ai-DOP 侧同步须由 MdpDbPullExecutor 真实执行;本操作只装载第三方模拟源库"])
  527. return {"ok": True, "runId": run_id, "loaded": count, "stats": stats, "layers": layers}
  528. @app.post("/api/db/reset")
  529. def db_reset(payload: RowsPayload):
  530. obj = _require_obj(payload.objectCode)
  531. table = (obj.get("dbSync") or {}).get("table")
  532. if not table:
  533. raise HTTPException(status_code=400, detail="object has no dbSync table")
  534. try:
  535. mock_db.reset_table(table)
  536. return {"ok": True, "table": table}
  537. except Exception as ex:
  538. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  539. @app.post("/api/db/update")
  540. def db_update(payload: dict):
  541. obj = _require_obj(payload.get("objectCode", ""))
  542. db_spec = obj.get("dbSync") or {}
  543. table = db_spec.get("table")
  544. updates = payload.get("updates") or {}
  545. where = payload.get("where") or {}
  546. if not table or not updates or not where:
  547. raise HTTPException(status_code=400, detail="need objectCode/updates/where")
  548. try:
  549. affected = mock_db.update_rows(table, updates, where,
  550. db_spec.get("incrementColumn", "sourceUpdatedAt"),
  551. payload.get("newIncrement"))
  552. return {"ok": True, "table": table, "affected": affected}
  553. except Exception as ex:
  554. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  555. @app.get("/api/db/stats/{object_code}")
  556. def db_stats(object_code: str):
  557. obj = _require_obj(object_code)
  558. db_spec = obj.get("dbSync") or {}
  559. if not db_spec.get("table"):
  560. raise HTTPException(status_code=400, detail="object has no dbSync table")
  561. try:
  562. return mock_db.table_stats(db_spec["table"], db_spec.get("incrementColumn", "sourceUpdatedAt"))
  563. except Exception as ex:
  564. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  565. @app.get("/api/db/aidop-config/{object_code}")
  566. def db_aidop_config(object_code: str):
  567. obj = _require_obj(object_code)
  568. base = f"http://127.0.0.1:{CONFIG['mockApiPort']}"
  569. return mock_db.generate_aidop_config(obj, base)
  570. # ---------------------------------------------------------------------------
  571. # 预检与三层报告(P1-G)
  572. # ---------------------------------------------------------------------------
  573. @app.get("/api/precheck")
  574. def precheck():
  575. base = CONFIG["aidopBaseUrl"]
  576. reach = aidop_probe.check_reachable(base, CONFIG["allowedTargetHosts"])
  577. inbound_ready = bool(_env("AIDOP_SIM_INBOUND_ACCESS_KEY") and _env("AIDOP_SIM_INBOUND_SECRET"))
  578. env_missing = [n for n in ("AIDOP_SIM_INBOUND_ACCESS_KEY", "AIDOP_SIM_INBOUND_SECRET",
  579. "AIDOP_SIM_MYSQL_PASSWORD") if not _env(n)]
  580. return {
  581. "aidopBaseUrl": base,
  582. "aidop": reach,
  583. "mockApi": {"running": _mock_running(), "port": CONFIG["mockApiPort"]},
  584. "mysql": {"database": _env("AIDOP_SIM_MYSQL_DATABASE") or mock_db.SIM_DB_DEFAULT,
  585. "passwordLoaded": bool(_env("AIDOP_SIM_MYSQL_PASSWORD"))},
  586. "inboundCredentialsLoaded": inbound_ready,
  587. "envMissing": env_missing,
  588. "testSources": mock_db.SIM_SOURCES,
  589. "note": "DB_SYNC/API_PULL 的来源、实体、租户登记须人工在 Ai-DOP 配置页核对(生成清单见 /api/db/aidop-config/{objectCode})",
  590. }
  591. @app.get("/api/precheck/inbound")
  592. def precheck_inbound():
  593. """26 个代码契约逐一 schema 预检(真实签名,只读)。"""
  594. client = _build_inbound_client()
  595. codes = sorted({(o.get("apiInbound") or {}).get("entityCode")
  596. for o in sa.load_catalog()["objects"]
  597. if (o.get("apiInbound") or {}).get("supported")})
  598. results = aidop_probe.precheck_all_inbound(client, codes, CONFIG["allowedTargetHosts"])
  599. return {"accessKey": mask_access_key(client.key), "count": len(results), "results": results}
  600. @app.get("/api/precheck/inbound/{entity_code}")
  601. def precheck_inbound_one(entity_code: str):
  602. client = _build_inbound_client()
  603. return aidop_probe.precheck_inbound_entity(client, entity_code.upper(), CONFIG["allowedTargetHosts"])
  604. # ---------------------------------------------------------------------------
  605. # API_INBOUND 执行(P1-E)
  606. # ---------------------------------------------------------------------------
  607. @app.post("/api/inbound/exec")
  608. def inbound_exec(payload: InboundExecPayload):
  609. obj, entity = _resolve_entity(payload)
  610. client = _build_inbound_client()
  611. run_id = payload.runId or sa.new_run_id()
  612. contract_version = payload.contractVersion
  613. if obj is None:
  614. # 仅按 entityCode 直发(无目录样例),rows 必填
  615. rows = payload.rows
  616. if rows is None and payload.operation in ("push", "bulk"):
  617. raise HTTPException(status_code=400, detail="entityCode-only exec requires rows")
  618. envelope = sa.inbound_envelope(rows or [])
  619. else:
  620. inbound = obj.get("apiInbound") or {}
  621. contract_version = contract_version or inbound.get("contractVersion")
  622. rows = payload.rows if payload.rows is not None else sa.adapt_inbound_rows(obj, run_id)["rows"]
  623. envelope = sa.inbound_envelope(rows)
  624. object_code = obj["objectCode"] if obj else entity
  625. op = payload.operation
  626. result: dict = {"runId": run_id, "entityCode": entity, "operation": op, "case": payload.case}
  627. def record(status: int, body, headers, layers: dict, extra: dict | None = None):
  628. result.update({"httpStatus": status, "response": body, "requestHeaders": headers, **(extra or {})})
  629. RUNS.add(run_id, object_code, "API_INBOUND",
  630. {"operation": op, "case": payload.case, "entityCode": entity,
  631. "rowCount": len(rows or []), "contractVersion": contract_version},
  632. {"httpStatus": status, "body": body, "headers": headers},
  633. layers, _followup_sql(obj or {"objectCode": entity, "apiInbound": {"entityCode": entity}},
  634. "API_INBOUND", run_id))
  635. return result
  636. # 预检(配置层)
  637. pre = aidop_probe.precheck_inbound_entity(client, entity, CONFIG["allowedTargetHosts"], contract_version)
  638. config_state = pre["configState"]
  639. result["precheck"] = {"configState": config_state, "httpStatus": pre["httpStatus"]}
  640. if op == "schema":
  641. status, body, headers = client.op_schema(entity, contract_version)
  642. layers = _layers("SUPPORTED", aidop_probe.classify_inbound_precheck(status, body), "NOT_RUN")
  643. return record(status, body, headers, layers)
  644. if config_state != "READY" and op in ("push", "bulk", "snapshot_open"):
  645. layers = _layers("SUPPORTED", config_state, "NOT_RUN")
  646. raise HTTPException(status_code=409, detail={
  647. "message": f"配置层 {config_state}:普通推数只对 READY 实体执行(不伪造成功)",
  648. "precheck": pre, "layers": layers})
  649. if op == "push":
  650. case = payload.case or "happy"
  651. if case == "happy":
  652. status, body, headers = client.op_push(entity, envelope, contract_version=contract_version)
  653. elif case == "missing-idem":
  654. status, body, headers = client.case_missing_idem(entity, envelope)
  655. elif case == "tamper-body":
  656. status, body, headers = client.case_tamper_body(entity, envelope)
  657. elif case == "expired-ts":
  658. status, body, headers = client.case_expired_ts(entity, envelope)
  659. elif case == "bad-signature":
  660. status, body, headers = client.case_bad_signature(entity, envelope)
  661. elif case == "nonce-replay":
  662. (s1, d1), (s2, d2) = client.case_nonce_replay(entity, envelope)
  663. status, body, headers = s2, {"first": [s1, d1], "replay": [s2, d2]}, {}
  664. elif case == "same-key-replay":
  665. (s1, d1), (s2, d2) = client.case_same_key_replay(entity, envelope)
  666. status, body, headers = s2, {"first": [s1, d1], "replay": [s2, d2]}, {}
  667. elif case == "same-key-diff-body":
  668. env2 = sa.inbound_envelope([{**r, "sourceUpdatedAt": sa.now_iso()} for r in (rows or [])])
  669. (s1, d1), (s2, d2) = client.case_same_key_diff_body(entity, envelope, env2)
  670. status, body, headers = s2, {"first": [s1, d1], "conflict": [s2, d2]}, {}
  671. # ---- 第二阶段 N3 新增用例(被 Ai-DOP 正确拒绝 = 测试通过)----
  672. elif case == "missing-access-key":
  673. status, body, headers = client.case_missing_access_key(entity, envelope)
  674. elif case == "wrong-access-key":
  675. status, body, headers = client.case_wrong_access_key(entity, envelope)
  676. elif case == "missing-required-field":
  677. status, body, headers = client.case_missing_required_field(
  678. entity, envelope, _first_required_field(entity))
  679. elif case == "wrong-field-type":
  680. # 用结构敌对值(嵌套对象)而非同类标量:字符串列塞数字可能被宽容转换,
  681. # 必须用无法映射到标量列的值才能触发类型校验拒绝
  682. status, body, headers = client.case_wrong_field_type(
  683. entity, envelope, _first_required_field(entity), {"__sim_type_fault__": True})
  684. elif case == "partial-reject":
  685. env = json.loads(json.dumps(envelope, ensure_ascii=False))
  686. if len(env["data"]["list"]) < 2:
  687. # 样例仅 1 行时复制一行,保证「1 好 1 坏」部分行拒绝可演示
  688. env["data"]["list"].append(
  689. json.loads(json.dumps(env["data"]["list"][0], ensure_ascii=False)))
  690. env["data"]["list"][1].pop(_first_required_field(entity), None)
  691. status, body, headers = client.op_push(entity, env, contract_version=contract_version)
  692. elif case == "stale":
  693. status, body, headers = client.case_stale_rows(entity, envelope)
  694. elif case == "unknown-entity":
  695. status, body, headers = client.case_unknown_entity(envelope)
  696. else:
  697. raise HTTPException(status_code=400, detail=f"unknown case: {case}")
  698. data_state = "ACCEPTED" if status == 202 else ("RUN_FAILED" if status >= 400 else "NOT_RUN")
  699. layers = _layers("SUPPORTED", config_state, data_state)
  700. return record(status, body, headers, layers,
  701. extra={"gaps": ["HTTP 202 仅代表摄取受理;贴源/标准层核验见 followupSql(TRANSFORM_PENDING)"]})
  702. if op == "receipt":
  703. if not payload.syncBatchId:
  704. raise HTTPException(status_code=400, detail="receipt requires syncBatchId")
  705. status, body, headers = client.op_receipt(payload.syncBatchId)
  706. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 200 else "RUN_FAILED")
  707. return record(status, body, headers, layers)
  708. if op == "bulk":
  709. # 后端 NDJSON 分块:每行须为完整信封(data.list)+ 根级 idempotencyKey(MdpInboundReceiveService.BulkAsync)
  710. bulk_rows = [{"idempotencyKey": f"{run_id}-L{i + 1}",
  711. "data": {"list": [r]}} for i, r in enumerate(rows or [])]
  712. if payload.case == "bulk-partial-failure":
  713. # N3-16:坏行缺必填字段,其余行完好(观察逐行结果与计数)
  714. field = _first_required_field(entity)
  715. if bulk_rows:
  716. if len(bulk_rows) < 2:
  717. # 样例仅 1 行时复制一行(新幂等键),保证「部分失败」可演示
  718. extra = json.loads(json.dumps(bulk_rows[0], ensure_ascii=False))
  719. extra["idempotencyKey"] = f"{run_id}-L{len(bulk_rows) + 1}"
  720. bulk_rows.append(extra)
  721. bulk_rows[-1]["data"]["list"][0].pop(field, None)
  722. status, body, headers = client.op_bulk(entity, bulk_rows, contract_version=contract_version)
  723. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 202 else "RUN_FAILED")
  724. return record(status, body, headers, layers)
  725. if op == "snapshot_open":
  726. status, body, headers = client.op_snapshot_open(entity)
  727. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status in (200, 201) else "RUN_FAILED")
  728. return record(status, body, headers, layers)
  729. if op == "snapshot_commit":
  730. if not payload.snapshotId:
  731. raise HTTPException(status_code=400, detail="snapshot_commit requires snapshotId")
  732. status, body, headers = client.op_snapshot_commit(entity, payload.snapshotId, payload.force)
  733. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status in (200, 202) else "RUN_FAILED")
  734. return record(status, body, headers, layers)
  735. if op == "digest":
  736. biz_date = payload.bizDate or time.strftime("%Y-%m-%d")
  737. status, body, headers = client.op_digest(entity, biz_date)
  738. layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 200 else "RUN_FAILED")
  739. return record(status, body, headers, layers)
  740. raise HTTPException(status_code=400, detail=f"unknown operation: {op}")
  741. # ---------------------------------------------------------------------------
  742. # 场景执行(P1-F)
  743. # ---------------------------------------------------------------------------
  744. @app.post("/api/scenarios/run")
  745. def scenario_run(payload: ScenarioPayload):
  746. scenario = next((s for s in sa.load_scenarios()["scenarios"]
  747. if s["scenarioId"] == payload.scenarioId), None)
  748. if not scenario:
  749. raise HTTPException(status_code=404, detail=f"unknown scenario: {payload.scenarioId}")
  750. run_id = payload.runId or sa.new_run_id()
  751. overrides = payload.channelOverride or {}
  752. nodes_out = []
  753. for node in scenario["nodes"]:
  754. obj = sa.find_object(node["objectCode"])
  755. channel = overrides.get(node["objectCode"], node["channel"])
  756. node_result = {"objectCode": node["objectCode"], "channel": channel,
  757. "relationKey": node.get("relationKey")}
  758. if not obj:
  759. node_result.update({"state": "CODE_UNSUPPORTED", "message": "目录中无此对象"})
  760. nodes_out.append(node_result)
  761. continue
  762. spec = {"DB_SYNC": obj.get("dbSync"), "API_PULL": obj.get("apiPull"),
  763. "API_INBOUND": obj.get("apiInbound")}.get(channel)
  764. if not spec or not spec.get("supported"):
  765. reason = (spec or {}).get("reason") or node.get("inboundNote") or "该通道不支持"
  766. node_result.update({"state": "CODE_UNSUPPORTED", "message": reason})
  767. nodes_out.append(node_result)
  768. continue
  769. try:
  770. if channel == "DB_SYNC":
  771. rows = sa.adapt_db_rows(obj, run_id)
  772. if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
  773. raise RuntimeError("缺少 AIDOP_SIM_MYSQL_PASSWORD")
  774. mock_db.init_schema({spec["table"]: {"rows": rows, "businessKey": spec.get("businessKey"),
  775. "incrementColumn": spec.get("incrementColumn")}})
  776. count = mock_db.upsert_rows(spec["table"], rows)
  777. node_result.update({"state": "ACCEPTED", "loaded": count, "table": spec["table"]})
  778. elif channel == "API_PULL":
  779. if not _mock_running():
  780. raise RuntimeError("Mock API 未启动(先 POST /api/mock-api/start)")
  781. rows = sa.adapt_api_rows(obj, run_id)
  782. MOCK_STATE.overrides[spec["path"]] = rows
  783. node_result.update({"state": "ACCEPTED", "overridePath": spec["path"], "rowCount": len(rows),
  784. "note": "已在 Mock API 挂载适配样例;由 Ai-DOP MdpApiPullExecutor 真实拉取"})
  785. else:
  786. client = _build_inbound_client()
  787. entity = spec["entityCode"]
  788. version = node.get("contractVersion") or spec.get("contractVersion")
  789. pre = aidop_probe.precheck_inbound_entity(client, entity, CONFIG["allowedTargetHosts"], version)
  790. if pre["configState"] != "READY":
  791. node_result.update({"state": pre["configState"],
  792. "message": f"配置层 {pre['configState']},未推数(不伪造成功)"})
  793. else:
  794. rows = sa.adapt_inbound_rows(obj, run_id)["rows"]
  795. status, body, _ = client.op_push(entity, sa.inbound_envelope(rows),
  796. contract_version=version)
  797. node_result.update({"state": "ACCEPTED" if status == 202 else "RUN_FAILED",
  798. "httpStatus": status, "response": body})
  799. except HTTPException:
  800. raise
  801. except aidop_probe.HostNotAllowedError as ex:
  802. node_result.update({"state": "SOURCE_UNREACHABLE", "message": str(ex)})
  803. except Exception as ex:
  804. node_result.update({"state": "RUN_FAILED", "message": f"{type(ex).__name__}: {ex}"})
  805. nodes_out.append(node_result)
  806. unsupported = [n["objectCode"] for n in nodes_out if n["state"] == "CODE_UNSUPPORTED"]
  807. layers = _layers("SUPPORTED",
  808. "READY" if all(n["state"] not in ("SOURCE_UNREACHABLE",) for n in nodes_out) else "SOURCE_UNREACHABLE",
  809. "ACCEPTED" if all(n["state"] in ("ACCEPTED", "CODE_UNSUPPORTED") for n in nodes_out) else "RUN_FAILED")
  810. entry = RUNS.add(run_id, ",".join(n["objectCode"] for n in nodes_out), f"SCENARIO:{payload.scenarioId}",
  811. {"scenario": scenario["name"]}, {"nodes": nodes_out}, layers,
  812. _followup_sql({"objectCode": "SCENARIO", "dbSync": {}}, "SCENARIO", run_id),
  813. gaps=([f"以下节点该通道不支持:{', '.join(unsupported)}(链路不完整,不得宣称全链路跑通)"]
  814. if unsupported else []),
  815. scenario_id=payload.scenarioId, nodes=nodes_out)
  816. return entry
  817. # ---------------------------------------------------------------------------
  818. # 运行记录
  819. # ---------------------------------------------------------------------------
  820. @app.get("/api/runs")
  821. def runs():
  822. return {"runs": RUNS.recent()}
  823. # ---------------------------------------------------------------------------
  824. # 异常测试(第二阶段 N1-N4,任务书 §12-§15)
  825. # ---------------------------------------------------------------------------
  826. @app.get("/api/fault/catalog")
  827. def fault_catalog_route():
  828. return {"scenarios": fault_catalog.FAULT_SCENARIOS,
  829. "counts": fault_catalog.scenario_counts(),
  830. "reportFields": fault_catalog.FAULT_REPORT_FIELDS}
  831. def _apply_db_fault(directive: dict, object_code: str) -> dict:
  832. """执行 N1 模拟库异常预置(只操作模拟库,硬阻断保证不触碰 Ai-DOP 库)。"""
  833. obj = _require_obj(object_code)
  834. db_spec = obj.get("dbSync") or {}
  835. table = db_spec.get("table")
  836. if not table:
  837. raise HTTPException(status_code=400, detail=f"{object_code} 无 dbSync 表(N1 场景需要)")
  838. if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
  839. raise HTTPException(status_code=400,
  840. detail="缺少 AIDOP_SIM_MYSQL_PASSWORD 环境变量:模拟库操作需要 MySQL 凭据")
  841. rows = sa.adapt_db_rows(obj, sa.new_run_id())
  842. spec = {"rows": rows, "businessKey": db_spec.get("businessKey"),
  843. "incrementColumn": db_spec.get("incrementColumn", "sourceUpdatedAt")}
  844. action = directive["type"]
  845. try:
  846. if action == "db_drop_table":
  847. mock_db.drop_table(table)
  848. return {"ok": True, "table": table, "note": f"已删除源表 {table}(Ai-DOP 侧保持配置不变再触发同步)"}
  849. if action == "db_load_null_bizkey":
  850. mock_db.drop_table(table) # 从正常结构出发,保证场景独立可复现
  851. mock_db.init_schema({table: spec})
  852. null_row = {k: (None if k == "bizKey" else v) for k, v in rows[0].items()}
  853. mock_db.insert_rows_raw(table, [null_row])
  854. return {"ok": True, "table": table, "note": f"已向 {table} 追加一行 bizKey=NULL 的行"}
  855. if action == "db_load_duplicate_bizkey":
  856. # 必须先 DROP:init_schema 的 DDL 是 CREATE TABLE IF NOT EXISTS,
  857. # 表已存在时不会重建,旧唯一键会残留导致重复行插入报 1062。
  858. mock_db.drop_table(table)
  859. mock_db.init_schema({table: {**spec, "unique_key": False}})
  860. mock_db.insert_rows_raw(table, [rows[0], dict(rows[0])])
  861. return {"ok": True, "table": table,
  862. "note": f"{table} 已重建为无唯一键表并装载两条相同 bizKey 的行"}
  863. if action == "db_update_no_advance":
  864. mock_db.drop_table(table) # 从正常结构出发,保证场景独立可复现
  865. mock_db.init_schema({table: spec})
  866. mock_db.upsert_rows(table, rows)
  867. mock_db.update_rows(table, {"itemName": "N1-08 更新后不推进增量列"},
  868. {"bizKey": rows[0]["bizKey"]}, spec["incrementColumn"],
  869. advance_increment=False)
  870. return {"ok": True, "table": table, "note": f"已更新 {table} 一行但未推进增量列"}
  871. if action == "db_slow_view":
  872. mock_db.init_schema({table: spec})
  873. view = f"v_slow_{table}"
  874. mock_db.create_slow_view(view, table, float(directive.get("sleepSeconds", 0.5)))
  875. return {"ok": True, "view": view, "table": table,
  876. "note": f"已创建慢查询视图 {view};请测试人员在 Ai-DOP 侧把 source_table_name 改为该视图名"}
  877. except HTTPException:
  878. raise
  879. except Exception as ex:
  880. raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
  881. def _apply_mock_api_fault(directive: dict) -> dict:
  882. s = MOCK_STATE.settings
  883. settings = directive.get("settings") or {}
  884. if settings.get("reset"):
  885. s.reset_faults()
  886. for key in ("force_status", "delay_seconds", "timeout_seconds", "empty_response",
  887. "invalid_json", "ignore_cursor", "no_sort"):
  888. if key in settings:
  889. setattr(s, key, settings[key])
  890. for key in ("content_type", "response_path"):
  891. if key in settings:
  892. setattr(s, key, settings[key])
  893. return {"ok": True, "settings": mock_api_settings(),
  894. "mockRunning": _mock_running(),
  895. "note": "" if _mock_running() else "提示:Mock API 未启动,先点「启动 Mock API」"}
  896. def _apply_mock_api_override(directive: dict, object_code: str) -> dict:
  897. obj = _require_obj(object_code)
  898. path = (obj.get("apiPull") or {}).get("path") or "/api/item"
  899. rows = sa.adapt_api_rows(obj, sa.new_run_id())
  900. mode = directive.get("mode")
  901. if mode == "missing_bizkey":
  902. for r in rows:
  903. r.pop("bizKey", None)
  904. elif mode == "duplicate_bizkey":
  905. rows = rows + [dict(rows[0])]
  906. MOCK_STATE.overrides[path] = rows
  907. return {"ok": True, "overridePath": path, "rowCount": len(rows),
  908. "mode": mode,
  909. "note": "已挂载覆盖行;Ai-DOP 拉取该 path 时生效(恢复默认时清除)"}
  910. @app.post("/api/fault/apply")
  911. def fault_apply(payload: FaultApplyPayload):
  912. scenario = fault_catalog.find_scenario(payload.scenarioId)
  913. if not scenario:
  914. raise HTTPException(status_code=404, detail=f"unknown scenarioId: {payload.scenarioId}")
  915. if scenario["kind"] == "guide":
  916. return {"applied": False, "guide": True, "scenario": scenario,
  917. "note": "此场景需测试人员在 Ai-DOP 侧人工操作(模拟器不修改 Ai-DOP 配置)"}
  918. directive = scenario["apply"]
  919. dtype = directive["type"]
  920. if dtype.startswith("db_"):
  921. return {"applied": True, "scenario": scenario,
  922. ** _apply_db_fault(directive, payload.objectCode or "ITEM")}
  923. if dtype == "mock_api":
  924. return {"applied": True, "scenario": scenario,
  925. **_apply_mock_api_fault(directive)}
  926. if dtype == "mock_api_override":
  927. return {"applied": True, "scenario": scenario,
  928. **_apply_mock_api_override(directive, payload.objectCode or "ITEM")}
  929. if dtype == "inbound_case":
  930. case = directive["case"]
  931. object_code = payload.objectCode or "ITEM"
  932. if case == "disabled-entity":
  933. entity = (payload.entityCode or "S5_INVENTORY_TXN").upper()
  934. try:
  935. pre = precheck_inbound_one(entity)
  936. except HTTPException as ex:
  937. pre = {"httpStatus": ex.status_code, "configState": "UNREACHABLE",
  938. "message": ex.detail}
  939. return {"applied": True, "scenario": scenario, "entityCode": entity,
  940. "precheck": pre,
  941. "note": "请测试人员先在 Ai-DOP 侧关闭该实体 inbound 开关,再用「API_INBOUND」页签向它推送;"
  942. "被 Ai-DOP 正确拒绝 = 测试通过"}
  943. if case == "snapshot-commit-failure":
  944. exec_payload = InboundExecPayload(objectCode=object_code,
  945. operation="snapshot_commit",
  946. snapshotId="SNAP-NOT-EXIST-0001")
  947. elif case == "bulk-partial-failure":
  948. exec_payload = InboundExecPayload(objectCode=object_code, operation="bulk",
  949. case="bulk-partial-failure")
  950. elif case == "unknown-entity":
  951. exec_payload = InboundExecPayload(objectCode=object_code, operation="push",
  952. case="unknown-entity")
  953. else:
  954. exec_payload = InboundExecPayload(objectCode=object_code, operation="push", case=case)
  955. try:
  956. result = inbound_exec(exec_payload)
  957. except HTTPException as ex:
  958. # 仅「Ai-DOP 预检拦截」(409 + dict detail)才属于异常场景的「实际响应」:
  959. # 正确拒绝 = 测试通过。模拟器自身的配置缺失(缺环境变量/白名单拦截等 400)
  960. # 必须如实透传,不得包装成 expectedReject,否则会把「工具未发出请求」
  961. # 误报成「Ai-DOP 拒绝了请求」。
  962. if ex.status_code == 409 and isinstance(ex.detail, dict):
  963. return {"applied": True, "scenario": scenario, "expectedReject": True,
  964. "httpStatus": ex.status_code, "detail": ex.detail,
  965. "note": "Ai-DOP 已在预检阶段拒绝(正确拒绝属于测试通过,不属于工具失败)"}
  966. raise
  967. return {"applied": True, "scenario": scenario, "result": result}
  968. raise HTTPException(status_code=400, detail=f"unknown apply directive: {dtype}")
  969. @app.post("/api/fault/recover")
  970. def fault_recover(payload: dict):
  971. """恢复默认:channel 可传 DB_SYNC|API_PULL|API_INBOUND,缺省全部。"""
  972. channel = payload.get("channel")
  973. object_code = payload.get("objectCode") or "ITEM"
  974. notes: list[str] = []
  975. if channel in (None, "API_PULL"):
  976. MOCK_STATE.settings.reset_faults()
  977. cleared = len(MOCK_STATE.overrides)
  978. MOCK_STATE.overrides.clear()
  979. notes.append(f"Mock API 异常注入已全部恢复默认;清除 {cleared} 个覆盖行挂载"
  980. f"(场景编排挂载需重跑场景恢复)")
  981. if channel in (None, "DB_SYNC"):
  982. obj = sa.find_object(object_code)
  983. db_spec = (obj or {}).get("dbSync") or {}
  984. table = db_spec.get("table")
  985. if not table:
  986. notes.append(f"{object_code} 无 dbSync 表,跳过模拟库恢复")
  987. elif not _env("AIDOP_SIM_MYSQL_PASSWORD"):
  988. notes.append("缺少 AIDOP_SIM_MYSQL_PASSWORD:模拟库恢复未执行"
  989. "(设置环境变量后重试;或按场景说明人工恢复)")
  990. else:
  991. try:
  992. rows = sa.adapt_db_rows(obj, sa.new_run_id())
  993. spec = {"rows": rows, "businessKey": db_spec.get("businessKey"),
  994. "incrementColumn": db_spec.get("incrementColumn", "sourceUpdatedAt")}
  995. # 先 DROP 再建:init_schema 的 DDL 是 CREATE TABLE IF NOT EXISTS,
  996. # 不先删则 N1-07 的无唯一键表结构会残留,恢复后仍是重复键表。
  997. mock_db.drop_table(table)
  998. mock_db.init_schema({table: spec}) # 重建带唯一键的正常表
  999. mock_db.upsert_rows(table, rows)
  1000. mock_db.drop_view(f"v_slow_{table}")
  1001. notes.append(f"{table} 已重建为正常表并装载样例行;慢查询视图已删除")
  1002. except Exception as ex:
  1003. notes.append(f"模拟库恢复失败:{type(ex).__name__}: {ex}")
  1004. if channel in (None, "API_INBOUND"):
  1005. notes.append("方式丙无状态注入无需恢复;Ai-DOP 侧实体开关由测试人员自行恢复")
  1006. return {"ok": True, "notes": notes}
  1007. @app.post("/api/fault-report/save")
  1008. def fault_report_save(payload: FaultReportPayload):
  1009. if not fault_catalog.find_scenario(payload.scenarioId):
  1010. raise HTTPException(status_code=400, detail=f"unknown scenarioId: {payload.scenarioId}")
  1011. entry = FAULT_REPORTS.save(payload.model_dump(by_alias=True), _secrets(), _access_keys())
  1012. return {"ok": True, "report": entry}
  1013. @app.get("/api/fault-report/list")
  1014. def fault_report_list():
  1015. return {"reports": FAULT_REPORTS.list()}
  1016. @app.get("/api/fault-report/export/{report_id}")
  1017. def fault_report_export(report_id: str):
  1018. entry = FAULT_REPORTS.get(report_id)
  1019. if not entry:
  1020. raise HTTPException(status_code=404, detail="report not found")
  1021. md = build_fault_report_markdown(entry)
  1022. filename = f"aidop-sim-fault-report-{entry['scenarioId']}-{report_id}.md"
  1023. return Response(content=md, media_type="text/markdown; charset=utf-8",
  1024. headers={"Content-Disposition": f'attachment; filename="{filename}"'})
  1025. @app.delete("/api/fault-report/{report_id}")
  1026. def fault_report_delete(report_id: str):
  1027. if not FAULT_REPORTS.delete(report_id):
  1028. raise HTTPException(status_code=404, detail="report not found")
  1029. return {"ok": True}
  1030. # ---------------------------------------------------------------------------
  1031. # 全链路报告(F5)
  1032. # ---------------------------------------------------------------------------
  1033. @app.post("/api/report/save")
  1034. def report_save(payload: ReportPayload):
  1035. if payload.channel not in CHANNELS:
  1036. raise HTTPException(status_code=400, detail=f"unknown channel: {payload.channel}")
  1037. if not payload.runId.strip():
  1038. raise HTTPException(status_code=400, detail="runId required")
  1039. entry = REPORTS.save(payload.model_dump(), _secrets(), _access_keys())
  1040. return {"ok": True, "report": entry}
  1041. @app.get("/api/report/list")
  1042. def report_list():
  1043. return {"reports": REPORTS.list()}
  1044. @app.get("/api/report/evidence/{channel}")
  1045. def report_evidence(channel: str):
  1046. """按方式取最近运行记录(已脱敏),供测试人员一键导入「模拟端证据」。"""
  1047. if channel not in CHANNELS:
  1048. raise HTTPException(status_code=404, detail="unknown channel")
  1049. entries = [r for r in RUNS.runs if r["channel"] == channel][-5:]
  1050. return {"entries": list(reversed(entries))}
  1051. @app.get("/api/report/export/{report_id}")
  1052. def report_export(report_id: str):
  1053. entry = REPORTS.get(report_id)
  1054. if not entry:
  1055. raise HTTPException(status_code=404, detail="report not found")
  1056. md = build_report_markdown(entry)
  1057. filename = f"aidop-sim-report-{entry['runId']}.md"
  1058. return Response(content=md, media_type="text/markdown; charset=utf-8",
  1059. headers={"Content-Disposition": f'attachment; filename="{filename}"'})
  1060. @app.delete("/api/report/{report_id}")
  1061. def report_delete(report_id: str):
  1062. if not REPORTS.delete(report_id):
  1063. raise HTTPException(status_code=404, detail="report not found")
  1064. return {"ok": True}
  1065. # ---------------------------------------------------------------------------
  1066. # 静态页面
  1067. # ---------------------------------------------------------------------------
  1068. @app.get("/")
  1069. def index():
  1070. return FileResponse(SIM_ROOT / "static" / "index.html")
  1071. app.mount("/static", StaticFiles(directory=SIM_ROOT / "static"), name="static")
  1072. def main() -> None:
  1073. print(f"[simulator] control panel: http://127.0.0.1:{DEFAULT_CONTROL_PORT}/")
  1074. print(f"[simulator] aidop target : {CONFIG['aidopBaseUrl']} (allowed hosts: {CONFIG['allowedTargetHosts']})")
  1075. uvicorn.run(app, host="127.0.0.1", port=DEFAULT_CONTROL_PORT, log_level="info")
  1076. if __name__ == "__main__":
  1077. main()