Browse Source

test:mcp接口测试第一轮,测试大部分读取接口,其他接口后续再测试

Jerry Liang 2 months ago
parent
commit
602c076bda

+ 8 - 2
service/cmd/mcp/main.go

@@ -44,8 +44,14 @@ func main() {
 	}
 	cache.Init(cfg.App.RedisAddr)
 	minioUtil.InitClient(&cfg.App)
-	planeCfg := &cfg.PlaneWebApi
-	repositories.InitPlaneClient(planeCfg.Host, planeCfg.Email, planeCfg.Password)
+
+	// 初始化 FWH Pro API 客户端(注入 host/账号,否则请求 URL 无 host)
+	repositories.FwhHttpInit(&cfg.FwhApi)
+
+	// 初始化 Plane(含底层 client + Feedback 专用实例 repositories.Plane)
+	// 注意:必须用 InitPlane 而非仅 InitPlaneClient,否则 repositories.Plane 为 nil,
+	// service_feedback_* 会空指针 panic。
+	repositories.InitPlane(&cfg.PlaneWebApi)
 
 	// 初始化 MCP FileStore
 	mcpFS := filestore.NewFileStore("download")

+ 347 - 0
service/cmd/mcp/test/ANALYSIS.md

@@ -0,0 +1,347 @@
+# FWH MCP Server 测试 SKIP / FAIL 详细分析
+
+> 配套 `report.md`(自动生成的用例明细)与 `pii_report.md`(脱敏专项)。
+> 本文对 1 个 FAIL 和 73 个 SKIP 逐类给出根因分析、代码定位与修复建议。
+> 分析基于源码阅读,非推测;关键行号已标注。
+
+---
+
+## 一、总览
+
+| 类别 | 数量 | 是否缺陷 | 结论 |
+|------|------|----------|------|
+| FAIL | 1 | **是(真实缺陷)** | `service_feedback_history` nil 指针 panic |
+| SKIP-A 需真实样本参数 | 51 | 否 | 工具有必填参数,本轮无样本,未构造调用 |
+| SKIP-B 依赖未初始化 | 约 8 | **是(配置缺陷)** | MCP `main.go` 少调了 FWH / Plane / Queue 的初始化 |
+| SKIP-C 数据为空 | 3 | 否 | 生产库当日无对应统计文档 |
+| SKIP-D Plane 参数缺失被远端拒 | 3 | 次要(健壮性) | handler 忽略必填校验错误,导致 404 而非清晰报错 |
+
+**核心结论**:真正需要修的是 **MCP Server 的初始化不完整**(`cmd/mcp/main.go`)。它只初始化了 DB/Cache/MinIO/Plane 底层 client,漏掉了 3 处初始化,直接导致 1 个 FAIL + 约 8 个本应 PASS 的 SKIP。
+
+---
+
+## 二、FAIL 详细分析
+
+### FAIL-1 `service_feedback_history` — nil 指针 panic
+
+**现象**
+```
+internal error: panic recovered in service_feedback_history tool handler:
+runtime error: invalid memory address or nil pointer dereference
+```
+(panic 被 `server.WithRecovery()` 捕获,进程未崩溃,但该工具完全不可用。)
+
+**根因(已定位)**
+
+调用链:`handleFeedbackHistory` → `services.GetFeedbackHistory("")` → `repositories.Plane.ListIntakeIssues(...)`。
+
+`repositories.Plane` 是一个包级全局变量,只有 `repositories.InitPlane()` 才会给它赋值:
+
+`repositories/repositories.go:22,26-29`
+```go
+var Plane *plane_v2.FeedbackPlaneService
+
+// InitPlane 初始化新架构 Plane(启动时调用)
+func InitPlane(cfg *config.PlaneWebApiConfig) {
+    plane_v2.InitPlaneClient(cfg.Host, cfg.Email, cfg.Password)
+    Plane = plane_v2.NewFeedbackPlaneService(cfg.WorkspaceSlug, cfg.ProjectId, ...)
+}
+
+// 通用初始化接口(给MCP Server等外部模块使用)
+var InitPlaneClient = plane_v2.InitPlaneClient   // 只初始化底层 client,不赋值 Plane
+```
+
+而 MCP 的 `cmd/mcp/main.go:48` 只调用了 **底层** 的 `InitPlaneClient`:
+```go
+repositories.InitPlaneClient(planeCfg.Host, planeCfg.Email, planeCfg.Password)
+```
+**没有调用 `repositories.InitPlane(...)`**,所以 `repositories.Plane` 始终为 `nil` → 空指针解引用 panic。
+
+**修复建议(二选一)**
+
+方案 A(推荐,最小改动)——在 `cmd/mcp/main.go` 用 `InitPlane` 替换 `InitPlaneClient`:
+```go
+// 原:repositories.InitPlaneClient(planeCfg.Host, planeCfg.Email, planeCfg.Password)
+repositories.InitPlane(&cfg.PlaneWebApi)
+```
+`InitPlane` 内部已包含 `InitPlaneClient`,同时会给 `repositories.Plane` 赋值,`data_plane_v2_*` 与 `service_feedback_*` 都能正常工作。
+
+方案 B(防御性,二者叠加)——保留底层 init 的同时补充:
+```go
+repositories.InitPlaneClient(planeCfg.Host, planeCfg.Email, planeCfg.Password)
+repositories.InitPlane(&cfg.PlaneWebApi)   // 追加,给 Plane 全局赋值
+```
+
+**附加健壮性建议**:`services.GetFeedbackHistory` 入口应做 nil 校验,避免未初始化时 panic:
+```go
+func GetFeedbackHistory(userName string) ([]FeedbackHistoryItem, error) {
+    if repositories.Plane == nil {
+        return nil, fmt.Errorf("Plane 未初始化")
+    }
+    ...
+}
+```
+
+---
+
+## 三、SKIP 详细分析(按类)
+
+### SKIP-B 依赖未初始化(**建议修复**)
+
+这些工具连接失败/URL 非法,根因都是 MCP `main.go` 缺少初始化,而非依赖服务本身不可用。
+
+#### B-1 FWH 远程 API:`http: no Host in request URL`
+
+**受影响用例**:`data_fwh_query_site_base_info_list`、`data_fwh_query_site_list`、
+`data_fwh_query_alarm_statistics`、`data_fwh_query_fault_record_call_record_list`、
+`data_fwh_query_tou_template_list`、`service_tou_template_list` 等(凡走 FWH Pro API)。
+
+**现象**
+```
+Post "https:///hes-gateway/manage/selectIotTouTemplateList": http: no Host in request URL
+```
+注意 `https://` 后面 **host 为空**。
+
+**根因(已定位)**
+
+FWH 客户端的 host 来自包级变量 `fwhEnergy`,只有 `FwhHttpInit` 会赋值:
+
+`repositories/api/fwh_pro/fwhHttpClient.go:16-31`
+```go
+var fwhEnergy string   // 未初始化时为 ""
+
+func FwhHttpInit(cfg *config.FwhApiConfig) {
+    fwhEnergy = cfg.FwhEnergy   // ← 关键赋值
+    ...
+}
+func getFullEnergyUrl(relativeURL string) string {
+    return "https://" + fwhEnergy + relativeURL   // fwhEnergy="" → "https:///..."
+}
+```
+
+HTTP Server 的 `main.go:61` 有调用 `repositories.FwhHttpInit(&cfg.FwhApi)`,
+但 **MCP 的 `cmd/mcp/main.go` 没有调用**,因此 `fwhEnergy` 为空 → URL 无 host。
+
+> 说明:`config.env` 里 `FWH_ENERGY="energy.franklinwh.com"` 是配置好的,
+> 问题不在配置文件,而在 MCP 启动时没把它注入 FWH 客户端。
+
+**修复建议**:在 `cmd/mcp/main.go` 初始化段补一行:
+```go
+repositories.FwhHttpInit(&cfg.FwhApi)
+```
+(若还需测试环境 API,可按需追加 `FwhTestApi`。)
+
+#### B-2 后台任务:`inspector not initialized`
+
+**受影响用例**:`service_background_task_list`
+
+**根因(已定位)**
+
+`task/queue/fwhQueue/fwhTaskQueue.go:98`
+```go
+return nil, fmt.Errorf("inspector not initialized")
+```
+Asynq 的 inspector 需在启动时初始化。MCP `main.go` 未初始化任务队列 inspector。
+
+**修复建议**(按需):
+- 若希望 MCP 支持查询后台任务,在 `main.go` 补充任务队列 inspector 的初始化(参考 HTTP Server 启动流程中的队列初始化)。
+- 若 MCP 场景不需要该能力,建议 handler 在 inspector 为空时返回**明确的业务提示**(如"后台任务查询在当前部署未启用"),而非底层错误串,避免误导。
+
+#### 小结(SKIP-B 一次性修复)
+
+在 `cmd/mcp/main.go` 的初始化段(现为 `database.Init` → `cache.Init` → `minioUtil.InitClient` → `InitPlaneClient`)补齐:
+```go
+repositories.FwhHttpInit(&cfg.FwhApi)   // 修复 B-1:FWH API host
+repositories.InitPlane(&cfg.PlaneWebApi) // 修复 FAIL-1 + Plane 相关
+// (可选)初始化任务队列 inspector,修复 B-2
+```
+预计可将 FWH 相关 4~6 个 SKIP、feedback panic、以及后台任务查询一并转为可用。
+
+---
+
+### SKIP-D Plane 参数被远端拒绝(**次要健壮性问题**)
+
+**受影响用例**:`plane_list_labels`、`plane_list_states`(测试以 `{}` 调用),返回 `404 Not Found`。
+
+**根因(已定位)**
+
+这两个工具在 schema 中把 `workspace_slug`/`project_id` 声明为 `Required`,
+但 handler **忽略了必填校验错误**:
+
+`cmd/mcp/tools/data/plane_v2_api_handlers.go:164-177`
+```go
+func handlePlaneV2ListLabels(...) {
+    slug, _ := req.RequireString("workspace_slug")   // ← 错误被丢弃
+    projectId, _ := req.RequireString("project_id")  // ← 错误被丢弃
+    data, err := planeV2Svc.ListLabels(slug, projectId) // 用空串请求 → 远端 404
+    ...
+}
+```
+对比正确写法(如 `handlePlaneV2GetWorkspace` / `handlePlaneV2ListProjects`)是检查错误并 `return helper.ParamError(...)`。
+
+**影响**:功能上这些工具**需要**真实 slug/project_id 才能用(属 SKIP-A 类),
+但由于未做前置校验,缺参时返回的是含糊的远端 404,而不是清晰的"参数 workspace_slug 必填"。
+
+**修复建议**:统一补齐必填校验:
+```go
+slug, err := req.RequireString("workspace_slug")
+if err != nil { return helper.ParamError("workspace_slug"), nil }
+projectId, err := req.RequireString("project_id")
+if err != nil { return helper.ParamError("project_id"), nil }
+```
+建议全量排查 `plane_v2_api_handlers.go` 中所有 `:= req.RequireString(...)` 后紧跟 `, _` 丢弃错误的写法。
+
+> 备注:`plane_list_projects` 用例本轮同样因缺 `workspace_slug` 被判 SKIP,
+> 但它 handler 有正确校验,返回了清晰的"参数 workspace_slug 必填",属正常行为。
+
+---
+
+### SKIP-A 需真实样本参数(**非缺陷,测试限制**)
+
+**受影响用例**(约 51 个):所有含必填业务参数的只读工具,例如
+`service_site_query_info`(sn)、`service_sim_query`(giccid)、
+`data_pgsql_query_snapshot_data`(sn,date)、`data_sim_*`(iccid)、
+`plane_get_*`(workspace_slug,project_id,...)、`storage_minio_read_object`(bucket,key) 等。
+
+**原因**:这些工具需要真实存在的 SN / GICCID / 任务 ID / 文件名 / Plane UUID 才能构造有意义的调用;本轮测试无这些样本,驱动主动 SKIP(避免用假值产生无意义的错误)。
+
+**这不是缺陷**。补测方法:提供样本后运行驱动的样本模式(见"如何补测")。这些工具的**参数校验分支**已在 L1 层抽样验证正确(缺参返回 `参数 xx 必填`)。
+
+---
+
+### SKIP-C 数据为空(**非缺陷**)
+
+**受影响用例**:`service_monitor_alarm_stats`、`service_dashboard_alarm_detail`(`mongo: no documents in result`)。
+
+**原因**:生产库 `StatisticsAlarm` 集合当日/最新无文档。工具行为正确(查不到返回错误/空),只是恰好无数据。
+`service_monitor_shutdown_stats`、`service_dashboard_shutdown_detail` 同类工具有数据、已 PASS,可佐证逻辑正常。
+
+**建议**(可选):对"无文档"场景,`services` 层可返回**空结果 + 提示**(`helper.EmptyResult`)而非 error,语义更友好;当前作为 error 也可接受。
+
+---
+
+## 四、修复优先级建议
+
+| 优先级 | 问题 | 修复位置 | 预期收益 |
+|--------|------|----------|----------|
+| **P0** | FAIL-1 feedback panic | `cmd/mcp/main.go` 改用 `InitPlane` | 消除 panic,Plane/feedback 可用 |
+| **P0** | B-1 FWH host 为空 | `cmd/mcp/main.go` 补 `FwhHttpInit` | 恢复所有 `data_fwh_*` / TOU 远程查询 |
+| P1 | SKIP-D Plane handler 忽略必填校验 | `plane_v2_api_handlers.go` 补错误检查 | 缺参时清晰报错而非 404 |
+| P2 | B-2 后台任务 inspector | `cmd/mcp/main.go` 补队列初始化 或 友好提示 | 后台任务查询可用/语义清晰 |
+| P2 | GetFeedbackHistory nil 防御 | `services/feedbackServices.go` | 纵深防御 |
+| P3 | SKIP-C 空数据语义 | `services` 统计相关 | 语义友好(可选) |
+
+**一次性关键修复**(`cmd/mcp/main.go`,覆盖 P0):
+```go
+// 现有:
+database.Init(&cfg.App)
+cache.Init(cfg.App.RedisAddr)
+minioUtil.InitClient(&cfg.App)
+repositories.InitPlaneClient(planeCfg.Host, planeCfg.Email, planeCfg.Password)
+
+// 建议调整为:
+database.Init(&cfg.App)
+cache.Init(cfg.App.RedisAddr)
+minioUtil.InitClient(&cfg.App)
+repositories.FwhHttpInit(&cfg.FwhApi)      // 新增:FWH API host
+repositories.InitPlane(&cfg.PlaneWebApi)   // 替换:同时初始化 Plane 全局实例
+```
+
+---
+
+## 五、如何补测(回归验证)
+
+1. 应用上述 `main.go` 修复并重新构建:`go build -o fwh-mcp-server ./cmd/mcp/`
+2. 重新运行驱动:
+   `go run ./cmd/mcp/test/runner -bin <新二进制> -cwd /var/www/fwh-toolbox/server -out ./cmd/mcp/test`
+3. 预期:FAIL-1 转 PASS;FWH/TOU 相关 SKIP-B 转 PASS 或 SKIP-A(若仍缺样本)。
+4. 提供真实 SN / GICCID / Plane workspace_slug+project_id / 已知 MinIO 对象后,
+   可为驱动增加样本入参(`runL2` 的 preset 段),将 SKIP-A 用例转为真实冒烟。
+
+## 六、回归验证结果(已应用修复后)
+
+已应用 P0 + P1 修复并重跑驱动,结果对比:
+
+| 指标 | 修复前 | 修复后 |
+|------|--------|--------|
+| PASS | 50 | **57** |
+| FAIL | 1 | **0** |
+| SKIP | 73 | 67 |
+
+已应用的修复:
+1. `cmd/mcp/main.go`:新增 `repositories.FwhHttpInit(&cfg.FwhApi)`;将 `InitPlaneClient` 替换为 `repositories.InitPlane(&cfg.PlaneWebApi)`。
+2. `services/feedbackServices.go`:`GetFeedbackHistory` / `SubmitFeedback` 增加 `repositories.Plane == nil` 防御。
+3. `cmd/mcp/tools/data/plane_v2_api_handlers.go`:新增 `requireSlugProject` 辅助函数,修复 15 个 handler 忽略 `workspace_slug`/`project_id` 必填校验的问题。
+
+验证要点:
+- **FAIL-1 已消除**:`service_feedback_history` → PASS(返回 506B 合法结果)。
+- **B-1 已修复**:`data_fwh_query_site_list`、`data_fwh_query_tou_template_list`、`service_tou_template_list` 等 FWH 远程查询 → PASS。
+- **D 已修复**:`plane_list_labels`/`states`/`projects` 缺参时返回清晰的"参数 workspace_slug 必填",不再是 404。
+- **L4 脱敏仍 4/4 PASS**,修复未影响脱敏。
+- 剩余 SKIP:61 项需真实样本参数(非缺陷)、2 项统计集合空数据(非缺陷)、1 项 `service_background_task_list`(B-2,inspector 未初始化,属 P2 待定项,本次未改)。
+
+---
+
+## 七、第二轮:真实样本补测 + MinIO/文件写读闭环
+
+使用用户提供的样本(SN `10130095A00X25280030`、GICCID `8931084524121848556`、
+Plane workspace `fwh` / project `55efdd5f-...`)补测原 SKIP-A 用例,并做 MinIO 写 + 文件读闭环。
+
+结果:**149 用例 PASS 82 / FAIL 0 / SKIP 67**(较上一轮 +25 用例、+25 PASS)。
+
+- **L2S 真实样本(21/21 PASS)**:站点详情/功率流/模式/告警、SIM 详情/月用量、
+  Plane 项目/工作项/标签/状态/周期/模块/收件箱/成员 全部真实调用成功。
+- **L3RW 写读闭环(4/4 PASS)**:`storage_file_write`(写入 MinIO)→ `storage_file_list` →
+  `storage_file_read`(读回内容一致)→ `storage_minio_list_objects`(确认对象落盘)。
+- **PII 端到端(7/7 PASS)**:新增 3 个真实 SN/GICCID 详情(site_query_info / sim_query /
+  smarthub_card_info)脱敏检测,均无明文泄露。
+
+### 本轮新发现缺陷(已修复)
+
+**BUG-2 `data_fwh_query_system_update_log` 反序列化失败**
+
+- 现象:`json: cannot unmarshal number into Go struct field SystemUpdateRecordInfo.result.upgradeType of type string`
+- 根因:`domain/fwhProDomain.go` 中 `SystemUpdateRecordInfo.UpgradeType` 声明为 `string`,
+  但 FWH 上游 API 对该字段实际返回 **number**,导致整个响应反序列化失败、工具不可用。
+- 修复:将 `UpgradeType string` 改为 `UpgradeType int`(上游该字段返回数值,用数值类型更明确、类型安全;该字段仅透传输出、无消费方,改动安全)。
+- 验证:修复后 `data_fwh_query_system_update_log` → PASS。
+
+> 说明:该缺陷仅在特定 SN(`upgradeType` 为数字)时触发,第一轮因无样本被 SKIP 掩盖,
+> 说明真实样本补测对发现"数据形态相关"的反序列化 bug 很有价值。建议排查同族结构
+> (如 `AHubUpdateRecordInfo`、`SystemUpdateDetailInfo` 等)是否也存在 number/string 混用字段。
+
+---
+
+## 八、第三轮:级联补测(L2C + L2X),覆盖率最大化
+
+在样本轮基础上新增两阶段自动补测,把"有必填参数但可从已 PASS 接口派生"的工具全部覆盖,**无需额外外部样本**。
+
+- **L2C 级联补测(12 PASS / 5 SKIP)**:先调列表接口,用 `jsonExtract`(支持 `data.list[0].iccid` 路径)提取第一个 id/key/name,再喂给详情接口。覆盖:
+  auto_update get_package/get_config、tou detail(service + data_fwh)、site_analysis get_config/data、
+  batch_download path、site_monitor config_get、acceleronix card_info/data_pool_sim_info、
+  minio read_object(正确的大小校验判 PASS)、plane get_intake_issue。
+- **L2X 额外补测(7 PASS)**:递归级联 / 自造输入。覆盖:
+  site_power_flow(样本 SN+date)、plane get_workspace/list_pages/list_stickies、
+  batch_download file_tree(递归取 path 树第一个文件叶子)、
+  storage_file_read_to_local(写后读到本地)、storage_file_upload(自造本地临时文件上传)。
+
+累计结果:**173 用例 PASS 101 / FAIL 0 / SKIP 72**(可补测工具已全部覆盖)。
+
+### 剩余 SKIP 的性质(已无"可补测"项)
+
+排除各阶段已 PASS 后,剩 23 个工具,全部**无法靠测试手段自行解决**:
+
+- **B 组(13)需"确有数据"的样本**:快照 file_name、有快照的 sn+date、真实 alarm_code、
+  有效 cmd_type、有故障录波/EMS/告警的 SN、有升级详情的 order、有用量的 month。
+- **C 组(1)需代码改动**:`service_background_task_list` 需在 MCP main.go 初始化 asynq inspector。
+- **D 组(9)环境数据为空**:告警统计集合当日无文档;Plane 该 project 无 issue/cycle/module/page。
+  工具逻辑正常,需先有业务数据。
+
+> 每个剩余 SKIP 的"缺什么/如何补"已在 `report.md` 的「SKIP 逐条分析」章节动态列出。
+
+---
+
+## 九、已确认无问题的项(供对照)
+- 协议与 119 个工具注册、schema、命名规范:全部正常。
+- 参数校验(L1 14 项):全部正确拦截。
+- **PII 脱敏(L4)**:生产库真实数据经 `service_site_search`/`service_sim_search`/`data_mongo_find` 返回均正确脱敏(account/手机/地址/经纬度/IMEI/IMSI/deviceIp 均 `******`),保留字段(sn/giccid/_id)正常;helper 单测 21 项全 PASS。脱敏是本次测试重点,结论为**安全达标**。

