Просмотр исходного кода

feat: 新增 Ai-DOP 第三方对接模拟器初版(tools/integration-simulator, P1-A~P1-H) + 系统集成文档索引登记; chore: bump server 1.0.597

Pengxy 3 дней назад
Родитель
Сommit
f63842f253
28 измененных файлов с 3909 добавлено и 3 удалено
  1. 3 0
      .gitignore
  2. 1 0
      doc/plan/系统集成/README.md
  3. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  4. 147 0
      tools/integration-simulator/README.md
  5. 751 0
      tools/integration-simulator/app.py
  6. 1 0
      tools/integration-simulator/catalog/__init__.py
  7. 389 0
      tools/integration-simulator/catalog/entities.json
  8. 324 0
      tools/integration-simulator/catalog/sample_adapters.py
  9. 44 0
      tools/integration-simulator/catalog/scenarios.json
  10. 1 0
      tools/integration-simulator/clients/__init__.py
  11. 111 0
      tools/integration-simulator/clients/aidop_probe.py
  12. 186 0
      tools/integration-simulator/clients/inbound_client.py
  13. 76 0
      tools/integration-simulator/clients/redaction.py
  14. 7 0
      tools/integration-simulator/config.example.json
  15. 1 0
      tools/integration-simulator/providers/__init__.py
  16. 220 0
      tools/integration-simulator/providers/mock_api.py
  17. 270 0
      tools/integration-simulator/providers/mock_db.py
  18. 5 0
      tools/integration-simulator/requirements.txt
  19. 171 0
      tools/integration-simulator/static/app.css
  20. 351 0
      tools/integration-simulator/static/app.js
  21. 220 0
      tools/integration-simulator/static/index.html
  22. 9 0
      tools/integration-simulator/tests/conftest.py
  23. 81 0
      tools/integration-simulator/tests/test_guard.py
  24. 67 0
      tools/integration-simulator/tests/test_inbound_signature.py
  25. 125 0
      tools/integration-simulator/tests/test_mock_api.py
  26. 107 0
      tools/integration-simulator/tests/test_mock_db.py
  27. 63 0
      tools/integration-simulator/tests/test_redaction.py
  28. 175 0
      tools/integration-simulator/tests/test_sample_adapters.py

+ 3 - 0
.gitignore

@@ -76,6 +76,9 @@ doc/plan/S0/
 doc/.feishu-cache/
 tools/feishu-drive-mcp/.env
 
+# Integration simulator local config (never commit; credentials come from env vars)
+tools/integration-simulator/config.json
+
 # C# Dev Kit language service cache
 *.csproj.lscache
 

+ 1 - 0
doc/plan/系统集成/README.md

@@ -22,6 +22,7 @@ python doc/plan/系统集成/_gen_integration_guide_docx.py
 |------|------|
 | [Ai-DOP第三方对接模拟器建设方案.md](./Ai-DOP第三方对接模拟器建设方案.md) | 独立本地第三方系统模拟器总体方案;第一阶段同时验证数据库同步 `DB_SYNC`、主动接口拉取 `API_PULL`、第三方接口推送 `API_INBOUND`,第二阶段扩展全链路自动对账、故障注入、SQL Server 与 CI |
 | [Ai-DOP第三方对接模拟器初步执行任务书.md](./Ai-DOP第三方对接模拟器初步执行任务书.md) | 可供后续开发者或大模型执行的两阶段初步任务书;含三层基线、文件清单、工作包、26 个推数契约、7 类推数操作、测试门禁和六面反向影响 |
+| [`tools/integration-simulator/`](../../../tools/integration-simulator/README.md) | **模拟器初版代码**(P1-A~P1-H 已交付);Python FastAPI 控制台 8017 + Mock API 8018 + MySQL 建库;三通道独立 source_code `SIM_MODE3_*`;42 对象 × 26 入站契约;凭据只走环境变量、Secret 不落盘不进浏览器 |
 
 ## 中立标准层改造(本目录,内部)
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.596</AssemblyVersion>
-    <FileVersion>1.0.596</FileVersion>
-    <Version>1.0.596</Version>
+    <AssemblyVersion>1.0.597</AssemblyVersion>
+    <FileVersion>1.0.597</FileVersion>
+    <Version>1.0.597</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 147 - 0
tools/integration-simulator/README.md

@@ -0,0 +1,147 @@
+# Ai-DOP 第三方对接模拟器(第一阶段初版)
+
+独立运行的本地第三方系统模拟器,用同一套可追踪业务样例分别模拟三种入站方式:
+
+| 通道 | 模拟端 | Ai-DOP 真实执行链 |
+|---|---|---|
+| `DB_SYNC` | `providers/mock_db.py` → 独立 MySQL schema `aidop_integration_sim` | `MdpSourcePullDispatcher` → `MdpDbPullExecutor` |
+| `API_PULL` | `providers/mock_api.py` → 本地 GET Mock(默认 8018) | `MdpSourcePullDispatcher` → `MdpApiPullExecutor` |
+| `API_INBOUND` | `clients/inbound_client.py` → HMAC 签名推数 | `MdpInboundController` → 接收服务 |
+
+派生文档:[`建设方案`](../../doc/plan/系统集成/Ai-DOP第三方对接模拟器建设方案.md) ·
+[`初步执行任务书`](../../doc/plan/系统集成/Ai-DOP第三方对接模拟器初步执行任务书.md)
+
+**本工具不写 Ai-DOP 贴源/标准/KPI 表,不修改 Ai-DOP 配置,不进入 Ai-DOP 菜单。**
+
+---
+
+## 1. 环境要求
+
+- Python 3.11+(Windows 需真实解释器,非 Microsoft Store 别名占位)
+- 独立 MySQL 测试实例,具备建库建表权限;**不得**使用 Ai-DOP 库或其账号
+- 可访问的 Ai-DOP 开发/UAT 地址,且已准备测试租户与测试 AccessKey
+
+```powershell
+cd d:\DEMONET
+python -m venv .venv-sim
+.venv-sim\Scripts\pip install -r tools/integration-simulator/requirements.txt
+```
+
+## 2. 配置
+
+```powershell
+Copy-Item tools/integration-simulator/config.example.json tools/integration-simulator/config.json
+# 按本机修改 aidopBaseUrl / mockApiPort / allowedTargetHosts
+```
+
+`config.json` 已加入 `.gitignore`,且**只放非敏感项**。凭据一律走环境变量:
+
+```powershell
+$env:AIDOP_SIM_MYSQL_HOST      = "127.0.0.1"
+$env:AIDOP_SIM_MYSQL_PORT      = "3306"
+$env:AIDOP_SIM_MYSQL_USER      = "sim_user"          # 专用账号,勿用 Ai-DOP 主库账号
+$env:AIDOP_SIM_MYSQL_PASSWORD  = "<模拟库密码>"
+$env:AIDOP_SIM_MYSQL_DATABASE  = "aidop_integration_sim"
+$env:AIDOP_SIM_INBOUND_ACCESS_KEY = "<测试 AccessKey>"
+$env:AIDOP_SIM_INBOUND_SECRET     = "<测试 Secret>"
+$env:AIDOP_SIM_ADMIN_TOKEN        = "<可选:只读管理 API 用>"
+```
+
+缺任何一项时,页面顶部横幅会明确提示缺哪个变量;Secret 永远不由页面输入、不回显、不落盘。
+
+## 3. 启动
+
+```powershell
+.venv-sim\Scripts\python tools/integration-simulator/app.py
+# 控制台:http://127.0.0.1:8017/
+```
+
+服务只绑定 `127.0.0.1`。目标 Ai-DOP host 必须命中 `allowedTargetHosts`,否则硬阻断。
+
+## 4. 页面用法
+
+| 页签 | 用途 |
+|---|---|
+| 对象测试 | 选对象 → 选通道 → 加载/编辑适配样例 → 预检 → 执行 → 看三层结论与核验 SQL |
+| 业务场景 | 三组跨对象链路(订单制造链 / 采购执行链 / 库存与成品链);不支持的节点标红「该通道不支持」 |
+| Mock API | 启停 8018;切换 NONE/TOKEN/BASIC/APIKEY;注入延迟与 401/403/429/500;改响应路径;看脱敏审计 |
+| 模拟 MySQL | 建库建表 / 装载样例 / 重置单表 / 行数与最大增量 / 生成 Ai-DOP 配置清单 |
+| API_INBOUND | 26 契约 schema 批量预检;七类操作与必测用例 |
+| 运行记录 | 最近运行(自动脱敏) |
+| 环境安全 | 凭据加载状态、白名单、隔离约束说明 |
+
+## 5. Ai-DOP 侧登记(人工执行)
+
+模拟器只**生成建议配置**(`GET /api/db/aidop-config/{objectCode}`),不自动写库。需在 Ai-DOP 配置页登记:
+
+- `mdp_source`:三个专用测试来源 `SIM_MODE3_DB` / `SIM_MODE3_API` / `SIM_MODE3_INBOUND`,共用 `system_code=SIM_MODE3`;
+- `mdp_entity`:源表/源 API path、目标贴源表、业务键、增量列(DB_SYNC 用 `sourceUpdatedAt`,API_PULL 用 cursor 语义);
+- `mdp_inbound_grant` + `SysOpenAccess`:测试 AccessKey 授权目标 entityCode 并绑定测试租户;
+- `mdp_entity.inbound_enabled=1`。
+
+三通道**不得共用 source_code**,且不得抢占现有 T8/165/自建单等生产权威来源。
+
+## 6. 结果分层(不得混为一谈)
+
+```text
+代码层:SUPPORTED / UNSUPPORTED
+配置层:READY / CONFIG_NOT_REGISTERED / CONFIG_NOT_ENABLED / GRANT_MISSING / SOURCE_UNREACHABLE
+数据层:NOT_RUN / ACCEPTED / STAGING_WRITTEN / TRANSFORM_PENDING / RUN_FAILED
+```
+
+第一阶段无安全的只读管理 API 核对贴源结果时,报告 `TRANSFORM_PENDING` 并给出人工核验 SQL,
+**不把 HTTP 202 说成业务全链路跑通,不把配置未开通说成代码未实现**。
+
+## 7. 测试
+
+```powershell
+.venv-sim\Scripts\pytest tools/integration-simulator/tests -v
+```
+
+| 文件 | 覆盖 |
+|---|---|
+| `tests/test_inbound_signature.py` | 签名金标向量(与独立实现交叉验证)、SHA256/HMAC、签名头不外泄 |
+| `tests/test_sample_adapters.py` | 目录三通道完整性、26 契约覆盖、样例路径存在、`SIM-{runId}-` 业务键、契约字段名、缺必填如实上报 |
+| `tests/test_mock_api.py` | cursor 过滤、四种鉴权、401/403/429/500 注入、响应路径切换、样例挂载、审计脱敏 |
+| `tests/test_mock_db.py` | DDL 与类型推断、幂等 upsert、库名硬阻断、配置清单;真实 MySQL 用例无密码时跳过并标注未实测 |
+| `tests/test_guard.py` | 非白名单目标阻断、预检状态分类、运行记录脱敏与容量 |
+| `tests/test_redaction.py` | Secret/连接串/Bearer/AccessKey 后四位脱敏 |
+
+> `tests/test_guard.py` 是任务书初始清单外新增的一份守卫测试(安全闸门与三层判定),其余文件名与任务书一致。
+
+## 8. 目录
+
+```text
+tools/integration-simulator/
+├─ app.py                     # 本地控制服务(只绑 127.0.0.1)+ 三层报告 + 运行记录
+├─ requirements.txt
+├─ config.example.json        # 仅非敏感默认项;复制为 config.json(已 gitignore)
+├─ clients/
+│  ├─ inbound_client.py       # HMAC 签名与七类推数操作
+│  ├─ aidop_probe.py          # 只读预检与白名单闸门
+│  └─ redaction.py            # 脱敏
+├─ providers/
+│  ├─ mock_api.py             # API_PULL 对端
+│  └─ mock_db.py              # MySQL 建表/装载/清理 + 配置清单生成
+├─ catalog/
+│  ├─ entities.json           # 42 个对象 × 三通道能力矩阵
+│  ├─ scenarios.json          # 三组跨对象基础场景
+│  └─ sample_adapters.py      # canonical 样例 → 三通道适配
+├─ static/                    # 本地页面
+└─ tests/
+```
+
+复用但**不迁移、不修改**的既有资产:
+
+```text
+doc/db/mdp/mock_api/endpoints.json
+doc/db/mdp/mock_api/samples/*.json
+tools/mock/inbound/push_demo.py
+```
+
+## 9. 第一阶段边界
+
+- 只做 MySQL 方言;SQL Server、故障注入编排、全链路自动对账、CLI/CI/容器化属第二阶段;
+- 不做高并发压测,默认低频单批;
+- 不为无契约对象伪造 API_INBOUND 接口(销售订单头、合同评审、发货计划/发货单、工序计划、MRP、采购申请、交货计划、现存量、过程检验、成品入库);
+- 删除测试数据不由模拟器执行,只提供运行编号与核验 SQL 清单。

+ 751 - 0
tools/integration-simulator/app.py

