فهرست منبع

fix(simulator): 完成三种方式基础人工联调(全链路报告/脱敏/导出) + mdp 入站参数绑定与响应修复; chore: bump server 1.0.600

Pengxy 20 ساعت پیش
والد
کامیت
6bca8c9818

BIN
_uat_screenshots/_report_list_after_save.png


BIN
_uat_screenshots/_report_tab_full.png


+ 1 - 0
doc/README.md

@@ -57,6 +57,7 @@
 | [plan/系统集成/Ai-DOP第三方系统集成指南.md](./plan/系统集成/Ai-DOP第三方系统集成指南.md) | 给第三方系统看的对接指南:入站对象、DB 同步与 API 约定、回写与联调验收 |
 | [plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md](./plan/系统集成/Ai-DOP第三方对接业务数据详表-业务页.md) | 对接详表(业务):九宫格/详情看板/诊断/ChatBI 需要哪些业务数据,不含库表名 |
 | [plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md](./plan/系统集成/Ai-DOP第三方对接业务数据详表-技术页.md) | 对接详表(技术):源字段落到中台哪张表、哪个栏位 |
+| [plan/Ai-DOP本体驱动智能体建设方案.md](./plan/Ai-DOP本体驱动智能体建设方案.md) | **智能体建设方案(待评审)**:以「本体驱动数据治理」四要素(实体/属性/关联/动作)为语义中枢,在现有 ChatBI/S9 诊断改善/数据中台/审批流之上增量建设四类智能体(数据感知/关系推理/风险预警/执行调度),含本体四表 DDL、四规则巡检引擎、工具注册闸门、P0~P4 分阶段计划与治理红线 |
 | [Windows后端WinSW守护重启方案.md](./Windows后端WinSW守护重启方案.md) | Windows 服务器上使用 WinSW 守护后端服务、自动重启与日志落盘 |
 | [plan/S1/S1-订单管理-审批流程实施方案.md](./plan/S1/S1-订单管理-审批流程实施方案.md) | S1 订单审批实施方案 |
 | [plan/S1-S3业务逻辑测试与数据准备方案.md](./plan/S1-S3业务逻辑测试与数据准备方案.md) | S1-S3 完整业务逻辑测试 case 与测试数据准备方案 |

+ 2 - 2
doc/db/mdp/mock_api/samples/inventory_txn.json

@@ -1,14 +1,14 @@
 [
   {
     "bizKey": "MOCK3-INV-1",
-    "transType": "PUR_RECEIPT",
+    "transType": "MAT_RECEIPT",
     "bizDocType": "PUR_RECEIPT",
     "itemNum": "RM-CT-001",
     "qtyChange": 10,
     "approvedFlag": 1,
     "voidFlag": 0,
     "summaryFlag": 0,
-    "effDate": "2026-09-20",
+    "effDate": "2026-10-12",
     "location": "A-01",
     "lotSerial": "LOT1",
     "createUser": "mock",

+ 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.599</AssemblyVersion>
-    <FileVersion>1.0.599</FileVersion>
-    <Version>1.0.599</Version>
+    <AssemblyVersion>1.0.600</AssemblyVersion>
+    <FileVersion>1.0.600</FileVersion>
+    <Version>1.0.600</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 7 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/MdpInboundController.cs

@@ -1,4 +1,5 @@
 using System.Security.Claims;
+using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
 using Microsoft.AspNetCore.Http;
 
@@ -128,7 +129,12 @@ public class MdpInboundController : ControllerBase
     }
 
     private IActionResult JsonOutcome(MdpInboundOutcome outcome) =>
-        new JsonResult(outcome.Body, MdpInboundJson.Options) { StatusCode = outcome.HttpStatus };
+        new ContentResult
+        {
+            Content = JsonSerializer.Serialize(outcome.Body, MdpInboundJson.Options),
+            ContentType = "application/json",
+            StatusCode = outcome.HttpStatus
+        };
 
     private string AccessKey() =>
         User.FindFirstValue(InboundSignatureDefaults.AccessKeyClaim) ?? string.Empty;

+ 3 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundAuthService.cs

@@ -23,14 +23,14 @@ public sealed class MdpInboundAuthService : ITransient
         var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
         var entity = await _db.Queryable<MdpEntity>()
             .Where(e => e.InboundEnabled == 1)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .FirstAsync(ct);
         if (entity == null)
             return (403, "entity not authorized");
 
         var grant = await _db.Queryable<MdpInboundGrant>()
             .Where(g => g.AccessKey == accessKey && g.Status == 1)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .FirstAsync(ct);
         if (grant == null)
             return (403, "entity not authorized");
@@ -63,7 +63,7 @@ public sealed class MdpInboundAuthService : ITransient
         var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
         return await _db.Queryable<MdpInboundGrant>()
             .Where(g => g.AccessKey == accessKey && g.Status == 1)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .FirstAsync(ct);
     }
 }

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundFieldMapper.cs