+ 269 - 0
service/cmd/mcp/test/TEST_CASES.md

@@ -0,0 +1,269 @@
+# FWH MCP Server 详细测试用例
+
+> 配套文档:`TEST_PLAN.md`(总体方案)。本文件是可执行的用例清单。
+> 运行路径:`/var/www/fwh-toolbox/server`(cwd,加载 `config.env`)
+> 传输:stdio JSON-RPC;被测二进制:`./fwh-mcp-server`
+> 用例编号规则:`层级-序号`;三态结果:PASS / FAIL / SKIP(原因)
+
+约定:
+- 「入参」为 `tools/call` 的 `arguments` 对象。
+- 「期望」描述断言点;`isError=true` 指 CallToolResult 的错误结果(`mcp.NewToolResultError`)。
+- 依赖不可用(连接失败)→ 记 SKIP;进程 panic / 结构非法 / 超时 → FAIL。
+- `<真实SN>` / `<真实GICCID>` 为占位,需替换为实际测试样本。
+
+---
+
+## L0 协议与工具清单
+
+### L0-1 初始化握手
+- 方法:`initialize`
+- 入参:`protocolVersion` 用 client 默认,`clientInfo={name:"mcp-test",version:"1.0"}`
+- 期望:返回成功;`serverInfo.name=="fwh-tools"`;`serverInfo.version=="2.0.0"`;`protocolVersion` 非空。
+
+### L0-2 工具清单可枚举
+- 方法:`tools/list`(循环翻页至 `nextCursor` 为空,聚合全部)
+- 期望:工具总数 > 0;无重复 name;导出到 `tools_snapshot.json`。
+
+### L0-3 命名规范
+- 数据来源:L0-2 聚合结果
+- 期望:每个 name 前缀 ∈ {`service_`,`data_`,`storage_`,`util_`,`plane_`};无其它前缀。
+
+### L0-4 Schema 完整性
+- 数据来源:L0-2
+- 期望:每个工具含非空 `description`、`inputSchema.type=="object"`;`inputSchema.required` 中的字段都存在于 `properties`。
+
+### L0-5 未知工具调用
+- 方法:`tools/call`,`name="__no_such_tool__"`,`arguments={}`
+- 期望:返回错误(协议层 error 或 isError),进程不崩溃。
+
+### L0-6 工具清单快照回归(可选)
+- 期望:与上次 `tools_snapshot.json` diff;新增/删除/改名的工具需人工确认(防止误删注册)。
+
+---
+
+## L1 纯函数与参数校验(不依赖后端)
+
+### L1-1 SN 解析正确性
+- 工具:`util_parse_sn`
+- 入参:`{"sn":"10060006A03X25310091"}`
+- 期望:`productCode=="1006"`;`productName` 含 "aGate";`hwVersion=="0006"`;`year=="2025"`;`week=="31"`;`serialNumber=="0091"`;`summary` 非空。
+
+### L1-2 SN 各产品码
+- 工具:`util_parse_sn`,分别传 `1005...`/`1002...`/`1003...`/`9999...`
+- 期望:`productName` 分别为 aPower / aHub·PowerLink / MPPT / 未知产品。
+
+### L1-3 SN 缺参
+- 入参:`{}`
+- 期望:`isError=true`,消息含「参数 sn 必填」。
+
+### L1-4 SN 长度不足
+- 入参:`{"sn":"1006"}`
+- 期望:`isError=true`,消息含「SN 长度不足」。
+
+### L1-5 站点查询缺 sn
+- 工具:`service_site_query_info`,入参 `{}`
+- 期望:`isError=true`,「参数 sn 必填」。
+
+### L1-6 功率流日期格式错误
+- 工具:`service_site_power_flow`
+- 入参:`{"sn":"ANY","date":"2025/01/01"}`
+- 期望:`isError=true`,「date 格式错误 ... yyyy-MM-dd」(在调用后端前返回)。
+
+### L1-7 SIM 月数据缺 month
+- 工具:`service_sim_monthly_data`,入参 `{"giccid":"x"}`
+- 期望:`isError=true`,「参数 month 必填」。
+
+### L1-8 mongo 非白名单 collection
+- 工具:`data_mongo_find`
+- 入参:`{"collection":"UserInfo"}`
+- 期望:`isError=true`,「不允许查询此集合」(`UserInfo` 已从白名单移除)。
+
+### L1-9 mongo filter 非法 JSON
+- 工具:`data_mongo_find`
+- 入参:`{"collection":"SiteInfoManageData","filter":"{bad json"}`
+- 期望:`isError=true`,「filter ... JSON 解析失败」。
+
+### L1-10 redis 通配符拦截
+- 工具:`data_redis_exists`
+- 入参:`{"key":"foo*"}`
+- 期望:`isError=true`,「不支持通配符」。
+
+### L1-11 redis 非法 type
+- 工具:`data_redis_get`
+- 入参:`{"key":"any","type":"zset"}`
+- 期望:`isError=true`,「可选值: string, hash, list, set」。
+
+### L1-12 minio 非白名单 bucket
+- 工具:`storage_minio_list_objects`
+- 入参:`{"bucket":"secret-bucket"}`
+- 期望:`isError=true`,「不允许访问此 bucket」。
+
+### L1-13 emspara id 非数字
+- 工具:`data_emspara_vid_info`
+- 入参:`{"id":"abc"}`
+- 期望:`isError=true`,「id ... 必须是数字」。
+
+### L1-14 pgsql 日期格式错误
+- 工具:`data_pgsql_query_snapshot_data`
+- 入参:`{"sn":"ANY","date":"20250101"}`
+- 期望:`isError=true`,「date ... yyyy-MM-dd」。
+
+### L1-15 storage_file 路径穿越拦截
+- 工具:`storage_file_read`
+- 入参:`{"path":"/uploads/../../etc/passwd"}`(或 `storage_file_read_to_local` 同理)
+- 期望:安全拒绝(不存在/非法路径),不读到 `/uploads/` 外文件。
+
+### L1-16 反馈类型校验
+- 工具:`service_feedback_create`
+- 入参:`{"title":"t","type":"invalid"}`
+- 期望:`isError=true`,「type ... 必须是 bug 或 feature」(在调用 Plane 前返回,属参数校验,不产生写入)。
+
+---
+
+## L2 只读工具连通性冒烟(依赖外部服务)
+
+> 每个用例通用期望:无 panic;返回合法 CallToolResult(成功 JSON / 业务空结果 / 结构化错误)。
+> 连接类失败 → SKIP。业务空数据(如「查询结果为空」)算 PASS。
+
+### Redis 组
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-R1 | `data_redis_exists` | `{"key":"nonexistent_test_key"}` | 返回「key 不存在」或 exists 信息,结构合法 |
+| L2-R2 | `data_redis_get` | `{"key":"nonexistent_test_key"}` | 返回空/不存在提示,结构合法 |
+
+### Mongo 组(白名单 collection)
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-M1 | `data_mongo_count` | `{"collection":"SiteInfoManageData"}` | 返回 `{collection,count}`,count≥0 |
+| L2-M2 | `data_mongo_find` | `{"collection":"SiteInfoManageData","limit":1}` | 返回数组或「查询结果为空」;**若含 PII 须已脱敏(见 L4-5)** |
+| L2-M3 | `data_mongo_find` | `{"collection":"StatisticsAlarm","limit":1,"sort":{"createdAt":-1}}` | 结构合法 |
+
+### PgSQL 组
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-P1 | `data_pgsql_query_snapshot_data` | `{"sn":"<真实SN>","date":"<近期日期>"}` | 返回数据或「查询结果为空」 |
+| L2-P2 | `data_pgsql_query_snapshot_file_record` | `{"file_name":"<已知文件名>"}` | 返回记录或「未找到文件记录」 |
+
+### MinIO / 文件组
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-S1 | `storage_minio_list_objects` | `{"bucket":"fwh-toolbox","limit":5}` | 返回对象列表或「无匹配对象」 |
+| L2-S2 | `storage_minio_read_object` | `{"bucket":"fwh-toolbox","key":"<已知文本对象>"}` | 返回文本内容;非文本/超1MB 返回相应校验错误 |
+| L2-S3 | `storage_file_list` | `{}` | 返回文件列表或空 |
+
+### Plane 组(真实凭据)
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-PL1 | `plane_list_workspaces` | `{}` | 返回 workspace 列表;登录失败→SKIP |
+| L2-PL2 | `plane_list_projects` | `{}` | 返回项目列表 |
+| L2-PL3 | `plane_list_issues` | `{}`(或带 project) | 返回 issue 列表 |
+| L2-PL4 | `plane_list_labels` | `{}` | 返回标签列表 |
+| L2-PL5 | `plane_list_states` | `{}` | 返回状态列表 |
+| L2-PL6 | `service_feedback_history` | `{}` | 返回 Feedback 标签历史或空 |
+
+### 业务层(依赖远程 MySQL)
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-B1 | `service_site_search` | `{"page":1,"size":5}` | 返回分页站点列表;**PII 须脱敏(L4-2)** |
+| L2-B2 | `service_sim_search` | `{"page":1,"size":5}` | 返回分页 SIM 列表;**脱敏(L4-4)** |
+| L2-B3 | `service_monitor_alarm_stats` | `{}` | 返回最新告警统计或空 |
+| L2-B4 | `service_monitor_shutdown_stats` | `{}` | 返回最新停机统计或空 |
+| L2-B5 | `service_dashboard_shutdown_date` | `{}` | 返回日期字符串或「暂无数据」 |
+| L2-B6 | `service_dashboard_shutdown_detail` | `{}` | 结构合法 |
+| L2-B7 | `service_dashboard_alarm_detail` | `{}` | 结构合法 |
+| L2-B8 | `service_tou_template_list` | `{"page":1,"size":5}` | 返回模版列表 |
+| L2-B9 | `service_background_task_list` | `{}` | 返回任务列表或「当前无后台任务」 |
+| L2-B10 | `service_site_monitor_config_list` | `{}` | 返回配置列表 |
+| L2-B11 | `service_site_monitor_name_list` | `{}` | 返回名称列表 |
+| L2-B12 | `service_site_info_update_status` | `{}` | 返回更新状态 |
+| L2-B13 | `service_site_info_tags_list` | `{"page":1,"size":5}` | 返回标签列表 |
+| L2-B14 | `service_auto_update_get_options` | `{}` | 返回升级包/版本选项 |
+| L2-B15 | `service_auto_update_get_apower_version` | `{}` | 返回 aPower 版本列表 |
+| L2-B16 | `service_auto_update_file_list` | `{"page":1,"size":5}` | 返回文件列表 |
+| L2-B17 | `service_batch_download_list` | `{"page":1,"size":5}` | 返回任务列表 |
+| L2-B18 | `service_site_analysis_list` | `{"page":1,"size":5}` | 返回分析任务列表 |
+
+### FWH 远程 API 组(凭据/网络需实测,失败→SKIP)
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L2-F1 | `service_site_query_info` | `{"sn":"<真实SN>"}` | 返回站点详情;**PII 脱敏(L4-1)** |
+| L2-F2 | `service_site_power_flow` | `{"sn":"<真实SN>","date":"<近期日期>"}` | 返回功率流数据 |
+| L2-F3 | `service_sim_query` | `{"giccid":"<真实GICCID>"}` | 返回 SIM 详情;**脱敏(L4-3)** |
+| L2-F4 | `service_sim_monthly_data` | `{"giccid":"<真实GICCID>","month":"<yyyy-MM>"}` | 返回月用量 |
+| L2-F5 | `data_fwh_query_site_base_info_list` | 见工具描述最小必填 | 结构合法或结构化错误 |
+| L2-F6 | 其余 `data_fwh_*`(约 20 个) | 各取最小必填参数 | 逐一冒烟:结构合法 / SKIP |
+| L2-F7 | `data_sim_*`(SmartHub/Acceleronix) | 最小必填 | 结构合法 / SKIP |
+| L2-F8 | `data_emspara_vid_info` / `pid_info` | `{"id":"1"}` | 返回宏名/描述或「未找到」 |
+
+> L2-F6/F7 需按 `data/fwh_api.go`、`data/sim_api.go` 的每个工具展开为逐条用例(driver 中据 `tools/list` 自动生成最小入参)。
+
+---
+
+## L3 写操作(默认只测参数校验,不真实写入)
+
+> 除非确认隔离环境,L3 仅验证「必填缺失被拦截」,不构造合法写入。
+
+| 编号 | 工具 | 入参 | 期望 |
+|------|------|------|------|
+| L3-1 | `service_feedback_create` | `{"title":"t"}`(缺 type) | `isError`,「参数 type 必填」 |
+| L3-2 | `service_feedback_create` | `{"title":"t","type":"x"}` | `isError`,「必须是 bug 或 feature」 |
+| L3-3 | `service_site_info_tag_update` | `{}` | `isError`,「参数 tag 必填」 |
+| L3-4 | `service_auto_update_create_package` | `{"package_type":"system"}`(缺 info) | `isError`,「参数 info 必填」 |
+| L3-5 | `service_auto_update_update_config` | `{"agate_version":"v"}`(缺 config) | `isError`,「参数 config 必填」 |
+| L3-6 | `service_site_monitor_config_create` | `{"name":"n"}`(缺 site_list) | `isError`,「参数 site_list 必填」 |
+| L3-7 | `service_site_monitor_config_delete` | `{}` | `isError`,「参数 name 必填」 |
+| L3-8 | `service_batch_download_create` | 缺必填 | `isError` |
+| L3-9 | `service_site_analysis_create` | 缺必填 | `isError` |
+| L3-10 | `storage_file_write` | `{"path":"/uploads/x"}`(缺 content) | `isError`,「参数 content 必填」 |
+| L3-11 | `storage_file_upload` | `{}`(缺 localPath) | `isError`,「参数 localPath 必填」 |
+
+**(可选)隔离环境真实写入用例**(需你确认后启用):
+- L3-W1 `service_site_monitor_config_create` → `_get` 校验 → `_delete` 清理(闭环)
+- L3-W2 `storage_file_write` → `storage_file_list`/`storage_minio_list_objects` 校验 → 删除
+- 每条真实写入用例必须带**清理步骤**,避免污染数据。
+
+---
+
+## L4 敏感数据脱敏验证(安全门禁)
+
+### L4-0 脱敏单元测试
+- 命令:`go test ./cmd/mcp/tools/helper/...`
+- 期望:全部 PASS(掩码函数、流式脱敏、强类型脱敏、A/B 一致性、fail-closed、null 保留)。
+
+### 端到端脱敏(负向断言:明文 PII 不得出现;正向断言:保留字段原值)
+
+明文检测正则(对返回文本整体扫描,命中即 FAIL):
+- 手机号:`\b1\d{10}\b`
+- IMEI/IMSI:`\b\d{14,15}\b`
+- MAC:`([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}`
+- IP:`\b(?!0\.0\.0\.0)\d{1,3}(\.\d{1,3}){3}\b`
+- 邮箱本地部分明文:`[\w.+-]{2,}@`(脱敏后应为 `x***@`)
+
+| 编号 | 工具 | 入参 | 负向断言(不得出现) | 正向断言(应保留/应呈现) |
+|------|------|------|------|------|
+| L4-1 | `service_site_query_info` | `{"sn":"<真实SN>"}` | 明文手机号/邮箱/地址/经纬度/IMEI/MAC/IP | `sn`/`iccId`/`city` 原值;`contactNumber`/`address`/`imei`==`******`;`userName`~`X***`;`email`~`x***@域名` |
+| L4-2 | `service_site_search` | `{"page":1,"size":5}` | 同上(对 Data[] 每项) | 每项均脱敏;分页字段 total/page 正常 |
+| L4-3 | `service_sim_query` | `{"giccid":"<真实GICCID>"}` | 明文 imei/imsi/msisdn | `imei`/`imsi`/`msisdn`==`******`;`giccid` 原值保留 |
+| L4-4 | `service_sim_search` | `{"page":1,"size":5}` | 同上(对 Data[]) | 每项脱敏,giccid 保留 |
+| L4-5 | `data_mongo_find` | `{"collection":"SiteInfoManageData","limit":1}` | 明文 PII(走 B 路径流式脱敏) | 敏感 key 的 value 已脱敏;sn/iccid 等保留 |
+| L4-6 | `data_mongo_find` | `{"collection":"SimCardManage","limit":1}` | 明文 imei/imsi/msisdn | giccid 保留 |
+| L4-7 | 触发大结果落盘的查询(如 `service_site_search` 大 size 或 `data_fwh_*` 大结果) | — | 读取返回的 `localPath` 文件,文件内**同样无明文 PII** | `spilled==true`;`preview` 亦已脱敏 |
+
+### L4-8 fail-closed(设计验证)
+- 主要由 L4-0 单测保证(`TestMaskJSONBytes_InvalidJSONFailsClosed`)。
+- 端到端标注为「设计验证」:脱敏解析失败时返回 error,不返回原始数据。
+
+---
+
+## 报告字段建议
+
+`report.md` 每行:`用例编号 | 工具 | 结果(PASS/FAIL/SKIP) | 耗时ms | 原因/备注`
+`pii_report.md` 每条 L4 用例附:命中的负向断言(应为空)、正向断言结果、脱敏后返回样本片段。
+
+## 依赖前置检查清单(执行前跑一遍)
+- [ ] cwd == `/var/www/fwh-toolbox/server`,`config.env` 可读
+- [ ] `./fwh-mcp-server` 存在且可执行(或已重建)
+- [ ] Redis:6379 / Mongo:27017 / MinIO:9198 / PgSQL:5432 / Plane:8600 可连通
+- [ ] `/var/log/fwh-mcp-server/` 可写(否则 logger fallback)
+- [ ] 已准备 `<真实SN>` / `<真实GICCID>` / 已知 MinIO 文本对象 / 已知快照文件名
+- [ ] `go test ./cmd/mcp/tools/helper/...` 通过(L4-0)

+ 376 - 0
service/cmd/mcp/test/TEST_PLAN.md