@@ -0,0 +1,751 @@
+"""Ai-DOP 第三方对接模拟器 · 本地控制服务(任务书 P1-B / P1-G)。
+
+- 只绑定 127.0.0.1,页面与 API 仅本机可用;
+- Secret/密码/Token 只从环境变量读取,任何 API 不回显;
+- 所有对外目标 host 走白名单硬阻断(默认拒绝);
+- 运行记录(RunStore)写入前统一脱敏;
+- 内嵌管理 Mock API(providers.mock_api),默认端口 8018,可启停/切鉴权/注入错误;
+- 三层报告:代码层 SUPPORTED/UNSUPPORTED、配置层 READY/NOT_*、数据层 NOT_RUN/ACCEPTED/TRANSFORM_PENDING/RUN_FAILED。
+
+启动:python tools/integration-simulator/app.py   (详见 README.md)
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+import time
+import uuid
+from pathlib import Path
+
+SIM_ROOT = Path(__file__).resolve().parent
+if str(SIM_ROOT) not in sys.path:
+    sys.path.insert(0, str(SIM_ROOT))
+
+import uvicorn
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+
+from catalog import sample_adapters as sa
+from clients import aidop_probe
+from clients.inbound_client import InboundClient
+from clients.redaction import mask_access_key, redact_obj
+from providers import mock_api, mock_db
+
+DEFAULT_CONTROL_PORT = 8017
+MAX_RUNS = 200
+
+# 状态枚举(任务书 §4)
+CONFIG_STATES = ("READY", "CONFIG_NOT_REGISTERED", "CONFIG_NOT_ENABLED",
+                 "GRANT_MISSING", "SOURCE_UNREACHABLE", "RUN_FAILED")
+
+
+def _env(name: str) -> str:
+    return os.environ.get(name, "") or ""
+
+
+def _secrets() -> list[str]:
+    return [v for v in (_env("AIDOP_SIM_INBOUND_SECRET"), _env("AIDOP_SIM_MYSQL_PASSWORD"),
+                        _env("AIDOP_SIM_ADMIN_TOKEN")) if v]
+
+
+def load_config() -> dict:
+    cfg = {
+        "aidopBaseUrl": "http://127.0.0.1:5005",
+        "mockApiPort": 8018,
+        "mysqlDatabase": mock_db.SIM_DB_DEFAULT,
+        "allowedTargetHosts": ["127.0.0.1", "localhost"],
+    }
+    cfg_file = SIM_ROOT / "config.json"
+    if cfg_file.exists():
+        import json
+        with cfg_file.open("r", encoding="utf-8") as f:
+            cfg.update(json.load(f))
+    return cfg
+
+
+CONFIG = load_config()
+MOCK_STATE = mock_api.MockApiState()
+MOCK_APP = mock_api.create_app(MOCK_STATE)
+
+
+class RunStore:
+    def __init__(self, limit: int = MAX_RUNS):
+        self.limit = limit
+        self.runs: list[dict] = []
+
+    def add(self, run_id: str, object_code: str, channel: str, request_summary: dict,
+            response: object, layers: dict, followup_sql: list[str] | None = None,
+            gaps: list[str] | None = None, scenario_id: str | None = None,
+            nodes: list[dict] | None = None) -> dict:
+        entry = {
+            "runId": run_id,
+            "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
+            "objectCode": object_code,
+            "channel": channel,
+            "scenarioId": scenario_id,
+            "nodes": nodes,
+            "request": redact_obj(request_summary, _secrets()),
+            "response": redact_obj(response, _secrets()),
+            "layers": layers,
+            "followupSql": followup_sql or [],
+            "knownGaps": gaps or [],
+        }
+        self.runs.append(entry)
+        if len(self.runs) > self.limit:
+            del self.runs[: len(self.runs) - self.limit]
+        return entry
+
+    def recent(self) -> list[dict]:
+        return list(reversed(self.runs[-50:]))
+
+
+RUNS = RunStore()
+
+app = FastAPI(title="Ai-DOP Integration Simulator", version="1.0.0")
+
+
+# ---------------------------------------------------------------------------
+# Mock API 内嵌启停
+# ---------------------------------------------------------------------------
+
+class _MockServerHandle:
+    def __init__(self):
+        self.server: uvicorn.Server | None = None
+        self.task: asyncio.Task | None = None
+        self.last_error: str = ""
+
+
+MOCK_HANDLE = _MockServerHandle()
+
+
+async def _start_mock_api() -> dict:
+    if MOCK_HANDLE.task and not MOCK_HANDLE.task.done():
+        return {"running": True, "port": CONFIG["mockApiPort"], "alreadyRunning": True}
+    config = uvicorn.Config(MOCK_APP, host="127.0.0.1", port=int(CONFIG["mockApiPort"]), log_level="warning")
+    server = uvicorn.Server(config)
+    MOCK_HANDLE.server = server
+    MOCK_HANDLE.task = asyncio.get_running_loop().create_task(server.serve())
+    await asyncio.sleep(0.4)
+    if server.startup_error:
+        MOCK_HANDLE.last_error = str(server.startup_error)
+        return {"running": False, "error": MOCK_HANDLE.last_error}
+    return {"running": True, "port": CONFIG["mockApiPort"]}
+
+
+async def _stop_mock_api() -> dict:
+    if MOCK_HANDLE.server and MOCK_HANDLE.task and not MOCK_HANDLE.task.done():
+        MOCK_HANDLE.server.should_exit = True
+        try:
+            await asyncio.wait_for(MOCK_HANDLE.task, timeout=5)
+        except asyncio.TimeoutError:
+            MOCK_HANDLE.task.cancel()
+        return {"running": False}
+    return {"running": False, "alreadyStopped": True}
+
+
+def _mock_running() -> bool:
+    return bool(MOCK_HANDLE.task and not MOCK_HANDLE.task.done() and
+                MOCK_HANDLE.server and MOCK_HANDLE.server.started)
+
+
+# ---------------------------------------------------------------------------
+# 基础模型
+# ---------------------------------------------------------------------------
+
+class RowsPayload(BaseModel):
+    objectCode: str
+    runId: str | None = None
+    rows: list[dict] | None = None
+
+
+class InboundExecPayload(BaseModel):
+    objectCode: str | None = None
+    entityCode: str | None = None
+    operation: str = "push"   # schema|push|receipt|bulk|snapshot_open|snapshot_commit|digest
+    runId: str | None = None
+    rows: list[dict] | None = None
+    contractVersion: str | None = None
+    # 用例与操作参数
+    case: str | None = None   # happy|missing-idem|tamper-body|expired-ts|nonce-replay|same-key-replay|same-key-diff-body|bad-signature
+    syncBatchId: str | None = None
+    snapshotId: str | None = None
+    force: bool = False
+    bizDate: str | None = None
+
+
+class ScenarioPayload(BaseModel):
+    scenarioId: str
+    runId: str | None = None
+    channelOverride: dict[str, str] | None = None
+
+
+# ---------------------------------------------------------------------------
+# 公共辅助
+# ---------------------------------------------------------------------------
+
+def _require_obj(object_code: str) -> dict:
+    obj = sa.find_object(object_code)
+    if not obj:
+        raise HTTPException(status_code=404, detail=f"unknown objectCode: {object_code}")
+    return obj
+
+
+def _build_inbound_client() -> InboundClient:
+    base = CONFIG["aidopBaseUrl"]
+    try:
+        aidop_probe.assert_host_allowed(base, CONFIG["allowedTargetHosts"])
+    except aidop_probe.HostNotAllowedError as ex:
+        raise HTTPException(status_code=400, detail=str(ex))
+    access_key = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
+    secret = _env("AIDOP_SIM_INBOUND_SECRET")
+    missing = [n for n, v in (("AIDOP_SIM_INBOUND_ACCESS_KEY", access_key),
+                              ("AIDOP_SIM_INBOUND_SECRET", secret)) if not v]
+    if missing:
+        raise HTTPException(status_code=400,
+                            detail=f"缺少环境变量: {', '.join(missing)}(Secret 只从环境变量读取,不落盘)")
+    return InboundClient(base, access_key, secret)
+
+
+def _resolve_entity(payload: InboundExecPayload) -> tuple[dict | None, str]:
+    """解析本次执行的对象与 entityCode。仅给 entityCode 时允许无目录对象直发(rows 必填)。"""
+    if payload.objectCode:
+        obj = _require_obj(payload.objectCode)
+    else:
+        obj = _require_obj_by_entity(payload.entityCode or "")
+    if payload.entityCode:
+        entity = payload.entityCode.upper()
+    else:
+        if not obj:
+            raise HTTPException(status_code=400, detail="need objectCode or entityCode")
+        inbound = obj.get("apiInbound") or {}
+        if not inbound.get("supported"):
+            raise HTTPException(status_code=400,
+                                detail=f"{obj['objectCode']} 不支持 API_INBOUND: {inbound.get('reason', '无契约')}")
+        entity = inbound["entityCode"]
+    return obj, entity
+
+
+def _require_obj_by_entity(entity_code: str) -> dict | None:
+    for obj in sa.load_catalog()["objects"]:
+        if (obj.get("apiInbound") or {}).get("entityCode") == entity_code.upper():
+            return obj
+    return None
+
+
+def _layers(code: str, config: str, data: str) -> dict:
+    return {"code": code, "config": config, "data": data}
+
+
+def _followup_sql(obj: dict, channel: str, run_id: str) -> list[str]:
+    prefix = sa.biz_prefix(run_id)
+    db = (obj.get("dbSync") or {}).get("table")
+    sqls = [
+        "SELECT source_code, status, COUNT(*) cnt, MAX(end_time) latest FROM mdp_sync_log "
+        "WHERE source_code LIKE 'SIM_MODE3%' GROUP BY source_code, status;",
+        "SELECT COUNT(*) FROM mdp_inbound_request WHERE create_time >= CURDATE();",
+    ]
+    if channel == "DB_SYNC" and db:
+        sqls.insert(0, f"-- 模拟库核对:\nSELECT COUNT(*), MAX(sourceUpdatedAt) FROM `{db}` WHERE bizKey LIKE '{prefix}%';")
+        sqls.append(f"SELECT * FROM `{db.replace('sim_', 'mdp_stg_sim_')}` WHERE raw_data LIKE '%{prefix}%' LIMIT 20;")
+    if channel == "API_INBOUND":
+        entity = (obj.get("apiInbound") or {}).get("entityCode")
+        sqls.append(f"SELECT status, accepted_rows, rejected_rows, sync_batch_id FROM mdp_inbound_request "
+                    f"WHERE entity_code='{entity}' ORDER BY id DESC LIMIT 10;")
+    sqls.append("-- 标准层/KPI 下游核验属第二阶段自动对账范围;第一阶段报告 TRANSFORM_PENDING,不猜测成功。")
+    return sqls
+
+
+# ---------------------------------------------------------------------------
+# 状态与目录
+# ---------------------------------------------------------------------------
+
+@app.get("/api/status")
+def status():
+    inbound_key = _env("AIDOP_SIM_INBOUND_ACCESS_KEY")
+    return {
+        "controlPort": DEFAULT_CONTROL_PORT,
+        "aidopBaseUrl": CONFIG["aidopBaseUrl"],
+        "allowedTargetHosts": CONFIG["allowedTargetHosts"],
+        "aidopReachable": None,  # 由 /api/precheck 实测,避免每次状态查询打真实请求
+        "mockApi": {"running": _mock_running(), "port": CONFIG["mockApiPort"],
+                    "authMode": MOCK_STATE.settings.auth_mode,
+                    "forceStatus": MOCK_STATE.settings.force_status,
+                    "lastError": MOCK_HANDLE.last_error},
+        "mysql": {
+            "database": _env("AIDOP_SIM_MYSQL_DATABASE") or mock_db.SIM_DB_DEFAULT,
+            "passwordLoaded": bool(_env("AIDOP_SIM_MYSQL_PASSWORD")),
+        },
+        "inbound": {
+            "accessKeyLoaded": bool(inbound_key),
+            "accessKeyMasked": mask_access_key(inbound_key),
+            "secretLoaded": bool(_env("AIDOP_SIM_INBOUND_SECRET")),
+        },
+        "adminTokenLoaded": bool(_env("AIDOP_SIM_ADMIN_TOKEN")),
+        "objectCounts": _channel_counts(),
+    }
+
+
+def _channel_counts() -> dict:
+    counts = {"DB_SYNC": 0, "API_PULL": 0, "API_INBOUND": 0, "total": 0}
+    for obj in sa.load_catalog()["objects"]:
+        counts["total"] += 1
+        if (obj.get("dbSync") or {}).get("supported"):
+            counts["DB_SYNC"] += 1
+        if (obj.get("apiPull") or {}).get("supported"):
+            counts["API_PULL"] += 1
+        if (obj.get("apiInbound") or {}).get("supported"):
+            counts["API_INBOUND"] += 1
+    return counts
+
+
+@app.get("/api/catalog")
+def catalog():
+    return sa.load_catalog()
+
+
+@app.get("/api/scenarios")
+def scenarios():
+    return sa.load_scenarios()
+
+
+@app.get("/api/sample/{object_code}")
+def get_sample(object_code: str, runId: str | None = None):
+    obj = _require_obj(object_code)
+    run_id = runId or sa.new_run_id()
+    inbound = obj.get("apiInbound") or {}
+    result: dict = {"objectCode": obj["objectCode"], "runId": run_id,
+                    "canonical": sa.load_canonical_rows(obj),
+                    "dbSync": None, "apiPull": None, "apiInbound": None}
+    if (obj.get("dbSync") or {}).get("supported"):
+        result["dbSync"] = sa.adapt_db_rows(obj, run_id)
+    if (obj.get("apiPull") or {}).get("supported"):
+        result["apiPull"] = sa.adapt_api_rows(obj, run_id)
+    if inbound.get("supported"):
+        adapted = sa.adapt_inbound_rows(obj, run_id)
+        result["apiInbound"] = {
+            "entityCode": inbound["entityCode"],
+            "contractVersion": inbound.get("contractVersion", "v1"),
+            "rows": adapted["rows"],
+            "missingRequired": adapted["missing_required"],
+            "envelope": sa.inbound_envelope(adapted["rows"]),
+        }
+    return result
+
+
+# ---------------------------------------------------------------------------
+# Mock API 控制
+# ---------------------------------------------------------------------------
+
+@app.post("/api/mock-api/start")
+async def mock_api_start():
+    return await _start_mock_api()
+
+
+@app.post("/api/mock-api/stop")
+async def mock_api_stop():
+    return await _stop_mock_api()
+
+
+@app.get("/api/mock-api/settings")
+def mock_api_settings():
+    s = MOCK_STATE.settings
+    return {"running": _mock_running(), "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
+            "forceStatus": s.force_status, "responsePath": s.response_path,
+            "apiKeyHeader": s.api_key_header, "overrides": sorted(MOCK_STATE.overrides.keys())}
+
+
+@app.post("/api/mock-api/control")
+def mock_api_control(payload: dict):
+    """统一控制入口:auth_mode/token/basic/apikey/delay/force_status/response_path/override。"""
+    s = MOCK_STATE.settings
+    if "auth_mode" in payload:
+        mode = str(payload["auth_mode"]).upper()
+        if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
+            raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
+        s.auth_mode = mode
+    for key, attr in (("token", "token"), ("basic_user", "basic_user"),
+                      ("basic_password", "basic_password"), ("api_key_header", "api_key_header"),
+                      ("api_key_value", "api_key_value"), ("response_path", "response_path")):
+        if payload.get(key):
+            setattr(s, attr, str(payload[key]))
+    if "delay_seconds" in payload:
+        s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
+    if "force_status" in payload:
+        s.force_status = int(payload["force_status"]) if payload["force_status"] else None
+    if payload.get("override_path") and isinstance(payload.get("override_rows"), list):
+        MOCK_STATE.overrides[payload["override_path"]] = payload["override_rows"]
+    if payload.get("clear_overrides"):
+        MOCK_STATE.overrides.clear()
+    return {"ok": True, "settings": mock_api_settings()}
+
+
+@app.get("/api/mock-api/audit")
+def mock_api_audit():
+    return {"entries": redact_obj(MOCK_STATE.audit[-50:], _secrets())}
+
+
+# ---------------------------------------------------------------------------
+# 模拟 MySQL 控制
+# ---------------------------------------------------------------------------
+
+@app.post("/api/db/ensure")
+def db_ensure(payload: RowsPayload):
+    """初始化模拟 schema 并确保对象源表存在(DDL 由样例推断)。"""
+    obj = _require_obj(payload.objectCode)
+    db_spec = obj.get("dbSync") or {}
+    if not db_spec.get("supported"):
+        raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
+    if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
+        raise HTTPException(status_code=400, detail="缺少环境变量 AIDOP_SIM_MYSQL_PASSWORD")
+    run_id = payload.runId or sa.new_run_id()
+    rows = payload.rows if payload.rows is not None else sa.adapt_db_rows(obj, run_id)
+    try:
+        result = mock_db.init_schema({db_spec["table"]: {
+            "rows": rows, "businessKey": db_spec.get("businessKey"),
+            "incrementColumn": db_spec.get("incrementColumn", "sourceUpdatedAt")}})
+        return {"ok": True, "runId": run_id, **result}
+    except mock_db.BlockedDatabaseError as ex:
+        raise HTTPException(status_code=400, detail=str(ex))
+    except Exception as ex:
+        raise HTTPException(status_code=502, detail=f"MySQL 不可达或执行失败: {type(ex).__name__}: {ex}")
+
+
+@app.post("/api/db/load")
+def db_load(payload: RowsPayload):
+    obj = _require_obj(payload.objectCode)
+    db_spec = obj.get("dbSync") or {}
+    if not db_spec.get("supported"):
+        raise HTTPException(status_code=400, detail=f"{obj['objectCode']} 不支持 DB_SYNC")
+    run_id = payload.runId or sa.new_run_id()
+    rows = payload.rows if payload.rows is not None else sa.adapt_db_rows(obj, run_id)
+    try:
+        count = mock_db.upsert_rows(db_spec["table"], rows)
+        stats = mock_db.table_stats(db_spec["table"], db_spec.get("incrementColumn", "sourceUpdatedAt"))
+    except Exception as ex:
+        raise HTTPException(status_code=502, detail=f"装载失败(先执行 /api/db/ensure?): {type(ex).__name__}: {ex}")
+    layers = _layers("SUPPORTED", "READY", "ACCEPTED")
+    RUNS.add(run_id, obj["objectCode"], "DB_SYNC",
+             {"action": "load", "table": db_spec["table"], "rowCount": count},
+             stats, layers, _followup_sql(obj, "DB_SYNC", run_id),
+             gaps=["Ai-DOP 侧同步须由 MdpDbPullExecutor 真实执行;本操作只装载第三方模拟源库"])
+    return {"ok": True, "runId": run_id, "loaded": count, "stats": stats, "layers": layers}
+
+
+@app.post("/api/db/reset")
+def db_reset(payload: RowsPayload):
+    obj = _require_obj(payload.objectCode)
+    table = (obj.get("dbSync") or {}).get("table")
+    if not table:
+        raise HTTPException(status_code=400, detail="object has no dbSync table")
+    try:
+        mock_db.reset_table(table)
+        return {"ok": True, "table": table}
+    except Exception as ex:
+        raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
+
+
+@app.post("/api/db/update")
+def db_update(payload: dict):
+    obj = _require_obj(payload.get("objectCode", ""))
+    db_spec = obj.get("dbSync") or {}
+    table = db_spec.get("table")
+    updates = payload.get("updates") or {}
+    where = payload.get("where") or {}
+    if not table or not updates or not where:
+        raise HTTPException(status_code=400, detail="need objectCode/updates/where")
+    try:
+        affected = mock_db.update_rows(table, updates, where,
+                                       db_spec.get("incrementColumn", "sourceUpdatedAt"),
+                                       payload.get("newIncrement"))
+        return {"ok": True, "table": table, "affected": affected}
+    except Exception as ex:
+        raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
+
+
+@app.get("/api/db/stats/{object_code}")
+def db_stats(object_code: str):
+    obj = _require_obj(object_code)
+    db_spec = obj.get("dbSync") or {}
+    if not db_spec.get("table"):
+        raise HTTPException(status_code=400, detail="object has no dbSync table")
+    try:
+        return mock_db.table_stats(db_spec["table"], db_spec.get("incrementColumn", "sourceUpdatedAt"))
+    except Exception as ex:
+        raise HTTPException(status_code=502, detail=f"{type(ex).__name__}: {ex}")
+
+
+@app.get("/api/db/aidop-config/{object_code}")
+def db_aidop_config(object_code: str):
+    obj = _require_obj(object_code)
+    base = f"http://127.0.0.1:{CONFIG['mockApiPort']}"
+    return mock_db.generate_aidop_config(obj, base)
+
+
+# ---------------------------------------------------------------------------
+# 预检与三层报告(P1-G)
+# ---------------------------------------------------------------------------
+
+@app.get("/api/precheck")
+def precheck():
+    base = CONFIG["aidopBaseUrl"]
+    reach = aidop_probe.check_reachable(base, CONFIG["allowedTargetHosts"])
+    inbound_ready = bool(_env("AIDOP_SIM_INBOUND_ACCESS_KEY") and _env("AIDOP_SIM_INBOUND_SECRET"))
+    env_missing = [n for n in ("AIDOP_SIM_INBOUND_ACCESS_KEY", "AIDOP_SIM_INBOUND_SECRET",
+                               "AIDOP_SIM_MYSQL_PASSWORD") if not _env(n)]
+    return {
+        "aidopBaseUrl": base,
+        "aidop": reach,
+        "mockApi": {"running": _mock_running(), "port": CONFIG["mockApiPort"]},
+        "mysql": {"database": _env("AIDOP_SIM_MYSQL_DATABASE") or mock_db.SIM_DB_DEFAULT,
+                  "passwordLoaded": bool(_env("AIDOP_SIM_MYSQL_PASSWORD"))},
+        "inboundCredentialsLoaded": inbound_ready,
+        "envMissing": env_missing,
+        "testSources": mock_db.SIM_SOURCES,
+        "note": "DB_SYNC/API_PULL 的来源、实体、租户登记须人工在 Ai-DOP 配置页核对(生成清单见 /api/db/aidop-config/{objectCode})",
+    }
+
+
+@app.get("/api/precheck/inbound")
+def precheck_inbound():
+    """26 个代码契约逐一 schema 预检(真实签名,只读)。"""
+    client = _build_inbound_client()
+    codes = sorted({(o.get("apiInbound") or {}).get("entityCode")
+                    for o in sa.load_catalog()["objects"]
+                    if (o.get("apiInbound") or {}).get("supported")})
+    results = aidop_probe.precheck_all_inbound(client, codes, CONFIG["allowedTargetHosts"])
+    return {"accessKey": mask_access_key(client.key), "count": len(results), "results": results}
+
+
+@app.get("/api/precheck/inbound/{entity_code}")
+def precheck_inbound_one(entity_code: str):
+    client = _build_inbound_client()
+    return aidop_probe.precheck_inbound_entity(client, entity_code.upper(), CONFIG["allowedTargetHosts"])
+
+
+# ---------------------------------------------------------------------------
+# API_INBOUND 执行(P1-E)
+# ---------------------------------------------------------------------------
+
+@app.post("/api/inbound/exec")
+def inbound_exec(payload: InboundExecPayload):
+    obj, entity = _resolve_entity(payload)
+    client = _build_inbound_client()
+    run_id = payload.runId or sa.new_run_id()
+    contract_version = payload.contractVersion
+
+    if obj is None:
+        # 仅按 entityCode 直发(无目录样例),rows 必填
+        rows = payload.rows
+        if rows is None and payload.operation in ("push", "bulk"):
+            raise HTTPException(status_code=400, detail="entityCode-only exec requires rows")
+        envelope = sa.inbound_envelope(rows or [])
+    else:
+        inbound = obj.get("apiInbound") or {}
+        contract_version = contract_version or inbound.get("contractVersion")
+        rows = payload.rows if payload.rows is not None else sa.adapt_inbound_rows(obj, run_id)["rows"]
+        envelope = sa.inbound_envelope(rows)
+
+    object_code = obj["objectCode"] if obj else entity
+    op = payload.operation
+    result: dict = {"runId": run_id, "entityCode": entity, "operation": op, "case": payload.case}
+
+    def record(status: int, body, headers, layers: dict, extra: dict | None = None):
+        result.update({"httpStatus": status, "response": body, "requestHeaders": headers, **(extra or {})})
+        RUNS.add(run_id, object_code, "API_INBOUND",
+                 {"operation": op, "case": payload.case, "entityCode": entity,
+                  "rowCount": len(rows or []), "contractVersion": contract_version},
+                 {"httpStatus": status, "body": body, "headers": headers},
+                 layers, _followup_sql(obj or {"objectCode": entity, "apiInbound": {"entityCode": entity}},
+                                       "API_INBOUND", run_id))
+        return result
+
+    # 预检(配置层)
+    pre = aidop_probe.precheck_inbound_entity(client, entity, CONFIG["allowedTargetHosts"], contract_version)
+    config_state = pre["configState"]
+    result["precheck"] = {"configState": config_state, "httpStatus": pre["httpStatus"]}
+
+    if op == "schema":
+        status, body, headers = client.op_schema(entity, contract_version)
+        layers = _layers("SUPPORTED", aidop_probe.classify_inbound_precheck(status, body), "NOT_RUN")
+        return record(status, body, headers, layers)
+
+    if config_state != "READY" and op in ("push", "bulk", "snapshot_open"):
+        layers = _layers("SUPPORTED", config_state, "NOT_RUN")
+        raise HTTPException(status_code=409, detail={
+            "message": f"配置层 {config_state}:普通推数只对 READY 实体执行(不伪造成功)",
+            "precheck": pre, "layers": layers})
+
+    if op == "push":
+        case = payload.case or "happy"
+        if case == "happy":
+            status, body, headers = client.op_push(entity, envelope, contract_version=contract_version)
+        elif case == "missing-idem":
+            status, body, headers = client.case_missing_idem(entity, envelope)
+        elif case == "tamper-body":
+            status, body, headers = client.case_tamper_body(entity, envelope)
+        elif case == "expired-ts":
+            status, body, headers = client.case_expired_ts(entity, envelope)
+        elif case == "bad-signature":
+            status, body, headers = client.case_bad_signature(entity, envelope)
+        elif case == "nonce-replay":
+            (s1, d1), (s2, d2) = client.case_nonce_replay(entity, envelope)
+            status, body, headers = s2, {"first": [s1, d1], "replay": [s2, d2]}, {}
+        elif case == "same-key-replay":
+            (s1, d1), (s2, d2) = client.case_same_key_replay(entity, envelope)
+            status, body, headers = s2, {"first": [s1, d1], "replay": [s2, d2]}, {}
+        elif case == "same-key-diff-body":
+            env2 = sa.inbound_envelope([{**r, "sourceUpdatedAt": sa.now_iso()} for r in (rows or [])])
+            (s1, d1), (s2, d2) = client.case_same_key_diff_body(entity, envelope, env2)
+            status, body, headers = s2, {"first": [s1, d1], "conflict": [s2, d2]}, {}
+        else:
+            raise HTTPException(status_code=400, detail=f"unknown case: {case}")
+        data_state = "ACCEPTED" if status == 202 else ("RUN_FAILED" if status >= 400 else "NOT_RUN")
+        layers = _layers("SUPPORTED", config_state, data_state)
+        return record(status, body, headers, layers,
+                      extra={"gaps": ["HTTP 202 仅代表摄取受理;贴源/标准层核验见 followupSql(TRANSFORM_PENDING)"]})
+
+    if op == "receipt":
+        if not payload.syncBatchId:
+            raise HTTPException(status_code=400, detail="receipt requires syncBatchId")
+        status, body, headers = client.op_receipt(payload.syncBatchId)
+        layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 200 else "RUN_FAILED")
+        return record(status, body, headers, layers)
+
+    if op == "bulk":
+        status, body, headers = client.op_bulk(entity, rows or [], contract_version=contract_version)
+        layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 202 else "RUN_FAILED")
+        return record(status, body, headers, layers)
+
+    if op == "snapshot_open":
+        status, body, headers = client.op_snapshot_open(entity)
+        layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status in (200, 201) else "RUN_FAILED")
+        return record(status, body, headers, layers)
+
+    if op == "snapshot_commit":
+        if not payload.snapshotId:
+            raise HTTPException(status_code=400, detail="snapshot_commit requires snapshotId")
+        status, body, headers = client.op_snapshot_commit(entity, payload.snapshotId, payload.force)
+        layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status in (200, 202) else "RUN_FAILED")
+        return record(status, body, headers, layers)
+
+    if op == "digest":
+        biz_date = payload.bizDate or time.strftime("%Y-%m-%d")
+        status, body, headers = client.op_digest(entity, biz_date)
+        layers = _layers("SUPPORTED", config_state, "ACCEPTED" if status == 200 else "RUN_FAILED")
+        return record(status, body, headers, layers)
+
+    raise HTTPException(status_code=400, detail=f"unknown operation: {op}")
+
+
+# ---------------------------------------------------------------------------
+# 场景执行(P1-F)
+# ---------------------------------------------------------------------------
+
+@app.post("/api/scenarios/run")
+def scenario_run(payload: ScenarioPayload):
+    scenario = next((s for s in sa.load_scenarios()["scenarios"]
+                     if s["scenarioId"] == payload.scenarioId), None)
+    if not scenario:
+        raise HTTPException(status_code=404, detail=f"unknown scenario: {payload.scenarioId}")
+    run_id = payload.runId or sa.new_run_id()
+    overrides = payload.channelOverride or {}
+    nodes_out = []
+    for node in scenario["nodes"]:
+        obj = sa.find_object(node["objectCode"])
+        channel = overrides.get(node["objectCode"], node["channel"])
+        node_result = {"objectCode": node["objectCode"], "channel": channel,
+                       "relationKey": node.get("relationKey")}
+        if not obj:
+            node_result.update({"state": "CODE_UNSUPPORTED", "message": "目录中无此对象"})
+            nodes_out.append(node_result)
+            continue
+        spec = {"DB_SYNC": obj.get("dbSync"), "API_PULL": obj.get("apiPull"),
+                "API_INBOUND": obj.get("apiInbound")}.get(channel)
+        if not spec or not spec.get("supported"):
+            reason = (spec or {}).get("reason") or node.get("inboundNote") or "该通道不支持"
+            node_result.update({"state": "CODE_UNSUPPORTED", "message": reason})
+            nodes_out.append(node_result)
+            continue
+        try:
+            if channel == "DB_SYNC":
+                rows = sa.adapt_db_rows(obj, run_id)
+                if not _env("AIDOP_SIM_MYSQL_PASSWORD"):
+                    raise RuntimeError("缺少 AIDOP_SIM_MYSQL_PASSWORD")
+                mock_db.init_schema({spec["table"]: {"rows": rows, "businessKey": spec.get("businessKey"),
+                                                     "incrementColumn": spec.get("incrementColumn")}})
+                count = mock_db.upsert_rows(spec["table"], rows)
+                node_result.update({"state": "ACCEPTED", "loaded": count, "table": spec["table"]})
+            elif channel == "API_PULL":
+                if not _mock_running():
+                    raise RuntimeError("Mock API 未启动(先 POST /api/mock-api/start)")
+                rows = sa.adapt_api_rows(obj, run_id)
+                MOCK_STATE.overrides[spec["path"]] = rows
+                node_result.update({"state": "ACCEPTED", "overridePath": spec["path"], "rowCount": len(rows),
+                                    "note": "已在 Mock API 挂载适配样例;由 Ai-DOP MdpApiPullExecutor 真实拉取"})
+            else:
+                client = _build_inbound_client()
+                entity = spec["entityCode"]
+                version = node.get("contractVersion") or spec.get("contractVersion")
+                pre = aidop_probe.precheck_inbound_entity(client, entity, CONFIG["allowedTargetHosts"], version)
+                if pre["configState"] != "READY":
+                    node_result.update({"state": pre["configState"],
+                                        "message": f"配置层 {pre['configState']},未推数(不伪造成功)"})
+                else:
+                    rows = sa.adapt_inbound_rows(obj, run_id)["rows"]
+                    status, body, _ = client.op_push(entity, sa.inbound_envelope(rows),
+                                                     contract_version=version)
+                    node_result.update({"state": "ACCEPTED" if status == 202 else "RUN_FAILED",
+                                        "httpStatus": status, "response": body})
+        except HTTPException:
+            raise
+        except aidop_probe.HostNotAllowedError as ex:
+            node_result.update({"state": "SOURCE_UNREACHABLE", "message": str(ex)})
+        except Exception as ex:
+            node_result.update({"state": "RUN_FAILED", "message": f"{type(ex).__name__}: {ex}"})
+        nodes_out.append(node_result)
+
+    unsupported = [n["objectCode"] for n in nodes_out if n["state"] == "CODE_UNSUPPORTED"]
+    layers = _layers("SUPPORTED",
+                     "READY" if all(n["state"] not in ("SOURCE_UNREACHABLE",) for n in nodes_out) else "SOURCE_UNREACHABLE",
+                     "ACCEPTED" if all(n["state"] in ("ACCEPTED", "CODE_UNSUPPORTED") for n in nodes_out) else "RUN_FAILED")
+    entry = RUNS.add(run_id, ",".join(n["objectCode"] for n in nodes_out), f"SCENARIO:{payload.scenarioId}",
+                     {"scenario": scenario["name"]}, {"nodes": nodes_out}, layers,
+                     _followup_sql({"objectCode": "SCENARIO", "dbSync": {}}, "SCENARIO", run_id),
+                     gaps=([f"以下节点该通道不支持:{', '.join(unsupported)}(链路不完整,不得宣称全链路跑通)"]
+                           if unsupported else []),
+                     scenario_id=payload.scenarioId, nodes=nodes_out)
+    return entry
+
+
+# ---------------------------------------------------------------------------
+# 运行记录
+# ---------------------------------------------------------------------------
+
+@app.get("/api/runs")
+def runs():
+    return {"runs": RUNS.recent()}
+
+
+# ---------------------------------------------------------------------------
+# 静态页面
+# ---------------------------------------------------------------------------
+
+@app.get("/")
+def index():
+    return FileResponse(SIM_ROOT / "static" / "index.html")
+
+
+app.mount("/static", StaticFiles(directory=SIM_ROOT / "static"), name="static")
+
+
+def main() -> None:
+    print(f"[simulator] control panel: http://127.0.0.1:{DEFAULT_CONTROL_PORT}/")
+    print(f"[simulator] aidop target : {CONFIG['aidopBaseUrl']} (allowed hosts: {CONFIG['allowedTargetHosts']})")
+    uvicorn.run(app, host="127.0.0.1", port=DEFAULT_CONTROL_PORT, log_level="info")
+
+
+if __name__ == "__main__":
+    main()

+ 1 - 0
tools/integration-simulator/catalog/__init__.py

@@ -0,0 +1 @@
+"""Ai-DOP 第三方对接模拟器 · 对象目录与样例适配包。"""

+ 389 - 0
tools/integration-simulator/catalog/entities.json

@@ -0,0 +1,389 @@
+{
+  "version": "1.0.0",
+  "generatedFrom": [
+    "doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md",
+    "doc/plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md",
+    "server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundFieldMapper.cs"
+  ],
+  "note": "sample 为 null 表示使用 sample_adapters.BUILTIN_SAMPLES 内置最小样例;apiInbound.reason 仅在 supported=false 时给出。",
+  "objects": [
+    {
+      "objectCode": "ITEM",
+      "businessName": "物料基础信息",
+      "domain": "MDM",
+      "sample": "doc/db/mdp/mock_api/samples/item.json",
+      "dbSync": { "supported": true, "table": "sim_item", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/item", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "MDM_ITEM", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "CUSTOMER",
+      "businessName": "客户主数据",
+      "domain": "MDM",
+      "sample": null,
+      "dbSync": { "supported": true, "table": "sim_customer", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/customer", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "MDM_CUSTOMER", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SUPPLIER",
+      "businessName": "供应商主数据",
+      "domain": "MDM",
+      "sample": "doc/db/mdp/mock_api/samples/supplier.json",
+      "dbSync": { "supported": true, "table": "sim_supplier", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/supplier", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "MDM_SUPPLIER", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "LOCATION",
+      "businessName": "库位主数据",
+      "domain": "MDM",
+      "sample": null,
+      "dbSync": { "supported": true, "table": "sim_location", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/location", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "MDM_LOCATION", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SOURCE_LIST",
+      "businessName": "货源清单",
+      "domain": "MDM",
+      "sample": "doc/db/mdp/mock_api/samples/source_list.json",
+      "dbSync": { "supported": true, "table": "sim_source_list", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/source-list", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "MDM_SOURCE_LIST", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "EMPLOYEE_HEADCOUNT",
+      "businessName": "员工在岗人数",
+      "domain": "MDM",
+      "sample": null,
+      "dbSync": { "supported": true, "table": "sim_employee_headcount", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/employee-headcount", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "MDM_EMPLOYEE_HEADCOUNT", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SALES_ORDER_HEAD",
+      "businessName": "销售订单头",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/sales_order.json",
+      "dbSync": { "supported": true, "table": "sim_sales_order_head", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sales-order", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(任务书 §12 P2-F 待 Ai-DOP 契约扩展)" }
+    },
+    {
+      "objectCode": "SALES_ORDER_ENTRY",
+      "businessName": "销售订单行",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/sales_order_entry.json",
+      "dbSync": { "supported": true, "table": "sim_sales_order_entry", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sales-order-entry", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S1_SALES_ORDER_ENTRY", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "CONTRACT_REVIEW",
+      "businessName": "合同评审",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/contract_review.json",
+      "dbSync": { "supported": true, "table": "sim_contract_review", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/contract-review", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "CONTRACT_REVIEW_FLOW",
+      "businessName": "合同评审环节",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/contract_review_flow.json",
+      "dbSync": { "supported": true, "table": "sim_contract_review_flow", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/contract-review-flow", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "REQUIREMENT_EXAMINE_RESULT",
+      "businessName": "齐套核验结果头",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/requirement_examine_result.json",
+      "dbSync": { "supported": true, "table": "sim_requirement_examine_result", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/requirement-examine-result", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S1_REQUIREMENT_EXAMINE_RESULT", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "REQUIREMENT_EXAMINE_DETAIL",
+      "businessName": "齐套核验明细",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/requirement_examine_detail.json",
+      "dbSync": { "supported": true, "table": "sim_requirement_examine_detail", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/requirement-examine-detail", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S1_REQUIREMENT_EXAMINE_DETAIL", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SUPPLY_DEMAND",
+      "businessName": "供需明细(MRP)",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/supply_demand.json",
+      "dbSync": { "supported": true, "table": "sim_supply_demand", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/supply-demand", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "LINKAGE_PLAN",
+      "businessName": "联动计划",
+      "domain": "S1",
+      "sample": "doc/db/mdp/mock_api/samples/linkage_plan.json",
+      "dbSync": { "supported": true, "table": "sim_linkage_plan", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/linkage-plan", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "WORK_ORDER",
+      "businessName": "生产工单",
+      "domain": "S2",
+      "sample": "doc/db/mdp/mock_api/samples/s3_work_order.json",
+      "dbSync": { "supported": true, "table": "sim_work_order", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/s3-work-order", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S2_WORK_ORDER_SCHEDULE", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "WORK_ORDER_ROUTING",
+      "businessName": "工单工艺路线",
+      "domain": "S2",
+      "sample": "doc/db/mdp/mock_api/samples/s3_work_order_routing.json",
+      "dbSync": { "supported": true, "table": "sim_work_order_routing", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/s3-work-order-routing", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "PERIOD_SEQUENCE",
+      "businessName": "工序级日计划(排产结果)",
+      "domain": "S2",
+      "sample": "doc/db/mdp/mock_api/samples/period_sequence.json",
+      "dbSync": { "supported": true, "table": "sim_period_sequence", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/period-sequence", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "SCHEDULE",
+      "businessName": "排程明细",
+      "domain": "S2",
+      "sample": "doc/db/mdp/mock_api/samples/schedule.json",
+      "dbSync": { "supported": true, "table": "sim_schedule", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/schedule", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "WORK_ORDER_BOM",
+      "businessName": "工单用料",
+      "domain": "S5",
+      "sample": "doc/db/mdp/mock_api/samples/work_order_bom.json",
+      "dbSync": { "supported": true, "table": "sim_work_order_bom", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/work-order-bom", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S5_WORK_ORDER_BOM", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "WORK_ORDER_LINE",
+      "businessName": "工单产线明细",
+      "domain": "S6",
+      "sample": "doc/db/mdp/mock_api/samples/work_order_detail.json",
+      "dbSync": { "supported": true, "table": "sim_work_order_line", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/work-order-detail", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S6_WORK_ORDER_LINE", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "REPORT_TXN",
+      "businessName": "报工流水",
+      "domain": "S6",
+      "sample": "doc/db/mdp/mock_api/samples/mes_report.json",
+      "dbSync": { "supported": true, "table": "sim_report_txn", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/mes/report", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S6_REPORT_TXN", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "PURCHASE_ORDER",
+      "businessName": "采购订单",
+      "domain": "S3",
+      "sample": "doc/db/mdp/mock_api/samples/purchase_order.json",
+      "dbSync": { "supported": true, "table": "sim_purchase_order", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/purchase-order", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S3_PURCHASE_ORDER", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "PURCHASE_RECEIPT",
+      "businessName": "采购收货",
+      "domain": "S3",
+      "sample": "doc/db/mdp/mock_api/samples/receipt.json",
+      "dbSync": { "supported": true, "table": "sim_purchase_receipt", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/receipt", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S3_PURCHASE_RECEIPT", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SUPPLIER_SHIPMENT",
+      "businessName": "供应商发货",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/s4_shipment.json",
+      "dbSync": { "supported": true, "table": "sim_supplier_shipment", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/s4-shipment", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S4_SHIPMENT", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "IQC",
+      "businessName": "来料检验",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/s4_iqc.json",
+      "dbSync": { "supported": true, "table": "sim_iqc", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/s4-iqc", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S4_IQC", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "PURCHASE_RETURN",
+      "businessName": "采购退货",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/s4_return.json",
+      "dbSync": { "supported": true, "table": "sim_purchase_return", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/s4-return", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S4_RETURN", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SHORTAGE",
+      "businessName": "供应商欠料",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/s4_shortage.json",
+      "dbSync": { "supported": true, "table": "sim_shortage", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/s4-shortage", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S4_SHORTAGE", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "PURCHASE_REQUEST",
+      "businessName": "采购申请",
+      "domain": "S3",
+      "sample": "doc/db/mdp/mock_api/samples/pr.json",
+      "dbSync": { "supported": true, "table": "sim_purchase_request", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/pr", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "DELIVERY_PLAN",
+      "businessName": "交货计划",
+      "domain": "S3",
+      "sample": "doc/db/mdp/mock_api/samples/delivery.json",
+      "dbSync": { "supported": true, "table": "sim_delivery_plan", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/delivery", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "SHIPPING_PLAN",
+      "businessName": "发货计划",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/shipping_plan.json",
+      "dbSync": { "supported": true, "table": "sim_shipping_plan", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/shipping-plan", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "SHIPPER_MASTER",
+      "businessName": "发货单头",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/shipper_master.json",
+      "dbSync": { "supported": true, "table": "sim_shipper_master", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/shipper-master", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "SHIPMENT",
+      "businessName": "销售发货明细",
+      "domain": "S4",
+      "sample": "doc/db/mdp/mock_api/samples/shipment.json",
+      "dbSync": { "supported": true, "table": "sim_shipment", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/shipment", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "INVENTORY_TXN",
+      "businessName": "出入库流水",
+      "domain": "S5",
+      "sample": "doc/db/mdp/mock_api/samples/inventory_txn.json",
+      "dbSync": { "supported": true, "table": "sim_inventory_txn", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/inventory-txn", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S5_INVENTORY_TXN", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "INVENTORY_OPENING_BALANCE",
+      "businessName": "库存期初",
+      "domain": "S5",
+      "sample": null,
+      "dbSync": { "supported": true, "table": "sim_inventory_opening_balance", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/inventory-opening-balance", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S5_INVENTORY_OPENING_BALANCE", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "INVENTORY_BALANCE_MONTHLY",
+      "businessName": "月度库存金额",
+      "domain": "S5",
+      "sample": "doc/db/mdp/mock_api/samples/inventory_balance_monthly.json",
+      "dbSync": { "supported": true, "table": "sim_inventory_balance_monthly", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/inventory-balance-monthly", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S5_INVENTORY_BALANCE_MONTHLY", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "ONHAND_INVENTORY",
+      "businessName": "现存量快照",
+      "domain": "S5",
+      "sample": "doc/db/mdp/mock_api/samples/inventory.json",
+      "dbSync": { "supported": true, "table": "sim_onhand_inventory", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/inventory", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "FQC_TASK",
+      "businessName": "成品报检",
+      "domain": "S7",
+      "sample": "doc/db/mdp/mock_api/samples/fqc_task.json",
+      "dbSync": { "supported": true, "table": "sim_fqc_task", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/fqc-task", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S7_FQC_TASK_TXN", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "FINISHED_RECEIPT",
+      "businessName": "成品入库",
+      "domain": "S7",
+      "sample": "doc/db/mdp/mock_api/samples/production_receipt_detail.json",
+      "dbSync": { "supported": true, "table": "sim_finished_receipt", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/production-receipt-detail", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    },
+    {
+      "objectCode": "FINISHED_ONHAND",
+      "businessName": "成品结存",
+      "domain": "S7",
+      "sample": null,
+      "dbSync": { "supported": true, "table": "sim_finished_onhand", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/finished-onhand", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S7_FINISHED_ONHAND", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "FINISHED_OPENING_BALANCE",
+      "businessName": "成品期初",
+      "domain": "S7",
+      "sample": null,
+      "dbSync": { "supported": true, "table": "sim_finished_opening_balance", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/finished-opening-balance", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S7_FINISHED_OPENING_BALANCE", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "SALES_ORDER_LINE_S7",
+      "businessName": "销售订单行(S7 成品口径)",
+      "domain": "S7",
+      "sample": "doc/db/mdp/mock_api/samples/sales_order_entry.json",
+      "dbSync": { "supported": true, "table": "sim_sales_order_line_s7", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/sim/sales-order-line-s7", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": true, "entityCode": "S7_SALES_ORDER_LINE", "contractVersion": "v2" }
+    },
+    {
+      "objectCode": "IPQC",
+      "businessName": "过程检验",
+      "domain": "S6",
+      "sample": "doc/db/mdp/mock_api/samples/ipqc.json",
+      "dbSync": { "supported": true, "table": "sim_ipqc", "businessKey": ["bizKey"], "incrementColumn": "sourceUpdatedAt" },
+      "apiPull": { "supported": true, "path": "/api/ipqc", "responsePath": "data.list", "dedupKeyPath": "bizKey" },
+      "apiInbound": { "supported": false, "reason": "当前无 API_INBOUND 代码契约(P2-F)" }
+    }
+  ]
+}

+ 324 - 0
tools/integration-simulator/catalog/sample_adapters.py

@@ -0,0 +1,324 @@
+"""三通道共用的对象目录、canonical 样例装载与适配器(任务书 P1-A / P1-F)。
+
+- 样例来源:doc/db/mdp/mock_api/samples/*.json(复用不迁移);无样例对象用 BUILTIN_SAMPLES。
+- 所有业务键统一附加运行编号前缀 SIM-{runId}-,不与真实业务键碰撞。
+- adapt_inbound_rows 按 MdpInboundFieldMapper.IdentityFields 的必填/可选字段做别名映射,
+  缺值字段进 missing_required,由调用方在预览中显示,不得静默造假业务数据。
+"""
+from __future__ import annotations
+
+import json
+import re
+import uuid
+from datetime import datetime, timezone, timedelta
+from pathlib import Path
+
+SIM_ROOT = Path(__file__).resolve().parents[1]
+REPO_ROOT = SIM_ROOT.parents[1]
+MOCK_API_DIR = REPO_ROOT / "doc" / "db" / "mdp" / "mock_api"
+CATALOG_DIR = SIM_ROOT / "catalog"
+
+RUN_ID_RE = re.compile(r"^\d{14}-[0-9a-f]{6}$")
+BIZ_KEY_PREFIX = "SIM-"
+
+# 通用字段别名(小写比较):把既有样例字段归一为 sourceUpdatedAt
+_UPDATED_AT_ALIASES = ("updateTime", "sourceUpdatedAt", "inspectStartTime", "WorkDate", "RctDate", "Date", "sxrq", "effDate")
+
+
+def new_run_id(now: datetime | None = None) -> str:
+    """运行编号 SIM-{yyyyMMddHHmmss}-{shortId} 中的时间戳与短 id 部分。"""
+    now = now or datetime.now(timezone(timedelta(hours=8)))
+    return f"{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
+
+
+def biz_prefix(run_id: str) -> str:
+    return f"{BIZ_KEY_PREFIX}{run_id}-"
+
+
+def now_iso() -> str:
+    return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%dT%H:%M:%S+08:00")
+
+
+# ---------------------------------------------------------------------------
+# 对象目录
+# ---------------------------------------------------------------------------
+
+_CATALOG_CACHE: dict | None = None
+
+
+def load_catalog() -> dict:
+    global _CATALOG_CACHE
+    if _CATALOG_CACHE is None:
+        with (CATALOG_DIR / "entities.json").open("r", encoding="utf-8") as f:
+            _CATALOG_CACHE = json.load(f)
+    return _CATALOG_CACHE
+
+
+def load_scenarios() -> dict:
+    with (CATALOG_DIR / "scenarios.json").open("r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def find_object(object_code: str) -> dict | None:
+    code = (object_code or "").strip().upper()
+    for obj in load_catalog()["objects"]:
+        if obj["objectCode"] == code:
+            return obj
+    return None
+
+
+# ---------------------------------------------------------------------------
+# canonical 样例
+# ---------------------------------------------------------------------------
+
+BUILTIN_SAMPLES: dict[str, list[dict]] = {
+    "CUSTOMER": [
+        {"bizKey": "CUST-001", "Cust": "CUST-001", "Name": "模拟客户001", "Domain": "SIM", "Status": "1"}
+    ],
+    "LOCATION": [
+        {"bizKey": "LOC-A01", "Location": "SIM-A-01", "Descr": "模拟库位A01", "Domain": "SIM", "Typed": "WH"}
+    ],
+    "EMPLOYEE_HEADCOUNT": [
+        {"bizKey": "EMP-0001", "Domain": "SIM", "Employee": "EMP-0001", "Name": "模拟员工001",
+         "Department": "生产部", "Position": "操作工", "EmploymentStatus": "在岗"}
+    ],
+    "INVENTORY_OPENING_BALANCE": [
+        {"bizKey": "OPEN-INV-001", "idid": "SIM-OPEN-INV-001", "code": "RM-CT-001", "sl": 100,
+         "slzx": 100, "shyn": "mock", "shtime": "2026-09-01T00:00:00", "date0": "2026-09-01"}
+    ],
+    "FINISHED_ONHAND": [
+        {"bizKey": "FG-ONHAND-001", "code": "FG-CT-001", "sl": 50, "date0": "2026-09-30", "location": "FG-01"}
+    ],
+    "FINISHED_OPENING_BALANCE": [
+        {"bizKey": "FG-OPEN-001", "code": "FG-CT-001", "sl": 20, "date0": "2026-09-01", "location": "FG-01"}
+    ],
+}
+
+
+def load_canonical_rows(obj: dict) -> list[dict]:
+    """读取对象 canonical 样例;sample 为 null 时使用内置最小样例。"""
+    sample = obj.get("sample")
+    if not sample:
+        rows = BUILTIN_SAMPLES.get(obj["objectCode"])
+        if rows is None:
+            raise FileNotFoundError(f"object {obj['objectCode']} has no sample and no builtin rows")
+        return [dict(r) for r in rows]
+    path = REPO_ROOT / sample
+    if not path.exists():
+        raise FileNotFoundError(f"sample not found: {sample}")
+    with path.open("r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, list):
+        raise ValueError(f"sample must be a JSON array: {sample}")
+    return [dict(r) for r in data]
+
+
+def _extract_updated_at(row: dict) -> str:
+    for alias in _UPDATED_AT_ALIASES:
+        for key in row:
+            if key.lower() == alias.lower() and row[key]:
+                return str(row[key])
+    return now_iso()
+
+
+def _prefix_biz_key(row: dict, run_id: str) -> str:
+    """给业务键加 SIM-{runId}- 前缀,返回新 bizKey。兼容 bizKey / Id / id 三种载体。"""
+    prefix = biz_prefix(run_id)
+    for key in ("bizKey", "Id", "id"):
+        if key in row and row[key] is not None:
+            raw = str(row[key])
+            row[key] = raw if raw.startswith(prefix) else prefix + raw
+            return str(row[key])
+    row["bizKey"] = prefix + uuid.uuid4().hex[:8]
+    return row["bizKey"]
+
+
+# ---------------------------------------------------------------------------
+# 通道适配器
+# ---------------------------------------------------------------------------
+
+def adapt_db_rows(obj: dict, run_id: str, rows: list[dict] | None = None) -> list[dict]:
+    """DB_SYNC:源表行 = canonical 行 + SIM 前缀业务键 + sourceUpdatedAt 增量列。"""
+    rows = [dict(r) for r in (rows if rows is not None else load_canonical_rows(obj))]
+    out = []
+    for row in rows:
+        _prefix_biz_key(row, run_id)
+        row.setdefault("Domain", "SIM")
+        row["sourceUpdatedAt"] = _extract_updated_at(row)
+        out.append(row)
+    return out
+
+
+def adapt_api_rows(obj: dict, run_id: str, rows: list[dict] | None = None) -> list[dict]:
+    """API_PULL:与 DB_SYNC 同语义,另保证 bizKey 存在且按 bizKey 排序(cursor 语义)。"""
+    rows = adapt_db_rows(obj, run_id, rows)
+    for row in rows:
+        row["bizKey"] = str(row.get("bizKey") or row.get("Id") or row.get("id"))
+    rows.sort(key=lambda r: str(r["bizKey"]))
+    return rows
+
+
+# entityCode -> 契约字段(与 MdpInboundFieldMapper.IdentityFields 一致);("字段", 必填, 别名列表)
+# 版本语义:C# 端只有显式 v2 才使用 IdentityFields(中立必填集合);v1 特化仅存在于
+# S5_INVENTORY_TXN 与 MDM_EMPLOYEE_HEADCOUNT。第一阶段目录统一登记 contractVersion=v2,
+# 因此本表按 IdentityFields 生成样例,避免“按 v1 造数、按 v2 校验”的口径错配。
+INBOUND_SPECS: dict[str, list[tuple[str, bool, list[str]]]] = {
+    "MDM_ITEM": [("ItemNum", True, ["itemCode", "item_number", "number", "ItemNum", "wlbm"]),
+                 ("Descr", True, ["itemName", "descr", "wlmc"]),
+                 ("Drawing", False, []), ("UM", False, ["uom"]), ("ItemType", False, []),
+                 ("Status", False, ["status"]), ("Domain", False, ["Domain"])],
+    "MDM_CUSTOMER": [("Cust", True, ["customerNo", "Cust"]), ("Name", True, ["customerName", "Name"]),
+                     ("Domain", False, ["Domain"]), ("Status", False, ["status"])],
+    "MDM_SUPPLIER": [("Supp", True, ["supplierCode", "Supp", "supplier_number"]),
+                     ("Name", True, ["supplierName", "Name"]),
+                     ("Domain", False, ["Domain"]), ("Status", False, ["status"])],
+    "MDM_LOCATION": [("Location", True, ["Location", "location"]), ("Descr", True, ["Descr", "descr", "Address"]),
+                     ("Domain", False, ["Domain"]), ("Typed", False, [])],
+    "MDM_SOURCE_LIST": [("Supp", True, ["supplier_number", "Supp", "supplierCode"]),
+                        ("ItemNum", True, ["number", "itemCode", "ItemNum"]),
+                        ("Domain", False, ["Domain"]), ("Status", False, ["status"])],
+    "MDM_EMPLOYEE_HEADCOUNT": [("Domain", True, ["Domain"]), ("Employee", True, ["Employee"]),
+                               ("Position", True, ["Position"]), ("EmploymentStatus", True, ["EmploymentStatus"]),
+                               ("Name", False, ["Name"]), ("Department", False, ["Department"])],
+    "S1_SALES_ORDER_ENTRY": [("bill_no", True, ["bill_no", "orderNo"]), ("entry_seq", True, ["entry_seq", "Line"]),
+                             ("seorder_id", True, ["seorder_id", "id", "Id"]), ("qty", True, ["qty", "Qty"]),
+                             ("item_number", False, ["item_code", "itemCode", "item_number", "ItemNum"]),
+                             ("item_name", False, ["itemName"]), ("plan_date", False, ["plan_date"]),
+                             ("date", False, ["date"]), ("progress", False, []), ("urgent", False, [])],
+    "S1_REQUIREMENT_EXAMINE_RESULT": [("Id", True, ["Id", "id"]), ("bill_no", True, ["bill_no"]),
+                                      ("morder_no", True, ["morder_no"]), ("sentry_id", False, ["sentry_id"]),
+                                      ("create_time", False, ["create_time"]), ("IsDeleted", False, [])],
+    "S1_REQUIREMENT_EXAMINE_DETAIL": [("Id", True, ["Id", "id"]), ("examine_id", True, ["examine_id"]),
+                                      ("item_number", True, ["item_number"]), ("lack_qty", True, ["lack_qty", "qty"]),
+                                      ("level", True, ["level"]), ("num", False, ["num"]),
+                                      ("item_name", False, ["item_name"]), ("needCount", False, []),
+                                      ("qty", False, ["qty"]), ("is_use", False, [])],
+    "S2_WORK_ORDER_SCHEDULE": [("WorkOrder", True, ["WorkOrd", "work_order", "workOrderNo", "OrderNo"]),
+                               ("ItemCode", True, ["ItemNum", "itemCode", "item_number", "componentItem"]),
+                               ("QtyOrdered", True, ["Qty", "qty", "QtyReq", "sl"]),
+                               ("DocType", False, []), ("DueDate", False, ["WorkDate", "DueDate"]),
+                               ("QtyCompleted", False, []), ("Status", False, ["Status", "status", "ztid"]),
+                               ("ProdLine", False, ["ProdLine"])],
+    "S3_PURCHASE_ORDER": [("PoNo", True, ["poNo", "PurOrd", "OrdNbr"]), ("PoLine", True, ["poLine", "OrdLine", "Line"]),
+                          ("ItemCode", True, ["itemCode", "ItemNum"]), ("OrderQty", True, ["qty", "Qty", "QtyOrded"]),
+                          ("SupplierCode", False, ["supplierCode", "Supp"]), ("DueDate", False, []),
+                          ("OrderDate", False, []), ("Status", False, ["status", "Status"])],
+    "S3_PURCHASE_RECEIPT": [("Domain", True, ["Domain"]), ("Receiver", True, ["Receiver", "RctNbr"]),
+                            ("Line", True, ["Line", "OrdLine"]), ("ItemCode", True, ["ItemNum", "itemCode"]),
+                            ("QtyReceived", True, ["QtyReceived", "qty"]), ("PoNo", False, ["OrdNbr", "poNo"]),
+                            ("SupplierCode", False, ["Supp", "supplierCode"]), ("ReceiptDate", False, ["RctDate"])],
+    "S4_SHIPMENT": [("ShipmentNo", True, ["shipmentNo", "shddh"]), ("PoNo", True, ["poNo"]),
+                    ("ItemCode", True, ["itemCode"]), ("ShipQty", True, ["qty", "Qty"]),
+                    ("PoLine", False, ["poLine"]), ("SupplierCode", False, ["supplierCode"]),
+                    ("ShipDate", False, ["ShipDate"])],
+    "S4_IQC": [("PoNo", True, ["poNo"]), ("PoLine", True, ["poLine"]), ("ItemCode", True, ["itemCode"]),
+               ("ReceiptQty", True, ["qty", "ReceiptQty"]), ("SupplierCode", False, ["supplierCode"]),
+               ("DefectQty", False, []), ("QcResult", False, ["result", "QcResult"]),
+               ("ReceiptDate", False, ["ReceiptDate"])],
+    "S4_RETURN": [("PoNo", True, ["poNo", "lydjbh"]), ("PoLine", True, ["poLine", "bbh"]),
+                  ("ItemCode", True, ["item_code", "wlbm"]), ("ReturnQty", True, ["qty", "ybl"]),
+                  ("SupplierCode", False, ["supplierCode"]), ("ReturnReason", False, ["bz"]),
+                  ("ReturnStatus", False, ["status"])],
+    "S4_SHORTAGE": [("WorkOrder", True, ["work_order", "WorkOrd"]),
+                    ("ItemCode", True, ["component_item_code", "ItemNum"]),
+                    ("ShortageQty", True, ["shortage_qty", "lack_qty"]), ("SupplierCode", False, []),
+                    ("RiskLevel", False, []), ("NeedDate", False, [])],
+    "S5_WORK_ORDER_BOM": [("Domain", True, ["Domain"]), ("OrderNo", True, ["orderNo", "WorkOrd"]),
+                          ("ItemCode", True, ["componentItem", "ItemNum", "itemCode"]),
+                          ("QtyRequired", True, ["qtyPer", "QtyReq", "qty"]),
+                          ("LineNo", False, ["Line"]), ("Unit", False, ["UM", "uom"])],
+    "S5_INVENTORY_TXN": [("Id", True, ["Id", "id", "bizKey"]), ("BizDocType", True, ["bizDocType", "transType"]),
+                         ("ItemCode", False, ["itemNum", "code"]), ("Qty", False, ["qtyChange", "sl"]),
+                         ("ApprovedFlag", False, ["approvedFlag"]), ("VoidFlag", False, ["voidFlag"]),
+                         ("SummaryFlag", False, ["summaryFlag"]), ("DocDate", False, ["effDate", "jhdate"]),
+                         ("TransTime", False, ["addtime"]), ("Domain", False, ["Domain"]),
+                         ("TransType", False, ["transType"]), ("Location", False, ["location"]),
+                         ("LotSerial", False, ["lotSerial"]), ("CreateUser", False, ["createUser"]),
+                         ("EndBalance", False, ["endBalance"]), ("BeginBalance", False, []),
+                         ("DocQty", False, []), ("LineClosedFlag", False, [])],
+    "S5_INVENTORY_OPENING_BALANCE": [("Id", True, ["Id", "idid", "bizKey"]), ("idid", False, ["idid"]),
+                                     ("code", False, ["code", "ItemNum"]), ("sl", False, ["sl"]),
+                                     ("slzx", False, ["slzx"]), ("shyn", False, ["shyn"]),
+                                     ("shtime", False, ["shtime"]), ("date0", False, ["date0"])],
+    "S5_INVENTORY_BALANCE_MONTHLY": [("PeriodYm", True, ["periodYm"]),
+                                     ("AvgBalanceAmount", True, ["avgBalanceAmount"]),
+                                     ("CategoryCode", False, []), ("WarehouseCode", False, ["warehouseCode"]),
+                                     ("ItemCode", False, ["itemCode"]), ("IssueCostAmount", False, ["issueCostAmount"])],
+    "S6_WORK_ORDER_LINE": [("Domain", True, ["Domain"]), ("OrderNo", True, ["WorkOrd", "orderNo"]),
+                           ("ItemCode", True, ["ItemNum", "itemCode"]), ("QtyPlanned", True, ["QtyReq", "qty", "Qty"]),
+                           ("QtyCompleted", False, []), ("PlanFinishDate", False, ["WorkDate"]),
+                           ("ReleaseTime", False, []), ("ClosedFlag", False, []), ("VoidFlag", False, [])],
+    "S6_REPORT_TXN": [("Domain", True, ["Domain"]), ("ReportId", True, ["Id", "noid", "ReportId"]),
+                      ("WorkOrderNo", True, ["noid", "WorkOrd", "workOrderNo"]),
+                      ("ReportDate", True, ["kgdate", "ReportDate"]), ("ReportQty", True, ["sl", "qty", "ReportQty"]),
+                      ("StartWorkDate", False, ["kgdate"])],
+    "S7_FQC_TASK_TXN": [("BillNo", True, ["billNo", "BillNo"]),
+                        ("ProductionOrderNo", True, ["productionOrderNo", "scph"]),
+                        ("MaterialCode", True, ["materialCode", "wlbm"]), ("Qty", True, ["qty"]),
+                        ("SalesOrderNo", False, ["salesOrderNo"]), ("Domain", False, ["Domain"])],
+    "S7_SALES_ORDER_LINE": [("Domain", True, ["Domain"]), ("OrderNo", True, ["bill_no", "orderNo", "OrdNbr"]),
+                            ("LineNo", True, ["entry_seq", "Line"]), ("ItemCode", True, ["item_code", "itemCode", "ItemNum"]),
+                            ("QtyPlanned", True, ["qty", "Qty"]), ("PlanFinishDate", True, ["plan_date", "WorkDate"]),
+                            ("QtyCompleted", False, []), ("ReleaseTime", False, []),
+                            ("ClosedFlag", False, []), ("VoidFlag", False, [])],
+    "S7_FINISHED_ONHAND": [("Id", True, ["Id", "bizKey"]), ("code", False, ["code", "ItemNum"]),
+                           ("sl", False, ["sl", "QtyRec"]), ("date0", False, ["date0", "Date"]),
+                           ("location", False, ["location", "LocationTo"])],
+    "S7_FINISHED_OPENING_BALANCE": [("Id", True, ["Id", "bizKey"]), ("code", False, ["code"]),
+                                    ("sl", False, ["sl"]), ("date0", False, ["date0"]),
+                                    ("location", False, ["location"])],
+}
+
+
+def _pick(row_lower: dict[str, object], aliases: list[str]) -> object | None:
+    for alias in aliases:
+        if alias.lower() in row_lower and row_lower[alias.lower()] not in (None, ""):
+            return row_lower[alias.lower()]
+    return None
+
+
+def adapt_inbound_rows(obj: dict, run_id: str, rows: list[dict] | None = None,
+                       domain: str = "SIM") -> dict:
+    """API_INBOUND:canonical 行 → 契约字段行。返回 rows / missing_required / biz_keys。"""
+    inbound = obj.get("apiInbound") or {}
+    entity_code = inbound.get("entityCode")
+    if not inbound.get("supported") or not entity_code:
+        raise ValueError(f"object {obj['objectCode']} does not support API_INBOUND")
+    spec = INBOUND_SPECS.get(entity_code)
+    if spec is None:
+        raise ValueError(f"no inbound spec for {entity_code}")
+
+    rows = [dict(r) for r in (rows if rows is not None else load_canonical_rows(obj))]
+    out: list[dict] = []
+    missing: set[str] = set()
+    biz_keys: list[str] = []
+    for row in rows:
+        biz_key = _prefix_biz_key(row, run_id)
+        biz_keys.append(biz_key)
+        row_lower = {str(k).lower(): v for k, v in row.items()}
+        mapped: dict[str, object] = {}
+        for field, required, aliases in spec:
+            value = _pick(row_lower, [field, *aliases])
+            if value is None and field == "Domain":
+                value = domain
+            if value is None and required:
+                missing.add(field)
+                continue
+            if value is not None:
+                mapped[field] = value
+        # Id 类必填键允许退化为 SIM 业务键
+        if "Id" in {f for f, r, _ in spec if r} and "Id" not in mapped:
+            mapped["Id"] = biz_key
+        mapped["sourceUpdatedAt"] = _extract_updated_at(row)
+        out.append(mapped)
+    return {"rows": out, "missing_required": sorted(missing), "biz_keys": biz_keys}
+
+
+def inbound_envelope(rows: list[dict], snapshot_id: str | None = None, seq: int | None = None) -> dict:
+    """推数信封,与 push_demo.py / MdpInboundReceiveService 契约一致。"""
+    data: dict = {"list": rows}
+    if snapshot_id:
+        data["snapshotId"] = snapshot_id
+    if seq is not None:
+        data["seq"] = seq
+    return {"data": data}

+ 44 - 0
tools/integration-simulator/catalog/scenarios.json

@@ -0,0 +1,44 @@
+{
+  "version": "1.0.0",
+  "note": "三组第一阶段跨对象基础场景(任务书 §9)。node.channel 为该节点在场景中的默认通道;执行时若对象不支持该通道,运行图必须显示“该通道不支持”,不得跳过后宣称链路完整。",
+  "scenarios": [
+    {
+      "scenarioId": "ORDER_TO_MANUFACTURE",
+      "name": "订单制造链",
+      "description": "物料 → 销售订单头/行 → 生产工单 → 工单用料 → 报工",
+      "nodes": [
+        { "objectCode": "ITEM", "channel": "DB_SYNC", "relationKey": "itemCode" },
+        { "objectCode": "SALES_ORDER_HEAD", "channel": "DB_SYNC", "relationKey": "orderNo", "inboundNote": "API_INBOUND 无契约,该通道不支持" },
+        { "objectCode": "SALES_ORDER_ENTRY", "channel": "API_INBOUND", "relationKey": "orderNo+entrySeq" },
+        { "objectCode": "WORK_ORDER", "channel": "API_INBOUND", "relationKey": "workOrderNo" },
+        { "objectCode": "WORK_ORDER_BOM", "channel": "API_INBOUND", "relationKey": "workOrderNo+itemCode" },
+        { "objectCode": "REPORT_TXN", "channel": "API_INBOUND", "relationKey": "workOrderNo+reportDate" }
+      ]
+    },
+    {
+      "scenarioId": "PURCHASE_EXECUTION",
+      "name": "采购执行链",
+      "description": "供应商 → 采购订单 → 供应商发货 → 收货/IQC → 退货",
+      "nodes": [
+        { "objectCode": "SUPPLIER", "channel": "DB_SYNC", "relationKey": "supplierCode" },
+        { "objectCode": "PURCHASE_ORDER", "channel": "API_INBOUND", "relationKey": "poNo+poLine+itemCode" },
+        { "objectCode": "SUPPLIER_SHIPMENT", "channel": "API_INBOUND", "relationKey": "shipmentNo+poNo" },
+        { "objectCode": "PURCHASE_RECEIPT", "channel": "API_INBOUND", "relationKey": "receiver+line+itemCode" },
+        { "objectCode": "IQC", "channel": "API_INBOUND", "relationKey": "poNo+poLine+itemCode" },
+        { "objectCode": "PURCHASE_RETURN", "channel": "API_INBOUND", "relationKey": "poNo+poLine+itemCode" }
+      ]
+    },
+    {
+      "scenarioId": "INVENTORY_FINISHED",
+      "name": "库存与成品链",
+      "description": "库存期初 → v2 出入库流水 → 成品报检 → 成品入库/结存",
+      "nodes": [
+        { "objectCode": "INVENTORY_OPENING_BALANCE", "channel": "API_INBOUND", "relationKey": "id+itemCode" },
+        { "objectCode": "INVENTORY_TXN", "channel": "API_INBOUND", "relationKey": "txnId+itemCode+bizDocType", "contractVersion": "v2" },
+        { "objectCode": "FQC_TASK", "channel": "API_INBOUND", "relationKey": "productionOrderNo+materialCode" },
+        { "objectCode": "FINISHED_RECEIPT", "channel": "DB_SYNC", "relationKey": "nbr+line", "inboundNote": "API_INBOUND 无契约,该通道不支持" },
+        { "objectCode": "FINISHED_ONHAND", "channel": "API_INBOUND", "relationKey": "id+code" }
+      ]
+    }
+  ]
+}

+ 1 - 0
tools/integration-simulator/clients/__init__.py

@@ -0,0 +1 @@
+"""Ai-DOP 第三方对接模拟器 · 客户端(签名推数、只读预检、脱敏)包。"""

+ 111 - 0
tools/integration-simulator/clients/aidop_probe.py

@@ -0,0 +1,111 @@
+"""Ai-DOP 只读预检(任务书 P1-G)。
+
+只做只读探测,不写任何 Ai-DOP 表:
+- 可达性:GET {base}/api/mdp/inbound/__probe_not_exists(404/401 均视为服务在线);
+- 实体预检:用真实签名调 GET schema,按 HTTP 状态与消息分层判定
+  READY / CONFIG_NOT_REGISTERED / CONFIG_NOT_ENABLED / GRANT_MISSING / SOURCE_UNREACHABLE;
+- 管理 API 预检(可选):仅当 AIDOP_SIM_ADMIN_TOKEN 提供时查询来源/实体配置,全部只读。
+
+目标 host 必须命中白名单,否则硬阻断(默认拒绝非白名单目标)。
+"""
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.request
+from urllib.parse import urlparse
+
+from clients.inbound_client import InboundClient
+
+GRANT_HINTS = ("grant", "授权", "accesskey", "access key", "开放身份", "identity")
+
+
+class HostNotAllowedError(RuntimeError):
+    pass
+
+
+def assert_host_allowed(base_url: str, allowed_hosts: list[str]) -> None:
+    host = (urlparse(base_url).hostname or "").lower()
+    allowed = {h.lower() for h in allowed_hosts}
+    if host not in allowed:
+        raise HostNotAllowedError(
+            f"target host '{host}' not in allowedTargetHosts {sorted(allowed)};生产目标默认阻断")
+
+
+def check_reachable(base_url: str, allowed_hosts: list[str], timeout: int = 5) -> dict:
+    assert_host_allowed(base_url, allowed_hosts)
+    url = base_url.rstrip("/") + "/api/mdp/inbound/__probe_not_exists"
+    req = urllib.request.Request(url, method="GET")
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return {"reachable": True, "httpStatus": resp.status}
+    except urllib.error.HTTPError as ex:
+        # 401/404 说明服务在线(缺签名头或路径不存在)
+        return {"reachable": True, "httpStatus": ex.code}
+    except Exception as ex:  # URLError / timeout
+        return {"reachable": False, "error": f"{type(ex).__name__}: {ex}"}
+
+
+def classify_inbound_precheck(status: int, body) -> str:
+    """schema 探测结果 → 配置层状态码。"""
+    if status == 0:
+        return "SOURCE_UNREACHABLE"
+    if status == 200:
+        return "READY"
+    message = ""
+    if isinstance(body, dict):
+        message = str(body.get("message") or "")
+    if status == 404:
+        return "CONFIG_NOT_REGISTERED"
+    if status in (401, 403):
+        low = message.lower()
+        if any(h in low for h in GRANT_HINTS):
+            return "GRANT_MISSING"
+        return "CONFIG_NOT_ENABLED"
+    return "RUN_FAILED"
+
+
+def precheck_inbound_entity(client: InboundClient, entity_code: str,
+                            allowed_hosts: list[str], contract_version: str | None = None) -> dict:
+    assert_host_allowed(client.base, allowed_hosts)
+    status, body, headers = client.op_schema(entity_code, contract_version=contract_version)
+    state = classify_inbound_precheck(status, body)
+    result = {
+        "entityCode": entity_code,
+        "configState": state,
+        "httpStatus": status,
+        "requestHeaders": headers,
+    }
+    if state == "READY" and isinstance(body, dict):
+        data = body.get("data") or {}
+        fields = data.get("fields") or []
+        result["requiredFields"] = [f.get("name") for f in fields if f.get("required")]
+        result["bizKeyExpr"] = data.get("bizKeyExpr")
+    elif isinstance(body, dict):
+        result["message"] = body.get("message")
+    return result
+
+
+def precheck_all_inbound(client: InboundClient, entity_codes: list[str],
+                         allowed_hosts: list[str]) -> list[dict]:
+    return [precheck_inbound_entity(client, code, allowed_hosts) for code in entity_codes]
+
+
+def query_admin_api(base_url: str, admin_token: str, path: str,
+                    allowed_hosts: list[str], timeout: int = 10):
+    """可选只读管理 API 查询;未提供 admin token 时由调用方跳过。"""
+    assert_host_allowed(base_url, allowed_hosts)
+    req = urllib.request.Request(
+        base_url.rstrip("/") + path,
+        headers={"Authorization": f"Bearer {admin_token}"},
+        method="GET")
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            return resp.status, json.loads(resp.read().decode("utf-8") or "{}")
+    except urllib.error.HTTPError as ex:
+        try:
+            return ex.code, json.loads(ex.read().decode("utf-8") or "{}")
+        except json.JSONDecodeError:
+            return ex.code, {}
+    except Exception as ex:
+        return 0, {"message": f"{type(ex).__name__}: {ex}"}

+ 186 - 0
tools/integration-simulator/clients/inbound_client.py

@@ -0,0 +1,186 @@
+"""API_INBOUND 签名客户端:与 MdpInboundController / InboundSignatureHandler 契约一致。
+
+签名串(方案 §5.2):
+    message = METHOD&path&accessKey&timestamp&nonce&bodySha256&idempotencyKey
+    signature = Base64(HMAC-SHA256(secret, message))
+
+Secret 只驻留本进程内存,不出现在返回值、日志与页面(脱敏由 redaction 模块负责)。
+七类操作:schema / push / receipt / bulk / snapshot open / snapshot commit / digest。
+测试用例语义与 tools/mock/inbound/push_demo.py 对齐(复用协议、不迁移文件)。
+"""
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+from base64 import b64encode
+
+CONTRACT_VERSION_HEADER = "X-Mdp-Contract-Version"
+
+
+def sha256_hex(data: bytes) -> str:
+    return hashlib.sha256(data).hexdigest()
+
+
+def sign(secret: str, message: str) -> str:
+    digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
+    return b64encode(digest).decode("ascii")
+
+
+def build_message(method: str, path: str, access_key: str, timestamp: str,
+                  nonce: str, body_sha256: str, idempotency_key: str) -> str:
+    return f"{method.upper()}&{path}&{access_key}&{timestamp}&{nonce}&{body_sha256}&{idempotency_key}"
+
+
+def _parse(text: str):
+    try:
+        return json.loads(text) if text else {}
+    except json.JSONDecodeError:
+        return text
+
+
+class InboundClient:
+    def __init__(self, base_url: str, access_key: str, secret: str, timeout: int = 30):
+        self.base = base_url.rstrip("/")
+        self.key = access_key
+        self._secret = secret
+        self.timeout = timeout
+
+    # -- 底层签名请求 ---------------------------------------------------------
+
+    def request(
+        self,
+        method: str,
+        path: str,
+        body: bytes | None = None,
+        *,
+        idem: str | None = None,
+        ts: int | None = None,
+        nonce: str | None = None,
+        skip_idem: bool = False,
+        tamper_body: bool = False,
+        bad_signature: bool = False,
+        contract_version: str | None = None,
+        extra_headers: dict | None = None,
+    ) -> tuple[int, dict | str, dict]:
+        """返回 (http_status, parsed_body, sent_headers)。sent_headers 中签名已遮蔽。"""
+        raw = body if body is not None else b""
+        digest = sha256_hex(raw)
+        if tamper_body:
+            raw = raw + b"x"
+        ts_s = str(ts if ts is not None else int(time.time()))
+        nonce = nonce or uuid.uuid4().hex
+        idem = idem or f"IDEM-{uuid.uuid4().hex}"
+        message = build_message(method, path, self.key, ts_s, nonce, digest, idem)
+        signature = sign(self._secret, message)
+        if bad_signature:
+            signature = sign(self._secret + "-wrong", message)
+        headers = {
+            "Content-Type": "application/json",
+            "X-Access-Key": self.key,
+            "X-Timestamp": ts_s,
+            "X-Nonce": nonce,
+            "X-Content-SHA256": digest,
+            "X-Signature": signature,
+        }
+        if not skip_idem:
+            headers["Idempotency-Key"] = idem
+        if contract_version:
+            headers[CONTRACT_VERSION_HEADER] = contract_version
+        if extra_headers:
+            headers.update(extra_headers)
+
+        data = raw if method.upper() != "GET" else None
+        req = urllib.request.Request(self.base + path, data=data, method=method.upper(), headers=headers)
+        redacted = dict(headers)
+        redacted["X-Signature"] = "***REDACTED***"
+        try:
+            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+                return resp.status, _parse(resp.read().decode("utf-8")), redacted
+        except urllib.error.HTTPError as ex:
+            return ex.code, _parse(ex.read().decode("utf-8")), redacted
+        except urllib.error.URLError as ex:
+            return 0, {"message": f"SOURCE_UNREACHABLE: {ex.reason}"}, redacted
+        except TimeoutError:
+            return 0, {"message": "SOURCE_UNREACHABLE: timeout"}, redacted
+
+    # -- 七类操作 -------------------------------------------------------------
+
+    def op_schema(self, entity: str, contract_version: str | None = None):
+        path = f"/api/mdp/inbound/{entity}/schema"
+        return self.request("GET", path, b"", contract_version=contract_version)
+
+    def op_push(self, entity: str, envelope: dict, **kwargs):
+        path = f"/api/mdp/inbound/{entity}"
+        body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
+        return self.request("POST", path, body, **kwargs)
+
+    def op_receipt(self, sync_batch_id: str):
+        path = f"/api/mdp/inbound/receipts/{urllib.parse.quote(sync_batch_id)}"
+        return self.request("GET", path, b"")
+
+    def op_bulk(self, entity: str, ndjson_lines: list[dict], **kwargs):
+        path = f"/api/mdp/inbound/{entity}/bulk"
+        body = ("\n".join(json.dumps(line, ensure_ascii=False) for line in ndjson_lines)).encode("utf-8")
+        headers = {"Content-Type": "application/x-ndjson"}
+        extra = dict(kwargs.pop("extra_headers", None) or {})
+        extra.update(headers)
+        return self.request("POST", path, body, extra_headers=extra, **kwargs)
+
+    def op_snapshot_open(self, entity: str):
+        path = f"/api/mdp/inbound/{entity}/snapshots"
+        return self.request("POST", path, b"")
+
+    def op_snapshot_commit(self, entity: str, snapshot_id: str, force: bool = False):
+        path = f"/api/mdp/inbound/{entity}/snapshots/{urllib.parse.quote(snapshot_id)}/commit"
+        if force:
+            path += "?force=true"
+        return self.request("POST", path, b"")
+
+    def op_digest(self, entity: str, biz_date: str):
+        path = f"/api/mdp/inbound/{entity}/digest?date={urllib.parse.quote(biz_date)}"
+        return self.request("GET", path, b"")
+
+    # -- 必测用例(P1-E)-------------------------------------------------------
+
+    def case_expired_ts(self, entity: str, envelope: dict):
+        return self.op_push(entity, envelope, ts=int(time.time()) - 3600)
+
+    def case_nonce_replay(self, entity: str, envelope: dict):
+        body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
+        path = f"/api/mdp/inbound/{entity}"
+        nonce = uuid.uuid4().hex
+        s1, d1, _ = self.request("POST", path, body, nonce=nonce)
+        s2, d2, _ = self.request("POST", path, body, nonce=nonce, idem=f"IDEM-{uuid.uuid4().hex}")
+        return (s1, d1), (s2, d2)
+
+    def case_same_key_replay(self, entity: str, envelope: dict):
+        body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
+        path = f"/api/mdp/inbound/{entity}"
+        idem = f"IDEM-REPLAY-{uuid.uuid4().hex}"
+        s1, d1, _ = self.request("POST", path, body, idem=idem)
+        s2, d2, _ = self.request("POST", path, body, idem=idem)
+        return (s1, d1), (s2, d2)
+
+    def case_same_key_diff_body(self, entity: str, env1: dict, env2: dict):
+        path = f"/api/mdp/inbound/{entity}"
+        idem = f"IDEM-DIFF-{uuid.uuid4().hex}"
+        b1 = json.dumps(env1, ensure_ascii=False).encode("utf-8")
+        b2 = json.dumps(env2, ensure_ascii=False).encode("utf-8")
+        s1, d1, _ = self.request("POST", path, b1, idem=idem)
+        s2, d2, _ = self.request("POST", path, b2, idem=idem)
+        return (s1, d1), (s2, d2)
+
+    def case_missing_idem(self, entity: str, envelope: dict):
+        return self.op_push(entity, envelope, skip_idem=True)
+
+    def case_tamper_body(self, entity: str, envelope: dict):
+        return self.op_push(entity, envelope, tamper_body=True)
+
+    def case_bad_signature(self, entity: str, envelope: dict):
+        return self.op_push(entity, envelope, bad_signature=True)

+ 76 - 0
tools/integration-simulator/clients/redaction.py

@@ -0,0 +1,76 @@
+"""日志、请求头与运行记录脱敏(任务书 P1-B 安全要求)。
+
+规则:
+- 已知 Secret / 密码 / Token 值出现即替换为 ***REDACTED***;
+- 敏感 Header 值只保留 AccessKey 后四位,其余整体遮蔽;
+- 连接串中 pwd=/password= 段遮蔽。
+"""
+from __future__ import annotations
+
+import re
+
+REDACTED = "***REDACTED***"
+
+SENSITIVE_HEADERS = {
+    "authorization", "x-signature", "x-access-key", "cookie", "set-cookie",
+    "proxy-authorization", "x-csrf-token",
+}
+
+# 键名命中即整体遮蔽(用于运行记录/报告中的嵌套字段)
+SENSITIVE_KEY_RE = re.compile(
+    r"(password|passwd|pwd|secret|token|signature|access[-_]?key|credential|jwt)", re.IGNORECASE)
+
+_CONN_PWD_RE = re.compile(r"(pwd|password)\s*=\s*[^;]+", re.IGNORECASE)
+_BEARER_RE = re.compile(r"(Bearer|Basic)\s+[A-Za-z0-9\-._~+/=]+", re.IGNORECASE)
+
+
+def mask_access_key(access_key: str | None) -> str:
+    """报告中 AccessKey 只保留后四位。"""
+    if not access_key:
+        return ""
+    return f"****{access_key[-4:]}" if len(access_key) > 4 else "****"
+
+
+def redact_headers(headers: dict, secrets: list[str] | None = None) -> dict:
+    out = {}
+    for k, v in headers.items():
+        if str(k).lower() in SENSITIVE_HEADERS:
+            out[k] = mask_access_key(str(v)) if str(k).lower() == "x-access-key" else REDACTED
+        else:
+            out[k] = redact_text(str(v), secrets)
+    return out
+
+
+def redact_text(text: str, secrets: list[str] | None = None) -> str:
+    if not text:
+        return text
+    result = text
+    for secret in secrets or []:
+        if secret and secret in result:
+            result = result.replace(secret, REDACTED)
+    result = _CONN_PWD_RE.sub(lambda m: f"{m.group(1)}={REDACTED}", result)
+    result = _BEARER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", result)
+    return result
+
+
+def redact_obj(obj, secrets: list[str] | None = None):
+    """递归脱敏 dict/list/str,用于运行记录与报告。
+
+    - 敏感 Header 名:Authorization/签名类整体遮蔽,AccessKey 只留后四位;
+    - 键名命中 SENSITIVE_KEY_RE 的字段整体遮蔽;
+    - 其余字符串按已知 Secret 值与连接串/Bearer 模式遮蔽。
+    """
+    if isinstance(obj, dict):
+        out = {}
+        for k, v in obj.items():
+            key = str(k).lower()
+            if key in SENSITIVE_HEADERS or SENSITIVE_KEY_RE.search(key):
+                out[k] = mask_access_key(str(v)) if "access" in key and "key" in key else REDACTED
+            else:
+                out[k] = redact_obj(v, secrets)
+        return out
+    if isinstance(obj, list):
+        return [redact_obj(v, secrets) for v in obj]
+    if isinstance(obj, str):
+        return redact_text(obj, secrets)
+    return obj

+ 7 - 0
tools/integration-simulator/config.example.json

@@ -0,0 +1,7 @@
+{
+  "aidopBaseUrl": "http://127.0.0.1:5005",
+  "mockApiPort": 8018,
+  "mysqlDatabase": "aidop_integration_sim",
+  "allowedTargetHosts": ["127.0.0.1", "localhost"],
+  "_comment": "复制为 config.json 后按本机环境修改;本文件与 config.json 均不得写入密码、Secret、AccessKey、JWT 或真实连接串。凭据只从环境变量读取:AIDOP_SIM_MYSQL_HOST/PORT/USER/PASSWORD/DATABASE、AIDOP_SIM_INBOUND_ACCESS_KEY、AIDOP_SIM_INBOUND_SECRET、AIDOP_SIM_ADMIN_TOKEN(可选,只读管理 API)。"
+}

+ 1 - 0
tools/integration-simulator/providers/__init__.py

@@ -0,0 +1 @@
+"""Ai-DOP 第三方对接模拟器 · 第三方对端(Mock API / Mock MySQL)包。"""

+ 220 - 0
tools/integration-simulator/providers/mock_api.py

@@ -0,0 +1,220 @@
+"""API_PULL 对端 Mock(任务书 P1-D)。
+
+契约严格对齐 MdpApiPullExecutor.cs 与既有 doc/db/mdp/mock_api/mock_server.py:
+- GET 单次请求,不分页;响应数组位于 data.list(可切换 responsePath);
+- 每行去重键 bizKey(dedup_key_path);?cursor=<上批最后一行 bizKey> 只返回更大行;
+- 鉴权模式 NONE / TOKEN(Bearer) / BASIC / APIKEY,对齐执行器 ApplyAuth 分支;
+- 可注入延迟与强制 HTTP 错误(401/403/429/500);
+- 端点复用 doc/db/mdp/mock_api/endpoints.json 与 samples/*.json(不迁移、不修改),
+  另支持运行时 override:把模拟器适配后的样例(带 SIM 前缀)挂到任意路径。
+
+每次调用写入内存审计(Header 脱敏),Secret 值以 redact_text 二次遮蔽。
+"""
+from __future__ import annotations
+
+import base64
+import json
+import sys
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+_SIM_ROOT = Path(__file__).resolve().parents[1]
+if str(_SIM_ROOT) not in sys.path:
+    sys.path.insert(0, str(_SIM_ROOT))
+
+from fastapi import FastAPI, HTTPException, Query, Request  # noqa: E402
+
+from catalog.sample_adapters import MOCK_API_DIR  # noqa: E402
+from clients.redaction import redact_headers  # noqa: E402
+
+
+@dataclass
+class MockApiSettings:
+    auth_mode: str = "TOKEN"          # NONE | TOKEN | BASIC | APIKEY
+    token: str = "sim-mock-token"
+    basic_user: str = "sim"
+    basic_password: str = "sim-basic-password"
+    api_key_header: str = "X-Api-Key"
+    api_key_value: str = "sim-api-key"
+    delay_seconds: float = 0.0
+    force_status: int | None = None   # 401/403/429/500 等
+    response_path: str = "data.list"
+    audit_limit: int = 200
+
+
+@dataclass
+class MockApiState:
+    settings: MockApiSettings = field(default_factory=MockApiSettings)
+    overrides: dict[str, list[dict]] = field(default_factory=dict)
+    audit: list[dict] = field(default_factory=list)
+
+    def secrets(self) -> list[str]:
+        s = self.settings
+        return [v for v in (s.token, s.basic_password, s.api_key_value) if v]
+
+
+def _load_endpoints() -> dict[str, str]:
+    with (MOCK_API_DIR / "endpoints.json").open("r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _load_rows(rel_path: str) -> list[dict]:
+    p = MOCK_API_DIR / rel_path
+    if not p.exists():
+        raise HTTPException(status_code=404, detail=f"sample not found: {rel_path}")
+    with p.open("r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _build_response(rows: list[dict], response_path: str) -> dict:
+    parts = [p for p in response_path.split(".") if p]
+    body: dict = {}
+    node = body
+    for part in parts[:-1]:
+        node[part] = {}
+        node = node[part]
+    node[parts[-1]] = rows if parts else rows
+    body.setdefault("code", 0)
+    body.setdefault("message", "ok")
+    return body
+
+
+def _check_auth(state: MockApiState, request: Request) -> None:
+    s = state.settings
+    mode = (s.auth_mode or "NONE").upper()
+    if mode == "NONE":
+        return
+    authorization = request.headers.get("Authorization")
+    if mode in ("TOKEN", "OAUTH2"):
+        if authorization != f"Bearer {s.token}":
+            raise HTTPException(status_code=401, detail="invalid or missing bearer token")
+    elif mode == "BASIC":
+        expected = "Basic " + base64.b64encode(f"{s.basic_user}:{s.basic_password}".encode()).decode()
+        if authorization != expected:
+            raise HTTPException(status_code=401, detail="invalid or missing basic credentials")
+    elif mode == "APIKEY":
+        if request.headers.get(s.api_key_header) != s.api_key_value:
+            raise HTTPException(status_code=401, detail=f"invalid or missing api key header {s.api_key_header}")
+    else:
+        raise HTTPException(status_code=401, detail=f"unsupported auth mode on mock side: {mode}")
+
+
+def _audit(state: MockApiState, entry: dict) -> None:
+    state.audit.append(entry)
+    if len(state.audit) > state.settings.audit_limit:
+        del state.audit[: len(state.audit) - state.settings.audit_limit]
+
+
+def create_app(state: MockApiState | None = None) -> FastAPI:
+    state = state or MockApiState()
+    app = FastAPI(title="Ai-DOP Integration Simulator - Mock API (API_PULL)", version="1.0.0")
+    app.state.sim = state
+
+    @app.get("/health")
+    def health():
+        return {"status": "UP", "authMode": state.settings.auth_mode}
+
+    @app.get("/__endpoints")
+    def list_endpoints():
+        return {
+            "authMode": state.settings.auth_mode,
+            "responsePath": state.settings.response_path,
+            "endpoints": _load_endpoints(),
+            "overrides": sorted(state.overrides.keys()),
+        }
+
+    @app.get("/__control/settings")
+    def get_settings():
+        s = state.settings
+        # 不回显任何凭据值
+        return {
+            "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
+            "forceStatus": s.force_status, "responsePath": s.response_path,
+            "apiKeyHeader": s.api_key_header,
+        }
+
+    @app.post("/__control/settings")
+    def update_settings(payload: dict):
+        s = state.settings
+        if "auth_mode" in payload:
+            mode = str(payload["auth_mode"]).upper()
+            if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
+                raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
+            s.auth_mode = mode
+        if "token" in payload and payload["token"]:
+            s.token = str(payload["token"])
+        if "basic_user" in payload and payload["basic_user"]:
+            s.basic_user = str(payload["basic_user"])
+        if "basic_password" in payload and payload["basic_password"]:
+            s.basic_password = str(payload["basic_password"])
+        if "api_key_header" in payload and payload["api_key_header"]:
+            s.api_key_header = str(payload["api_key_header"])
+        if "api_key_value" in payload and payload["api_key_value"]:
+            s.api_key_value = str(payload["api_key_value"])
+        if "delay_seconds" in payload:
+            s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
+        if "force_status" in payload:
+            s.force_status = int(payload["force_status"]) if payload["force_status"] else None
+        if "response_path" in payload and payload["response_path"]:
+            s.response_path = str(payload["response_path"])
+        return get_settings()
+
+    @app.post("/__control/overrides")
+    def set_override(payload: dict):
+        path = str(payload.get("path") or "")
+        rows = payload.get("rows")
+        if not path.startswith("/api/") or not isinstance(rows, list):
+            raise HTTPException(status_code=400, detail="need {path:/api/..., rows:[...]}")
+        state.overrides[path] = rows
+        return {"path": path, "rowCount": len(rows)}
+
+    @app.delete("/__control/overrides")
+    def clear_overrides():
+        state.overrides.clear()
+        return {"cleared": True}
+
+    @app.get("/__control/audit")
+    def get_audit():
+        return {"entries": state.audit[-50:]}
+
+    @app.get("/api/{obj:path}")
+    def get_object(
+        obj: str,
+        request: Request,
+        cursor: str | None = Query(default=None, description="上批最后一行 bizKey(增量游标)"),
+    ):
+        started = time.time()
+        path = request.url.path
+        if state.settings.delay_seconds > 0:
+            time.sleep(state.settings.delay_seconds)
+        try:
+            if state.settings.force_status:
+                raise HTTPException(status_code=state.settings.force_status,
+                                    detail=f"forced error {state.settings.force_status}")
+            _check_auth(state, request)
+            if path in state.overrides:
+                rows = [dict(r) for r in state.overrides[path]]
+            else:
+                endpoints = _load_endpoints()
+                if path not in endpoints:
+                    raise HTTPException(status_code=404,
+                                        detail=f"no mock for {path}; add it to endpoints.json or set an override")
+                rows = _load_rows(endpoints[path])
+            rows.sort(key=lambda r: str(r.get("bizKey", "")))
+            total = len(rows)
+            if cursor:
+                rows = [r for r in rows if str(r.get("bizKey", "")) > cursor]
+            return _build_response(rows, state.settings.response_path)
+        finally:
+            _audit(state, {
+                "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
+                "path": path,
+                "cursor": cursor,
+                "authMode": state.settings.auth_mode,
+                "forceStatus": state.settings.force_status,
+                "elapsedMs": int((time.time() - started) * 1000),
+                "headers": redact_headers(dict(request.headers), state.secrets()),
+            })
+
+    return app

+ 270 - 0
tools/integration-simulator/providers/mock_db.py

@@ -0,0 +1,270 @@
+"""DB_SYNC 模拟源库(任务书 P1-C)。
+
+职责边界:
+- 只在独立 MySQL 模拟 schema(默认 aidop_integration_sim)建表、装载、清理;
+- 绝不连接 Ai-DOP 库写贴源/标准/KPI 表;库名含 aidop 且非模拟库名时硬阻断;
+- 不使用 Ai-DOP 主库账号;连接凭据从环境变量读取(密码 AIDOP_SIM_MYSQL_PASSWORD);
+- 生成 Ai-DOP 配置清单(mdp_source / mdp_entity 建议行),仅供人工在配置页登记,不自动写入。
+"""
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime
+
+# 允许创建的模拟库名;其余含 aidop 的库名一律阻断
+SIM_DB_DEFAULT = "aidop_integration_sim"
+_BLOCKED_DB_HINTS = ("aidopdev", "aidop_uat", "aidopprod", "aidop_prod", "admin", "mdp")
+
+
+class BlockedDatabaseError(RuntimeError):
+    pass
+
+
+def assert_sim_database(database: str) -> None:
+    db = (database or "").strip().lower()
+    if not db:
+        raise BlockedDatabaseError("database name required")
+    if db != SIM_DB_DEFAULT and any(h in db for h in _BLOCKED_DB_HINTS):
+        raise BlockedDatabaseError(
+            f"database '{database}' looks like an Ai-DOP database; simulator may only use '{SIM_DB_DEFAULT}'")
+    if db != SIM_DB_DEFAULT and not db.startswith("aidop_integration_sim"):
+        # 允许 aidop_integration_sim_* 派生测试库,其余含 aidop 的阻断
+        if "aidop" in db:
+            raise BlockedDatabaseError(
+                f"database '{database}' contains 'aidop' but is not the simulator schema")
+
+
+def mysql_conn_params(database: str | None = None) -> dict:
+    db = database or os.environ.get("AIDOP_SIM_MYSQL_DATABASE", SIM_DB_DEFAULT)
+    assert_sim_database(db)
+    return {
+        "host": os.environ.get("AIDOP_SIM_MYSQL_HOST", "127.0.0.1"),
+        "port": int(os.environ.get("AIDOP_SIM_MYSQL_PORT", "3306")),
+        "user": os.environ.get("AIDOP_SIM_MYSQL_USER", "root"),
+        "password": os.environ.get("AIDOP_SIM_MYSQL_PASSWORD", ""),
+        "database": db,
+        "charset": "utf8mb4",
+    }
+
+
+def connect(database: str | None = None):
+    import pymysql  # 延迟导入:单元测试不依赖真实驱动
+    params = mysql_conn_params(database)
+    return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
+
+
+# ---------------------------------------------------------------------------
+# DDL 生成(纯函数,可单测)
+# ---------------------------------------------------------------------------
+
+def infer_mysql_type(values: list) -> str:
+    has_text = has_int = has_float = has_bool = False
+    max_len = 0
+    for v in values:
+        if v is None:
+            continue
+        if isinstance(v, bool):
+            has_bool = True
+        elif isinstance(v, int):
+            has_int = True
+        elif isinstance(v, float):
+            has_float = True
+        else:
+            has_text = True
+            max_len = max(max_len, len(str(v)))
+    if has_text:
+        return f"VARCHAR({max(64, min(512, (max_len // 32 + 1) * 32))})"
+    if has_bool:
+        return "TINYINT(1)"
+    if has_float:
+        return "DECIMAL(18,6)"
+    if has_int:
+        return "BIGINT"
+    return "VARCHAR(191)"
+
+
+def generate_ddl(table: str, rows: list[dict], business_key: list[str] | None = None,
+                 increment_column: str = "sourceUpdatedAt") -> str:
+    """按样例行推断列类型;业务键唯一索引;增量列建普通索引。列名保持样例原样(反引号包裹)。"""
+    if not rows:
+        raise ValueError(f"cannot generate DDL for {table} from empty rows")
+    business_key = business_key or ["bizKey"]
+    columns: dict[str, list] = {}
+    for row in rows:
+        for k, v in row.items():
+            columns.setdefault(k, []).append(v)
+    lines = []
+    for name, values in columns.items():
+        lines.append(f"  `{name}` {infer_mysql_type(values)} NULL")
+    uk = ", ".join(f"`{k}`" for k in business_key if k in columns)
+    if uk:
+        lines.append(f"  UNIQUE KEY `uk_{table}` ({uk})")
+    if increment_column in columns:
+        lines.append(f"  KEY `ix_{table}_incr` (`{increment_column}`)")
+    return (f"CREATE TABLE IF NOT EXISTS `{table}` (\n" + ",\n".join(lines) +
+            "\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
+
+
+# ---------------------------------------------------------------------------
+# 库操作
+# ---------------------------------------------------------------------------
+
+def _col_names(rows: list[dict]) -> list[str]:
+    cols: list[str] = []
+    for row in rows:
+        for k in row:
+            if k not in cols:
+                cols.append(k)
+    return cols
+
+
+def _insert_sql(table: str, cols: list[str]) -> str:
+    names = ", ".join(f"`{c}`" for c in cols)
+    marks = ", ".join(["%s"] * len(cols))
+    updates = ", ".join(f"`{c}`=VALUES(`{c}`)" for c in cols)
+    return f"INSERT INTO `{table}` ({names}) VALUES ({marks}) ON DUPLICATE KEY UPDATE {updates}"
+
+
+def _convert(v):
+    if isinstance(v, (dict, list)):
+        return json.dumps(v, ensure_ascii=False)
+    if isinstance(v, bool):
+        return int(v)
+    return v
+
+
+def init_schema(tables: dict[str, dict]) -> dict:
+    """tables: {table: {"rows": [...], "businessKey": [...], "incrementColumn": str}}。建库建表。"""
+    result = {"database": None, "tables": []}
+    import pymysql
+    params = mysql_conn_params()
+    database = params.pop("database")
+    conn = pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
+    try:
+        with conn.cursor() as cur:
+            cur.execute(f"CREATE DATABASE IF NOT EXISTS `{database}` DEFAULT CHARACTER SET utf8mb4")
+            cur.execute(f"USE `{database}`")
+            result["database"] = database
+            for table, spec in tables.items():
+                ddl = generate_ddl(table, spec["rows"], spec.get("businessKey"),
+                                   spec.get("incrementColumn", "sourceUpdatedAt"))
+                cur.execute(ddl)
+                result["tables"].append(table)
+    finally:
+        conn.close()
+    return result
+
+
+def _use_db():
+    import pymysql
+    params = mysql_conn_params()
+    return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
+
+
+def reset_table(table: str) -> None:
+    conn = _use_db()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(f"TRUNCATE TABLE `{table}`")
+    finally:
+        conn.close()
+
+
+def upsert_rows(table: str, rows: list[dict]) -> int:
+    if not rows:
+        return 0
+    cols = _col_names(rows)
+    sql = _insert_sql(table, cols)
+    conn = _use_db()
+    try:
+        with conn.cursor() as cur:
+            cur.executemany(sql, [tuple(_convert(row.get(c)) for c in cols) for row in rows])
+            return len(rows)
+    finally:
+        conn.close()
+
+
+def update_rows(table: str, updates: dict, where: dict,
+                increment_column: str = "sourceUpdatedAt",
+                new_increment: str | None = None) -> int:
+    """更新已有数据并推进增量列(DB_SYNC 增量更新用例)。"""
+    sets = {**updates}
+    if increment_column:
+        sets[increment_column] = new_increment or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
+    set_clause = ", ".join(f"`{k}`=%s" for k in sets)
+    where_clause = " AND ".join(f"`{k}`=%s" for k in where)
+    sql = f"UPDATE `{table}` SET {set_clause} WHERE {where_clause}"
+    conn = _use_db()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(sql, tuple(sets.values()) + tuple(where.values()))
+            return cur.rowcount
+    finally:
+        conn.close()
+
+
+def table_stats(table: str, increment_column: str = "sourceUpdatedAt") -> dict:
+    conn = _use_db()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(f"SELECT COUNT(*) AS cnt, MAX(`{increment_column}`) AS max_incr FROM `{table}`")
+            row = cur.fetchone() or {}
+            return {"table": table, "rowCount": row.get("cnt", 0),
+                    "maxIncrement": str(row.get("max_incr") or "")}
+    finally:
+        conn.close()
+
+
+# ---------------------------------------------------------------------------
+# Ai-DOP 配置清单生成(只输出建议,不写库)
+# ---------------------------------------------------------------------------
+
+SIM_SYSTEM_CODE = "SIM_MODE3"
+SIM_SOURCES = {
+    "DB_SYNC": "SIM_MODE3_DB",
+    "API_PULL": "SIM_MODE3_API",
+    "API_INBOUND": "SIM_MODE3_INBOUND",
+}
+
+
+def generate_aidop_config(obj: dict, mock_api_base_url: str = "http://127.0.0.1:8018") -> dict:
+    """按对象生成 mdp_source / mdp_entity 建议配置,供人工在 Ai-DOP 配置页登记。"""
+    db_sync = obj.get("dbSync") or {}
+    api_pull = obj.get("apiPull") or {}
+    inbound = obj.get("apiInbound") or {}
+    config: dict = {"objectCode": obj["objectCode"], "systemCode": SIM_SYSTEM_CODE, "sources": [], "entities": []}
+    if db_sync.get("supported"):
+        config["sources"].append({
+            "source_code": SIM_SOURCES["DB_SYNC"], "source_type": "DB", "conn_mode": "EXTERNAL",
+            "hint": "连接指向模拟库 aidop_integration_sim(独立账号,勿用 Ai-DOP 主库账号)",
+        })
+        config["entities"].append({
+            "entity_code": f"SIM_{obj['objectCode']}_DB", "source_code": SIM_SOURCES["DB_SYNC"],
+            "source_table_name": db_sync.get("table"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
+            "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
+            "incr_column": db_sync.get("incrementColumn"),
+        })
+    if api_pull.get("supported"):
+        config["sources"].append({
+            "source_code": SIM_SOURCES["API_PULL"], "source_type": "API",
+            "api_base_url": mock_api_base_url, "api_auth_type": "TOKEN",
+            "hint": "token 值即 Mock 控制台当前 TOKEN(环境变量/控制台设置,不落盘)",
+        })
+        config["entities"].append({
+            "entity_code": f"SIM_{obj['objectCode']}_API", "source_code": SIM_SOURCES["API_PULL"],
+            "source_api_path": api_pull.get("path"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
+            "response_data_path": api_pull.get("responsePath"), "dedup_key_path": api_pull.get("dedupKeyPath"),
+            "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
+            "incr_column": "cursor",
+        })
+    if inbound.get("supported"):
+        config["sources"].append({
+            "source_code": SIM_SOURCES["API_INBOUND"], "source_type": "API_INBOUND",
+            "hint": "需在 mdp_inbound_grant 为测试 AccessKey 授权该 entityCode,并绑定测试租户开放身份",
+        })
+        config["entities"].append({
+            "entity_code": inbound.get("entityCode"), "source_code": SIM_SOURCES["API_INBOUND"],
+            "inbound_enabled": 1,
+        })
+    return config

+ 5 - 0
tools/integration-simulator/requirements.txt

@@ -0,0 +1,5 @@
+fastapi>=0.110
+uvicorn>=0.27
+PyMySQL>=1.1
+pytest>=8.0
+httpx>=0.27

+ 171 - 0
tools/integration-simulator/static/app.css

@@ -0,0 +1,171 @@
+:root {
+  --bg: #f5f7fa;
+  --card: #ffffff;
+  --line: #dde3ec;
+  --text: #1f2937;
+  --muted: #6b7280;
+  --ok: #16a34a;
+  --warn: #d97706;
+  --err: #dc2626;
+  --accent: #2563eb;
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  font-size: 14px;
+}
+
+header {
+  background: #111827;
+  color: #fff;
+  padding: 14px 20px;
+}
+
+header h1 { margin: 0 0 8px; font-size: 18px; font-weight: 600; }
+
+.tag {
+  font-size: 12px;
+  font-weight: 400;
+  background: #374151;
+  padding: 2px 8px;
+  border-radius: 10px;
+  vertical-align: middle;
+}
+
+.banner { padding: 6px 10px; border-radius: 4px; background: #374151; font-size: 13px; }
+.banner.ok { background: #14532d; }
+.banner.warn { background: #78350f; }
+.banner.err { background: #7f1d1d; }
+
+nav {
+  display: flex;
+  gap: 4px;
+  padding: 8px 20px 0;
+  background: var(--card);
+  border-bottom: 1px solid var(--line);
+  flex-wrap: wrap;
+}
+
+nav button {
+  border: 1px solid transparent;
+  border-bottom: none;
+  background: transparent;
+  padding: 8px 14px;
+  cursor: pointer;
+  border-radius: 6px 6px 0 0;
+  color: var(--muted);
+  font-size: 13px;
+}
+
+nav button.active {
+  background: var(--bg);
+  border-color: var(--line);
+  color: var(--accent);
+  font-weight: 600;
+}
+
+.tab { display: none; padding: 16px 20px; }
+.tab.active { display: block; }
+
+.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+@media (max-width: 1100px) { .grid2 { grid-template-columns: 1fr; } }
+
+.card {
+  background: var(--card);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  padding: 16px;
+}
+
+.card h2 { font-size: 15px; margin: 0 0 12px; }
+.card h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); }
+
+label { display: block; margin-bottom: 10px; font-size: 13px; color: var(--muted); }
+
+select, input[type="text"], input[type="number"], textarea {
+  width: 100%;
+  margin-top: 4px;
+  padding: 6px 8px;
+  border: 1px solid var(--line);
+  border-radius: 4px;
+  font-family: inherit;
+  font-size: 13px;
+  color: var(--text);
+  background: #fff;
+}
+
+textarea { font-family: Consolas, "Courier New", monospace; font-size: 12px; resize: vertical; }
+
+button {
+  border: 1px solid var(--line);
+  background: #fff;
+  padding: 6px 14px;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 13px;
+}
+
+button:hover { border-color: var(--accent); color: var(--accent); }
+button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.primary:hover { opacity: .9; color: #fff; }
+button:disabled { opacity: .5; cursor: not-allowed; }
+
+.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0; }
+
+.muted { color: var(--muted); font-size: 12px; }
+.warn { color: var(--warn); font-size: 12px; }
+
+.state { font-size: 12px; padding: 4px 0; }
+.state.ok { color: var(--ok); }
+.state.warn { color: var(--warn); }
+.state.err { color: var(--err); }
+
+.output {
+  background: #0f172a;
+  color: #d1e3ff;
+  padding: 10px;
+  border-radius: 6px;
+  font-family: Consolas, "Courier New", monospace;
+  font-size: 12px;
+  max-height: 420px;
+  overflow: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.output.small { max-height: 260px; }
+
+.layers { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
+
+.chip {
+  background: #eef2ff;
+  border: 1px solid #c7d2fe;
+  color: #3730a3;
+  border-radius: 12px;
+  padding: 3px 10px;
+  font-size: 12px;
+}
+
+.graph { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
+
+.node {
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 8px 10px;
+  background: #f8fafc;
+  font-size: 12px;
+  text-align: center;
+  min-width: 110px;
+}
+
+.node.ok { border-color: #bbf7d0; background: #f0fdf4; }
+.node.no { border-color: #fecaca; background: #fef2f2; color: var(--err); }
+
+.arrow { color: var(--muted); }
+
+code { background: #eef2f7; padding: 1px 4px; border-radius: 3px; }

+ 351 - 0
tools/integration-simulator/static/app.js

@@ -0,0 +1,351 @@
+/* Ai-DOP 第三方对接模拟器 · 本地控制台脚本
+ * 安全约束:不使用 localStorage/sessionStorage 保存任何认证信息;Secret 只在服务端。
+ */
+'use strict';
+
+const $ = (id) => document.getElementById(id);
+
+async function api(path, options = {}) {
+  const resp = await fetch(path, {
+    headers: { 'Content-Type': 'application/json' },
+    ...options,
+  });
+  const text = await resp.text();
+  let data;
+  try { data = text ? JSON.parse(text) : {}; } catch { data = text; }
+  if (!resp.ok) {
+    const detail = (data && data.detail) ? data.detail : data;
+    throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail, null, 2));
+  }
+  return data;
+}
+
+function show(el, data) {
+  $(el).textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
+}
+
+function setError(el, ex) {
+  $(el).textContent = '❌ ' + (ex && ex.message ? ex.message : String(ex));
+}
+
+/* ---------------- Tabs ---------------- */
+document.querySelectorAll('nav button').forEach((btn) => {
+  btn.addEventListener('click', () => {
+    document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
+    document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
+    btn.classList.add('active');
+    $(btn.dataset.tab).classList.add('active');
+  });
+});
+
+/* ---------------- 全局状态 ---------------- */
+let CATALOG = null;
+let SCENARIOS = null;
+
+function objects() { return (CATALOG && CATALOG.objects) || []; }
+
+function findObj(code) { return objects().find((o) => o.objectCode === code); }
+
+function channelSpec(obj, channel) {
+  if (!obj) return null;
+  return { DB_SYNC: obj.dbSync, API_PULL: obj.apiPull, API_INBOUND: obj.apiInbound }[channel] || null;
+}
+
+function fillObjectSelect(sel, filterFn) {
+  sel.innerHTML = '';
+  objects().filter(filterFn || (() => true)).forEach((o) => {
+    const opt = document.createElement('option');
+    opt.value = o.objectCode;
+    opt.textContent = `${o.objectCode} · ${o.businessName}`;
+    sel.appendChild(opt);
+  });
+}
+
+/* ---------------- 环境状态 ---------------- */
+async function refreshStatus() {
+  try {
+    const s = await api('/api/status');
+    const miss = [];
+    if (!s.inbound.accessKeyLoaded) miss.push('AIDOP_SIM_INBOUND_ACCESS_KEY');
+    if (!s.inbound.secretLoaded) miss.push('AIDOP_SIM_INBOUND_SECRET');
+    if (!s.mysql.passwordLoaded) miss.push('AIDOP_SIM_MYSQL_PASSWORD');
+    const banner = $('env-banner');
+    if (miss.length) {
+      banner.className = 'banner warn';
+      banner.textContent = `⚠ 缺少环境变量:${miss.join('、')}(Secret 只从环境变量读取,页面不提供输入)`;
+    } else {
+      banner.className = 'banner ok';
+      banner.textContent = `✓ 凭据已加载(AccessKey ${s.inbound.accessKeyMasked})`;
+    }
+    $('mock-state').textContent = s.mockApi.running
+      ? `运行中 :${s.mockApi.port}(${s.mockApi.authMode})` : '未运行';
+    $('mock-state').className = 'state ' + (s.mockApi.running ? 'ok' : 'warn');
+    show('security-output', s);
+    const counts = s.objectCounts;
+    $('run-object-info').dataset.counts = JSON.stringify(counts);
+  } catch (ex) {
+    $('env-banner').className = 'banner err';
+    $('env-banner').textContent = '❌ 控制服务不可用:' + ex.message;
+  }
+}
+
+/* ---------------- 对象测试页 ---------------- */
+function currentRunId() {
+  return $('run-id').value.trim() || null;
+}
+
+function refreshChannelState() {
+  const obj = findObj($('run-object').value);
+  const channel = $('run-channel').value;
+  const spec = channelSpec(obj, channel);
+  const el = $('run-channel-state');
+  if (!obj) { el.textContent = ''; return; }
+  if (!spec || !spec.supported) {
+    el.className = 'state err';
+    el.textContent = `代码层 UNSUPPORTED:${(spec && spec.reason) || '该通道不支持'}`;
+    $('btn-exec').disabled = true;
+  } else {
+    el.className = 'state ok';
+    const extra = spec.entityCode ? `(entityCode=${spec.entityCode},契约 ${spec.contractVersion || 'v1'})`
+      : spec.table ? `(源表 ${spec.table})` : spec.path ? `(路径 ${spec.path})` : '';
+    el.textContent = `代码层 SUPPORTED${extra};配置层需预检实测`;
+    $('btn-exec').disabled = false;
+  }
+}
+
+async function loadSample() {
+  try {
+    const obj = $('run-object').value;
+    const runId = currentRunId();
+    const qs = runId ? `?runId=${encodeURIComponent(runId)}` : '';
+    const data = await api(`/api/sample/${obj}${qs}`);
+    $('run-id').value = data.runId;
+    const channel = $('run-channel').value;
+    let rows = [];
+    let missing = [];
+    if (channel === 'DB_SYNC' && data.dbSync) rows = data.dbSync;
+    else if (channel === 'API_PULL' && data.apiPull) rows = data.apiPull;
+    else if (channel === 'API_INBOUND' && data.apiInbound) {
+      rows = data.apiInbound.rows;
+      missing = data.apiInbound.missingRequired || [];
+    }
+    $('run-rows').value = JSON.stringify(rows, null, 2);
+    $('run-missing').textContent = missing.length
+      ? `⚠ 样例缺少契约必填字段:${missing.join('、')}(推数将被部分拒绝,请补全样例)` : '';
+  } catch (ex) { alert('加载样例失败:' + ex.message); }
+}
+
+function parseRows() {
+  const text = $('run-rows').value.trim();
+  if (!text) return null;
+  const rows = JSON.parse(text);
+  if (!Array.isArray(rows)) throw new Error('样例必须是 JSON 数组');
+  return rows;
+}
+
+async function execRun() {
+  const obj = findObj($('run-object').value);
+  const channel = $('run-channel').value;
+  const spec = channelSpec(obj, channel);
+  if (!spec || !spec.supported) { alert('该对象不支持此通道(代码层 UNSUPPORTED)'); return; }
+  try {
+    const rows = parseRows();
+    const runId = currentRunId();
+    let result;
+    if (channel === 'DB_SYNC') {
+      await api('/api/db/ensure', { method: 'POST', body: JSON.stringify({ objectCode: obj.objectCode, runId, rows }) });
+      result = await api('/api/db/load', { method: 'POST', body: JSON.stringify({ objectCode: obj.objectCode, runId, rows }) });
+      result.hint = '模拟源库已装载;请在 Ai-DOP 配置页登记 SIM_MODE3_DB 来源后,由模块同步入口触发 MdpDbPullExecutor 真实拉取';
+    } else if (channel === 'API_PULL') {
+      result = await api('/api/mock-api/control', {
+        method: 'POST',
+        body: JSON.stringify({ override_path: spec.path, override_rows: rows || [] }),
+      });
+      result = {
+        ok: true, overridePath: spec.path,
+        rowCount: (rows || []).length,
+        hint: '适配样例已挂载到 Mock API;请确认 Mock 已启动、Ai-DOP 已登记 SIM_MODE3_API 来源,由 MdpApiPullExecutor 真实拉取',
+        layers: { code: 'SUPPORTED', config: 'READY(模拟端)', data: 'NOT_RUN' },
+      };
+    } else {
+      result = await api('/api/inbound/exec', {
+        method: 'POST',
+        body: JSON.stringify({ objectCode: obj.objectCode, operation: 'push', case: 'happy', runId, rows }),
+      });
+    }
+    renderRunResult(result);
+  } catch (ex) {
+    $('run-layers').innerHTML = '';
+    setError('run-output', ex);
+  }
+}
+
+function renderRunResult(result) {
+  const layers = result.layers || {};
+  $('run-layers').innerHTML = [
+    `<span class="chip">代码层:${layers.code || '—'}</span>`,
+    `<span class="chip">配置层:${layers.config || '—'}</span>`,
+    `<span class="chip">数据层:${layers.data || '—'}</span>`,
+  ].join(' ');
+  show('run-output', result);
+  show('run-sql', (result.followupSql || []).join('\n') || '—');
+}
+
+async function precheckRun() {
+  const obj = findObj($('run-object').value);
+  const channel = $('run-channel').value;
+  try {
+    const base = await api('/api/precheck');
+    let out = { precheck: base };
+    if (channel === 'API_INBOUND' && (obj.apiInbound || {}).supported) {
+      out.entityPrecheck = await api(`/api/precheck/inbound/${obj.apiInbound.entityCode}`);
+    }
+    out.aidopConfigSuggestion = await api(`/api/db/aidop-config/${obj.objectCode}`);
+    show('run-output', out);
+    const cfg = (out.entityPrecheck || {}).configState;
+    $('run-layers').innerHTML = [
+      `<span class="chip">代码层:SUPPORTED</span>`,
+      `<span class="chip">配置层:${cfg || (base.aidop.reachable ? '待人工核对来源/实体登记' : 'SOURCE_UNREACHABLE')}</span>`,
+      `<span class="chip">数据层:NOT_RUN</span>`,
+    ].join(' ');
+  } catch (ex) { setError('run-output', ex); }
+}
+
+/* ---------------- 场景页 ---------------- */
+function renderScenarioGraph() {
+  const sc = (SCENARIOS.scenarios || []).find((s) => s.scenarioId === $('scenario-select').value);
+  if (!sc) return;
+  $('scenario-desc').textContent = sc.description;
+  $('scenario-graph').innerHTML = sc.nodes.map((n) => {
+    const obj = findObj(n.objectCode);
+    const spec = channelSpec(obj, n.channel);
+    const ok = spec && spec.supported;
+    return `<div class="node ${ok ? 'ok' : 'no'}">
+      <b>${n.objectCode}</b><br/>
+      <span>${n.channel}</span><br/>
+      <small>${ok ? (n.relationKey || '') : '该通道不支持'}</small>
+    </div>`;
+  }).join('<span class="arrow">→</span>');
+}
+
+async function runScenario() {
+  try {
+    const result = await api('/api/scenarios/run', {
+      method: 'POST',
+      body: JSON.stringify({ scenarioId: $('scenario-select').value, runId: currentRunId() }),
+    });
+    show('scenario-output', result);
+  } catch (ex) { setError('scenario-output', ex); }
+}
+
+/* ---------------- Mock API 页 ---------------- */
+async function mockControl(payload) {
+  try { show('mock-audit', await api('/api/mock-api/control', { method: 'POST', body: JSON.stringify(payload) })); refreshStatus(); }
+  catch (ex) { setError('mock-audit', ex); }
+}
+
+/* ---------------- DB 页 ---------------- */
+async function dbAction(path, payload, out) {
+  try { show(out, await api(path, { method: 'POST', body: JSON.stringify(payload) })); }
+  catch (ex) { setError(out, ex); }
+}
+
+/* ---------------- Inbound 页 ---------------- */
+async function precheckAll() {
+  try { show('inbound-precheck', await api('/api/precheck/inbound')); }
+  catch (ex) { setError('inbound-precheck', ex); }
+}
+
+async function inboundExec() {
+  const body = {
+    entityCode: $('inbound-entity').value,
+    operation: $('inbound-op').value,
+    case: $('inbound-case').value,
+    syncBatchId: $('inbound-batch').value.trim() || null,
+    snapshotId: $('inbound-snapshot').value.trim() || null,
+    bizDate: $('inbound-date').value.trim() || null,
+    runId: currentRunId(),
+  };
+  try { show('inbound-output', await api('/api/inbound/exec', { method: 'POST', body: JSON.stringify(body) })); }
+  catch (ex) { setError('inbound-output', ex); }
+}
+
+/* ---------------- 初始化 ---------------- */
+async function init() {
+  CATALOG = await api('/api/catalog');
+  SCENARIOS = await api('/api/scenarios');
+
+  fillObjectSelect($('run-object'));
+  fillObjectSelect($('db-object'), (o) => (o.dbSync || {}).supported);
+  const inboundSel = $('inbound-entity');
+  inboundSel.innerHTML = '';
+  const codes = [...new Set(objects()
+    .filter((o) => (o.apiInbound || {}).supported)
+    .map((o) => o.apiInbound.entityCode))].sort();
+  codes.forEach((c) => {
+    const opt = document.createElement('option');
+    opt.value = c; opt.textContent = c;
+    inboundSel.appendChild(opt);
+  });
+
+  const scSel = $('scenario-select');
+  (SCENARIOS.scenarios || []).forEach((s) => {
+    const opt = document.createElement('option');
+    opt.value = s.scenarioId; opt.textContent = `${s.name}(${s.scenarioId})`;
+    scSel.appendChild(opt);
+  });
+
+  $('run-object').addEventListener('change', refreshChannelState);
+  $('run-channel').addEventListener('change', refreshChannelState);
+  $('btn-load-sample').addEventListener('click', loadSample);
+  $('btn-exec').addEventListener('click', execRun);
+  $('btn-precheck').addEventListener('click', precheckRun);
+  $('scenario-select').addEventListener('change', renderScenarioGraph);
+  $('btn-scenario-run').addEventListener('click', runScenario);
+
+  $('btn-mock-start').addEventListener('click', async () => {
+    try { show('mock-audit', await api('/api/mock-api/start', { method: 'POST' })); refreshStatus(); }
+    catch (ex) { setError('mock-audit', ex); }
+  });
+  $('btn-mock-stop').addEventListener('click', async () => {
+    try { show('mock-audit', await api('/api/mock-api/stop', { method: 'POST' })); refreshStatus(); }
+    catch (ex) { setError('mock-audit', ex); }
+  });
+  $('btn-mock-apply').addEventListener('click', () => mockControl({
+    auth_mode: $('mock-auth').value,
+    delay_seconds: parseFloat($('mock-delay').value || '0'),
+    force_status: $('mock-force').value ? parseInt($('mock-force').value, 10) : null,
+    response_path: $('mock-resp-path').value.trim() || 'data.list',
+  }));
+  $('btn-mock-clear-overrides').addEventListener('click', () => mockControl({ clear_overrides: true }));
+  $('btn-mock-audit').addEventListener('click', async () => {
+    try { show('mock-audit', await api('/api/mock-api/audit')); } catch (ex) { setError('mock-audit', ex); }
+  });
+
+  const dbObj = () => $('db-object').value;
+  $('btn-db-ensure').addEventListener('click', () => dbAction('/api/db/ensure', { objectCode: dbObj(), runId: currentRunId() }, 'db-output'));
+  $('btn-db-load').addEventListener('click', () => dbAction('/api/db/load', { objectCode: dbObj(), runId: currentRunId() }, 'db-output'));
+  $('btn-db-reset').addEventListener('click', () => dbAction('/api/db/reset', { objectCode: dbObj() }, 'db-output'));
+  $('btn-db-stats').addEventListener('click', async () => {
+    try { show('db-output', await api(`/api/db/stats/${dbObj()}`)); } catch (ex) { setError('db-output', ex); }
+  });
+  $('btn-db-config').addEventListener('click', async () => {
+    try { show('db-config-output', await api(`/api/db/aidop-config/${dbObj()}`)); } catch (ex) { setError('db-config-output', ex); }
+  });
+
+  $('btn-precheck-all').addEventListener('click', precheckAll);
+  $('btn-inbound-exec').addEventListener('click', inboundExec);
+  $('btn-runs-refresh').addEventListener('click', async () => {
+    try { show('runs-output', await api('/api/runs')); } catch (ex) { setError('runs-output', ex); }
+  });
+
+  refreshChannelState();
+  renderScenarioGraph();
+  await refreshStatus();
+  setInterval(refreshStatus, 15000);
+}
+
+init().catch((ex) => {
+  $('env-banner').className = 'banner err';
+  $('env-banner').textContent = '❌ 初始化失败:' + ex.message;
+});