@@ -192,7 +192,7 @@ public sealed class MdpInboundFieldMapper : ITransient
         var rows = await _db.Queryable<MdpFieldMap>()
             .Where(m => m.TenantId == tenantId && m.Status == 1)
             .Where(m => m.AccessKey == accessKey || m.AccessKey == "*")
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .ToListAsync(ct);
 
         var merged = new Dictionary<string, MdpFieldMap>(StringComparer.OrdinalIgnoreCase);

+ 3 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundReceiveService.cs

@@ -441,7 +441,7 @@ public sealed class MdpInboundReceiveService : ITransient
 
         var reqs = await _db.Queryable<MdpInboundRequest>()
             .Where(r => r.AccessKey == accessKey && r.CreateTime >= from && r.CreateTime < to)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .ToListAsync(ct);
 
         var accepted = reqs.Where(r => r.Status == "COMMITTED").Sum(r => r.Accepted);
@@ -492,7 +492,7 @@ public sealed class MdpInboundReceiveService : ITransient
     {
         return await _db.Queryable<MdpEntity>()
             .Where(e => e.InboundEnabled == 1)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entityCode))
+            .Where("UPPER(entity_code) = @code", new { code = entityCode })
             .FirstAsync(ct);
     }
 
@@ -594,7 +594,7 @@ public sealed class MdpInboundReceiveService : ITransient
                 .Where(r => r.TenantId == args.TenantId
                             && r.AccessKey == args.AccessKey
                             && r.IdempotencyKey == args.IdempotencyKey)
-                .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entity.EntityCode.ToUpperInvariant()))
+                .Where("UPPER(entity_code) = @code", new { code = entity.EntityCode.ToUpperInvariant() })
                 .FirstAsync(ct)
                         ?? throw new MdpInboundBatchException(409, "in-progress");
 

+ 4 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Inbound/MdpInboundSnapshotService.cs

@@ -36,7 +36,7 @@ public sealed class MdpInboundSnapshotService : ITransient
 
         var existing = await _db.Queryable<MdpInboundSnapshot>()
             .Where(s => s.TenantId == tenantId && s.AccessKey == accessKey && s.Status == "OPEN")
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .FirstAsync(ct);
         if (existing != null)
             return Fail(409, "snapshot already open");
@@ -120,7 +120,7 @@ public sealed class MdpInboundSnapshotService : ITransient
             return Fail(403, "entity not authorized");
         var entity = await _db.Queryable<MdpEntity>()
             .Where(e => e.InboundEnabled == 1)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
+            .Where("UPPER(entity_code) = @code", new { code })
             .FirstAsync(ct);
         if (entity == null)
             return Fail(403, "entity not authorized");
@@ -267,7 +267,7 @@ public sealed class MdpInboundSnapshotService : ITransient
         await _db.Updateable<MdpInboundSnapshot>()
             .SetColumns(s => new MdpInboundSnapshot { Status = "EXPIRED", UpdateTime = DateTime.Now })
             .Where(s => s.TenantId == tenantId && s.AccessKey == accessKey && s.Status == "OPEN" && s.ExpireAt < DateTime.Now)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entityCode))
+            .Where("UPPER(entity_code) = @code", new { code = entityCode })
             .ExecuteCommandAsync(ct);
     }
 
@@ -275,7 +275,7 @@ public sealed class MdpInboundSnapshotService : ITransient
         string snapshotId, string accessKey, long tenantId, string entityCode, CancellationToken ct) =>
         _db.Queryable<MdpInboundSnapshot>()
             .Where(s => s.SnapshotId == snapshotId && s.AccessKey == accessKey && s.TenantId == tenantId)