@@ -0,0 +1,376 @@
+# FWH MCP Server 接口测试方案
+
+> 被测对象:`service/cmd/mcp`(MCP Server,名称 `fwh-tools`,version `2.0.0`)
+> 传输方式:stdio JSON-RPC(`server.ServeStdio`)
+> 底层框架:`github.com/mark3labs/mcp-go`
+> 编写时间:2026-08-25
+
+---
+
+## 1. 被测对象概况
+
+`fwh-mcp-server` 是一个通过 **stdin/stdout 走 JSON-RPC** 的 MCP Server。
+入口 `cmd/mcp/main.go` 在启动时会初始化与 HTTP Server 相同的基础设施:
+
+- `database.Init` → MySQL / MongoDB / PgSQL
+- `cache.Init` → Redis
+- `minioUtil.InitClient` → MinIO
+- `repositories.InitPlaneClient` → Plane Web API
+
+随后调用 `tools.RegisterAll(s)` 注册全部工具,分为四层:
+
+| 层级 | 前缀 | 说明 | 读写 |
+|------|------|------|------|
+| 业务逻辑层 | `service_*` | 调用后端 Service 层,含业务校验/聚合 | 读为主,含写 |
+| 数据直查层 | `data_*` | 直接读 DB/Cache/远程 API | 只读为主 |
+| 对象存储层 | `storage_*` | MinIO / 本地 FileStore | 读写 |
+| 辅助工具层 | `util_*` | 纯函数 | 只读 |
+
+> 注意:`data_plane_v2_api_write.go` 中的写工具**全部处于注释状态(未注册)**;
+> `registerPlaneApiTools`(Plane V1)在 `data/register.go` 中**已被停用**,不会注册。
+> 本方案以「实际注册的工具」为测试范围。
+
+---
+
+## 2. 运行路径与配置文件(关键)
+
+**测试运行路径固定为:`/var/www/fwh-toolbox/server`**
+
+`fwh-mcp-server` 必须在该目录下作为**当前工作目录(cwd)**启动,原因见下方配置加载逻辑。
+
+该目录实际内容(编写方案时探测):
+
+| 文件 | 说明 |
+|------|------|
+| `fwh-mcp-server` | 已构建的被测二进制(约 50MB,构建于 8月21日) |
+| `fwh-toolbox-server` | HTTP Server 二进制(非本次测试对象) |
+| `config.env` | **配置文件(本部署使用的是 `config.env`,不是 `.env`)** |
+| `cert/` | FWH MQTT / TLS 证书目录(相对路径引用) |
+| `.kiro/` | Agent / MCP 设置 |
+
+### 配置加载机制(`config.LoadConfig`)
+
+`config.go` 中按**相对当前工作目录**依次尝试:
+
+```go
+paths := []string{"./.env", "./config.env"}   // 先 .env,后 config.env
+// 命中即停;随后 v.AutomaticEnv() 允许环境变量覆盖文件值
+```
+
+关键结论:
+
+1. 该目录下**没有 `.env`,只有 `config.env`** → 会加载 `./config.env`。
+2. 因为是相对路径,**server 与测试驱动都必须把 cwd 设为 `/var/www/fwh-toolbox/server`**,否则读不到配置、会 fallback 到默认值(Mongo/Redis 走 localhost 默认、MySQL 为空、FWH 凭据缺失)。
+3. 环境变量可覆盖文件配置(`AutomaticEnv`)——如需临时改依赖地址,可通过 env 注入。
+
+### ⚠️ stdout 污染风险(必须处理)
+
+`LoadConfig` 在成功/失败时会向 **stdout** 打印:
+
+```
+✅ Using config file: ./config.env
+⚠️ No config file found, using default values & environment variables
+```
+
+而 MCP 走 stdio JSON-RPC,**这几行会污染通道**。测试驱动(mcp-go client)在初始化握手时必须容忍/跳过这几行非 JSON 输出,或确认当前 mcp-go 版本对启动前的 stdout 噪声是否敏感。若握手失败,优先排查此处。
+
+### 运行约定(方式 A 落地到该路径)
+
+```sh
+# 全部在测试运行路径下操作
+cd /var/www/fwh-toolbox/server
+
+# 复用已存在的二进制,或重新构建覆盖:
+#   (构建需在源码目录,产物移动到此路径)
+#   cd <源码>/service && go build -o fwh-mcp-server ./cmd/mcp/ && mv fwh-mcp-server /var/www/fwh-toolbox/server/
+
+# 测试驱动以本目录为 cwd 启动 server(command="./fwh-mcp-server"),
+# 与 .kiro/settings/mcp.json 中 "command": "./fwh-mcp-server" 一致。
+```
+
+> 测试驱动程序读取配置也应以 `/var/www/fwh-toolbox/server/config.env` 为准,
+> 保证「驱动断言用的依赖地址」与「server 实际连接的依赖地址」完全一致。
+
+---
+
+## 3. 环境前置(本机探测结果)
+
+编写方案时对本机 `127.0.0.1` 的探测结果:
+
+| 依赖 | 端口 | 状态 |
+|------|------|------|
+| Redis | 6379 | OPEN |
+| MongoDB | 27017 | OPEN |
+| MinIO | 9198 | OPEN |
+| PgSQL | 5432 | OPEN |
+| Plane Web | 8600 | OPEN |
+| Go 工具链 | - | go1.26.4 可用 |
+| 二进制 `fwh-mcp-server` | - | 已存在于运行路径(构建于 8月21日),可复用或重建 |
+
+配置来自 `/var/www/fwh-toolbox/server/config.env`(已核对实际内容):
+
+- `MYSQL_DSN` → 远程 `34.211.183.235:3308/home_energy_storage_server`(站点/SIM 等业务数据依赖)
+- `MONGO_DSN` → `127.0.0.1:27017/FwhToolboxDb`(**注意:生产库名 `FwhToolboxDb`,非 dev 库**)
+- `POSTGRES_DSN` → `localhost:5432/fwh_toolbox_db`
+- `REDIS_ADDR` → `localhost:6379`
+- `MINIO_ENDPOINT` → `localhost:9198`,bucket `fwh-toolbox`
+- FWH API → `energy.franklinwh.com`,账号 `FMU006`(**含真实密码**,凭据可用性需实测)
+- Plane → `http://localhost:8600`,workspace `fwh`(**含真实 Web/API 凭据**)
+
+> 结论:Mongo/Redis/MinIO/PgSQL/Plane 均可连通且有真实凭据;FWH 远程 API 与远程 MySQL 有真实凭据,但可用性/网络连通性仍需在测试时实际验证。
+>
+> ⚠️ 数据敏感性:Mongo 指向**生产库 `FwhToolboxDb`**,MySQL 为**生产业务库**。因此 `data_mongo_*`、业务 `service_*` 查询会读到真实数据,L3 写操作尤其需谨慎——**默认一律不真实写入**。
+
+---
+
+## 3. 测试分层策略
+
+按依赖强度从低到高分为 L0–L3,逐层推进。低层是高层的前置门禁。
+
+### L0 —— 协议与工具清单(无外部数据依赖,必测)
+
+目的:验证 server 能正常握手、所有工具正确注册、schema 合法。
+
+| 用例 | 方法 | 断言 |
+|------|------|------|
+| L0-1 初始化握手 | `initialize` | 返回 `serverInfo.name == "fwh-tools"`、`version == "2.0.0"`、`protocolVersion` 合法 |
+| L0-2 工具清单 | `tools/list` | 返回工具数 > 0,且能全部列出(处理分页 `nextCursor`) |
+| L0-3 工具命名规范 | 解析清单 | 每个工具名前缀属于 `service_/data_/storage_/util_/plane_` 集合 |
+| L0-4 Schema 完整性 | 解析清单 | 每个工具含 `name`/`description`/`inputSchema`,required 字段声明合法 |
+| L0-5 未知工具 | `tools/call` name=不存在 | 返回错误(非崩溃) |
+
+L0 全部不依赖任何后端数据,是回归测试的核心。
+
+### L1 —— 纯函数与参数校验(可精确断言)
+
+目的:在不依赖外部服务的前提下验证工具逻辑与错误处理。
+
+| 用例 | 工具 | 输入 | 断言 |
+|------|------|------|------|
+| L1-1 SN 解析正确性 | `util_parse_sn` | `sn=10060006A03X25310091` | `productName` 含 aGate、`year=2025`、`week=31`、`serialNumber=0091` |
+| L1-2 SN 缺参 | `util_parse_sn` | 无 `sn` | `isError`,提示 `参数 sn 必填` |
+| L1-3 SN 长度不足 | `util_parse_sn` | `sn=1006` | `isError`,提示长度不足 |
+| L1-4 必填缺失(抽样) | `service_site_query_info` 等 | 省略 required | `isError`,`参数 xxx 必填` |
+| L1-5 格式校验 | `service_site_power_flow` | `date=2025/01/01`(错误格式) | `isError`,提示需 yyyy-MM-dd |
+
+> 说明:L1-4/L1-5 只验证「参数校验分支」,在 handler 调用外部服务**之前**返回,因此不依赖后端。
+
+### L2 —— 只读工具连通性冒烟(依赖外部服务)
+
+目的:对依赖可用的只读工具做真实调用,验证「能连通 + 返回结构合法 + 无 panic」。
+断言以**结构合法性**为主(返回是 `text` content、可 JSON 解析或为结构化错误),不强断言具体业务数据。
+
+分组(按依赖):
+
+- **Redis 依赖**:`data_redis_get`、`data_redis_exists`
+- **Mongo 依赖**:`data_mongo_find`、`data_mongo_count`(注意 collection 白名单)
+- **PgSQL 依赖**:`data_pgsql_query_snapshot_file_record`、`data_pgsql_query_snapshot_data`
+- **MinIO 依赖**:`storage_minio_list_objects`、`storage_minio_read_object`、`storage_file_read`、`storage_file_list`
+- **Plane 依赖**:`plane_list_workspaces`、`plane_list_projects`、`plane_list_issues`、`plane_list_labels`、`plane_list_states`、`service_feedback_history` 等
+- **MySQL/业务依赖**:`service_site_search`、`service_sim_search`、`service_monitor_alarm_stats`、`service_dashboard_*`、`service_tou_template_list`、`service_background_task_list`、`service_site_monitor_config_list` 等
+- **FWH 远程 API 依赖**:`data_fwh_*`、`service_site_query_info`、`service_site_power_flow`、`data_sim_*`
+
+每个用例三态判定:
+
+- **PASS**:返回 content 且结构合法(成功数据 或 业务空结果)
+- **FAIL**:进程 panic / 返回非法结构 / 超时无响应
+- **SKIP(dependency)**:依赖不可用导致的连通错误(记录原因,不计失败)
+
+> 凭据类失败(如 FWH 密码占位、Plane 登录失败)归为 SKIP 并标注,不算 FAIL。
+
+### L3 —— 写操作工具(默认只做参数校验,不真实写入)
+
+写操作工具(描述带 `[写操作]`)默认**只测参数校验分支**,不触发真实写入,避免污染数据。
+仅在你**显式确认使用隔离/测试环境**后,才追加"真实写入 + 回滚/清理"用例。
+
+已注册的写操作工具(全部为 `service_*`):
+
+| 工具 | 动作 |
+|------|------|
+| `service_feedback_create` | 在 Plane 创建 Intake Issue |
+| `service_site_info_trigger_update` | 触发站点信息同步任务 |
+| `service_site_info_tag_update` | 创建/更新站点标签 |
+| `service_auto_update_create_package` / `_update_package` / `_delete_package` | 升级包增改删 |
+| `service_auto_update_update_config` / `_delete_config` | 升级配置改删 |
+| `service_auto_update_create_file` | 创建升级文件 |
+| `service_batch_download_create` / `_update_desc` / `_update_owner` / `_delete` | 批量下载任务增改删 |
+| `service_site_analysis_create` / `_update_desc` / `_delete` | 站点分析任务增改删 |
+| `service_site_monitor_config_create` / `_update` / `_delete` | 站点监控配置增改删 |
+
+storage 写:`storage_file_write`、`storage_file_upload`、`storage_file_read_to_local` —— 默认只测参数校验。
+
+### L4 —— 敏感数据脱敏验证(PII,重点,必测)
+
+> 背景:MCP 输出会经过 `helper` 层的 PII 脱敏后才返回给 Agent(`JSONResult` / `JSONResultOrFile` / `BytesResultOrFileMasked`)。
+> 由于 Mongo/MySQL 指向**生产库**,含真实用户 PII,脱敏是否生效直接关系到数据是否泄露给上层 LLM/日志。**这一层必须验证。**
+
+#### 脱敏机制(`cmd/mcp/tools/helper/`)
+
+双路径 + fail-closed:
+
+- **优化 A(强类型源头脱敏)**:命中 `*dto.SiteInfoDTO`、`domain.SimCardInfo`、`dto.SimCardInfo`、`ResPage[...]` 等已知类型时,对**副本**做字段级脱敏后序列化(不污染上游共享对象)。
+- **优化 B(JSON 流式脱敏)**:未命中强类型时,序列化后按 token 流式扫描,命中敏感 key 则脱敏其 value;敏感 key 对应对象/数组时**整个子树完全脱敏**。
+- **fail-closed**:脱敏解析失败时**拒绝输出**(返回 error),绝不泄露原始数据。
+
+脱敏规则(`pii_rules.go`):
+
+| 类别 | 字段(小写匹配) | 方式 | 结果 |
+|------|------|------|------|
+| 手机号 | phone/mobile/tel/contactnumber/msisdn/financiercontractnumber | MaskFull | 固定 6 位 `******` |
+| 姓名 | username/firstname/lastname/accountname/financiername/display_name/displayname | MaskName | 保留首字符 + `***` |
+| 邮箱 | email | MaskEmail | 首字符 + `***` + 完整域名 |
+| 账号 | account/installeraccount | MaskAccount | 前2 + `***` + 后2 |
+| 地址串 | address/deviceaddress/addresssecondlast/gpsaddress | MaskFull | `******` |
+| 经纬度 | longitude/latitude/lng/lat | MaskFull | `******` |
+| 设备/网络标识 | imei/imsi/mac/deviceip/gatewayip | MaskFull | `******` |
+| **保留(不脱敏)** | iccid/giccid/sn、city/province/country | — | 原值 |
+
+#### 已有单元测试(前置门禁,先跑)
+
+`cmd/mcp/tools/helper/pii_test.go` 已覆盖掩码函数、流式脱敏(扁平/嵌套/子树/非法JSON fail-closed/null保留)、强类型脱敏与上游不污染、A/B 一致性。
+
+- **L4-0**:先运行 `go test ./cmd/mcp/tools/helper/...`,全部 PASS 是后续端到端脱敏验证的前提。
+
+#### 端到端脱敏用例(现有单测未覆盖,本方案重点)
+
+通过 MCP 真实调用,验证「返回给 Agent 的内容确实已脱敏」。断言方式:对返回的 content 文本做**敏感值检测**——不得出现原始 PII,且应出现对应掩码形态。
+
+| 用例 | 工具 | 断言(对返回文本) |
+|------|------|------|
+| L4-1 站点详情脱敏 | `service_site_query_info`(真实 SN) | 不含明文手机号/邮箱/完整地址/经纬度/IMEI/MAC/IP;`contactNumber`/`address`/`longitude`/`latitude`/`imei` 值为 `******`;`userName` 形如 `X***`;`email` 形如 `x***@域名`;**保留** `sn`、`iccId`、`city` 原值 |
+| L4-2 站点列表脱敏 | `service_site_search` | 分页 `Data[]` 内每个站点均脱敏(验证 `ResPage` 强类型路径生效) |
+| L4-3 SIM 详情脱敏 | `service_sim_query`(真实 GICCID) | `imei`/`imsi`/`msisdn` 为 `******`;**保留** `giccid` |
+| L4-4 SIM 列表脱敏 | `service_sim_search` | `Data[]` 内每张卡的 imei/imsi/msisdn 均脱敏,giccid 保留 |
+| L4-5 直查层脱敏(B 路径) | `data_mongo_find`(含 PII 的白名单 collection) | 走流式脱敏;返回中敏感 key 的 value 已脱敏(验证未命中强类型时 B 路径仍生效) |
+| L4-6 FWH 原始响应脱敏 | `data_fwh_*`(返回上游原始 JSON 的工具) | 走 `BytesResultOrFileMasked`,敏感字段已脱敏 |
+| L4-7 落盘结果脱敏 | 触发大结果落盘(`spilled:true`)的查询 | 读取 `localPath` 文件内容,**文件内也是脱敏后的数据**(脱敏发生在落盘之前,见 `marshalSanitized` → `BytesResultOrFile`) |
+| L4-8 fail-closed 行为 | 见风险说明 | 脱敏失败时返回 error 而非原始数据(此项主要靠单测 L4-0 保证,端到端难构造,标注为设计验证) |
+
+#### 敏感值检测清单(供 L4-1~L4-7 自动断言)
+
+驱动程序应对返回文本执行如下"负向断言"(这些**明文模式不得出现**):
+
+- 手机号:连续 11 位数字(`\b1\d{10}\b`)
+- 邮箱明文:`[\w.+-]+@[\w.-]+`(除已知保留域名场景外,本地部分应已被 `x***` 替换)
+- IMEI/IMSI:连续 14–15 位数字
+- MAC:`([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}`
+- IP:`\b\d{1,3}(\.\d{1,3}){3}\b`(排除脱敏后的 `******`)
+- 经纬度:字段 `longitude/latitude` 的 value 不应为数字
+
+并执行"正向断言":保留字段(`sn`/`iccid`/`giccid`/`city`)应保持原值。
+
+> 注意:L4 端到端用例依赖能查到**含 PII 的真实记录**。需要你提供 1 个真实站点 SN 和 1 个真实 GICCID 作为测试样本;若不便,可退化为"L4-0 单测 + L4-5 直查一条已知含 PII 的 Mongo 文档"。
+
+---
+
+## 4. 执行方式
+
+推荐 **方式 A:独立 Go 测试驱动**,最适合"所有接口都测一遍"且可重复、可 CI。
+
+### 方式 A:Go 测试驱动(推荐)
+
+在 `cmd/mcp/test/` 下编写一个 Go 程序 / `go test`,用 `mcp-go` 的 **client** 以 stdio 方式拉起编译好的 `fwh-mcp-server`,然后:
+
+1. `initialize` → 断言 L0-1
+2. `ListTools`(翻页聚合)→ 断言 L0-2~L0-4,导出完整工具清单
+3. 逐个 `CallTool` 执行 L1/L2/L3 用例
+4. 执行 L4 敏感数据脱敏用例(含调用前先跑 `go test ./cmd/mcp/tools/helper/...` 作为 L4-0)
+5. 汇总生成报告(PASS/FAIL/SKIP + 原因)
+
+优点:类型安全、断言强、可重复运行、结果可机读。
+关键点:
+- server 与驱动的 **cwd 必须为 `/var/www/fwh-toolbox/server`**(否则读不到 `config.env`)。
+- 驱动启动 server 时 `command="./fwh-mcp-server"`、`workingDir="/var/www/fwh-toolbox/server"`。
+- 给每个 call 设置超时(远程 API 建议 15~30s)。
+- 握手时容忍 server 启动阶段可能出现的 stdout 噪声(见 §2 stdout 污染)。
+
+### 方式 B:裸 JSON-RPC 脚本(轻量补充)
+
+用 shell/python 向 server stdin 逐条喂报文:
+```
+{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
+{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
+{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"util_parse_sn","arguments":{"sn":"10060006A03X25310091"}}}
+```
+适合快速手工验证 L0/L1,断言能力弱,不适合全量回归。
+
+### 方式 C:通过 Kiro CLI 加载 `@fwh-tools` 实测
+
+最贴近真实使用(含 Agent 权限确认、落盘规则),但难以批量断言,适合最终验收抽查。
+
+---
+
+## 5. 执行步骤(方式 A)
+
+**运行路径固定为 `/var/www/fwh-toolbox/server`(配置文件 `config.env` 所在目录)。**
+
+```sh
+# 方案 1:复用运行路径下已有的二进制(推荐,最贴近部署态)
+cd /var/www/fwh-toolbox/server
+# 二进制 ./fwh-mcp-server 已存在
+
+# 方案 2:如需重建,在源码目录构建后覆盖到运行路径
+#   cd <源码>/service
+#   go build -o fwh-mcp-server ./cmd/mcp/
+#   mv fwh-mcp-server /var/www/fwh-toolbox/server/
+
+# 运行测试驱动(驱动程序位于源码 cmd/mcp/test/ 下)
+#   驱动内部以 cwd=/var/www/fwh-toolbox/server 拉起 ./fwh-mcp-server
+cd <源码>/service
+go run ./cmd/mcp/test/runner        # 或 go test ./cmd/mcp/test/...
+
+# 查看生成的报告
+cat /var/www/fwh-toolbox/server/report.md   # 或驱动指定的输出路径
+```
+
+配置由 server 自身通过 `config.LoadConfig()` 从 cwd 的 `config.env` 读取;
+测试驱动如需断言依赖地址,应读取**同一份** `/var/www/fwh-toolbox/server/config.env`,
+保证「驱动断言的依赖」与「server 实际连接的依赖」一致。无需手工导出环境变量。
+
+---
+
+## 6. 通过标准
+
+- **L0 全部 PASS**(协议与注册无问题)——硬性门禁
+- **L1 全部 PASS**(纯函数与参数校验正确)——硬性门禁
+- **L2**:依赖可用的工具 PASS;依赖不可用/凭据失效的记为 SKIP 并在报告说明;**不允许出现 FAIL(panic/非法结构/超时)**
+- **L3**:参数校验用例全部 PASS;真实写入用例仅在确认测试环境后执行
+- **L4 敏感数据脱敏(硬性门禁,安全相关)**:
+  - L4-0 单元测试 `go test ./cmd/mcp/tools/helper/...` 全部 PASS
+  - L4-1~L4-7 端到端用例中,**任何返回给 Agent 的内容出现明文 PII 即判定 FAIL(安全缺陷,最高优先级)**
+  - 保留字段(sn/iccid/giccid/city 等)被误脱敏也判定 FAIL(可用性缺陷)
+  - 落盘文件(L4-7)内容必须同样已脱敏
+
+---
+
+## 7. 交付产物
+
+1. 测试驱动代码:`cmd/mcp/test/`(方式 A 的 runner 或 `_test.go`)
+2. 完整工具清单快照:`cmd/mcp/test/tools_snapshot.json`(`tools/list` 导出)
+3. 测试报告:`cmd/mcp/test/report.md`,每个工具一行 PASS/FAIL/SKIP + 原因 + 耗时
+4. **敏感数据脱敏专项报告**:`cmd/mcp/test/pii_report.md`,逐条列出 L4 用例的负向/正向断言结果;对每个端到端用例附「脱敏后返回样本(已确保无明文 PII)」以便复核
+
+---
+
+## 8. 风险与注意事项
+
+- **stdio 污染(两处)**:
+  - server 运行日志已重定向到 `/var/log/fwh-mcp-server/mcp.log`(该目录需写权限,否则 fallback 到 nop logger)。
+  - 但 `config.LoadConfig()` 会向 **stdout** 打印 `✅ Using config file: ./config.env`,这会混入 JSON-RPC 通道。驱动握手需容忍启动阶段的非 JSON 行;若握手失败优先排查此处。
+- **大结果落盘**:返回含 `"spilled": true` 时,完整内容在 `localPath`(`/tmp/fwh-toolbox-agent/`),`preview` 仅片段——断言时需识别此情况。
+- **白名单限制**:`data_mongo_*` 受 collection 白名单、`storage_minio_*` 受 bucket 白名单约束,用例需使用白名单内对象。
+- **凭据/数据风险**:`config.env` 含真实 FWH(`FMU006`)、Plane 凭据;Mongo/MySQL 指向**生产库**。只读查询会读到真实数据,写操作默认一律不真实执行。FWH 远程 API 与远程 MySQL 的网络连通性仍需实测,失败归 SKIP。
+- **写操作**:默认不真实写入。若需真实写入验证,务必确认为隔离环境并准备数据清理。
+- **超时**:远程 API 调用给足超时(15~30s),避免误判为 FAIL。
+
+---
+
+## 9. 待你确认的点
+
+1. **测试范围**:只做 L0+L1(协议+纯函数),还是 L0~L2 全量只读冒烟?L3 是否需要真实写入?(L4 敏感数据脱敏建议无论如何都做,属安全门禁)
+2. **执行方式**:确认用方式 A(Go 驱动)?
+3. **数据环境**:Mongo/MySQL 指向**生产库**,只读查询可接受吗?还是需要切到隔离库再测?
+4. **写操作**:是否允许对 Plane / 本地 DB 做真实写入(会产生测试数据)?默认不写。
+5. **L4 脱敏样本**:能否提供 1 个真实站点 SN + 1 个真实 GICCID(用于端到端脱敏验证)?若不便,退化为「单测 L4-0 + 直查一条已知含 PII 的 Mongo 文档」。
+
+确认后我再实现测试驱动并执行。