+ 220 - 0
tools/integration-simulator/static/index.html

@@ -0,0 +1,220 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>Ai-DOP 第三方对接模拟器</title>
+  <link rel="stylesheet" href="/static/app.css" />
+</head>
+<body>
+  <header>
+    <h1>Ai-DOP 第三方对接模拟器 <span class="tag">第一阶段 · 本地控制台</span></h1>
+    <div id="env-banner" class="banner">环境状态加载中…</div>
+  </header>
+
+  <nav>
+    <button data-tab="tab-run" class="active">对象测试</button>
+    <button data-tab="tab-scenario">业务场景</button>
+    <button data-tab="tab-mockapi">Mock API(API_PULL)</button>
+    <button data-tab="tab-db">模拟 MySQL(DB_SYNC)</button>
+    <button data-tab="tab-inbound">API_INBOUND</button>
+    <button data-tab="tab-runs">运行记录</button>
+    <button data-tab="tab-security">环境安全</button>
+  </nav>
+
+  <!-- 对象测试 -->
+  <section id="tab-run" class="tab active">
+    <div class="grid2">
+      <div class="card">
+        <h2>1. 选择对象与通道</h2>
+        <label>业务对象
+          <select id="run-object"></select>
+        </label>
+        <div id="run-object-info" class="muted"></div>
+        <label>对接方式
+          <select id="run-channel">
+            <option value="DB_SYNC">DB_SYNC(数据库同步)</option>
+            <option value="API_PULL">API_PULL(接口拉取)</option>
+            <option value="API_INBOUND">API_INBOUND(接口推送)</option>
+          </select>
+        </label>
+        <div id="run-channel-state" class="state"></div>
+        <label>运行编号
+          <input id="run-id" type="text" placeholder="留空自动生成 SIM-yyyyMMddHHmmss-xxxxxx" />
+        </label>
+        <h2>2. 样例编辑(canonical → 通道适配)</h2>
+        <textarea id="run-rows" rows="12" spellcheck="false" placeholder="点击「加载适配样例」后可编辑 JSON 数组"></textarea>
+        <div class="row">
+          <button id="btn-load-sample">加载适配样例</button>
+          <span id="run-missing" class="warn"></span>
+        </div>
+      </div>
+      <div class="card">
+        <h2>3. 执行与结果</h2>
+        <div class="row" id="run-actions">
+          <button id="btn-precheck">预检</button>
+          <button id="btn-exec" class="primary">执行</button>
+        </div>
+        <div id="run-layers" class="layers"></div>
+        <h3>请求 / 响应预览(已脱敏)</h3>
+        <pre id="run-output" class="output">尚未执行</pre>
+        <h3>后续人工核验 SQL</h3>
+        <pre id="run-sql" class="output small">—</pre>
+      </div>
+    </div>
+  </section>
+
+  <!-- 业务场景 -->
+  <section id="tab-scenario" class="tab">
+    <div class="card">
+      <h2>跨对象基础场景(三通道同业务语义)</h2>
+      <label>场景
+        <select id="scenario-select"></select>
+      </label>
+      <div id="scenario-desc" class="muted"></div>
+      <div id="scenario-graph" class="graph"></div>
+      <div class="row">
+        <button id="btn-scenario-run" class="primary">执行场景</button>
+        <span class="muted">不支持的节点会明确标记「该通道不支持」,不跳过后宣称链路完整</span>
+      </div>
+      <pre id="scenario-output" class="output">尚未执行</pre>
+    </div>
+  </section>
+
+  <!-- Mock API -->
+  <section id="tab-mockapi" class="tab">
+    <div class="grid2">
+      <div class="card">
+        <h2>Mock API 控制(API_PULL 对端)</h2>
+        <div class="row">
+          <button id="btn-mock-start" class="primary">启动</button>
+          <button id="btn-mock-stop">停止</button>
+          <span id="mock-state" class="state"></span>
+        </div>
+        <label>鉴权模式
+          <select id="mock-auth">
+            <option value="NONE">NONE</option>
+            <option value="TOKEN" selected>TOKEN(Bearer)</option>
+            <option value="BASIC">BASIC</option>
+            <option value="APIKEY">APIKEY</option>
+          </select>
+        </label>
+        <label>延迟(秒)<input id="mock-delay" type="number" step="0.5" min="0" value="0" /></label>
+        <label>强制 HTTP 错误
+          <select id="mock-force">
+            <option value="">不强制</option>
+            <option value="401">401</option>
+            <option value="403">403</option>
+            <option value="429">429</option>
+            <option value="500">500</option>
+          </select>
+        </label>
+        <label>响应路径 <input id="mock-resp-path" type="text" value="data.list" /></label>
+        <div class="row">
+          <button id="btn-mock-apply">应用设置</button>
+          <button id="btn-mock-clear-overrides">清空样例挂载</button>
+        </div>
+      </div>
+      <div class="card">
+        <h2>调用审计(脱敏)</h2>
+        <div class="row"><button id="btn-mock-audit">刷新审计</button></div>
+        <pre id="mock-audit" class="output">—</pre>
+      </div>
+    </div>
+  </section>
+
+  <!-- 模拟 MySQL -->
+  <section id="tab-db" class="tab">
+    <div class="grid2">
+      <div class="card">
+        <h2>模拟 MySQL 源库(DB_SYNC 对端)</h2>
+        <p class="muted">库名固定 <code>aidop_integration_sim</code>;凭据从环境变量读取,页面不保存密码。</p>
+        <label>对象 <select id="db-object"></select></label>
+        <div class="row">
+          <button id="btn-db-ensure" class="primary">建库建表</button>
+          <button id="btn-db-load">装载样例</button>
+          <button id="btn-db-reset">重置单表</button>
+          <button id="btn-db-stats">行数/最大增量</button>
+          <button id="btn-db-config">生成 Ai-DOP 配置清单</button>
+        </div>
+        <pre id="db-output" class="output">—</pre>
+      </div>
+      <div class="card">
+        <h2>Ai-DOP 侧登记建议(人工执行,不自动写配置)</h2>
+        <pre id="db-config-output" class="output small">选择对象后点击「生成 Ai-DOP 配置清单」</pre>
+      </div>
+    </div>
+  </section>
+
+  <!-- API_INBOUND -->
+  <section id="tab-inbound" class="tab">
+    <div class="grid2">
+      <div class="card">
+        <h2>26 契约 schema 预检</h2>
+        <div class="row">
+          <button id="btn-precheck-all" class="primary">全部预检</button>
+          <span class="muted">真实签名 GET schema;区分 READY / CONFIG_NOT_REGISTERED / CONFIG_NOT_ENABLED / GRANT_MISSING</span>
+        </div>
+        <pre id="inbound-precheck" class="output">—</pre>
+      </div>
+      <div class="card">
+        <h2>七类操作与必测用例</h2>
+        <label>实体 <select id="inbound-entity"></select></label>
+        <label>操作
+          <select id="inbound-op">
+            <option value="schema">1. schema</option>
+            <option value="push" selected>2. 普通 POST</option>
+            <option value="receipt">3. receipt</option>
+            <option value="bulk">4. bulk(NDJSON)</option>
+            <option value="snapshot_open">5. snapshot open</option>
+            <option value="snapshot_commit">6. snapshot commit</option>
+            <option value="digest">7. digest</option>
+          </select>
+        </label>
+        <label>用例(普通 POST)
+          <select id="inbound-case">
+            <option value="happy">正常推数</option>
+            <option value="missing-idem">缺幂等键</option>
+            <option value="tamper-body">body 摘要不一致</option>
+            <option value="expired-ts">过期 timestamp</option>
+            <option value="nonce-replay">nonce 重放</option>
+            <option value="same-key-replay">同幂等键同 body</option>
+            <option value="same-key-diff-body">同幂等键不同 body</option>
+            <option value="bad-signature">错误签名</option>
+          </select>
+        </label>
+        <label>receipt 批次号 <input id="inbound-batch" type="text" placeholder="syncBatchId" /></label>
+        <label>snapshotId <input id="inbound-snapshot" type="text" /></label>
+        <label>digest 业务日期 <input id="inbound-date" type="text" placeholder="yyyy-MM-dd" /></label>
+        <div class="row"><button id="btn-inbound-exec" class="primary">执行</button></div>
+        <pre id="inbound-output" class="output">—</pre>
+      </div>
+    </div>
+  </section>
+
+  <!-- 运行记录 -->
+  <section id="tab-runs" class="tab">
+    <div class="card">
+      <h2>最近运行记录(自动脱敏)</h2>
+      <div class="row"><button id="btn-runs-refresh">刷新</button></div>
+      <pre id="runs-output" class="output">—</pre>
+    </div>
+  </section>
+
+  <!-- 环境安全 -->
+  <section id="tab-security" class="tab">
+    <div class="card">
+      <h2>环境与安全状态</h2>
+      <pre id="security-output" class="output">加载中…</pre>
+      <ul class="muted">
+        <li>Secret / 数据库密码 / Admin Token 只从环境变量读取,本页面不可读取、不保存(无 localStorage)。</li>
+        <li>Ai-DOP 目标 host 必须在 <code>config.json → allowedTargetHosts</code> 白名单内,默认仅 127.0.0.1 / localhost。</li>
+        <li>报告中 AccessKey 只保留后四位;认证头、连接串自动脱敏。</li>
+        <li>模拟器不写 Ai-DOP 贴源/标准/KPI 表;三种通道使用独立测试 source_code(SIM_MODE3_*)。</li>
+      </ul>
+    </div>
+  </section>
+
+  <script src="/static/app.js"></script>
+</body>
+</html>