-            .Where("UPPER(entity_code) = @code", new SugarParameter("@code", (entityCode ?? "").ToUpperInvariant()))
+            .Where("UPPER(entity_code) = @code", new { code = (entityCode ?? "").ToUpperInvariant() })
             .FirstAsync(ct);
 
     private Task<int> MarkStatusAsync(long id, string status, CancellationToken ct) =>

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/MdpSourceHealthCheckJob.cs

@@ -162,7 +162,7 @@ public class MdpSourceHealthCheckJob : IJob
             {
                 var last = await db.Queryable<MdpInboundRequest>()
                     .Where(r => r.Status == "COMMITTED")
-                    .Where("UPPER(entity_code) = @code", new SugarParameter("@code", entity.EntityCode.ToUpperInvariant()))
+                    .Where("UPPER(entity_code) = @code", new { code = entity.EntityCode.ToUpperInvariant() })
                     .OrderBy(r => r.CreateTime, OrderByType.Desc)
                     .FirstAsync(ct);
                 lastCommitted = last?.CreateTime;

+ 160 - 4
tools/integration-simulator/app.py

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

+ 11 - 1
tools/integration-simulator/catalog/sample_adapters.py

@@ -113,11 +113,21 @@ def load_canonical_rows(obj: dict) -> list[dict]:
     return [dict(r) for r in data]
 
 
+def _ensure_offset(value: str) -> str:
+    """纯日期或缺少时区偏移的时间 → 补 +08:00(Ai-DOP v2 契约要求 sourceUpdatedAt 带偏移)。"""
+    v = value.strip()
+    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", v):
+        return v + "T00:00:00+08:00"
+    if "T" in v and not re.search(r"[+-]\d{2}:\d{2}$", v):
+        return v + "+08:00"
+    return v
+
+
 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 _ensure_offset(str(row[key]))
     return now_iso()
 
 

+ 12 - 8
tools/integration-simulator/clients/inbound_client.py

@@ -67,8 +67,12 @@ class InboundClient:
         bad_signature: bool = False,
         contract_version: str | None = None,
         extra_headers: dict | None = None,
+        sign_path: str | None = None,
     ) -> tuple[int, dict | str, dict]:
-        """返回 (http_status, parsed_body, sent_headers)。sent_headers 中签名已遮蔽。"""
+        """返回 (http_status, parsed_body, sent_headers)。sent_headers 中签名已遮蔽。
+
+        sign_path:签名串路径与请求 URL 路径不同时使用(查询串不得进入签名,
+        对齐 Ai-DOP InboundSignatureHandler 用 Request.Path.Value 验签)。"""
         raw = body if body is not None else b""
         digest = sha256_hex(raw)
         if tamper_body:
@@ -76,7 +80,7 @@ class InboundClient:
         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)
+        message = build_message(method, sign_path or path, self.key, ts_s, nonce, digest, idem)
         signature = sign(self._secret, message)
         if bad_signature:
             signature = sign(self._secret + "-wrong", message)
@@ -137,14 +141,14 @@ class InboundClient:
         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"")
+        sign_path = f"/api/mdp/inbound/{entity}/snapshots/{urllib.parse.quote(snapshot_id)}/commit"
+        path = sign_path + ("?force=true" if force else "")
+        return self.request("POST", path, b"", sign_path=sign_path)
 
     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"")
+        sign_path = f"/api/mdp/inbound/{entity}/digest"
+        path = sign_path + f"?date={urllib.parse.quote(biz_date)}"
+        return self.request("GET", path, b"", sign_path=sign_path)
 
     # -- 必测用例(P1-E)-------------------------------------------------------
 

+ 30 - 9
tools/integration-simulator/clients/redaction.py

@@ -3,7 +3,9 @@
 规则:
 - 已知 Secret / 密码 / Token 值出现即替换为 ***REDACTED***;
 - 敏感 Header 值只保留 AccessKey 后四位,其余整体遮蔽;