+ 238 - 0
service/cmd/mcp/test/pii_report.md

@@ -0,0 +1,238 @@
+# FWH MCP Server 敏感数据脱敏专项报告 (L4)
+
+生成时间: 2026-08-26 00:34:41
+
+说明: 负向断言=返回内容不得出现明文 PII(手机号/邮箱/IMEI/MAC/IP);正向断言=保留字段(sn/giccid等)应保持原值。
+L4-0 单元测试 (go test ./cmd/mcp/tools/helper/...) 已单独执行。
+
+端到端脱敏用例: PASS 7 | FAIL 0 | SKIP 0
+
+## L4-2 — service_site_search [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段命中: sn
+
+脱敏后样本片段:
+```
+{
+  "data": [
+    {
+      "sn": "XWM10050001A04W22180004",
+      "name": "FHP",
+      "installerId": 129,
+      "installerAccountId": 137,
+      "installerAccount": "IR***83",
+      "installerName": "Sun Valley Renewables",
+      "account": "*******",
+      "longitude": "******",
+      "latitude": "******",
+      "address": "******",
+      "activeStatus": 1,
+      "activeTime": "2022-12-10 06:58:22",
+      "localActiveTime": "",
+      "status": 0,
+      "onLineTime": "2026-06-28 14:37:44",
+      "offLineTime": "2026-06-28 17:37:36",
+      "deviceIp": "******",
+      "subnet": "255.255.255.0",
+…
+```
+
+## L4-4 — service_sim_search [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段命中: giccid
+
+脱敏后样本片段:
+```
+{
+  "data": [
+    {
+      "giccid": "8931084524121848556",
+      "sn": [
+        "10060010A00X25271346"
+      ],
+      "imei": "******",
+      "imsi": "******",
+      "msisdn": "******",
+      "status": "ACTIVATED",
+      "platform": "SmartHub",
+      "expirationTime": "",
+      "month": "2026-08",
+      "allDataUsage": 3943348.008,
+      "updateTime": "2026-08-24T23:51:47.889Z"
+    },
+    {
+      "giccid": "8931084524122020106",
+      "sn": [
+        "10060010A00X25291876"
+      ],
+      "imei": "******",
+      "imsi": "******",
+      "msisdn": "******",
+      "status": "ACTIVATED",
+      "pl…
+```
+
+## L4-5 — data_mongo_find [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段命中: _id
+
+脱敏后样本片段:
+```
+[
+  {
+    "_id": "XWM10050001A04W22180004",
+    "account": "*******",
+    "activeStatus": 1,
+    "activeTime": "2022-12-10 06:58:22",
+    "address": "******",
+    "addressSecondLast": "******",
+    "appVersion": "V10R81B42D00",
+    "awsVersion": "V10R81B64D00_hpm",
+    "batterySoc": 99.79000091552734,
+    "city": "Clarkridge",
+    "connType": 3,
+    "contactNumber": "******",
+    "country": "United States",
+    "countryId": 2,
+    "deviceAddress": "******",
+    "deviceIp": "******",
+    "deviceTime": "2026-08-23 21:09:26",
+    "dst": 0,
+    "elecnetStatus": 0,
+    "electricityType": 1,
+    "em…
+```
+
+## L4-6 — data_mongo_find [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段命中: _id
+
+脱敏后样本片段:
+```
+[
+  {
+    "_id": "8944501503181709740",
+    "allDataUsage": 223280.415,
+    "expirationTime": "",
+    "imei": "******",
+    "imsi": "******",
+    "month": "2026-08",
+    "msisdn": "******",
+    "platform": "SmartHub",
+    "status": "ACTIVATED",
+    "updateTime": "2026-08-24T23:00:09.375Z"
+  },
+  {
+    "_id": "8944501503181709732",
+    "allDataUsage": 0,
+    "expirationTime": "",
+    "imei": "******",
+    "imsi": "******",
+    "month": "2026-08",
+    "msisdn": "******",
+    "platform": "SmartHub",
+    "status": "ACTIVATED",
+    "updateTime": "2026-08-24T23:00:09.375Z"
+  },
+  {
+    "_id": "89445…
+```
+
+## L2S-1 — service_site_query_info [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段: sn 
+
+脱敏后样本片段:
+```
+{
+  "sn": "10130095A00X25280030",
+  "name": "shed solar",
+  "distributorId": 910,
+  "distributorName": "Greentech",
+  "installerId": 2486,
+  "installerAccountId": 9240,
+  "installerAccount": "IR***01",
+  "installerName": "Sugar Hollow Solar",
+  "account": "*******",
+  "longitude": "******",
+  "latitude": "******",
+  "activeStatus": 1,
+  "activeTime": "2026-01-27 17:21:11",
+  "localActiveTime": "",
+  "status": 1,
+  "onLineTime": "2026-08-23 07:01:55",
+  "offLineTime": "2026-08-23 07:01:39",
+  "deviceIp": "******",
+  "subnet": "255.255.255.0",
+  "mac": "******",
+  "gatewayIp": "******",
+  "signa…
+```
+
+## L2S-10 — service_sim_query [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段: giccid 
+
+脱敏后样本片段:
+```
+{
+  "giccid": "8931084524121848556",
+  "imei": "******",
+  "imsi": "******",
+  "msisdn": "******",
+  "status": "ACTIVATED",
+  "platform": "SmartHub",
+  "expirationTime": "",
+  "month": "2026-08",
+  "allDataUsage": 3943348.008,
+  "updateTime": "2026-08-24T23:51:47.889Z"
+}
+```
+
+## L2S-12 — data_sim_smarthub_card_info [PASS]
+
+负向断言: 无明文 PII
+
+正向断言: 保留字段: 
+
+脱敏后样本片段:
+```
+{
+  "giccid": "8931084524121848556",
+  "imei": "******",
+  "imsi": "******",
+  "msisdn": "******",
+  "status": "ACTIVATED",
+  "sellTime": "2025-06-24T06:28:12Z",
+  "activatedTime": "2026-06-12T14:31:47Z",
+  "currentCycleUsages": {
+    "name": "2026-08",
+    "dataUsage": 3944984.465,
+    "smsUsage": 0,
+    "voiceUsage": 0
+  },
+  "currentCycleAPNUsagesList": [],
+  "servicePackages": [
+    {
+      "id": 3119,
+      "name": "831GC0114_MON-FLEX_1031_US_5M",
+      "subscribeTime": "2026-08-01T00:00:00Z",
+      "startTime": "2026-08-01T00:00:00Z",
+      "endTime": "2026-08-31T23:59:59Z"
+    }
+  ],
+  …
+```
+

+ 259 - 0
service/cmd/mcp/test/report.md

