| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022 |
- 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", map[string]any{"sn": sampleSN}},
- // 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)
- }
|