-- 连接串中 pwd=/password= 段遮蔽。
+- 连接串中 pwd=/password= 段遮蔽;
+- 裸 JWT(eyJ…)与 URL 内嵌凭据(scheme://user:pwd@host)遮蔽;
+- 已知 AccessKey 值出现在自由文本时只保留后四位(任务书 §24)。
 """
 from __future__ import annotations
 
@@ -21,7 +23,14 @@ 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)
+# 中文自由文本口令:密码/口令 + 值(仅 ASCII 值,避免误伤普通描述)
+_CN_PWD_RE = re.compile(r"(密码|口令)\s*[::=]?\s*[A-Za-z0-9_\-@#$%!*]{4,}")
 _BEARER_RE = re.compile(r"(Bearer|Basic)\s+[A-Za-z0-9\-._~+/=]+", re.IGNORECASE)
+# 裸 JWT:header.payload.signature 三段 Base64URL(即使没有 Bearer 前缀)
+_JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")
+# URL 内嵌凭据:mysql://user:pwd@host / https://user:pass@host
+_URL_CRED_RE = re.compile(
+    r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:/\s@]+):(?P<pwd>[^@/\s]+)@")
 
 
 def mask_access_key(access_key: str | None) -> str:
@@ -31,17 +40,19 @@ def mask_access_key(access_key: str | None) -> str:
     return f"****{access_key[-4:]}" if len(access_key) > 4 else "****"
 
 
-def redact_headers(headers: dict, secrets: list[str] | None = None) -> dict:
+def redact_headers(headers: dict, secrets: list[str] | None = None,
+                   access_keys: 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)
+            out[k] = redact_text(str(v), secrets, access_keys)
     return out
 
 
-def redact_text(text: str, secrets: list[str] | None = None) -> str:
+def redact_text(text: str, secrets: list[str] | None = None,
+                access_keys: list[str] | None = None) -> str:
     if not text:
         return text
     result = text
@@ -49,16 +60,26 @@ def redact_text(text: str, secrets: list[str] | None = None) -> str:
         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 = _CN_PWD_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", result)
     result = _BEARER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", result)
+    result = _JWT_RE.sub(REDACTED, result)
+    result = _URL_CRED_RE.sub(
+        lambda m: f"{m.group('scheme')}{m.group('user')}:{REDACTED}@", result)
+    # 已知 AccessKey 值:只保留后四位(优先于全量遮蔽)
+    for access_key in access_keys or []:
+        if access_key and access_key in result:
+            result = result.replace(access_key, mask_access_key(access_key))
     return result
 
 
-def redact_obj(obj, secrets: list[str] | None = None):
+def redact_obj(obj, secrets: list[str] | None = None,
+               access_keys: list[str] | None = None):
     """递归脱敏 dict/list/str,用于运行记录与报告。
 
     - 敏感 Header 名:Authorization/签名类整体遮蔽,AccessKey 只留后四位;
     - 键名命中 SENSITIVE_KEY_RE 的字段整体遮蔽;
-    - 其余字符串按已知 Secret 值与连接串/Bearer 模式遮蔽。
+    - 其余字符串按已知 Secret 值、连接串/Bearer/JWT/URL 凭据模式遮蔽,
+      已知 AccessKey 值只保留后四位。
     """
     if isinstance(obj, dict):
         out = {}
@@ -67,10 +88,10 @@ def redact_obj(obj, secrets: list[str] | None = None):
             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)
+                out[k] = redact_obj(v, secrets, access_keys)
         return out
     if isinstance(obj, list):
-        return [redact_obj(v, secrets) for v in obj]
+        return [redact_obj(v, secrets, access_keys) for v in obj]
     if isinstance(obj, str):
-        return redact_text(obj, secrets)
+        return redact_text(obj, secrets, access_keys)
     return obj

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

@@ -115,6 +115,12 @@ def create_app(state: MockApiState | None = None) -> FastAPI:
     def health():
         return {"status": "UP", "authMode": state.settings.auth_mode}
 
+    @app.get("/")
+    def root():
+        # Ai-DOP 数据源测连对 api_base_url 发起 GET 根路径,返回 200 使测连显示成功
+        return {"status": "UP", "name": "Ai-DOP Integration Simulator Mock API",
+                "authMode": state.settings.auth_mode}
+
     @app.get("/__endpoints")
     def list_endpoints():
         return {

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

@@ -270,6 +270,72 @@ async function inboundExec() {
   catch (ex) { setError('inbound-output', ex); }
 }
 
+/* ---------------- 全链路报告(F5) ---------------- */
+let LAST_REPORT_ID = null;
+
+function reportFields() {
+  return {
+    runId: $('report-runid').value.trim(),
+    channel: $('report-channel').value,
+    environment: $('report-env').value.trim(),
+    objectCode: $('report-object').value.trim(),
+    simPrep: $('report-simprep').value.trim(),
+    manualSteps: $('report-steps').value.trim(),
+    simEvidence: $('report-simevidence').value.trim(),
+    aidopConn: $('report-conn').value.trim(),
+    flowIds: $('report-flowids').value.trim(),
+    stagingCheck: $('report-staging').value.trim(),
+    targetResult: $('report-target').value.trim(),
+    conclusion: $('report-conclusion').value.trim(),
+    gaps: $('report-gaps').value.trim(),
+    cleanup: $('report-cleanup').value.trim(),
+  };
+}
+
+async function refreshReportList() {
+  try {
+    const data = await api('/api/report/list');
+    show('report-list-output', data);
+    return data.reports || [];
+  } catch (ex) { setError('report-list-output', ex); return []; }
+}
+
+async function saveReport() {
+  const fields = reportFields();
+  if (!fields.runId || !fields.channel) { alert('请先填写运行编号 runId'); return; }
+  try {
+    const r = await api('/api/report/save', { method: 'POST', body: JSON.stringify(fields) });
+    LAST_REPORT_ID = r.report.id;
+    $('report-runid').value = r.report.runId || fields.runId;
+    await refreshReportList();
+    alert(`已保存报告 ${r.report.id}(保存前已脱敏;不保存密码、Secret、JWT、连接串)`);
+  } catch (ex) { setError('report-list-output', ex); }
+}
+
+function exportReport(id) {
+  if (!id) { alert('请先「保存报告」,再导出 Markdown'); return; }
+  const a = document.createElement('a');
+  a.href = '/api/report/export/' + encodeURIComponent(id);
+  a.download = '';
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+}
+
+async function importReportEvidence() {
+  const channel = $('report-channel').value;
+  try {
+    const data = await api('/api/report/evidence/' + channel);
+    const entries = data.entries || [];
+    const lines = entries.length
+      ? entries.map((e) => `[${e.time}] ${e.objectCode} · ${e.channel} · runId=${e.runId}\n`
+        + JSON.stringify({ request: e.request, response: e.response, layers: e.layers }, null, 2))
+        .join('\n\n')
+      : `(${channel} 暂无运行记录;先在对应页签执行一次操作)`;
+    $('report-simevidence').value = lines;
+  } catch (ex) { alert('导入失败:' + ex.message); }
+}
+
 /* ---------------- 初始化 ---------------- */
 async function init() {
   CATALOG = await api('/api/catalog');
@@ -339,6 +405,12 @@ async function init() {
     try { show('runs-output', await api('/api/runs')); } catch (ex) { setError('runs-output', ex); }
   });
 
+  $('btn-report-save').addEventListener('click', saveReport);
+  $('btn-report-export').addEventListener('click', () => exportReport(LAST_REPORT_ID));
+  $('btn-report-refresh').addEventListener('click', refreshReportList);
+  $('btn-report-evidence').addEventListener('click', importReportEvidence);
+  refreshReportList();
+
   refreshChannelState();
   renderScenarioGraph();
   await refreshStatus();

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

@@ -18,6 +18,7 @@
     <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-report">全链路报告</button>
     <button data-tab="tab-runs">运行记录</button>
     <button data-tab="tab-security">环境安全</button>
   </nav>
@@ -192,6 +193,64 @@
     </div>
   </section>
 
+  <!-- 全链路报告 -->
+  <section id="tab-report" class="tab">
+    <div class="grid2">
+      <div class="card">
+        <h2>全链路测试报告(人工填写摘要)</h2>
+        <p class="muted">保存前自动脱敏:密码、Secret、JWT、完整 Authorization、连接串不入报告;AccessKey 仅保留后四位。</p>
+        <label>集成方式
+          <select id="report-channel">
+            <option value="DB_SYNC">方式甲 DB_SYNC(数据库同步)</option>
+            <option value="API_PULL">方式乙 API_PULL(接口拉取)</option>
+            <option value="API_INBOUND">方式丙 API_INBOUND(接口推送)</option>
+          </select>
+        </label>
+        <label>运行编号 runId <input id="report-runid" type="text" placeholder="SIM-yyyyMMddHHmmss" /></label>
+        <label>环境与测试租户 <input id="report-env" type="text" placeholder="如:开发库 / 租户 T1" /></label>
+        <label>对象 <input id="report-object" type="text" placeholder="如:ITEM / PURCHASE_ORDER / S5_INVENTORY_TXN" /></label>
+        <label>模拟端准备结果
+          <textarea id="report-simprep" rows="2" placeholder="建表装载行数 / Mock 挂载样例 / 推数受理结果"></textarea>
+        </label>
+        <label>人工执行步骤
+          <textarea id="report-steps" rows="3" placeholder="1. 在 Ai-DOP 登记 SIM_MODE3_* 来源;2. 测连;3. 触发同步/拉取/推送…"></textarea>
+        </label>
+        <label>模拟端证据
+          <textarea id="report-simevidence" rows="5" placeholder="源表行数、Mock 审计、入站响应摘要;可点「从运行记录导入」"></textarea>
+        </label>
+        <div class="row">
+          <button id="btn-report-evidence">从运行记录导入证据</button>
+          <span class="muted">导入内容已脱敏</span>
+        </div>
+        <label>Ai-DOP 测连结果 <textarea id="report-conn" rows="2" placeholder="测连成功/失败与提示"></textarea></label>
+        <label>同步/入站流水编号 <input id="report-flowids" type="text" placeholder="mdp_sync_log / mdp_inbound_request 编号" /></label>
+      </div>
+      <div class="card">
+        <h2>Ai-DOP 侧人工核验与结论</h2>
+        <label>贴源核验结果
+          <textarea id="report-staging" rows="2" placeholder="贴源表行数、与源端比对结果"></textarea>
+        </label>
+        <label>目标业务结果
+          <textarea id="report-target" rows="3" placeholder="标准层/目标表查询结果(只读核验)"></textarea>
+        </label>
+        <label>结论 <input id="report-conclusion" type="text" placeholder="如:全链路打通 / 部分通过 / 未通过" /></label>
+        <label>未完成项
+          <textarea id="report-gaps" rows="2" placeholder="未验证项与原因"></textarea>
+        </label>
+        <label>清理提示
+          <textarea id="report-cleanup" rows="2" placeholder="如:删除 SIM_MODE3_DB 来源、停止 Mock、清理测试数据"></textarea>
+        </label>
+        <div class="row">
+          <button id="btn-report-save" class="primary">保存报告(自动脱敏)</button>
+          <button id="btn-report-export">导出 Markdown</button>
+          <button id="btn-report-refresh">刷新列表</button>
+        </div>
+        <h3>已保存报告(内存态,重启即清空)</h3>
+        <pre id="report-list-output" class="output small">—</pre>
+      </div>
+    </div>
+  </section>
+
   <!-- 运行记录 -->
   <section id="tab-runs" class="tab">
     <div class="card">

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

@@ -0,0 +1,107 @@
+"""F5 全链路报告:只存摘要、保存/导出前脱敏、Markdown 结构与渠道校验(任务书 §10 / §24)。"""
+from __future__ import annotations
+
+import json
+
+from app import CHANNELS, REPORT_FIELDS, REPORTS, build_report_markdown
+from clients.redaction import REDACTED
+
+SECRET = "S3CRET-VALUE-DO-NOT-LEAK"
+PASSWORD = "pw-123456"
+ACCESS_KEY = "TESTKPARTNER"
+
+
+def _fields(**kw) -> dict:
+    base = {
+        "runId": "SIM-RPT-001",
+        "channel": "DB_SYNC",
+        "environment": "开发库 / 租户 T1",
+        "objectCode": "ITEM",
+        "simPrep": "建表并装载 3 行",
+        "manualSteps": "在 Ai-DOP 登记 SIM_MODE3_DB 来源并触发同步",
+        "simEvidence": "源表 3 行,最大增量 2026-10-12",
+        "aidopConn": "测连成功",
+        "flowIds": "SYNC-1001",
+        "stagingCheck": "贴源 3 行,与源端一致",
+        "targetResult": "目标表可查 3 行",
+        "conclusion": "全链路打通",
+        "gaps": "",
+        "cleanup": "删除 SIM_MODE3_DB 来源",
+    }
+    base.update(kw)
+    return base
+
+
+def test_report_fields_cover_taskbook_sections():
+    labels = [label for _, label in REPORT_FIELDS]
+    for expected in ("运行编号", "集成方式", "环境与测试租户", "模拟端准备结果",
+                     "人工执行步骤", "模拟端证据", "Ai-DOP 测连结果",
+                     "同步/入站流水编号", "贴源核验结果", "目标业务结果",
+                     "结论", "未完成项", "清理提示"):
+        assert any(expected in label for label in labels), f"report missing section: {expected}"
+
+
+def test_report_save_redacts_secret_and_password():
+    # 真实运行中密码来自环境变量 _secrets();这里把密码作为已知 Secret 传入,模拟同一路径
+    entry = REPORTS.save(_fields(simPrep=f"用密码 {PASSWORD} 连接,Secret={SECRET} 初始化"),
+                         secrets=[SECRET, PASSWORD])
+    dumped = json.dumps(entry, ensure_ascii=False)
+    assert SECRET not in dumped
+    assert PASSWORD not in dumped
+    assert REDACTED in dumped
+
+
+def test_report_save_masks_chinese_password_label():
+    # 测试人员自由文本写「密码 xxx」时也须遮蔽(任务书 §24),无需知道真实密码
+    entry = REPORTS.save(_fields(simPrep=f"数据库连接:密码 {PASSWORD},端口 3306"))
+    dumped = json.dumps(entry, ensure_ascii=False)
+    assert PASSWORD not in dumped
+    assert "3306" in dumped  # 非敏感部分保留
+
+
+def test_report_save_masks_jwt_and_connection_string():
+    entry = REPORTS.save(_fields(
+        flowIds="token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdEFGH1234,"
+                "conn=mysql://root:pw-123456@127.0.0.1:3306/aidop_integration_sim"))
+    dumped = json.dumps(entry, ensure_ascii=False)
+    assert "eyJhbGciOiJIUzI1NiJ9" not in dumped
+    assert "pw-123456" not in dumped
+    assert "aidop_integration_sim" in dumped  # 非敏感部分保留
+
+
+def test_report_save_keeps_only_last_four_of_access_key():
+    entry = REPORTS.save(_fields(environment=f"开放身份 AccessKey={ACCESS_KEY}"),
+                         access_keys=[ACCESS_KEY])
+    dumped = json.dumps(entry, ensure_ascii=False)
+    assert ACCESS_KEY not in dumped
+    assert "****TNER" in dumped
+
+
+def test_report_markdown_contains_all_sections_and_redaction_note():
+    entry = REPORTS.save(_fields())
+    md = build_report_markdown(entry)
+    for label in ("运行编号 runId", "方式甲 DB_SYNC", "环境与测试租户", "对象",
+                  "模拟端准备结果", "人工执行步骤", "模拟端证据", "Ai-DOP 测连结果",
+                  "同步/入站流水编号", "贴源核验结果", "目标业务结果", "结论",
+                  "未完成项", "清理提示", "脱敏说明"):
+        assert label in md
+    assert entry["runId"] in md
+    assert "SIM-RPT-001" in md
+
+
+def test_report_markdown_uses_channel_label():
+    md = build_report_markdown(_fields(channel="API_INBOUND"))
+    assert "方式丙 API_INBOUND(接口推送)" in md
+
+
+def test_channels_are_the_three_integration_modes():
+    assert set(CHANNELS) == {"DB_SYNC", "API_PULL", "API_INBOUND"}
+
+
+def test_report_store_limit_and_delete():
+    store = REPORTS
+    entry = store.save(_fields(runId="SIM-RPT-DEL"))
+    assert store.get(entry["id"]) is not None
+    assert store.delete(entry["id"]) is True
+    assert store.get(entry["id"]) is None
+    assert store.delete(entry["id"]) is False