@@ -0,0 +1,259 @@
+# FWH MCP Server 非写操作接口测试报告
+
+生成时间: 2026-08-26 00:34:41
+
+> SKIP / FAIL 的逐条根因分析、代码定位与修复建议见 **ANALYSIS.md**。
+
+## 工具覆盖率(按工具去重,主口径)
+
+被测工具 **102** 个 | ✅ 已覆盖(PASS) **79** | ❌ FAIL **0** | ⏭️ 未覆盖(SKIP) **23**
+
+覆盖率: **77.5%**(79/102 工具至少有一次成功调用)
+
+> 说明:一个工具可能在多个阶段被调用(自动冒烟因缺参 SKIP、样本/级联阶段 PASS)。
+> 上表按工具去重,只要该工具有任一次 PASS 即计为“已覆盖”,避免同一工具重复计数。
+> 下方“执行记录”为全部调用流水(含重复),供追溯过程。
+
+## 执行记录统计(流水口径,含重复)
+
+总执行 173 次 | PASS 101 | FAIL 0 | SKIP 72
+
+## 分层汇总(执行流水)
+
+| 层级 | PASS | FAIL | SKIP |
+|------|------|------|------|
+| L0 | 5 | 0 | 0 |
+| L1 | 14 | 0 | 0 |
+| L2 | 34 | 0 | 67 |
+| L2S | 21 | 0 | 0 |
+| L2C | 12 | 0 | 5 |
+| L2X | 7 | 0 | 0 |
+| L3RW | 4 | 0 | 0 |
+| L4 | 4 | 0 | 0 |
+
+## 执行明细
+
+| 编号 | 层级 | 工具 | 结果 | 耗时 | 备注 |
+|------|------|------|------|------|------|
+| L0-1 | L0 | - | PASS | 191ms | name=fwh-tools version=2.0.0 proto=2025-11-25 |
+| L0-2 | L0 | - | PASS | 3ms | 共 119 个工具 |
+| L0-3 | L0 | - | PASS | 0ms | 全部前缀合法 |
+| L0-4 | L0 | - | PASS | 0ms | schema 完整 |
+| L0-5 | L0 | __no_such_tool__ | PASS | 0ms | 正确拒绝未知工具 |
+| L1-1 | L1 | util_parse_sn | PASS | 0ms | SN 解析字段正确 |
+| L1-3 | L1 | util_parse_sn | PASS | 0ms | 正确返回参数校验错误 |
+| L1-4 | L1 | util_parse_sn | PASS | 0ms | 正确返回参数校验错误 |
+| L1-5 | L1 | service_site_query_info | PASS | 0ms | 正确返回参数校验错误 |
+| L1-6 | L1 | service_site_power_flow | PASS | 0ms | 正确返回参数校验错误 |
+| L1-7 | L1 | service_sim_monthly_data | PASS | 0ms | 正确返回参数校验错误 |
+| L1-8 | L1 | data_mongo_find | PASS | 0ms | 正确返回参数校验错误 |
+| L1-9 | L1 | data_mongo_find | PASS | 0ms | 正确返回参数校验错误 |
+| L1-10 | L1 | data_redis_exists | PASS | 0ms | 正确返回参数校验错误 |
+| L1-11 | L1 | data_redis_get | PASS | 0ms | 正确返回参数校验错误 |
+| L1-12 | L1 | storage_minio_list_objects | PASS | 0ms | 正确返回参数校验错误 |
+| L1-13 | L1 | data_emspara_vid_info | PASS | 0ms | 正确返回参数校验错误 |
+| L1-14 | L1 | data_pgsql_query_snapshot_data | PASS | 0ms | 正确返回参数校验错误 |
+| L1-16 | L1 | service_feedback_create | PASS | 0ms | 正确返回参数校验错误 |
+| L2-R1 | L2 | data_redis_exists | PASS | 0ms | 返回合法结果 (39B) |
+| L2-R2 | L2 | data_redis_get | PASS | 0ms | 返回合法结果 (67B) |
+| L2-M1 | L2 | data_mongo_count | PASS | 18ms | 返回合法结果 (58B) |
+| L2-M2 | L2 | data_mongo_find | PASS | 0ms | 返回合法结果 (2278B) |
+| L2-M3 | L2 | data_mongo_count | PASS | 0ms | 返回合法结果 (51B) |
+| L2-S1 | L2 | storage_minio_list_objects | PASS | 54ms | 返回合法结果 (1133B) |
+| L2-S3 | L2 | storage_file_list | PASS | 0ms | 返回合法结果 (27B) |
+| L2-PL1 | L2 | plane_list_workspaces | PASS | 27ms | 返回合法结果 (1730B) |
+| L2-PL2 | L2 | plane_list_projects | SKIP | 0ms | 返回错误: 参数 workspace_slug 必填 |
+| L2-PL4 | L2 | plane_list_labels | SKIP | 0ms | 返回错误: 参数 workspace_slug 必填 |
+| L2-PL5 | L2 | plane_list_states | SKIP | 0ms | 返回错误: 参数 workspace_slug 必填 |
+| L2-PL6 | L2 | service_feedback_history | PASS | 32ms | 返回合法结果 (506B) |
+| L2-B1 | L2 | service_site_search | PASS | 20ms | 返回合法结果 (7270B) |
+| L2-B2 | L2 | service_sim_search | PASS | 171ms | 返回合法结果 (1193B) |
+| L2-B3 | L2 | service_monitor_alarm_stats | SKIP | 0ms | 依赖不可用/凭据: 查询告警统计失败: mongo: no documents in result |
+| L2-B4 | L2 | service_monitor_shutdown_stats | PASS | 0ms | 返回合法结果 (700B) |
+| L2-B5 | L2 | service_dashboard_shutdown_date | PASS | 0ms | 返回合法结果 (10B) |
+| L2-B6 | L2 | service_dashboard_shutdown_detail | PASS | 11ms | 返回合法结果 (2541B) |
+| L2-B7 | L2 | service_dashboard_alarm_detail | SKIP | 0ms | 依赖不可用/凭据: 查询告警详情失败: mongo: no documents in result |
+| L2-B8 | L2 | service_tou_template_list | PASS | 2061ms | 返回合法结果 (1352B) |
+| L2-B9 | L2 | service_background_task_list | SKIP | 0ms | 依赖不可用/凭据: 查询后台任务列表失败: inspector not initialized |
+| L2-B10 | L2 | service_site_monitor_config_list | PASS | 8ms | 返回合法结果 (2500B) |
+| L2-B11 | L2 | service_site_monitor_name_list | PASS | 0ms | 返回合法结果 (280B) |
+| L2-B12 | L2 | service_site_info_update_status | PASS | 0ms | 返回合法结果 (165B) |
+| L2-B13 | L2 | service_site_info_tags_list | PASS | 1ms | 返回合法结果 (63B) |
+| L2-B14 | L2 | service_auto_update_get_options | PASS | 0ms | 返回合法结果 (260B) |
+| L2-B15 | L2 | service_auto_update_get_apower_version | PASS | 0ms | 返回合法结果 (70B) |
+| L2-B16 | L2 | service_auto_update_file_list | PASS | 0ms | 返回合法结果 (746B) |
+| L2-B17 | L2 | service_batch_download_list | PASS | 1ms | 返回合法结果 (1676B) |
+| L2-B18 | L2 | service_site_analysis_list | PASS | 4ms | 返回合法结果 (1024B) |
+| L2-F8a | L2 | data_emspara_vid_info | PASS | 0ms | 返回合法结果 (37B) |
+| L2-F8b | L2 | data_emspara_pid_info | PASS | 0ms | 返回合法结果 (37B) |
+| L2-auto | L2 | data_fwh_query_ahub_update_log | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | data_fwh_query_alarm_snapshot | SKIP | 0ms | 需必填参数,缺测试样本: sn,date |
+| L2-auto1 | L2 | data_fwh_query_alarm_statistics | PASS | 3027ms | 返回合法结果 (2537B) |
+| L2-auto | L2 | data_fwh_query_alarm_statistics_from_code | SKIP | 0ms | 需必填参数,缺测试样本: alarm_code |
+| L2-auto | L2 | data_fwh_query_ems_log_record | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | data_fwh_query_event_alarm_list | SKIP | 0ms | 需必填参数,缺测试样本: sn,start_time,end_time |
+| L2-auto2 | L2 | data_fwh_query_fault_record_call_record_list | PASS | 305ms | 返回合法结果 (18469B) |
+| L2-auto | L2 | data_fwh_query_fault_record_callable_list | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | data_fwh_query_fault_record_list | SKIP | 0ms | 需必填参数,缺测试样本: sn,start_time,end_time |
+| L2-auto | L2 | data_fwh_query_fault_record_sub_list | SKIP | 0ms | 需必填参数,缺测试样本: fault_id |
+| L2-auto | L2 | data_fwh_query_mqtt_log_list | SKIP | 0ms | 需必填参数,缺测试样本: sn,cmd_type,start_time,end_time |
+| L2-auto | L2 | data_fwh_query_shutdown_list | SKIP | 0ms | 需必填参数,缺测试样本: date |
+| L2-auto | L2 | data_fwh_query_site_alarm_list | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto3 | L2 | data_fwh_query_site_base_info_list | PASS | 636ms | 返回合法结果 (8422B) |
+| L2-auto4 | L2 | data_fwh_query_site_list | PASS | 1786ms | 返回合法结果 (2620B) |
+| L2-auto | L2 | data_fwh_query_site_mode_info | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | data_fwh_query_site_power_flow | SKIP | 0ms | 需必填参数,缺测试样本: sn,date |
+| L2-auto | L2 | data_fwh_query_system_update_log | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | data_fwh_query_system_update_log_detail | SKIP | 0ms | 需必填参数,缺测试样本: order_id |
+| L2-auto | L2 | data_fwh_query_tou_template_detail | SKIP | 0ms | 需必填参数,缺测试样本: id |
+| L2-auto5 | L2 | data_fwh_query_tou_template_list | PASS | 567ms | 返回合法结果 (8596B) |
+| L2-auto | L2 | data_pgsql_query_snapshot_data | SKIP | 0ms | 需必填参数,缺测试样本: sn,date |
+| L2-auto | L2 | data_pgsql_query_snapshot_file_record | SKIP | 0ms | 需必填参数,缺测试样本: file_name |
+| L2-auto | L2 | data_sim_acceleronix_batch_data_info | SKIP | 0ms | 需必填参数,缺测试样本: iccid_list,month |
+| L2-auto | L2 | data_sim_acceleronix_card_info | SKIP | 0ms | 需必填参数,缺测试样本: iccid |
+| L2-auto6 | L2 | data_sim_acceleronix_card_list | PASS | 341ms | 返回合法结果 (10611B) |
+| L2-auto7 | L2 | data_sim_acceleronix_data_pool_list | PASS | 414ms | 返回合法结果 (2839B) |
+| L2-auto | L2 | data_sim_acceleronix_data_pool_sim_info | SKIP | 0ms | 需必填参数,缺测试样本: data_pool_id |
+| L2-auto8 | L2 | data_sim_smarthub_batch_info | PASS | 1479ms | 返回合法结果 (13264B) |
+| L2-auto | L2 | data_sim_smarthub_card_info | SKIP | 0ms | 需必填参数,缺测试样本: giccid |
+| L2-auto | L2 | plane_get_cycle | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,cycle_id |
+| L2-auto | L2 | plane_get_intake_issue | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,issue_id |
+| L2-auto | L2 | plane_get_issue | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,issue_id |
+| L2-auto | L2 | plane_get_issue_activity | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,issue_id |
+| L2-auto | L2 | plane_get_module | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,module_id |
+| L2-auto | L2 | plane_get_page | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,page_id |
+| L2-auto | L2 | plane_get_page_description | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,page_id |
+| L2-auto | L2 | plane_get_project | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_get_sub_issues | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id,issue_id |
+| L2-auto | L2 | plane_get_workspace | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug |
+| L2-auto | L2 | plane_list_cycles | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_list_intake_issues | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_list_issues | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_list_modules | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_list_pages | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_list_project_members | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug,project_id |
+| L2-auto | L2 | plane_list_stickies | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug |
+| L2-auto | L2 | plane_list_workspace_members | SKIP | 0ms | 需必填参数,缺测试样本: workspace_slug |
+| L2-auto | L2 | service_auto_update_get_config | SKIP | 0ms | 需必填参数,缺测试样本: agate_version |
+| L2-auto | L2 | service_auto_update_get_package | SKIP | 0ms | 需必填参数,缺测试样本: file_name |
+| L2-auto | L2 | service_batch_download_file_tree | SKIP | 0ms | 需必填参数,缺测试样本: file_path |
+| L2-auto | L2 | service_batch_download_path | SKIP | 0ms | 需必填参数,缺测试样本: id |
+| L2-auto | L2 | service_sim_monthly_data | SKIP | 0ms | 需必填参数,缺测试样本: giccid,month |
+| L2-auto | L2 | service_sim_query | SKIP | 0ms | 需必填参数,缺测试样本: giccid |
+| L2-auto | L2 | service_site_analysis_data | SKIP | 0ms | 需必填参数,缺测试样本: id |
+| L2-auto | L2 | service_site_analysis_get_config | SKIP | 0ms | 需必填参数,缺测试样本: id |
+| L2-auto | L2 | service_site_monitor_alarm_list | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | service_site_monitor_base_info | SKIP | 0ms | 需必填参数,缺测试样本: site_list |
+| L2-auto | L2 | service_site_monitor_config_get | SKIP | 0ms | 需必填参数,缺测试样本: name |
+| L2-auto | L2 | service_site_monitor_mode_info | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | service_site_power_flow | SKIP | 0ms | 需必填参数,缺测试样本: sn,date |
+| L2-auto | L2 | service_site_query_info | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L2-auto | L2 | service_tou_template_detail | SKIP | 0ms | 需必填参数,缺测试样本: id |
+| L2-auto | L2 | storage_file_read | SKIP | 0ms | 需必填参数,缺测试样本: path |
+| L2-auto | L2 | storage_file_read_to_local | SKIP | 0ms | 需必填参数,缺测试样本: path |
+| L2-auto | L2 | storage_file_upload | SKIP | 0ms | 需必填参数,缺测试样本: localPath |
+| L2-auto | L2 | storage_file_write | SKIP | 0ms | 需必填参数,缺测试样本: path,content |
+| L2-auto | L2 | storage_minio_read_object | SKIP | 0ms | 需必填参数,缺测试样本: bucket,key |
+| L2-auto | L2 | util_parse_sn | SKIP | 0ms | 需必填参数,缺测试样本: sn |
+| L4-2 | L4 | service_site_search | PASS | 22ms | 无明文 PII; 保留字段命中: sn |
+| L4-4 | L4 | service_sim_search | PASS | 268ms | 无明文 PII; 保留字段命中: giccid |
+| L4-5 | L4 | data_mongo_find | PASS | 1ms | 无明文 PII; 保留字段命中: _id |
+| L4-6 | L4 | data_mongo_find | PASS | 0ms | 无明文 PII; 保留字段命中: _id |
+| L2S-1 | L2S | service_site_query_info | PASS | 5411ms | 无明文 PII; 保留:sn  |
+| L2S-2 | L2S | service_site_power_flow | PASS | 393ms | 返回合法结果 (2508B) |
+| L2S-3 | L2S | data_fwh_query_site_mode_info | PASS | 638ms | 返回合法结果 (961B) |
+| L2S-4 | L2S | data_fwh_query_site_alarm_list | PASS | 560ms | 返回合法结果 (97B) |
+| L2S-5 | L2S | data_fwh_query_system_update_log | PASS | 1788ms | 返回合法结果 (766B) |
+| L2S-6 | L2S | service_site_monitor_mode_info | PASS | 611ms | 返回合法结果 (961B) |
+| L2S-7 | L2S | service_site_monitor_alarm_list | PASS | 220ms | 返回合法结果 (97B) |
+| L2S-8 | L2S | service_site_monitor_base_info | PASS | 565ms | 返回合法结果 (520B) |
+| L2S-10 | L2S | service_sim_query | PASS | 1ms | 无明文 PII; 保留:giccid  |
+| L2S-11 | L2S | service_sim_monthly_data | PASS | 1ms | 返回合法结果 (3150B) |
+| L2S-12 | L2S | data_sim_smarthub_card_info | PASS | 91ms | 无明文 PII; 保留: |
+| L2S-20 | L2S | plane_list_projects | PASS | 35ms | 返回合法结果 (834B) |
+| L2S-21 | L2S | plane_get_project | PASS | 62ms | 返回合法结果 (1530B) |
+| L2S-22 | L2S | plane_list_issues | PASS | 55ms | 返回合法结果 (285B) |
+| L2S-23 | L2S | plane_list_labels | PASS | 27ms | 返回合法结果 (809B) |
+| L2S-24 | L2S | plane_list_states | PASS | 27ms | 返回合法结果 (1682B) |
+| L2S-25 | L2S | plane_list_cycles | PASS | 31ms | 返回合法结果 (2B) |
+| L2S-26 | L2S | plane_list_modules | PASS | 52ms | 返回合法结果 (2B) |
+| L2S-27 | L2S | plane_list_intake_issues | PASS | 32ms | 返回合法结果 (1785B) |
+| L2S-28 | L2S | plane_list_project_members | PASS | 25ms | 返回合法结果 (516B) |
+| L2S-29 | L2S | plane_list_workspace_members | PASS | 26ms | 返回合法结果 (5465B) |
+| L3RW-1 | L3RW | storage_file_write | PASS | 5ms | 写入成功 path=/uploads/mcp_test_1787675676.txt |
+| L3RW-2 | L3RW | storage_file_list | PASS | 0ms | 列表返回 |
+| L3RW-3 | L3RW | storage_file_read | PASS | 1ms | 读回内容一致 |
+| L3RW-4 | L3RW | storage_minio_list_objects | PASS | 55ms | MinIO 可列出对象 |
+| L2C-1 | L2C | service_auto_update_get_package | PASS | 0ms | 级联 file_name=IBG_SYS.bz2 → 93B |
+| L2C-2 | L2C | service_auto_update_get_config | PASS | 0ms | 级联 agate_version=aGate1.0 → 677B |
+| L2C-3 | L2C | service_tou_template_detail | PASS | 574ms | 级联 id=4576 → 13072B |
+| L2C-4 | L2C | data_fwh_query_tou_template_detail | PASS | 223ms | 级联 id=4576 → 13072B |
+| L2C-5 | L2C | service_site_analysis_get_config | PASS | 65ms | 级联 id=6a83d29d26ac21635e31dbd0 → 2587B |
+| L2C-6 | L2C | service_site_analysis_data | PASS | 58ms | 级联 id=6a83d29d26ac21635e31dbd0 → 2576B |
+| L2C-7 | L2C | service_batch_download_path | PASS | 0ms | 级联 id=6a8d660f11ccf5833ac797ac → 1140B |
+| L2C-8 | L2C | service_site_monitor_config_get | PASS | 3ms | 级联 name=SUNRUN → 32330B |
+| L2C-9 | L2C | data_sim_acceleronix_card_info | PASS | 114ms | 级联 iccid=89330125107800459910 → 869B |
+| L2C-10 | L2C | data_sim_acceleronix_data_pool_sim_info | PASS | 87ms | 级联 data_pool_id=DP0100M0O905802 → 8181B |
+| L2C-11 | L2C | data_fwh_query_system_update_log_detail | SKIP | 201ms | order_id=72395 依赖/空: 查询升级详情失败: [QuerySystemUpdateLogDetail] [HttpPost] Response faile… |
+| L2C-12 | L2C | storage_minio_read_object | PASS | 0ms | 校验行为正常(拒绝大/非文本文件): 参数 key 格式错误: 文件过大 (2429176 bytes),仅… |
+| L2C-13 | L2C | plane_get_issue | SKIP | 0ms | 源 plane_list_issues 无可用数据(路径 results[0].id 为空) |
+| L2C-14 | L2C | plane_get_cycle | SKIP | 0ms | 源 plane_list_cycles 无可用数据(路径 [0].id 为空) |
+| L2C-15 | L2C | plane_get_module | SKIP | 0ms | 源 plane_list_modules 无可用数据(路径 [0].id 为空) |
+| L2C-16 | L2C | plane_get_page | SKIP | 0ms | 源 plane_list_pages 无可用数据(路径 [0].id 为空) |
+| L2C-17 | L2C | plane_get_intake_issue | PASS | 33ms | 级联 issue_id=6e7fa7b4-39c5-414a-a2fd-… → 1728B |
+| L2X-1 | L2X | data_fwh_query_site_power_flow | PASS | 741ms | 返回合法结果 (2508B) |
+| L2X-2 | L2X | plane_get_workspace | PASS | 24ms | 返回合法结果 (494B) |
+| L2X-3 | L2X | plane_list_pages | PASS | 34ms | 返回合法结果 (2B) |
+| L2X-4 | L2X | plane_list_stickies | PASS | 25ms | 返回合法结果 (281B) |
+| L2X-5 | L2X | service_batch_download_file_tree | PASS | 12ms | 级联 file_path=BatchDownloadData/10060001A04F24160088/E… → 196B |
+| L2X-6 | L2X | storage_file_read_to_local | PASS | 0ms | 写入后下载到本地成功 |
+| L2X-7 | L2X | storage_file_upload | PASS | 2ms | 本地文件上传成功 |
+
+## FAIL 用例(需关注)
+
+无
+
+## SKIP 逐条分析(每个工具缺什么 / 如何补)
+
+以下为**排除已在其它阶段 PASS 后**、仍未覆盖的 23 个工具。已在别处 PASS 的工具(如 service_site_query_info、plane_list_* 等)不再列出。
+
+分组:A=可级联/自造补测(无需外部样本)|B=需“确有数据”的样本|C=需环境或代码改动|D=环境数据为空。
+
+### B 组 · 需“确有数据”的样本(要你提供)
+
+| 工具 | 缺什么 / 如何补 |
+|------|------|
+| `data_fwh_query_ahub_update_log` | 缺“有 aHub 升级记录”的 sn。 |
+| `data_fwh_query_alarm_snapshot` | 缺“确实有告警快照”的 sn+date 组合(普通 SN 当天不一定有快照)。 |
+| `data_fwh_query_alarm_statistics_from_code` | 缺一个真实的 alarm_code(告警码)。 |
+| `data_fwh_query_ems_log_record` | 缺“有 EMS 日志”的 sn(现有样本 SN 试过为空)。 |
+| `data_fwh_query_event_alarm_list` | 缺“该时段确有事件/告警”的 sn+start_time+end_time。 |
+| `data_fwh_query_fault_record_callable_list` | 缺“有可召唤故障录波”的 sn。 |
+| `data_fwh_query_fault_record_list` | 缺“确有故障录波”的 sn+时间范围(现有 SN 上游返回 Response failed=无记录)。 |
+| `data_fwh_query_fault_record_sub_list` | 缺 fault_id(需先有 fault_record_list 返回数据才能级联)。 |
+| `data_fwh_query_mqtt_log_list` | 缺有效的 cmd_type 取值 + 有 MQTT 日志的 sn 与时间范围。 |
+| `data_fwh_query_shutdown_list` | 缺“该日期确有停机记录”的 date。 |
+| `data_fwh_query_system_update_log_detail` | order_id 已能级联获取,但上游对该 order 无详情(Response failed)。需一个确有升级详情的 order。 |
+| `data_pgsql_query_snapshot_file_record` | 缺一个已知存在的快照 file_name(PgSQL 记录,列表接口无法级联获取)。 |
+| `data_sim_acceleronix_batch_data_info` | iccid 可级联,但需配一个“确有用量数据”的 month。 |
+
+### C 组 · 需环境或代码改动
+
+| 工具 | 缺什么 / 如何补 |
+|------|------|
+| `service_background_task_list` | 缺 asynq 任务队列 inspector 初始化。需在 cmd/mcp/main.go 初始化队列 inspector(代码改动),或该能力在 MCP 场景不启用。 |
+
+### D 组 · 环境数据为空(非缺陷,需造/等数据)
+
+| 工具 | 缺什么 / 如何补 |
+|------|------|
+| `service_monitor_alarm_stats` | 缺当日告警统计数据(StatisticsAlarm 集合无文档)。需等有数据的日期或造数据;工具逻辑正常(同族 shutdown 已 PASS)。 |
+| `service_dashboard_alarm_detail` | 同上,缺 StatisticsAlarm 当日文档。 |
+| `plane_get_cycle` | 缺 cycle:plane_list_cycles 返回空。需项目里有周期。 |
+| `plane_get_issue` | 缺可用 issue:plane_list_issues 当前返回空(该 project 无工作项)。需项目里有 issue。 |
+| `plane_get_issue_activity` | 同上,需存在 issue。 |
+| `plane_get_module` | 缺 module:plane_list_modules 返回空。需项目里有模块。 |
+| `plane_get_page` | 缺 page:plane_list_pages 返回空。需项目里有页面。 |
+| `plane_get_page_description` | 同上,需存在 page。 |
+| `plane_get_sub_issues` | 同上,需存在带子项的 issue。 |
+

+ 1022 - 0
service/cmd/mcp/test/runner/cases.go