+ 9 - 0
tools/integration-simulator/tests/conftest.py

@@ -0,0 +1,9 @@
+"""pytest 公共夹具:把模拟器根目录加入 sys.path。"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+SIM_ROOT = Path(__file__).resolve().parents[1]
+if str(SIM_ROOT) not in sys.path:
+    sys.path.insert(0, str(SIM_ROOT))

+ 81 - 0
tools/integration-simulator/tests/test_guard.py

@@ -0,0 +1,81 @@
+"""安全闸门与三层状态判定守卫(任务书 P1-B 安全 / P1-G 结果分层)。"""
+from __future__ import annotations
+
+import pytest
+
+from clients import aidop_probe
+
+ALLOWED = ["127.0.0.1", "localhost"]
+
+
+@pytest.mark.parametrize("url", [
+    "http://127.0.0.1:5005",
+    "http://localhost:5005",
+])
+def test_whitelisted_hosts_pass(url):
+    aidop_probe.assert_host_allowed(url, ALLOWED)
+
+
+@pytest.mark.parametrize("url", [
+    "http://10.20.30.40:5005",
+    "https://aidop.customer.com",
+    "http://192.168.1.99:5005",
+    "http://prod-aidop.internal",
+])
+def test_non_whitelisted_hosts_blocked(url):
+    with pytest.raises(aidop_probe.HostNotAllowedError):
+        aidop_probe.assert_host_allowed(url, ALLOWED)
+
+
+@pytest.mark.parametrize("status,body,expected", [
+    (0, {"message": "SOURCE_UNREACHABLE: refused"}, "SOURCE_UNREACHABLE"),
+    (200, {"code": 0, "data": {"fields": []}}, "READY"),
+    (404, {"message": "entity not registered"}, "CONFIG_NOT_REGISTERED"),
+    (403, {"message": "inbound not enabled for entity"}, "CONFIG_NOT_ENABLED"),
+    (403, {"message": "grant missing for accessKey"}, "GRANT_MISSING"),
+    (401, {"message": "AccessKey 未绑定开放身份"}, "GRANT_MISSING"),
+    (401, {"message": "signature mismatch"}, "CONFIG_NOT_ENABLED"),
+    (500, {"message": "boom"}, "RUN_FAILED"),
+])
+def test_precheck_state_classification(status, body, expected):
+    assert aidop_probe.classify_inbound_precheck(status, body) == expected
+
+
+def test_unreachable_target_reports_source_unreachable():
+    result = aidop_probe.check_reachable("http://127.0.0.1:1", ALLOWED, timeout=2)
+    assert result["reachable"] is False
+
+
+def test_probe_rejects_non_whitelisted_target():
+    with pytest.raises(aidop_probe.HostNotAllowedError):
+        aidop_probe.check_reachable("http://10.0.0.9:5005", ALLOWED, timeout=2)
+
+
+def test_run_store_redacts_before_persist():
+    """运行记录写入前必须脱敏(不依赖已启动服务,直接测 RunStore)。"""
+    import app as sim_app
+
+    store = sim_app.RunStore(limit=5)
+    entry = store.add(
+        run_id="20260930120000-abc123", object_code="ITEM", channel="API_INBOUND",
+        request_summary={"operation": "push"},
+        response={"headers": {"X-Signature": "SIG==", "Authorization": "Bearer abc.def"},
+                  "note": "password=hunter2"},
+        layers={"code": "SUPPORTED", "config": "READY", "data": "ACCEPTED"},
+        followup_sql=["SELECT 1;"])
+    dumped = str(entry)
+    assert "SIG==" not in dumped
+    assert "abc.def" not in dumped
+    assert "hunter2" not in dumped
+    assert entry["layers"]["data"] == "ACCEPTED"
+    assert store.recent()[0]["runId"] == "20260930120000-abc123"
+
+
+def test_run_store_respects_limit():
+    import app as sim_app
+
+    store = sim_app.RunStore(limit=3)
+    for i in range(6):
+        store.add(str(i), "ITEM", "DB_SYNC", {}, {}, {})
+    assert len(store.runs) == 3
+    assert [r["runId"] for r in store.recent()] == ["5", "4", "3"]

+ 67 - 0
tools/integration-simulator/tests/test_inbound_signature.py

@@ -0,0 +1,67 @@
+"""签名金标向量与 HMAC/SHA256 守卫(任务书 P1-H)。
+
+金标向量由独立实现(Node.js crypto)生成,与 C# 端 InboundSignatureHandler 同一算法:
+    message = METHOD&path&accessKey&timestamp&nonce&bodySha256&idempotencyKey
+    signature = Base64(HMAC-SHA256(secret, message))
+"""
+from __future__ import annotations
+
+from clients.inbound_client import InboundClient, build_message, sha256_hex, sign
+
+EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+GOLDEN = {
+    "secret": "sim-secret-golden",
+    "method": "POST",
+    "path": "/api/mdp/inbound/S1_SALES_ORDER_ENTRY",
+    "accessKey": "SIMKEY01",
+    "timestamp": "1750000000",
+    "nonce": "nonce-fixed-0001",
+    "bodySha256": EMPTY_SHA256,
+    "idempotencyKey": "IDEM-GOLDEN-0001",
+    "signature": "caR7gxdSziU4IXI/kX4u8X11tOq0jGRmzzNHn8rEEAU=",
+}
+
+
+def test_sha256_empty_body():
+    assert sha256_hex(b"") == EMPTY_SHA256
+
+
+def test_signature_golden_vector():
+    message = build_message(
+        GOLDEN["method"], GOLDEN["path"], GOLDEN["accessKey"], GOLDEN["timestamp"],
+        GOLDEN["nonce"], GOLDEN["bodySha256"], GOLDEN["idempotencyKey"])
+    assert message == (
+        "POST&/api/mdp/inbound/S1_SALES_ORDER_ENTRY&SIMKEY01&1750000000"
+        "&nonce-fixed-0001&" + EMPTY_SHA256 + "&IDEM-GOLDEN-0001")
+    assert sign(GOLDEN["secret"], message) == GOLDEN["signature"]
+
+
+def test_signature_changes_with_secret():
+    message = build_message("POST", "/api/mdp/inbound/MDM_ITEM", "K", "1", "n", EMPTY_SHA256, "I")
+    assert sign("secret-a", message) != sign("secret-b", message)
+
+
+def test_request_headers_do_not_leak_signature():
+    """sent_headers 中签名必须遮蔽;Secret 不出现在任何返回头。"""
+    client = InboundClient("http://127.0.0.1:1", "SIMKEY01", GOLDEN["secret"])
+    # 目标端口 1 必然连接失败,但 headers 在异常前已构造并返回
+    status, body, headers = client.request(
+        "GET", "/api/mdp/inbound/MDM_ITEM/schema", b"",
+        idem=GOLDEN["idempotencyKey"], ts=int(GOLDEN["timestamp"]),
+        nonce=GOLDEN["nonce"], contract_version="v2")
+    assert status == 0
+    assert headers["X-Signature"] == "***REDACTED***"
+    assert headers["X-Access-Key"] == "SIMKEY01"
+    assert headers["X-Content-SHA256"] == EMPTY_SHA256
+    assert headers["X-Mdp-Contract-Version"] == "v2"
+    assert GOLDEN["signature"] not in str(headers)
+    for value in headers.values():
+        assert GOLDEN["secret"] not in str(value)
+    assert "SOURCE_UNREACHABLE" in str(body)
+
+
+def test_missing_idempotency_header_when_skipped():
+    client = InboundClient("http://127.0.0.1:1", "SIMKEY01", "s")
+    _, _, headers = client.request("POST", "/api/mdp/inbound/MDM_ITEM", b"{}", skip_idem=True)
+    assert "Idempotency-Key" not in headers

+ 125 - 0
tools/integration-simulator/tests/test_mock_api.py

@@ -0,0 +1,125 @@
+"""Mock API(API_PULL 对端)行为守卫:cursor 过滤、鉴权模式、错误注入、响应路径、审计脱敏。
+
+用 FastAPI TestClient 进程内验证,不占用端口、不需要 Ai-DOP 在线。
+契约对齐 MdpApiPullExecutor.cs:GET 单次请求、data.list、dedup_key_path=bizKey、?cursor=。
+"""
+from __future__ import annotations
+
+import base64
+
+import pytest
+
+fastapi_testclient = pytest.importorskip("fastapi.testclient")
+TestClient = fastapi_testclient.TestClient
+
+from providers import mock_api  # noqa: E402
+
+
+@pytest.fixture()
+def client():
+    state = mock_api.MockApiState()
+    state.settings.auth_mode = "NONE"
+    return TestClient(mock_api.create_app(state)), state
+
+
+def test_health_and_endpoints_listed(client):
+    c, _ = client
+    assert c.get("/health").json()["status"] == "UP"
+    body = c.get("/__endpoints").json()
+    assert "/api/item" in body["endpoints"]
+
+
+def test_data_list_shape_and_dedup_key(client):
+    c, _ = client
+    body = c.get("/api/item").json()
+    rows = body["data"]["list"]
+    assert body["code"] == 0
+    assert rows and all("bizKey" in r for r in rows)
+
+
+def test_cursor_returns_only_greater_bizkeys(client):
+    c, _ = client
+    rows = c.get("/api/sales-order-entry").json()["data"]["list"]
+    keys = [r["bizKey"] for r in rows]
+    assert keys == sorted(keys)
+    cut = keys[0]
+    # cursor 是查询参数,必须 URL 编码:样例 bizKey 含 '#'(片段分隔符),
+    # 直接拼接 URL 会被截断,故用 params= 让 httpx 正确编码。
+    after = c.get("/api/sales-order-entry", params={"cursor": cut}).json()["data"]["list"]
+    assert [r["bizKey"] for r in after] == [k for k in keys if k > cut]
+
+
+def test_unknown_path_is_404(client):
+    c, _ = client
+    assert c.get("/api/not-registered-object").status_code == 404
+
+
+def test_token_auth_rejects_and_accepts(client):
+    c, state = client
+    state.settings.auth_mode = "TOKEN"
+    state.settings.token = "sim-token"
+    assert c.get("/api/item").status_code == 401
+    assert c.get("/api/item", headers={"Authorization": "Bearer wrong"}).status_code == 401
+    assert c.get("/api/item", headers={"Authorization": "Bearer sim-token"}).status_code == 200
+
+
+def test_basic_auth(client):
+    c, state = client
+    state.settings.auth_mode = "BASIC"
+    state.settings.basic_user = "sim"
+    state.settings.basic_password = "pw"
+    token = base64.b64encode(b"sim:pw").decode()
+    assert c.get("/api/item").status_code == 401
+    assert c.get("/api/item", headers={"Authorization": f"Basic {token}"}).status_code == 200
+
+
+def test_apikey_auth(client):
+    c, state = client
+    state.settings.auth_mode = "APIKEY"
+    state.settings.api_key_header = "X-Api-Key"
+    state.settings.api_key_value = "k1"
+    assert c.get("/api/item").status_code == 401
+    assert c.get("/api/item", headers={"X-Api-Key": "k1"}).status_code == 200
+
+
+@pytest.mark.parametrize("status_code", [401, 403, 429, 500])
+def test_forced_http_errors(client, status_code):
+    c, state = client
+    state.settings.force_status = status_code
+    assert c.get("/api/item").status_code == status_code
+
+
+def test_response_path_is_switchable(client):
+    c, state = client
+    state.settings.response_path = "result.rows"
+    body = c.get("/api/item").json()
+    assert isinstance(body["result"]["rows"], list)
+
+
+def test_override_rows_win_and_can_be_cleared(client):
+    c, state = client
+    state.overrides["/api/item"] = [{"bizKey": "SIM-OVERRIDE-2"}, {"bizKey": "SIM-OVERRIDE-1"}]
+    rows = c.get("/api/item").json()["data"]["list"]
+    assert [r["bizKey"] for r in rows] == ["SIM-OVERRIDE-1", "SIM-OVERRIDE-2"]
+    state.overrides.clear()
+    assert c.get("/api/item").json()["data"]["list"]
+
+
+def test_empty_result_is_still_success(client):
+    c, state = client
+    state.overrides["/api/item"] = []
+    body = c.get("/api/item").json()
+    assert body["data"]["list"] == []
+    assert body["code"] == 0
+
+
+def test_audit_redacts_authorization_and_token(client):
+    c, state = client
+    state.settings.auth_mode = "TOKEN"
+    state.settings.token = "super-secret-token"
+    c.get("/api/item", headers={"Authorization": "Bearer super-secret-token"})
+    entries = state.audit
+    assert entries
+    dumped = str(entries)
+    assert "super-secret-token" not in dumped
+    assert entries[-1]["headers"]["authorization"] == "***REDACTED***"

+ 107 - 0
tools/integration-simulator/tests/test_mock_db.py

@@ -0,0 +1,107 @@
+"""模拟 MySQL(DB_SYNC 对端)守卫:DDL 生成、类型推断、库名硬阻断、配置清单。
+
+需要真实 MySQL 的用例在未设置 AIDOP_SIM_MYSQL_PASSWORD 时跳过,并明确标注“未实测”,
+不得把跳过当作通过。
+"""
+from __future__ import annotations
+
+import os
+
+import pytest
+
+from catalog import sample_adapters as sa
+from providers import mock_db
+
+RUN_ID = "20260930120000-abc123"
+
+
+def test_blocks_aidop_databases():
+    for bad in ("aidopdev", "aidop_uat", "aidop_prod", "aidop", "admin", "mdp_std"):
+        with pytest.raises(mock_db.BlockedDatabaseError):
+            mock_db.assert_sim_database(bad)
+
+
+def test_allows_simulator_schema():
+    mock_db.assert_sim_database(mock_db.SIM_DB_DEFAULT)
+    mock_db.assert_sim_database("aidop_integration_sim_uat")
+
+
+def test_rejects_empty_database_name():
+    with pytest.raises(mock_db.BlockedDatabaseError):
+        mock_db.assert_sim_database("")
+
+
+def test_conn_params_never_carry_aidop_db(monkeypatch):
+    monkeypatch.setenv("AIDOP_SIM_MYSQL_DATABASE", "aidopdev")
+    with pytest.raises(mock_db.BlockedDatabaseError):
+        mock_db.mysql_conn_params()
+
+
+def test_type_inference():
+    assert mock_db.infer_mysql_type([1, 2, None]) == "BIGINT"
+    assert mock_db.infer_mysql_type([1.5]) == "DECIMAL(18,6)"
+    assert mock_db.infer_mysql_type([True]) == "TINYINT(1)"
+    assert mock_db.infer_mysql_type(["abc"]).startswith("VARCHAR(")
+    assert mock_db.infer_mysql_type([None]) == "VARCHAR(191)"
+
+
+def test_ddl_has_business_unique_key_and_increment_index():
+    rows = sa.adapt_db_rows(sa.find_object("ITEM"), RUN_ID)
+    ddl = mock_db.generate_ddl("sim_item", rows, ["bizKey"], "sourceUpdatedAt")
+    assert ddl.startswith("CREATE TABLE IF NOT EXISTS `sim_item`")
+    assert "UNIQUE KEY `uk_sim_item` (`bizKey`)" in ddl
+    assert "KEY `ix_sim_item_incr` (`sourceUpdatedAt`)" in ddl
+    assert "ENGINE=InnoDB" in ddl
+
+
+def test_ddl_rejects_empty_rows():
+    with pytest.raises(ValueError):
+        mock_db.generate_ddl("sim_x", [], ["bizKey"])
+
+
+def test_upsert_sql_is_idempotent():
+    sql = mock_db._insert_sql("sim_item", ["bizKey", "sourceUpdatedAt"])
+    assert "ON DUPLICATE KEY UPDATE" in sql
+    assert sql.count("%s") == 2
+
+
+def test_aidop_config_uses_dedicated_test_sources():
+    cfg = mock_db.generate_aidop_config(sa.find_object("ITEM"))
+    codes = {s["source_code"] for s in cfg["sources"]}
+    assert codes == {"SIM_MODE3_DB", "SIM_MODE3_API", "SIM_MODE3_INBOUND"}
+    assert cfg["systemCode"] == "SIM_MODE3"
+    db_entity = next(e for e in cfg["entities"] if e["source_code"] == "SIM_MODE3_DB")
+    assert db_entity["source_table_name"] == "sim_item"
+    assert db_entity["incr_column"] == "sourceUpdatedAt"
+
+
+def test_aidop_config_omits_inbound_when_unsupported():
+    cfg = mock_db.generate_aidop_config(sa.find_object("SALES_ORDER_HEAD"))
+    assert "SIM_MODE3_INBOUND" not in {s["source_code"] for s in cfg["sources"]}
+
+
+requires_mysql = pytest.mark.skipif(
+    not os.environ.get("AIDOP_SIM_MYSQL_PASSWORD"),
+    reason="未设置 AIDOP_SIM_MYSQL_PASSWORD:真实 MySQL 用例未执行(不代表通过)")
+
+
+@requires_mysql
+def test_live_schema_load_and_increment():
+    obj = sa.find_object("ITEM")
+    spec = obj["dbSync"]
+    rows = sa.adapt_db_rows(obj, RUN_ID)
+    mock_db.init_schema({spec["table"]: {"rows": rows, "businessKey": spec["businessKey"],
+                                         "incrementColumn": spec["incrementColumn"]}})
+    mock_db.reset_table(spec["table"])
+    assert mock_db.upsert_rows(spec["table"], rows) == len(rows)
+    # 重复装载不得产生重复业务行
+    mock_db.upsert_rows(spec["table"], rows)
+    stats = mock_db.table_stats(spec["table"], spec["incrementColumn"])
+    assert stats["rowCount"] == len(rows)
+    assert stats["maxIncrement"]
+    # 更新并推进增量列
+    affected = mock_db.update_rows(spec["table"], {"itemName": "更新后"},
+                                   {"bizKey": rows[0]["bizKey"]}, spec["incrementColumn"],
+                                   "2026-09-30T23:59:59")
+    assert affected == 1
+    assert mock_db.table_stats(spec["table"], spec["incrementColumn"])["maxIncrement"] >= "2026-09-30"

+ 63 - 0
tools/integration-simulator/tests/test_redaction.py

@@ -0,0 +1,63 @@
+"""脱敏守卫:Secret、认证头、连接串、AccessKey 后四位(任务书 P1-B 安全 / P1-H)。"""
+from __future__ import annotations
+
+import json
+
+from clients.redaction import mask_access_key, redact_headers, redact_obj, redact_text
+
+SECRET = "S3CRET-VALUE-DO-NOT-LEAK"
+PASSWORD = "pw-123456"
+
+
+def test_mask_access_key_keeps_last_four():
+    assert mask_access_key("TESTKPARTNER") == "****TNER"
+    assert mask_access_key("ab") == "****"
+    assert mask_access_key("") == ""
+
+
+def test_redact_text_removes_known_secret():
+    text = f"signature computed with {SECRET} for tenant"
+    out = redact_text(text, [SECRET])
+    assert SECRET not in out
+    assert "***REDACTED***" in out
+
+
+def test_redact_text_masks_connection_string_password():
+    text = f"server=127.0.0.1;uid=root;password={PASSWORD};database=aidop_integration_sim"
+    out = redact_text(text)
+    assert PASSWORD not in out
+    assert "aidop_integration_sim" in out
+
+
+def test_redact_text_masks_bearer_and_basic():
+    assert "abc.def.ghi" not in redact_text("Authorization: Bearer abc.def.ghi")
+    assert "dXNlcjpwdw==" not in redact_text("Authorization: Basic dXNlcjpwdw==")
+
+
+def test_redact_headers_masks_sensitive_headers():
+    headers = {
+        "X-Access-Key": "TESTKPARTNER",
+        "X-Signature": "BASE64SIG==",
+        "Authorization": f"Bearer {SECRET}",
+        "X-Nonce": "n-1",
+    }
+    out = redact_headers(headers, [SECRET])
+    assert out["X-Access-Key"] == "****TNER"
+    assert out["X-Signature"] == "***REDACTED***"
+    assert out["Authorization"] == "***REDACTED***"
+    assert out["X-Nonce"] == "n-1"
+
+
+def test_redact_obj_recurses_and_masks_by_key_name():
+    payload = {
+        "request": {"headers": {"x-signature": "SIG", "x-access-key": "TESTKPARTNER"}},
+        "notes": [f"secret was {SECRET}", {"password": PASSWORD}],
+        "httpStatus": 202,
+    }
+    out = redact_obj(payload, [SECRET])
+    dumped = json.dumps(out, ensure_ascii=False)
+    assert SECRET not in dumped
+    assert PASSWORD not in dumped
+    assert out["request"]["headers"]["x-signature"] == "***REDACTED***"
+    assert out["request"]["headers"]["x-access-key"] == "****TNER"
+    assert out["httpStatus"] == 202

+ 175 - 0
tools/integration-simulator/tests/test_sample_adapters.py

@@ -0,0 +1,175 @@
+"""对象目录与样例适配守卫(任务书 P1-A / P1-F / P1-H)。"""
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from catalog import sample_adapters as sa
+
+# 方案 §7.2 列出的 26 个 API_INBOUND 代码契约
+EXPECTED_INBOUND_CONTRACTS = {
+    "MDM_ITEM", "MDM_CUSTOMER", "MDM_SUPPLIER", "MDM_LOCATION", "MDM_SOURCE_LIST",
+    "MDM_EMPLOYEE_HEADCOUNT", "S1_SALES_ORDER_ENTRY", "S1_REQUIREMENT_EXAMINE_RESULT",
+    "S1_REQUIREMENT_EXAMINE_DETAIL", "S2_WORK_ORDER_SCHEDULE", "S3_PURCHASE_ORDER",
+    "S3_PURCHASE_RECEIPT", "S4_SHIPMENT", "S4_IQC", "S4_RETURN", "S4_SHORTAGE",
+    "S5_WORK_ORDER_BOM", "S5_INVENTORY_TXN", "S5_INVENTORY_OPENING_BALANCE",
+    "S5_INVENTORY_BALANCE_MONTHLY", "S6_WORK_ORDER_LINE", "S6_REPORT_TXN",
+    "S7_FQC_TASK_TXN", "S7_SALES_ORDER_LINE", "S7_FINISHED_ONHAND",
+    "S7_FINISHED_OPENING_BALANCE",
+}
+
+RUN_ID = "20260930120000-abc123"
+
+
+def test_catalog_loads_and_has_three_channels():
+    catalog = sa.load_catalog()
+    assert catalog["objects"], "catalog must not be empty"
+    for obj in catalog["objects"]:
+        for channel in ("dbSync", "apiPull", "apiInbound"):
+            assert channel in obj, f"{obj['objectCode']} missing {channel}"
+            assert "supported" in obj[channel]
+        # 不支持必须写明原因,不得把“配置未开”写成“代码不支持”
+        for channel in ("dbSync", "apiPull", "apiInbound"):
+            if not obj[channel]["supported"]:
+                assert obj[channel].get("reason"), f"{obj['objectCode']}.{channel} unsupported without reason"
+
+
+def test_catalog_covers_all_26_inbound_contracts():
+    registered = {(o.get("apiInbound") or {}).get("entityCode")
+                  for o in sa.load_catalog()["objects"]
+                  if (o.get("apiInbound") or {}).get("supported")}
+    assert registered == EXPECTED_INBOUND_CONTRACTS, (
+        f"missing={sorted(EXPECTED_INBOUND_CONTRACTS - registered)}, "
+        f"unexpected={sorted(registered - EXPECTED_INBOUND_CONTRACTS)}")
+
+
+def test_every_inbound_contract_has_field_spec():
+    for code in EXPECTED_INBOUND_CONTRACTS:
+        assert code in sa.INBOUND_SPECS, f"no field spec for {code}"
+        assert any(required for _, required, _ in sa.INBOUND_SPECS[code]), f"{code} has no required field"
+
+
+def test_contract_version_matches_field_spec_semantics():
+    """INBOUND_SPECS 按 IdentityFields(v2 中立集合)造数,目录必须统一登记 v2。
+
+    C# 端 NormalizeVersion 只将显式 v2 路由到 IdentityFields;若目录写 v1 而适配表按 v2
+    生成,就会出现“按 v1 造数、按 v2 校验”的口径错配。
+    """
+    for obj in sa.load_catalog()["objects"]:
+        inbound = obj.get("apiInbound") or {}
+        if inbound.get("supported"):
+            assert inbound.get("contractVersion") == "v2", \
+                f"{obj['objectCode']} contractVersion 应为 v2,实为 {inbound.get('contractVersion')}"
+
+
+def test_builtin_sample_rows_satisfy_required_fields():
+    """内置最小样例必须能填满契约必填项(否则预检会把造数缺陷当成配置问题)。"""
+    run_id = "20260930120000-abc123"
+    for obj in sa.load_catalog()["objects"]:
+        if obj.get("sample") or not (obj.get("apiInbound") or {}).get("supported"):
+            continue
+        adapted = sa.adapt_inbound_rows(obj, run_id)
+        assert not adapted["missing_required"], \
+            f"{obj['objectCode']} builtin sample misses {adapted['missing_required']}"
+
+
+def test_sample_paths_exist():
+    for obj in sa.load_catalog()["objects"]:
+        if obj.get("sample"):
+            assert (sa.REPO_ROOT / obj["sample"]).exists(), f"sample missing: {obj['sample']}"
+        else:
+            assert obj["objectCode"] in sa.BUILTIN_SAMPLES, \
+                f"{obj['objectCode']} has neither sample file nor builtin rows"
+
+
+def test_run_id_format():
+    assert re.match(r"^\d{14}-[0-9a-f]{6}$", sa.new_run_id())
+
+
+def test_db_rows_carry_sim_prefix_and_increment_column():
+    obj = sa.find_object("ITEM")
+    rows = sa.adapt_db_rows(obj, RUN_ID)
+    assert rows
+    for row in rows:
+        assert str(row["bizKey"]).startswith(f"SIM-{RUN_ID}-")
+        assert row.get("sourceUpdatedAt"), "increment column must be filled"
+        assert row.get("Domain") == "SIM"
+
+
+def test_prefix_is_idempotent():
+    obj = sa.find_object("ITEM")
+    once = sa.adapt_db_rows(obj, RUN_ID)
+    twice = sa.adapt_db_rows(obj, RUN_ID, once)
+    assert [r["bizKey"] for r in once] == [r["bizKey"] for r in twice]
+
+
+def test_api_rows_sorted_by_bizkey_for_cursor():
+    obj = sa.find_object("SALES_ORDER_ENTRY")
+    rows = sa.adapt_api_rows(obj, RUN_ID)
+    keys = [str(r["bizKey"]) for r in rows]
+    assert keys == sorted(keys)
+
+
+@pytest.mark.parametrize("object_code,entity_code", [
+    ("ITEM", "MDM_ITEM"),
+    ("SALES_ORDER_ENTRY", "S1_SALES_ORDER_ENTRY"),
+    ("WORK_ORDER", "S2_WORK_ORDER_SCHEDULE"),
+    ("PURCHASE_ORDER", "S3_PURCHASE_ORDER"),
+    ("IQC", "S4_IQC"),
+    ("INVENTORY_TXN", "S5_INVENTORY_TXN"),
+    ("REPORT_TXN", "S6_REPORT_TXN"),
+    ("FQC_TASK", "S7_FQC_TASK_TXN"),
+])
+def test_inbound_rows_use_contract_field_names(object_code, entity_code):
+    obj = sa.find_object(object_code)
+    assert (obj["apiInbound"] or {}).get("entityCode") == entity_code
+    adapted = sa.adapt_inbound_rows(obj, RUN_ID)
+    spec_fields = {f for f, _, _ in sa.INBOUND_SPECS[entity_code]}
+    for row in adapted["rows"]:
+        unknown = set(row) - spec_fields - {"sourceUpdatedAt", "sourceVersion", "op"}
+        assert not unknown, f"{entity_code} produced non-contract fields: {sorted(unknown)}"
+        assert str(row.get("sourceUpdatedAt")), "sourceUpdatedAt is required by common contract"
+
+
+def test_inbound_reports_missing_required_instead_of_faking():
+    obj = sa.find_object("SALES_ORDER_ENTRY")
+    adapted = sa.adapt_inbound_rows(obj, RUN_ID, rows=[{"bizKey": "X-1", "qty": 5}])
+    # bill_no / entry_seq / seorder_id 无别名来源,必须报告缺失而不是编造
+    assert {"bill_no", "entry_seq", "seorder_id"} <= set(adapted["missing_required"])
+
+
+def test_inbound_rejects_object_without_contract():
+    obj = sa.find_object("SALES_ORDER_HEAD")
+    assert not (obj["apiInbound"] or {}).get("supported")
+    with pytest.raises(ValueError):
+        sa.adapt_inbound_rows(obj, RUN_ID)
+
+
+def test_envelope_shape():
+    env = sa.inbound_envelope([{"a": 1}], snapshot_id="SNAP-1", seq=2)
+    assert env == {"data": {"list": [{"a": 1}], "snapshotId": "SNAP-1", "seq": 2}}
+
+
+def test_scenarios_reference_known_objects_and_mark_unsupported():
+    scenarios = sa.load_scenarios()["scenarios"]
+    assert len(scenarios) == 3
+    for sc in scenarios:
+        assert sc["nodes"]
+        for node in sc["nodes"]:
+            obj = sa.find_object(node["objectCode"])
+            assert obj, f"scenario {sc['scenarioId']} references unknown object {node['objectCode']}"
+            spec = {"DB_SYNC": obj["dbSync"], "API_PULL": obj["apiPull"],
+                    "API_INBOUND": obj["apiInbound"]}[node["channel"]]
+            if not spec.get("supported"):
+                assert node.get("inboundNote") or spec.get("reason"), \
+                    f"{node['objectCode']} unsupported channel without note"
+
+
+def test_samples_contain_no_real_looking_secrets():
+    """样例不得含真实凭据形态字段。"""
+    banned = re.compile(r"(password|secret|access_?key|token|jwt)", re.IGNORECASE)
+    for obj in sa.load_catalog()["objects"]:
+        for row in sa.load_canonical_rows(obj):
+            for key in row:
+                assert not banned.search(str(key)), f"{obj['objectCode']} sample has credential-like field {key}"