|
|
@@ -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)
|
|
|
+}
|