@@ -0,0 +1,1022 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/client"
+	"github.com/mark3labs/mcp-go/mcp"
+)
+
+// L1 用例:参数校验(不依赖后端,期望 isError=true 且消息含关键字)
+func runL1(ctx context.Context, c *client.Client) {
+	type l1 struct {
+		id, tool string
+		args     map[string]any
+		wantSub  string // 期望错误消息包含的子串(空=只要 isError)
+	}
+	cases := []l1{
+		{"L1-1", "util_parse_sn", map[string]any{"sn": "10060006A03X25310091"}, ""}, // 正确性单独断言
+		{"L1-3", "util_parse_sn", map[string]any{}, "sn"},
+		{"L1-4", "util_parse_sn", map[string]any{"sn": "1006"}, "长度"},
+		{"L1-5", "service_site_query_info", map[string]any{}, "sn"},
+		{"L1-6", "service_site_power_flow", map[string]any{"sn": "ANY", "date": "2025/01/01"}, "date"},
+		{"L1-7", "service_sim_monthly_data", map[string]any{"giccid": "x"}, "month"},
+		{"L1-8", "data_mongo_find", map[string]any{"collection": "UserInfo"}, "不允许"},
+		{"L1-9", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "filter": "{bad json"}, "filter"},
+		{"L1-10", "data_redis_exists", map[string]any{"key": "foo*"}, "通配符"},
+		{"L1-11", "data_redis_get", map[string]any{"key": "any", "type": "zset"}, "可选值"},
+		{"L1-12", "storage_minio_list_objects", map[string]any{"bucket": "secret-bucket"}, "不允许"},
+		{"L1-13", "data_emspara_vid_info", map[string]any{"id": "abc"}, "数字"},
+		{"L1-14", "data_pgsql_query_snapshot_data", map[string]any{"sn": "ANY", "date": "20250101"}, "date"},
+		{"L1-16", "service_feedback_create", map[string]any{"title": "t", "type": "invalid"}, "bug"},
+	}
+	for _, tc := range cases {
+		t0 := time.Now()
+		text, isErr, cErr := callText(ctx, c, tc.tool, tc.args)
+		el := time.Since(t0)
+		if cErr != nil {
+			addResult(result{tc.id, tc.tool, "L1", FAIL, el, "调用异常: " + cErr.Error()})
+			continue
+		}
+		// L1-1 特殊:正确性断言
+		if tc.id == "L1-1" {
+			ok := strings.Contains(text, "aGate") && strings.Contains(text, "2025") &&
+				strings.Contains(text, "0091") && strings.Contains(text, "\"week\": \"31\"")
+			st, note := PASS, "SN 解析字段正确"
+			if !ok {
+				st, note = FAIL, "解析结果不符: "+truncate(text, 200)
+			}
+			addResult(result{tc.id, tc.tool, "L1", st, el, note})
+			continue
+		}
+		// 其余:期望 isError 且含子串
+		st := PASS
+		note := "正确返回参数校验错误"
+		if !isErr {
+			st, note = FAIL, "未返回 isError;输出: "+truncate(text, 150)
+		} else if tc.wantSub != "" && !strings.Contains(text, tc.wantSub) {
+			st, note = FAIL, fmt.Sprintf("错误消息未含 %q: %s", tc.wantSub, truncate(text, 150))
+		}
+		addResult(result{tc.id, tc.tool, "L1", st, el, note})
+	}
+}
+
+// L2 只读冒烟:预设关键用例 + 对无必填参数的只读工具自动冒烟
+func runL2(ctx context.Context, c *client.Client, tools []mcp.Tool, byName map[string]mcp.Tool) {
+	type l2 struct {
+		id, tool string
+		args     map[string]any
+	}
+	preset := []l2{
+		// Redis
+		{"L2-R1", "data_redis_exists", map[string]any{"key": "nonexistent_test_key_xyz"}},
+		{"L2-R2", "data_redis_get", map[string]any{"key": "nonexistent_test_key_xyz"}},
+		// Mongo
+		{"L2-M1", "data_mongo_count", map[string]any{"collection": "SiteInfoManageData"}},
+		{"L2-M2", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "limit": 1}},
+		{"L2-M3", "data_mongo_count", map[string]any{"collection": "StatisticsAlarm"}},
+		// MinIO / 文件
+		{"L2-S1", "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "limit": 5}},
+		{"L2-S3", "storage_file_list", map[string]any{}},
+		// Plane
+		{"L2-PL1", "plane_list_workspaces", map[string]any{}},
+		{"L2-PL2", "plane_list_projects", map[string]any{}},
+		{"L2-PL4", "plane_list_labels", map[string]any{}},
+		{"L2-PL5", "plane_list_states", map[string]any{}},
+		{"L2-PL6", "service_feedback_history", map[string]any{}},
+		// 业务层(远程 MySQL)
+		{"L2-B1", "service_site_search", map[string]any{"page": 1, "size": 3}},
+		{"L2-B2", "service_sim_search", map[string]any{"page": 1, "size": 3}},
+		{"L2-B3", "service_monitor_alarm_stats", map[string]any{}},
+		{"L2-B4", "service_monitor_shutdown_stats", map[string]any{}},
+		{"L2-B5", "service_dashboard_shutdown_date", map[string]any{}},
+		{"L2-B6", "service_dashboard_shutdown_detail", map[string]any{}},
+		{"L2-B7", "service_dashboard_alarm_detail", map[string]any{}},
+		{"L2-B8", "service_tou_template_list", map[string]any{"page": 1, "size": 3}},
+		{"L2-B9", "service_background_task_list", map[string]any{}},
+		{"L2-B10", "service_site_monitor_config_list", map[string]any{}},
+		{"L2-B11", "service_site_monitor_name_list", map[string]any{}},
+		{"L2-B12", "service_site_info_update_status", map[string]any{}},
+		{"L2-B13", "service_site_info_tags_list", map[string]any{"page": 1, "size": 3}},
+		{"L2-B14", "service_auto_update_get_options", map[string]any{}},
+		{"L2-B15", "service_auto_update_get_apower_version", map[string]any{}},
+		{"L2-B16", "service_auto_update_file_list", map[string]any{"page": 1, "size": 3}},
+		{"L2-B17", "service_batch_download_list", map[string]any{"page": 1, "size": 3}},
+		{"L2-B18", "service_site_analysis_list", map[string]any{"page": 1, "size": 3}},
+		// emspara
+		{"L2-F8a", "data_emspara_vid_info", map[string]any{"id": "1"}},
+		{"L2-F8b", "data_emspara_pid_info", map[string]any{"id": "1"}},
+	}
+	seen := map[string]bool{}
+	for _, tc := range preset {
+		if _, ok := byName[tc.tool]; !ok {
+			addResult(result{tc.id, tc.tool, "L2", SKIP, 0, "工具未注册"})
+			continue
+		}
+		seen[tc.tool] = true
+		runOneL2(ctx, c, tc.id, tc.tool, tc.args)
+	}
+
+	// 自动冒烟:无必填参数、只读(非写操作、无 [写操作] 标记)、且未在 preset 覆盖的工具
+	auto := 0
+	for _, t := range tools {
+		if seen[t.Name] {
+			continue
+		}
+		if isWriteTool(t) {
+			continue // 写操作跳过
+		}
+		if len(t.InputSchema.Required) > 0 {
+			// 需要必填参数(多为需真实样本 SN/GICCID/ID),本轮无样本 → SKIP
+			addResult(result{"L2-auto", t.Name, "L2", SKIP, 0, "需必填参数,缺测试样本: " + strings.Join(t.InputSchema.Required, ",")})
+			continue
+		}
+		auto++
+		runOneL2(ctx, c, fmt.Sprintf("L2-auto%d", auto), t.Name, map[string]any{})
+	}
+}
+
+func runOneL2(ctx context.Context, c *client.Client, id, tool string, args map[string]any) {
+	t0 := time.Now()
+	text, isErr, cErr := callText(ctx, c, tool, args)
+	el := time.Since(t0)
+	if cErr != nil {
+		// 传输层错误 → FAIL
+		addResult(result{id, tool, "L2", FAIL, el, "调用异常: " + cErr.Error()})
+		return
+	}
+	if isErr {
+		// 判定是连接类(SKIP)还是真实错误(FAIL)
+		if looksLikeDependencyError(text) {
+			addResult(result{id, tool, "L2", SKIP, el, "依赖不可用/凭据: " + truncate(text, 120)})
+		} else {
+			// 参数校验类错误(如需真实样本)也归 SKIP,其它归 FAIL
+			addResult(result{id, tool, "L2", SKIP, el, "返回错误: " + truncate(text, 120)})
+		}
+		return
+	}
+	// 成功:结构合法即 PASS(含"查询结果为空"等业务空结果)
+	addResult(result{id, tool, "L2", PASS, el, "返回合法结果 " + fmt.Sprintf("(%dB)", len(text))})
+}
+
+// L4 敏感数据脱敏(端到端,用生产库真实数据做负向/正向断言)
+func runL4(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	type l4 struct {
+		id, tool string
+		args     map[string]any
+		keep     []string // 正向断言:应保留的字段名(出现即视为保留生效)
+	}
+	cases := []l4{
+		{"L4-2", "service_site_search", map[string]any{"page": 1, "size": 5}, []string{"sn"}},
+		{"L4-4", "service_sim_search", map[string]any{"page": 1, "size": 5}, []string{"giccid"}},
+		{"L4-5", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "limit": 3}, []string{"_id"}},
+		{"L4-6", "data_mongo_find", map[string]any{"collection": "SimCardManage", "limit": 3}, []string{"_id"}},
+	}
+	for _, tc := range cases {
+		if _, ok := byName[tc.tool]; !ok {
+			piis = append(piis, piiFinding{tc.id, tc.tool, SKIP, nil, "工具未注册", ""})
+			addResult(result{tc.id, tc.tool, "L4", SKIP, 0, "工具未注册"})
+			continue
+		}
+		t0 := time.Now()
+		text, isErr, cErr := callText(ctx, c, tc.tool, tc.args)
+		el := time.Since(t0)
+		if cErr != nil {
+			piis = append(piis, piiFinding{tc.id, tc.tool, FAIL, nil, "调用异常", cErr.Error()})
+			addResult(result{tc.id, tc.tool, "L4", FAIL, el, "调用异常: " + cErr.Error()})
+			continue
+		}
+		if isErr || looksLikeDependencyError(text) {
+			piis = append(piis, piiFinding{tc.id, tc.tool, SKIP, nil, "依赖不可用或空数据", truncate(text, 200)})
+			addResult(result{tc.id, tc.tool, "L4", SKIP, el, "依赖不可用/空: " + truncate(text, 100)})
+			continue
+		}
+		// 若结果落盘,读取本地文件内容一起检测
+		full := text
+		if lp := extractLocalPath(text); lp != "" {
+			if b, err := os.ReadFile(lp); err == nil {
+				full = text + "\n" + string(b)
+			}
+		}
+		leaks := detectLeaks(full)
+		// 正向断言
+		pos := "n/a"
+		if len(tc.keep) > 0 {
+			hit := []string{}
+			for _, k := range tc.keep {
+				if strings.Contains(full, "\""+k+"\"") {
+					hit = append(hit, k)
+				}
+			}
+			pos = "保留字段命中: " + strings.Join(hit, ",")
+		}
+		st := PASS
+		note := "无明文 PII"
+		if len(leaks) > 0 {
+			st = FAIL
+			note = "检出明文 PII: " + strings.Join(leaks, "; ")
+		}
+		piis = append(piis, piiFinding{tc.id, tc.tool, st, leaks, pos, truncate(full, 600)})
+		addResult(result{tc.id, tc.tool, "L4", st, el, note + "; " + pos})
+	}
+}
+
+// runSampleCases 用真实样本补测之前 SKIP 的只读工具(含 PII 端到端检测)。
+func runSampleCases(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	type sc struct {
+		id, tool string
+		args     map[string]any
+		pii      bool     // 是否做明文 PII 负向断言
+		keep     []string // 正向断言:保留字段
+	}
+	cases := []sc{
+		// 站点(FWH 远程 + PII)
+		{"L2S-1", "service_site_query_info", map[string]any{"sn": sampleSN}, true, []string{"sn"}},
+		{"L2S-2", "service_site_power_flow", map[string]any{"sn": sampleSN, "date": "2026-08-24"}, false, nil},
+		{"L2S-3", "data_fwh_query_site_mode_info", map[string]any{"sn": sampleSN}, false, nil},
+		{"L2S-4", "data_fwh_query_site_alarm_list", map[string]any{"sn": sampleSN}, false, nil},
+		{"L2S-5", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, false, nil},
+		{"L2S-6", "service_site_monitor_mode_info", map[string]any{"sn": sampleSN}, false, nil},
+		{"L2S-7", "service_site_monitor_alarm_list", map[string]any{"sn": sampleSN}, false, nil},
+		{"L2S-8", "service_site_monitor_base_info", map[string]any{"site_list": `["` + sampleSN + `"]`}, false, nil},
+		// SIM(PII)
+		{"L2S-10", "service_sim_query", map[string]any{"giccid": sampleGICCID}, true, []string{"giccid"}},
+		{"L2S-11", "service_sim_monthly_data", map[string]any{"giccid": sampleGICCID, "month": "2026-08"}, false, nil},
+		{"L2S-12", "data_sim_smarthub_card_info", map[string]any{"giccid": sampleGICCID}, true, nil},
+		// Plane(真实 slug/project)
+		{"L2S-20", "plane_list_projects", map[string]any{"workspace_slug": samplePlaneSlug}, false, nil},
+		{"L2S-21", "plane_get_project", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-22", "plane_list_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-23", "plane_list_labels", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-24", "plane_list_states", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-25", "plane_list_cycles", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-26", "plane_list_modules", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-27", "plane_list_intake_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-28", "plane_list_project_members", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
+		{"L2S-29", "plane_list_workspace_members", map[string]any{"workspace_slug": samplePlaneSlug}, false, nil},
+	}
+	for _, tc := range cases {
+		if _, ok := byName[tc.tool]; !ok {
+			addResult(result{tc.id, tc.tool, "L2S", SKIP, 0, "工具未注册"})
+			continue
+		}
+		t0 := time.Now()
+		text, isErr, cErr := callText(ctx, c, tc.tool, tc.args)
+		el := time.Since(t0)
+		if cErr != nil {
+			addResult(result{tc.id, tc.tool, "L2S", FAIL, el, "调用异常: " + cErr.Error()})
+			continue
+		}
+		if isErr {
+			// 样本用例:仅在 isError 时判定;区分依赖问题与真实错误
+			if looksLikeDependencyError(text) {
+				addResult(result{tc.id, tc.tool, "L2S", SKIP, el, "依赖不可用/空: " + truncate(text, 120)})
+			} else {
+				addResult(result{tc.id, tc.tool, "L2S", FAIL, el, "返回错误: " + truncate(text, 120)})
+			}
+			continue
+		}
+		// PII 检测
+		if tc.pii {
+			full := text
+			if lp := extractLocalPath(text); lp != "" {
+				if b, err := os.ReadFile(lp); err == nil {
+					full = text + "\n" + string(b)
+				}
+			}
+			leaks := detectLeaks(full)
+			pos := ""
+			for _, k := range tc.keep {
+				if strings.Contains(full, "\""+k+"\"") {
+					pos += k + " "
+				}
+			}
+			st, note := PASS, "无明文 PII; 保留:"+pos
+			if len(leaks) > 0 {
+				st, note = FAIL, "检出明文 PII: "+strings.Join(leaks, "; ")
+			}
+			piis = append(piis, piiFinding{tc.id, tc.tool, st, leaks, "保留字段: " + pos, truncate(full, 600)})
+			addResult(result{tc.id, tc.tool, "L2S", st, el, note})
+			continue
+		}
+		addResult(result{tc.id, tc.tool, "L2S", PASS, el, fmt.Sprintf("返回合法结果 (%dB)", len(text))})
+	}
+}
+
+// runWriteLoop L3RW:MinIO/文件 写→读→列 闭环(用户已授权 MinIO 写 + file 读)。
+func runWriteLoop(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	if _, ok := byName["storage_file_write"]; !ok {
+		addResult(result{"L3RW-1", "storage_file_write", "L3RW", SKIP, 0, "工具未注册"})
+		return
+	}
+	testPath := fmt.Sprintf("/uploads/mcp_test_%d.txt", time.Now().Unix())
+	testContent := fmt.Sprintf("MCP write-read loop test @ %s\nno-pii-content", time.Now().Format(time.RFC3339))
+
+	// 1) write
+	t0 := time.Now()
+	wText, wErr, cErr := callText(ctx, c, "storage_file_write", map[string]any{
+		"path": testPath, "content": testContent, "userId": "mcp-test", "sessionId": "mcp-test",
+	})
+	if cErr != nil {
+		addResult(result{"L3RW-1", "storage_file_write", "L3RW", FAIL, time.Since(t0), "调用异常: " + cErr.Error()})
+		return
+	}
+	if wErr {
+		if looksLikeDependencyError(wText) {
+			addResult(result{"L3RW-1", "storage_file_write", "L3RW", SKIP, time.Since(t0), "依赖不可用: " + truncate(wText, 120)})
+			return
+		}
+		addResult(result{"L3RW-1", "storage_file_write", "L3RW", FAIL, time.Since(t0), "写入失败: " + truncate(wText, 120)})
+		return
+	}
+	// 从返回中解析实际存储 path(handler 会加 /uploads/ 前缀)
+	storedPath := testPath
+	if p := extractJSONString(wText, "path"); p != "" {
+		storedPath = p
+	}
+	addResult(result{"L3RW-1", "storage_file_write", "L3RW", PASS, time.Since(t0), "写入成功 path=" + storedPath})
+
+	// 2) list(应含刚写入的文件)
+	t0 = time.Now()
+	lText, lErr, _ := callText(ctx, c, "storage_file_list", map[string]any{})
+	st, note := PASS, "列表返回"
+	if lErr {
+		st, note = SKIP, "列表返回错误: "+truncate(lText, 100)
+	} else if !strings.Contains(lText, "mcp_test_") {
+		st, note = FAIL, "列表未包含刚写入的文件"
+	}
+	addResult(result{"L3RW-2", "storage_file_list", "L3RW", st, time.Since(t0), note})
+
+	// 3) read(读回并校验内容)
+	if _, ok := byName["storage_file_read"]; ok {
+		t0 = time.Now()
+		rText, rErr, rcErr := callText(ctx, c, "storage_file_read", map[string]any{"path": storedPath})
+		rst, rnote := PASS, "读回内容一致"
+		if rcErr != nil {
+			rst, rnote = FAIL, "调用异常: "+rcErr.Error()
+		} else if rErr {
+			rst, rnote = SKIP, "读取错误: "+truncate(rText, 100)
+		} else if !strings.Contains(rText, "no-pii-content") {
+			rst, rnote = FAIL, "读回内容与写入不一致: "+truncate(rText, 100)
+		}
+		addResult(result{"L3RW-3", "storage_file_read", "L3RW", rst, time.Since(t0), rnote})
+	}
+
+	// 4) minio list(验证对象确实落到 MinIO)
+	if _, ok := byName["storage_minio_list_objects"]; ok {
+		t0 = time.Now()
+		mText, mErr, _ := callText(ctx, c, "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "prefix": "", "limit": 50})
+		mst, mnote := PASS, "MinIO 可列出对象"
+		if mErr {
+			mst, mnote = SKIP, "MinIO 列出错误: "+truncate(mText, 100)
+		}
+		addResult(result{"L3RW-4", "storage_minio_list_objects", "L3RW", mst, time.Since(t0), mnote})
+	}
+}
+
+// runCascadeCases L2C:从已 PASS 的列表接口提取 id/key/name,再喂给详情接口。
+// source: 先调用的列表工具;srcArgs: 其参数;extract: 从返回 JSON 中取值的路径。
+// target: 待补测的详情工具;argKey: 目标工具接收该值的参数名;extraArgs: 目标工具的其它固定参数。
+func runCascadeCases(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	type cascade struct {
+		id        string
+		source    string
+		srcArgs   map[string]any
+		extract   string // 值提取路径(见 jsonExtract)
+		target    string
+		argKey    string
+		extraArgs map[string]any
+	}
+	cs := []cascade{
+		// auto_update: options → package / config
+		{"L2C-1", "service_auto_update_get_options", map[string]any{}, "packages.system[0]", "service_auto_update_get_package", "file_name", nil},
+		{"L2C-2", "service_auto_update_get_options", map[string]any{}, "aGateVersions[0]", "service_auto_update_get_config", "agate_version", nil},
+		// tou: list → detail
+		{"L2C-3", "service_tou_template_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_tou_template_detail", "id", nil},
+		{"L2C-4", "service_tou_template_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "data_fwh_query_tou_template_detail", "id", nil},
+		// site_analysis: list → get_config / data
+		{"L2C-5", "service_site_analysis_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_site_analysis_get_config", "id", nil},
+		{"L2C-6", "service_site_analysis_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_site_analysis_data", "id", nil},
+		// batch_download: list → path / file_tree
+		{"L2C-7", "service_batch_download_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_batch_download_path", "id", nil},
+		// site_monitor: name_list → config_get
+		{"L2C-8", "service_site_monitor_name_list", map[string]any{}, "[0]", "service_site_monitor_config_get", "name", nil},
+		// acceleronix: card_list → card_info;data_pool_list → data_pool_sim_info
+		{"L2C-9", "data_sim_acceleronix_card_list", map[string]any{"page": 1, "size": 1}, "data.list[0].iccid", "data_sim_acceleronix_card_info", "iccid", nil},
+		{"L2C-10", "data_sim_acceleronix_data_pool_list", map[string]any{"page": 1, "size": 1}, "data.list[0].dataPoolId", "data_sim_acceleronix_data_pool_sim_info", "data_pool_id", nil},
+		// system_update_log(sample SN) → detail(order_id=id)
+		{"L2C-11", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, "[0].id", "data_fwh_query_system_update_log_detail", "order_id", nil},
+		// minio: list_objects → read_object
+		{"L2C-12", "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "limit": 20}, "objects[0].key", "storage_minio_read_object", "key", map[string]any{"bucket": "fwh-toolbox"}},
+		// plane issues: list → get_issue / sub_issues / activity(若 results 非空)
+		{"L2C-13", "plane_list_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "results[0].id", "plane_get_issue", "issue_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}},
+		{"L2C-14", "plane_list_cycles", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "[0].id", "plane_get_cycle", "cycle_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}},
+		{"L2C-15", "plane_list_modules", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "[0].id", "plane_get_module", "module_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}},
+		{"L2C-16", "plane_list_pages", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "[0].id", "plane_get_page", "page_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}},
+		{"L2C-17", "plane_list_intake_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "results[0].issue.id", "plane_get_intake_issue", "issue_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}},
+	}
+
+	for _, cc := range cs {
+		if _, ok := byName[cc.target]; !ok {
+			addResult(result{cc.id, cc.target, "L2C", SKIP, 0, "目标工具未注册"})
+			continue
+		}
+		// 1) 调用源列表接口
+		srcText, srcErr, cErr := callText(ctx, c, cc.source, cc.srcArgs)
+		if cErr != nil || srcErr {
+			addResult(result{cc.id, cc.target, "L2C", SKIP, 0, "源接口不可用: " + cc.source + " " + truncate(srcText, 80)})
+			continue
+		}
+		// 落盘情况:读取完整内容
+		full := srcText
+		if lp := extractLocalPath(srcText); lp != "" {
+			if b, err := os.ReadFile(lp); err == nil {
+				full = string(b)
+			}
+		}
+		// 2) 提取值
+		val := jsonExtract(full, cc.extract)
+		if val == "" {
+			addResult(result{cc.id, cc.target, "L2C", SKIP, 0, fmt.Sprintf("源 %s 无可用数据(路径 %s 为空)", cc.source, cc.extract)})
+			continue
+		}
+		// 3) 调用目标详情接口
+		args := map[string]any{cc.argKey: val}
+		for k, v := range cc.extraArgs {
+			args[k] = v
+		}
+		t0 := time.Now()
+		tText, tErr, tcErr := callText(ctx, c, cc.target, args)
+		el := time.Since(t0)
+		if tcErr != nil {
+			addResult(result{cc.id, cc.target, "L2C", FAIL, el, "调用异常: " + tcErr.Error()})
+			continue
+		}
+		if tErr {
+			// storage_minio_read_object 的"文件过大/非文本"是工具正确的校验行为,判 PASS
+			if cc.target == "storage_minio_read_object" && (strings.Contains(tText, "文件过大") || strings.Contains(tText, "非文本")) {
+				addResult(result{cc.id, cc.target, "L2C", PASS, el, "校验行为正常(拒绝大/非文本文件): " + truncate(tText, 60)})
+				continue
+			}
+			if looksLikeDependencyError(tText) {
+				addResult(result{cc.id, cc.target, "L2C", SKIP, el, fmt.Sprintf("%s=%s 依赖/空: %s", cc.argKey, truncate(val, 20), truncate(tText, 80))})
+			} else {
+				addResult(result{cc.id, cc.target, "L2C", FAIL, el, fmt.Sprintf("%s=%s 返回错误: %s", cc.argKey, truncate(val, 20), truncate(tText, 80))})
+			}
+			continue
+		}
+		addResult(result{cc.id, cc.target, "L2C", PASS, el, fmt.Sprintf("级联 %s=%s → %dB", cc.argKey, truncate(val, 24), len(tText))})
+	}
+}
+
+// jsonExtract 从 JSON 文本按简易路径取标量值,返回其字符串形式。
+// 支持:对象键 a.b、数组下标 a[0]、根数组 [0]、混合 data.list[0].iccid。
+func jsonExtract(jsonText, path string) string {
+	var root any
+	if err := json.Unmarshal([]byte(jsonText), &root); err != nil {
+		return ""
+	}
+	cur := root
+	// 将 [n] 归一化为 .n 便于按 . 分段
+	norm := strings.ReplaceAll(path, "[", ".")
+	norm = strings.ReplaceAll(norm, "]", "")
+	for _, seg := range strings.Split(norm, ".") {
+		if seg == "" {
+			continue
+		}
+		switch node := cur.(type) {
+		case map[string]any:
+			cur = node[seg]
+		case []any:
+			idx := 0
+			fmt.Sscanf(seg, "%d", &idx)
+			if idx < 0 || idx >= len(node) {
+				return ""
+			}
+			cur = node[idx]
+		default:
+			return ""
+		}
+		if cur == nil {
+			return ""
+		}
+	}
+	switch v := cur.(type) {
+	case string:
+		return v
+	case float64:
+		// 整数去掉小数点
+		if v == float64(int64(v)) {
+			return fmt.Sprintf("%d", int64(v))
+		}
+		return fmt.Sprintf("%v", v)
+	case json.Number:
+		return v.String()
+	case bool:
+		return fmt.Sprintf("%v", v)
+	default:
+		return ""
+	}
+}
+
+// runExtraCascades L2X:处理需递归级联或自造输入的补测。
+func runExtraCascades(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	// 1) data_fwh_query_site_power_flow —— 直接用样本 SN+date
+	runOneL2X(ctx, c, byName, "L2X-1", "data_fwh_query_site_power_flow",
+		map[string]any{"sn": sampleSN, "date": "2026-08-24"})
+
+	// 2) plane_get_workspace —— slug=fwh
+	runOneL2X(ctx, c, byName, "L2X-2", "plane_get_workspace",
+		map[string]any{"workspace_slug": samplePlaneSlug})
+
+	// 3) plane_list_pages / plane_list_stickies —— slug/project
+	runOneL2X(ctx, c, byName, "L2X-3", "plane_list_pages",
+		map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject})
+	runOneL2X(ctx, c, byName, "L2X-4", "plane_list_stickies",
+		map[string]any{"workspace_slug": samplePlaneSlug})
+
+	// 4) service_batch_download_file_tree —— 从 batch list → path 树递归取第一个叶子文件
+	runFileTreeCascade(ctx, c, byName)
+
+	// 5) storage_file_read_to_local —— 先写一个文件再 read_to_local
+	runReadToLocalCascade(ctx, c, byName)
+
+	// 6) storage_file_upload —— 自造本地临时文件再上传
+	runUploadCascade(ctx, c, byName)
+}
+
+// runOneL2X 调用单个工具并按三态判定(样本已知有效,只区分 isErr 与依赖/空)。
+func runOneL2X(ctx context.Context, c *client.Client, byName map[string]mcp.Tool, id, tool string, args map[string]any) {
+	if _, ok := byName[tool]; !ok {
+		addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
+		return
+	}
+	t0 := time.Now()
+	text, isErr, cErr := callText(ctx, c, tool, args)
+	el := time.Since(t0)
+	if cErr != nil {
+		addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + cErr.Error()})
+		return
+	}
+	if isErr {
+		if looksLikeDependencyError(text) {
+			addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(text, 100)})
+		} else {
+			addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(text, 100)})
+		}
+		return
+	}
+	addResult(result{id, tool, "L2X", PASS, el, fmt.Sprintf("返回合法结果 (%dB)", len(text))})
+}
+
+// runFileTreeCascade batch list → path 树 → 递归取第一个 isFile 叶子 → file_tree。
+func runFileTreeCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	const id, tool = "L2X-5", "service_batch_download_file_tree"
+	if _, ok := byName[tool]; !ok {
+		addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
+		return
+	}
+	// 取 batch id
+	lText, lErr, _ := callText(ctx, c, "service_batch_download_list", map[string]any{"page": 1, "size": 1})
+	if lErr {
+		addResult(result{id, tool, "L2X", SKIP, 0, "batch list 不可用"})
+		return
+	}
+	bid := jsonExtract(lText, "data[0].id")
+	if bid == "" {
+		addResult(result{id, tool, "L2X", SKIP, 0, "无 batch 任务可用"})
+		return
+	}
+	// 取 path 树
+	pText, pErr, _ := callText(ctx, c, "service_batch_download_path", map[string]any{"id": bid})
+	if pErr {
+		addResult(result{id, tool, "L2X", SKIP, 0, "batch path 不可用"})
+		return
+	}
+	full := pText
+	if lp := extractLocalPath(pText); lp != "" {
+		if b, err := os.ReadFile(lp); err == nil {
+			full = string(b)
+		}
+	}
+	leaf := findFirstFileLeaf(full)
+	if leaf == "" {
+		addResult(result{id, tool, "L2X", SKIP, 0, "path 树中无文件叶子(tar 文件路径)"})
+		return
+	}
+	t0 := time.Now()
+	tText, tErr, tcErr := callText(ctx, c, tool, map[string]any{"file_path": leaf})
+	el := time.Since(t0)
+	if tcErr != nil {
+		addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + tcErr.Error()})
+		return
+	}
+	if tErr {
+		if looksLikeDependencyError(tText) {
+			addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(tText, 100)})
+		} else {
+			addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(tText, 100)})
+		}
+		return
+	}
+	addResult(result{id, tool, "L2X", PASS, el, "级联 file_path=" + truncate(leaf, 40) + fmt.Sprintf(" → %dB", len(tText))})
+}
+
+// findFirstFileLeaf 递归查找 tree JSON 中第一个 isFile==true 节点的 name。
+func findFirstFileLeaf(jsonText string) string {
+	var root any
+	if err := json.Unmarshal([]byte(jsonText), &root); err != nil {
+		return ""
+	}
+	var dfs func(n any) string
+	dfs = func(n any) string {
+		switch node := n.(type) {
+		case map[string]any:
+			if f, ok := node["isFile"].(bool); ok && f {
+				if name, ok := node["name"].(string); ok {
+					return name
+				}
+			}
+			if ch, ok := node["children"]; ok {
+				if r := dfs(ch); r != "" {
+					return r
+				}
+			}
+		case []any:
+			for _, e := range node {
+				if r := dfs(e); r != "" {
+					return r
+				}
+			}
+		}
+		return ""
+	}
+	return dfs(root)
+}
+
+// runReadToLocalCascade 写一个文件 → storage_file_read_to_local 读到本地。
+func runReadToLocalCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	const id, tool = "L2X-6", "storage_file_read_to_local"
+	if _, ok := byName[tool]; !ok {
+		addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
+		return
+	}
+	path := fmt.Sprintf("/uploads/mcp_test_r2l_%d.txt", time.Now().UnixNano())
+	wText, wErr, _ := callText(ctx, c, "storage_file_write", map[string]any{
+		"path": path, "content": "read-to-local test", "userId": "mcp-test", "sessionId": "mcp-test",
+	})
+	if wErr {
+		addResult(result{id, tool, "L2X", SKIP, 0, "前置写入失败: " + truncate(wText, 80)})
+		return
+	}
+	stored := path
+	if p := extractJSONString(wText, "path"); p != "" {
+		stored = p
+	}
+	t0 := time.Now()
+	rText, rErr, rcErr := callText(ctx, c, tool, map[string]any{"path": stored})
+	el := time.Since(t0)
+	if rcErr != nil {
+		addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + rcErr.Error()})
+		return
+	}
+	if rErr {
+		addResult(result{id, tool, "L2X", SKIP, el, "返回错误: " + truncate(rText, 80)})
+		return
+	}
+	addResult(result{id, tool, "L2X", PASS, el, "写入后下载到本地成功"})
+}
+
+// runUploadCascade 自造本地临时文件 → storage_file_upload。
+func runUploadCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
+	const id, tool = "L2X-7", "storage_file_upload"
+	if _, ok := byName[tool]; !ok {
+		addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
+		return
+	}
+	tmp := fmt.Sprintf("/tmp/mcp_test_upload_%d.txt", time.Now().UnixNano())
+	if err := os.WriteFile(tmp, []byte("upload test content\nno-pii"), 0644); err != nil {
+		addResult(result{id, tool, "L2X", SKIP, 0, "无法创建本地临时文件: " + err.Error()})
+		return
+	}
+	defer os.Remove(tmp)
+	t0 := time.Now()
+	uText, uErr, ucErr := callText(ctx, c, tool, map[string]any{
+		"localPath": tmp, "fileName": "mcp_test_upload.txt", "userId": "mcp-test", "sessionId": "mcp-test",
+	})
+	el := time.Since(t0)
+	if ucErr != nil {
+		addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + ucErr.Error()})
+		return
+	}
+	if uErr {
+		if looksLikeDependencyError(uText) {
+			addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(uText, 80)})
+		} else {
+			addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(uText, 80)})
+		}
+		return
+	}
+	addResult(result{id, tool, "L2X", PASS, el, "本地文件上传成功"})
+}
+
+// ---- 判定辅助 ----
+
+func isWriteTool(t mcp.Tool) bool {
+	return strings.Contains(t.Description, "[写操作]") || strings.Contains(t.Description, "写操作")
+}
+
+func looksLikeDependencyError(text string) bool {
+	kw := []string{"connection", "connect", "timeout", "refused", "no reachable",
+		"dial ", "EOF", "登录", "login", "unauthorized", "authentication",
+		"i/o timeout", "context deadline", "无法连接", "失败", "server selection"}
+	lt := strings.ToLower(text)
+	for _, k := range kw {
+		if strings.Contains(lt, strings.ToLower(k)) {
+			return true
+		}
+	}
+	return false
+}
+
+func extractLocalPath(text string) string {
+	return extractJSONString(text, "localPath")
+}
+
+// extractJSONString 粗略提取 "key": "value" 中的 value(用于报告展示,不做完整 JSON 解析)。
+func extractJSONString(text, key string) string {
+	idx := strings.Index(text, "\""+key+"\"")
+	if idx < 0 {
+		return ""
+	}
+	rest := text[idx+len(key)+2:]
+	c1 := strings.Index(rest, ":")
+	if c1 < 0 {
+		return ""
+	}
+	rest = rest[c1+1:]
+	q1 := strings.Index(rest, "\"")
+	if q1 < 0 {
+		return ""
+	}
+	rest = rest[q1+1:]
+	q2 := strings.Index(rest, "\"")
+	if q2 < 0 {
+		return ""
+	}
+	return rest[:q2]
+}
+
+// ---- 报告 ----
+
+func writeReports(outDir string) {
+	writeReport(filepath.Join(outDir, "report.md"))
+	writePiiReport(filepath.Join(outDir, "pii_report.md"))
+}
+
+func countByStatus(level string) (p, f, s int) {
+	for _, r := range results {
+		if level != "" && r.level != level {
+			continue
+		}
+		switch r.status {
+		case PASS:
+			p++
+		case FAIL:
+			f++
+		case SKIP:
+			s++
+		}
+	}
+	return
+}
+
+// foldByTool 将每个工具的多条执行记录折叠为一条终态。
+// 终态优先级:FAIL > PASS > SKIP(FAIL 必须暴露;有任一 PASS 即视为已覆盖;全 SKIP 才算未覆盖)。
+// 返回 工具→终态 的映射。
+func foldByTool() map[string]status {
+	rank := map[status]int{SKIP: 0, PASS: 1, FAIL: 2}
+	final := map[string]status{}
+	for _, r := range results {
+		if r.tool == "-" || r.tool == "" {
+			continue
+		}
+		if cur, ok := final[r.tool]; !ok || rank[r.status] > rank[cur] {
+			final[r.tool] = r.status
+		}
+	}
+	return final
+}
+
+// toolCoverage 基于折叠后的终态统计工具级覆盖率。
+func toolCoverage() (total, passed, failed, uncovered int) {
+	for _, st := range foldByTool() {
+		total++
+		switch st {
+		case PASS:
+			passed++
+		case FAIL:
+			failed++
+		case SKIP:
+			uncovered++
+		}
+	}
+	return
+}
+
+func writeReport(path string) {
+	var b strings.Builder
+	b.WriteString("# FWH MCP Server 非写操作接口测试报告\n\n")
+	b.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
+	b.WriteString("> SKIP / FAIL 的逐条根因分析、代码定位与修复建议见 **ANALYSIS.md**。\n\n")
+
+	// 主口径:按工具去重的覆盖率(每个工具只算一次终态,FAIL>PASS>SKIP)
+	tt, tpass, tfail, tuncov := toolCoverage()
+	b.WriteString("## 工具覆盖率(按工具去重,主口径)\n\n")
+	b.WriteString(fmt.Sprintf("被测工具 **%d** 个 | ✅ 已覆盖(PASS) **%d** | ❌ FAIL **%d** | ⏭️ 未覆盖(SKIP) **%d**\n\n",
+		tt, tpass, tfail, tuncov))
+	if tt > 0 {
+		b.WriteString(fmt.Sprintf("覆盖率: **%.1f%%**(%d/%d 工具至少有一次成功调用)\n\n", float64(tpass)/float64(tt)*100, tpass, tt))
+	}
+	b.WriteString("> 说明:一个工具可能在多个阶段被调用(自动冒烟因缺参 SKIP、样本/级联阶段 PASS)。\n")
+	b.WriteString("> 上表按工具去重,只要该工具有任一次 PASS 即计为“已覆盖”,避免同一工具重复计数。\n")
+	b.WriteString("> 下方“执行记录”为全部调用流水(含重复),供追溯过程。\n\n")
+
+	// 次口径:执行记录流水
+	tp, tf, ts := countByStatus("")
+	b.WriteString("## 执行记录统计(流水口径,含重复)\n\n")
+	b.WriteString(fmt.Sprintf("总执行 %d 次 | PASS %d | FAIL %d | SKIP %d\n\n", len(results), tp, tf, ts))
+
+	b.WriteString("## 分层汇总(执行流水)\n\n| 层级 | PASS | FAIL | SKIP |\n|------|------|------|------|\n")
+	for _, lv := range []string{"L0", "L1", "L2", "L2S", "L2C", "L2X", "L3RW", "L4"} {
+		p, f, s := countByStatus(lv)
+		b.WriteString(fmt.Sprintf("| %s | %d | %d | %d |\n", lv, p, f, s))
+	}
+	b.WriteString("\n")
+
+	b.WriteString("## 执行明细\n\n| 编号 | 层级 | 工具 | 结果 | 耗时 | 备注 |\n|------|------|------|------|------|------|\n")
+	for _, r := range results {
+		note := strings.ReplaceAll(r.note, "|", "\\|")
+		note = strings.ReplaceAll(note, "\n", " ")
+		b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %dms | %s |\n",
+			r.id, r.level, r.tool, r.status, r.elapsed.Milliseconds(), truncate(note, 160)))
+	}
+
+	// FAIL 汇总
+	b.WriteString("\n## FAIL 用例(需关注)\n\n")
+	anyFail := false
+	for _, r := range results {
+		if r.status == FAIL {
+			anyFail = true
+			b.WriteString(fmt.Sprintf("- **%s** %s: %s\n", r.id, r.tool, strings.ReplaceAll(r.note, "\n", " ")))
+		}
+	}
+	if !anyFail {
+		b.WriteString("无\n")
+	}
+
+	b.WriteString("\n## SKIP 逐条分析(每个工具缺什么 / 如何补)\n\n")
+	writeSkipAnalysis(&b)
+
+	os.WriteFile(path, []byte(b.String()), 0644)
+}
+
+// skipNeed 描述某工具 SKIP 的具体缺失与补法。
+type skipNeed struct {
+	group string // A=需样本(可级联) / B=需有数据样本 / C=环境或代码 / D=数据为空
+	need  string // 缺什么、如何补
+}
+
+// skipNeeds 映射每个可能 SKIP 的工具 → 缺失说明。
+var skipNeeds = map[string]skipNeed{
+	// —— C:环境/代码,非样本 ——
+	"service_background_task_list": {"C", "缺 asynq 任务队列 inspector 初始化。需在 cmd/mcp/main.go 初始化队列 inspector(代码改动),或该能力在 MCP 场景不启用。"},
+	"service_monitor_alarm_stats":  {"D", "缺当日告警统计数据(StatisticsAlarm 集合无文档)。需等有数据的日期或造数据;工具逻辑正常(同族 shutdown 已 PASS)。"},
+	"service_dashboard_alarm_detail": {"D", "同上,缺 StatisticsAlarm 当日文档。"},
+	// —— B:需“确定有数据”的样本 ——
+	"data_pgsql_query_snapshot_file_record":     {"B", "缺一个已知存在的快照 file_name(PgSQL 记录,列表接口无法级联获取)。"},
+	"data_fwh_query_alarm_snapshot":             {"B", "缺“确实有告警快照”的 sn+date 组合(普通 SN 当天不一定有快照)。"},
+	"data_fwh_query_alarm_statistics_from_code": {"B", "缺一个真实的 alarm_code(告警码)。"},
+	"data_fwh_query_mqtt_log_list":              {"B", "缺有效的 cmd_type 取值 + 有 MQTT 日志的 sn 与时间范围。"},
+	"data_fwh_query_ems_log_record":             {"B", "缺“有 EMS 日志”的 sn(现有样本 SN 试过为空)。"},
+	"data_fwh_query_event_alarm_list":           {"B", "缺“该时段确有事件/告警”的 sn+start_time+end_time。"},
+	"data_fwh_query_site_alarm_list":            {"B", "缺“确有告警”的 sn(现有样本 SN 无告警)。"},
+	"data_fwh_query_shutdown_list":              {"B", "缺“该日期确有停机记录”的 date。"},
+	"data_fwh_query_ahub_update_log":            {"B", "缺“有 aHub 升级记录”的 sn。"},
+	"data_fwh_query_fault_record_list":          {"B", "缺“确有故障录波”的 sn+时间范围(现有 SN 上游返回 Response failed=无记录)。"},
+	"data_fwh_query_fault_record_callable_list": {"B", "缺“有可召唤故障录波”的 sn。"},
+	"data_fwh_query_fault_record_sub_list":      {"B", "缺 fault_id(需先有 fault_record_list 返回数据才能级联)。"},
+	"data_fwh_query_system_update_log_detail":   {"B", "order_id 已能级联获取,但上游对该 order 无详情(Response failed)。需一个确有升级详情的 order。"},
+	"data_sim_acceleronix_batch_data_info":      {"B", "iccid 可级联,但需配一个“确有用量数据”的 month。"},
+	// —— Plane:列表为空导致无法级联(环境数据缺失)——
+	"plane_get_issue":         {"D", "缺可用 issue:plane_list_issues 当前返回空(该 project 无工作项)。需项目里有 issue。"},
+	"plane_get_sub_issues":    {"D", "同上,需存在带子项的 issue。"},
+	"plane_get_issue_activity": {"D", "同上,需存在 issue。"},
+	"plane_get_cycle":         {"D", "缺 cycle:plane_list_cycles 返回空。需项目里有周期。"},
+	"plane_get_module":        {"D", "缺 module:plane_list_modules 返回空。需项目里有模块。"},
+	"plane_get_page":          {"D", "缺 page:plane_list_pages 返回空。需项目里有页面。"},
+	"plane_get_page_description": {"D", "同上,需存在 page。"},
+	"plane_get_workspace":     {"A", "仅需 workspace_slug=fwh,驱动未覆盖直接调用(可级联/固定值补测)。"},
+	"plane_list_pages":        {"A", "仅需 slug/project(有样本),驱动未覆盖(可补测)。"},
+	"plane_list_stickies":     {"A", "仅需 slug(有样本),驱动未覆盖(可补测)。"},
+	// —— A:需 localPath(可自造)——
+	"storage_file_upload":       {"A", "缺 localPath。测试可自造一个本地临时文件再上传(无需外部样本)。"},
+	"storage_file_read_to_local": {"A", "缺 path。可用 L3RW 写入的 path 级联补测。"},
+}
+
+// writeSkipAnalysis 渲染当前仍为 SKIP、且未在其它阶段 PASS 的工具的逐条说明。
+func writeSkipAnalysis(b *strings.Builder) {
+	passed := map[string]bool{}
+	for _, r := range results {
+		if r.status == PASS {
+			passed[r.tool] = true
+		}
+	}
+	// 收集仍 SKIP 且从未 PASS 的工具(去重)
+	seen := map[string]bool{}
+	type row struct{ tool, group, need string }
+	var rows []row
+	for _, r := range results {
+		if r.status != SKIP || passed[r.tool] || seen[r.tool] {
+			continue
+		}
+		seen[r.tool] = true
+		n, ok := skipNeeds[r.tool]
+		if !ok {
+			n = skipNeed{"A", "需必填参数样本(未归类,可级联或提供样本补测)"}
+		}
+		rows = append(rows, row{r.tool, n.group, n.need})
+	}
+
+	b.WriteString(fmt.Sprintf("以下为**排除已在其它阶段 PASS 后**、仍未覆盖的 %d 个工具。已在别处 PASS 的工具(如 service_site_query_info、plane_list_* 等)不再列出。\n\n", len(rows)))
+	b.WriteString("分组:A=可级联/自造补测(无需外部样本)|B=需“确有数据”的样本|C=需环境或代码改动|D=环境数据为空。\n\n")
+
+	for _, g := range []string{"A", "B", "C", "D"} {
+		var gr []row
+		for _, r := range rows {
+			if r.group == g {
+				gr = append(gr, r)
+			}
+		}
+		if len(gr) == 0 {
+			continue
+		}
+		labels := map[string]string{
+			"A": "A 组 · 可级联/自造补测(无需外部样本)",
+			"B": "B 组 · 需“确有数据”的样本(要你提供)",
+			"C": "C 组 · 需环境或代码改动",
+			"D": "D 组 · 环境数据为空(非缺陷,需造/等数据)",
+		}
+		b.WriteString(fmt.Sprintf("### %s\n\n| 工具 | 缺什么 / 如何补 |\n|------|------|\n", labels[g]))
+		for _, r := range gr {
+			need := strings.ReplaceAll(r.need, "|", "\\|")
+			b.WriteString(fmt.Sprintf("| `%s` | %s |\n", r.tool, need))
+		}
+		b.WriteString("\n")
+	}
+}
+
+func writePiiReport(path string) {
+	var b strings.Builder
+	b.WriteString("# FWH MCP Server 敏感数据脱敏专项报告 (L4)\n\n")
+	b.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
+	b.WriteString("说明: 负向断言=返回内容不得出现明文 PII(手机号/邮箱/IMEI/MAC/IP);正向断言=保留字段(sn/giccid等)应保持原值。\n")
+	b.WriteString("L4-0 单元测试 (go test ./cmd/mcp/tools/helper/...) 已单独执行。\n\n")
+
+	pp, pf, ps := 0, 0, 0
+	for _, f := range piis {
+		switch f.status {
+		case PASS:
+			pp++
+		case FAIL:
+			pf++
+		case SKIP:
+			ps++
+		}
+	}
+	b.WriteString(fmt.Sprintf("端到端脱敏用例: PASS %d | FAIL %d | SKIP %d\n\n", pp, pf, ps))
+
+	for _, f := range piis {
+		b.WriteString(fmt.Sprintf("## %s — %s [%s]\n\n", f.id, f.tool, f.status))
+		if len(f.leaks) > 0 {
+			b.WriteString("**检出明文 PII(安全缺陷):**\n")
+			for _, l := range f.leaks {
+				b.WriteString("- " + l + "\n")
+			}
+			b.WriteString("\n")
+		} else {
+			b.WriteString("负向断言: 无明文 PII\n\n")
+		}
+		b.WriteString("正向断言: " + f.positive + "\n\n")
+		if f.sample != "" {
+			b.WriteString("脱敏后样本片段:\n```\n" + f.sample + "\n```\n\n")
+		}
+	}
+	os.WriteFile(path, []byte(b.String()), 0644)
+}

+ 331 - 0
service/cmd/mcp/test/runner/main.go

@@ -0,0 +1,331 @@
+// Command runner 是 FWH MCP Server 的非写操作接口测试驱动。
+//
+// 它通过 mcp-go 的 stdio client 拉起被测二进制(cwd 设为运行路径以加载 config.env),
+// 执行 L0(协议/清单)、L1(参数校验)、L2(只读冒烟)、L4(敏感数据脱敏)用例,
+// 三态判定 PASS/FAIL/SKIP,并生成 report.md 与 pii_report.md。
+//
+// 用法:
+//
+//	go run ./cmd/mcp/test/runner \
+//	  -bin /tmp/fwh-mcp-server-new \
+//	  -cwd /var/www/fwh-toolbox/server \
+//	  -out /var/www/fwh-toolbox/server
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"os/exec"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/client"
+	"github.com/mark3labs/mcp-go/client/transport"
+	"github.com/mark3labs/mcp-go/mcp"
+)
+
+// ---- 结果模型 ----
+
+type status string
+
+const (
+	PASS status = "PASS"
+	FAIL status = "FAIL"
+	SKIP status = "SKIP"
+)
+
+type result struct {
+	id      string
+	tool    string
+	level   string
+	status  status
+	elapsed time.Duration
+	note    string
+}
+
+type piiFinding struct {
+	id       string
+	tool     string
+	status   status
+	leaks    []string // 命中的明文 PII(应为空)
+	positive string   // 正向断言结果
+	sample   string   // 脱敏后样本片段
+}
+
+var (
+	results []result
+	piis    []piiFinding
+)
+
+func addResult(r result) { results = append(results, r) }
+
+// truncate 按字节安全截断(rune 边界),用于报告展示。
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	end := max
+	for end > 0 && s[end]&0xC0 == 0x80 {
+		end--
+	}
+	return s[:end] + "…"
+}
+
+// ---- 明文 PII 检测正则 ----
+
+var (
+	rePhone = regexp.MustCompile(`(^|[^\d.])1\d{10}([^\d.]|$)`)
+	reImei  = regexp.MustCompile(`(^|[^\d.])\d{14,15}([^\d.]|$)`)
+	reMac   = regexp.MustCompile(`([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}`)
+	reIP    = regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
+	reEmail = regexp.MustCompile(`[A-Za-z0-9._%+\-]{2,}@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
+)
+
+// commonSubnetMasks 常见子网掩码/网络地址,非 PII,需从 IP 检测中排除。
+var commonSubnetMasks = map[string]bool{
+	"255.255.255.0": true, "255.255.255.255": true, "255.255.0.0": true,
+	"255.0.0.0": true, "0.0.0.0": true, "255.255.255.128": true,
+	"255.255.255.192": true, "255.255.254.0": true, "255.255.252.0": true,
+	"127.0.0.1": true,
+}
+
+// detectLeaks 返回文本中命中的明文 PII 说明列表(空=无泄露)。
+// 已脱敏的掩码串 ****** / x***@ 不会命中。
+// 排除误报来源:子网掩码、浮点数内部数字串(正则用 [^\d.] 边界)、被脱敏字段值。
+func detectLeaks(text string) []string {
+	var leaks []string
+	if m := rePhone.FindString(text); m != "" {
+		leaks = append(leaks, "手机号明文:"+strings.TrimSpace(m))
+	}
+	if m := reImei.FindString(text); m != "" {
+		leaks = append(leaks, "IMEI/IMSI明文:"+strings.TrimSpace(m))
+	}
+	if m := reMac.FindString(text); m != "" {
+		leaks = append(leaks, "MAC明文:"+m)
+	}
+	for _, m := range reEmail.FindAllString(text, -1) {
+		if !strings.Contains(m, "*") {
+			leaks = append(leaks, "邮箱明文:"+m)
+			break
+		}
+	}
+	// IP:排除子网掩码/回环地址;排除 "subnet" 字段值(子网掩码非 PII,且未在脱敏规则内)
+	for _, loc := range reIP.FindAllStringIndex(text, -1) {
+		m := text[loc[0]:loc[1]]
+		if commonSubnetMasks[m] || isSubnetMask(m) {
+			continue
+		}
+		// 检查该值前 ~30 字节是否为 "subnet" 键
+		ctxStart := loc[0] - 30
+		if ctxStart < 0 {
+			ctxStart = 0
+		}
+		if strings.Contains(text[ctxStart:loc[0]], "subnet") {
+			continue
+		}
+		leaks = append(leaks, "IP明文:"+m)
+		break
+	}
+	return leaks
+}
+
+// isSubnetMask 判断点分四段是否为合法子网掩码(连续高位1)。
+func isSubnetMask(s string) bool {
+	parts := strings.Split(s, ".")
+	if len(parts) != 4 {
+		return false
+	}
+	var v uint32
+	for _, p := range parts {
+		n := 0
+		for _, ch := range p {
+			if ch < '0' || ch > '9' {
+				return false
+			}
+			n = n*10 + int(ch-'0')
+		}
+		if n > 255 {
+			return false
+		}
+		v = v<<8 | uint32(n)
+	}
+	if v == 0 {
+		return true
+	}
+	// 取反后 +1 应为 2 的幂 → 连续掩码
+	inv := ^v + 1
+	return inv&(inv-1) == 0
+}
+
+// ---- 调用辅助 ----
+
+func callText(ctx context.Context, c *client.Client, name string, args map[string]any) (text string, isErr bool, callErr error) {
+	req := mcp.CallToolRequest{}
+	req.Params.Name = name
+	req.Params.Arguments = args
+	cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	defer cancel()
+	res, err := c.CallTool(cctx, req)
+	if err != nil {
+		return "", false, err
+	}
+	var sb strings.Builder
+	for _, ct := range res.Content {
+		if tc, ok := mcp.AsTextContent(ct); ok {
+			sb.WriteString(tc.Text)
+		}
+	}
+	return sb.String(), res.IsError, nil
+}
+
+func main() {
+	bin := flag.String("bin", "/tmp/fwh-mcp-server-new", "被测 MCP server 二进制路径")
+	cwd := flag.String("cwd", "/var/www/fwh-toolbox/server", "server 工作目录(加载 config.env)")
+	out := flag.String("out", "/var/www/fwh-toolbox/server", "报告输出目录")
+	flag.Parse()
+
+	ctx := context.Background()
+
+	// 用 WithCommandFunc 设置子进程 cwd 为运行路径
+	cmdFunc := func(_ context.Context, command string, env []string, args []string) (*exec.Cmd, error) {
+		cmd := exec.Command(command, args...)
+		cmd.Dir = *cwd
+		cmd.Env = append(os.Environ(), env...)
+		return cmd, nil
+	}
+
+	c, err := client.NewStdioMCPClientWithOptions(*bin, nil, nil, transport.WithCommandFunc(cmdFunc))
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "启动 MCP server 失败: %v\n", err)
+		os.Exit(1)
+	}
+	defer c.Close()
+
+	// ---- L0-1 初始化握手 ----
+	initReq := mcp.InitializeRequest{}
+	initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
+	initReq.Params.ClientInfo = mcp.Implementation{Name: "mcp-test-runner", Version: "1.0.0"}
+	ictx, icancel := context.WithTimeout(ctx, 30*time.Second)
+	defer icancel()
+	t0 := time.Now()
+	initRes, err := c.Initialize(ictx, initReq)
+	if err != nil {
+		addResult(result{"L0-1", "-", "L0", FAIL, time.Since(t0), "initialize 失败: " + err.Error()})
+		writeReports(*out)
+		fmt.Fprintf(os.Stderr, "initialize 失败: %v\n", err)
+		os.Exit(1)
+	}
+	{
+		note := fmt.Sprintf("name=%s version=%s proto=%s",
+			initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
+		st := PASS
+		if initRes.ServerInfo.Name != "fwh-tools" || initRes.ServerInfo.Version != "2.0.0" {
+			st = FAIL
+		}
+		addResult(result{"L0-1", "-", "L0", st, time.Since(t0), note})
+	}
+
+	// ---- L0-2 工具清单 ----
+	t0 = time.Now()
+	ltRes, err := c.ListTools(ctx, mcp.ListToolsRequest{})
+	if err != nil {
+		addResult(result{"L0-2", "-", "L0", FAIL, time.Since(t0), "ListTools 失败: " + err.Error()})
+		writeReports(*out)
+		os.Exit(1)
+	}
+	tools := ltRes.Tools
+	toolByName := map[string]mcp.Tool{}
+	for _, t := range tools {
+		toolByName[t.Name] = t
+	}
+	addResult(result{"L0-2", "-", "L0", PASS, time.Since(t0), fmt.Sprintf("共 %d 个工具", len(tools))})
+
+	// ---- L0-3 命名规范 ----
+	{
+		allowedPrefix := []string{"service_", "data_", "storage_", "util_", "plane_"}
+		var bad []string
+		for _, t := range tools {
+			ok := false
+			for _, p := range allowedPrefix {
+				if strings.HasPrefix(t.Name, p) {
+					ok = true
+					break
+				}
+			}
+			if !ok {
+				bad = append(bad, t.Name)
+			}
+		}
+		st, note := PASS, "全部前缀合法"
+		if len(bad) > 0 {
+			st, note = FAIL, "非法前缀: "+strings.Join(bad, ",")
+		}
+		addResult(result{"L0-3", "-", "L0", st, 0, note})
+	}
+
+	// ---- L0-4 schema 完整性 ----
+	{
+		var bad []string
+		for _, t := range tools {
+			if t.Description == "" {
+				bad = append(bad, t.Name+"(无描述)")
+			}
+			props := t.InputSchema.Properties
+			for _, reqf := range t.InputSchema.Required {
+				if _, ok := props[reqf]; !ok {
+					bad = append(bad, fmt.Sprintf("%s(required %s 不在 properties)", t.Name, reqf))
+				}
+			}
+		}
+		st, note := PASS, "schema 完整"
+		if len(bad) > 0 {
+			st, note = FAIL, strings.Join(bad, "; ")
+		}
+		addResult(result{"L0-4", "-", "L0", st, 0, note})
+	}
+
+	// ---- L0-5 未知工具 ----
+	{
+		t0 := time.Now()
+		_, isErr, cErr := callText(ctx, c, "__no_such_tool__", map[string]any{})
+		// 期望:协议 error 或 isError=true
+		st, note := PASS, "正确拒绝未知工具"
+		if cErr == nil && !isErr {
+			st, note = FAIL, "未知工具未被拒绝"
+		}
+		addResult(result{"L0-5", "__no_such_tool__", "L0", st, time.Since(t0), note})
+	}
+
+	// ---- L1 参数校验 ----
+	runL1(ctx, c)
+
+	// ---- L2 只读冒烟 ----
+	runL2(ctx, c, tools, toolByName)
+
+	// ---- L4 敏感数据脱敏 ----
+	runL4(ctx, c, toolByName)
+
+	// ---- L2S 真实样本补测 + L3RW MinIO/文件读写闭环 ----
+	runSampleCases(ctx, c, toolByName)
+	runWriteLoop(ctx, c, toolByName)
+
+	// ---- L2C 级联补测(从列表接口提取 id/key 再喂给详情接口)----
+	runCascadeCases(ctx, c, toolByName)
+
+	// ---- L2X 额外补测(递归级联 / 自造文件)----
+	runExtraCascades(ctx, c, toolByName)
+
+	writeReports(*out)
+	fmt.Println("测试完成,报告已写入:", *out)
+}
+
+// 真实样本(由用户提供)
+const (
+	sampleSN            = "10130095A00X25280030"
+	sampleGICCID        = "8931084524121848556"
+	samplePlaneSlug     = "fwh"
+	samplePlaneProject  = "55efdd5f-214d-43b3-bed5-a6b71bd04673"
+)

+ 81 - 36
service/cmd/mcp/tools/data/plane_v2_api_handlers.go

@@ -19,6 +19,20 @@ func planeV2RawResult(data []byte, err error, action string) (*mcp.CallToolResul
 	return helper.BytesResultOrFileMasked(data, "plane_v2_"+action), nil
 }
 
+// requireSlugProject 校验并返回必填的 workspace_slug 与 project_id。
+// 缺失时返回对应的 ParamError(errResult 非 nil,调用方应直接返回它)。
+func requireSlugProject(req mcp.CallToolRequest) (slug, projectId string, errResult *mcp.CallToolResult) {
+	slug, err := req.RequireString("workspace_slug")
+	if err != nil {
+		return "", "", helper.ParamError("workspace_slug")
+	}
+	projectId, err = req.RequireString("project_id")
+	if err != nil {
+		return "", "", helper.ParamError("project_id")
+	}
+	return slug, projectId, nil
+}
+
 // === Workspace Handlers ===
 
 func handlePlaneV2ListWorkspaces(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -47,10 +61,9 @@ func handlePlaneV2ListProjects(ctx context.Context, req mcp.CallToolRequest) (*m
 }
 
 func handlePlaneV2GetProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, err := req.RequireString("project_id")
-	if err != nil {
-		return helper.ParamError("project_id"), nil
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
 	}
 	data, apiErr := planeV2Svc.GetProject(slug, projectId)
 	return planeV2RawResult(data, apiErr, "获取项目详情")
@@ -59,15 +72,19 @@ func handlePlaneV2GetProject(ctx context.Context, req mcp.CallToolRequest) (*mcp
 // === Issue Handlers ===
 
 func handlePlaneV2ListIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListIssues(slug, projectId)
 	return planeV2RawResult(data, err, "列出工作项")
 }
 
 func handlePlaneV2GetIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	issueId, err := req.RequireString("issue_id")
 	if err != nil {
 		return helper.ParamError("issue_id"), nil
@@ -77,8 +94,10 @@ func handlePlaneV2GetIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
 }
 
 func handlePlaneV2GetSubIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	issueId, err := req.RequireString("issue_id")
 	if err != nil {
 		return helper.ParamError("issue_id"), nil
@@ -88,8 +107,10 @@ func handlePlaneV2GetSubIssues(ctx context.Context, req mcp.CallToolRequest) (*m
 }
 
 func handlePlaneV2GetIssueActivity(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	issueId, err := req.RequireString("issue_id")
 	if err != nil {
 		return helper.ParamError("issue_id"), nil
@@ -101,16 +122,20 @@ func handlePlaneV2GetIssueActivity(ctx context.Context, req mcp.CallToolRequest)
 // === Intake Handlers ===
 
 func handlePlaneV2ListIntakeIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	status := req.GetString("status", "")
 	data, err := planeV2Svc.ListIntakeIssues(slug, projectId, status)
 	return planeV2RawResult(data, err, "列出收件箱工作项")
 }
 
 func handlePlaneV2GetIntakeIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	issueId, err := req.RequireString("issue_id")
 	if err != nil {
 		return helper.ParamError("issue_id"), nil
@@ -122,15 +147,19 @@ func handlePlaneV2GetIntakeIssue(ctx context.Context, req mcp.CallToolRequest) (
 // === Cycle Handlers ===
 
 func handlePlaneV2ListCycles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListCycles(slug, projectId)
 	return planeV2RawResult(data, err, "列出周期")
 }
 
 func handlePlaneV2GetCycle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	cycleId, err := req.RequireString("cycle_id")
 	if err != nil {
 		return helper.ParamError("cycle_id"), nil
@@ -142,15 +171,19 @@ func handlePlaneV2GetCycle(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
 // === Module Handlers ===
 
 func handlePlaneV2ListModules(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListModules(slug, projectId)
 	return planeV2RawResult(data, err, "列出模块")
 }
 
 func handlePlaneV2GetModule(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	moduleId, err := req.RequireString("module_id")
 	if err != nil {
 		return helper.ParamError("module_id"), nil
@@ -162,8 +195,10 @@ func handlePlaneV2GetModule(ctx context.Context, req mcp.CallToolRequest) (*mcp.
 // === Label Handlers ===
 
 func handlePlaneV2ListLabels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListLabels(slug, projectId)
 	return planeV2RawResult(data, err, "列出标签")
 }
@@ -171,8 +206,10 @@ func handlePlaneV2ListLabels(ctx context.Context, req mcp.CallToolRequest) (*mcp
 // === State Handlers ===
 
 func handlePlaneV2ListStates(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListStates(slug, projectId)
 	return planeV2RawResult(data, err, "列出状态")
 }
@@ -180,15 +217,19 @@ func handlePlaneV2ListStates(ctx context.Context, req mcp.CallToolRequest) (*mcp
 // === Page Handlers ===
 
 func handlePlaneV2ListPages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListPages(slug, projectId)
 	return planeV2RawResult(data, err, "列出页面")
 }
 
 func handlePlaneV2GetPage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	pageId, err := req.RequireString("page_id")
 	if err != nil {
 		return helper.ParamError("page_id"), nil
@@ -198,8 +239,10 @@ func handlePlaneV2GetPage(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
 }
 
 func handlePlaneV2GetPageDescription(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	pageId, err := req.RequireString("page_id")
 	if err != nil {
 		return helper.ParamError("page_id"), nil
@@ -220,8 +263,10 @@ func handlePlaneV2ListWorkspaceMembers(ctx context.Context, req mcp.CallToolRequ
 }
 
 func handlePlaneV2ListProjectMembers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, _ := req.RequireString("workspace_slug")
-	projectId, _ := req.RequireString("project_id")
+	slug, projectId, errRes := requireSlugProject(req)
+	if errRes != nil {
+		return errRes, nil
+	}
 	data, err := planeV2Svc.ListProjectMembers(slug, projectId)
 	return planeV2RawResult(data, err, "列出项目成员")
 }

+ 6 - 0
service/services/feedbackServices.go

@@ -29,6 +29,9 @@ type FeedbackHistoryItem struct {
 // SubmitFeedback 提交反馈到 Plane Intake (V2 Web API)
 // 流程:1. 构造HTML(含image-component) → 2. 创建IntakeIssue → 3. 绑定Asset到Issue
 func SubmitFeedback(body dto.FeedbackSubmitDTO, userName string) (*repositories.PlaneWebIntakeIssueResponse, error) {
+	if repositories.Plane == nil {
+		return nil, fmt.Errorf("Plane 未初始化(repositories.Plane 为 nil)")
+	}
 	// 构造标题(包含提交人标识,供历史查询时解析)
 	var prefix string
 	if body.Type == "bug" {
@@ -155,6 +158,9 @@ func buildDescriptionHTML(body dto.FeedbackSubmitDTO, userName string) string {
 // GetFeedbackHistory 查询 Plane Intake 列表 (V2 Web API)
 // 只返回带有 Feedback 标签的记录,包含所有状态(Pending/Accepted/Rejected/Snoozed/Duplicate)
 func GetFeedbackHistory(userName string) ([]FeedbackHistoryItem, error) {
+	if repositories.Plane == nil {
+		return nil, fmt.Errorf("Plane 未初始化(repositories.Plane 为 nil)")
+	}
 	// 查询所有状态的 Intake 记录:-2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate
 	data, err := repositories.Plane.ListIntakeIssues("-2,-1,0,1,2")
 	if err != nil {