cases.go 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  1. package main
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "time"
  10. "github.com/mark3labs/mcp-go/client"
  11. "github.com/mark3labs/mcp-go/mcp"
  12. )
  13. // L1 用例:参数校验(不依赖后端,期望 isError=true 且消息含关键字)
  14. func runL1(ctx context.Context, c *client.Client) {
  15. type l1 struct {
  16. id, tool string
  17. args map[string]any
  18. wantSub string // 期望错误消息包含的子串(空=只要 isError)
  19. }
  20. cases := []l1{
  21. {"L1-1", "util_parse_sn", map[string]any{"sn": "10060006A03X25310091"}, ""}, // 正确性单独断言
  22. {"L1-3", "util_parse_sn", map[string]any{}, "sn"},
  23. {"L1-4", "util_parse_sn", map[string]any{"sn": "1006"}, "长度"},
  24. {"L1-5", "service_site_query_info", map[string]any{}, "sn"},
  25. {"L1-6", "service_site_power_flow", map[string]any{"sn": "ANY", "date": "2025/01/01"}, "date"},
  26. {"L1-7", "service_sim_monthly_data", map[string]any{"giccid": "x"}, "month"},
  27. {"L1-8", "data_mongo_find", map[string]any{"collection": "UserInfo"}, "不允许"},
  28. {"L1-9", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "filter": "{bad json"}, "filter"},
  29. {"L1-10", "data_redis_exists", map[string]any{"key": "foo*"}, "通配符"},
  30. {"L1-11", "data_redis_get", map[string]any{"key": "any", "type": "zset"}, "可选值"},
  31. {"L1-12", "storage_minio_list_objects", map[string]any{"bucket": "secret-bucket"}, "不允许"},
  32. {"L1-13", "data_emspara_vid_info", map[string]any{"id": "abc"}, "数字"},
  33. {"L1-14", "data_pgsql_query_snapshot_data", map[string]any{"sn": "ANY", "date": "20250101"}, "date"},
  34. {"L1-16", "service_feedback_create", map[string]any{"title": "t", "type": "invalid"}, "bug"},
  35. }
  36. for _, tc := range cases {
  37. t0 := time.Now()
  38. text, isErr, cErr := callText(ctx, c, tc.tool, tc.args)
  39. el := time.Since(t0)
  40. if cErr != nil {
  41. addResult(result{tc.id, tc.tool, "L1", FAIL, el, "调用异常: " + cErr.Error()})
  42. continue
  43. }
  44. // L1-1 特殊:正确性断言
  45. if tc.id == "L1-1" {
  46. ok := strings.Contains(text, "aGate") && strings.Contains(text, "2025") &&
  47. strings.Contains(text, "0091") && strings.Contains(text, "\"week\": \"31\"")
  48. st, note := PASS, "SN 解析字段正确"
  49. if !ok {
  50. st, note = FAIL, "解析结果不符: "+truncate(text, 200)
  51. }
  52. addResult(result{tc.id, tc.tool, "L1", st, el, note})
  53. continue
  54. }
  55. // 其余:期望 isError 且含子串
  56. st := PASS
  57. note := "正确返回参数校验错误"
  58. if !isErr {
  59. st, note = FAIL, "未返回 isError;输出: "+truncate(text, 150)
  60. } else if tc.wantSub != "" && !strings.Contains(text, tc.wantSub) {
  61. st, note = FAIL, fmt.Sprintf("错误消息未含 %q: %s", tc.wantSub, truncate(text, 150))
  62. }
  63. addResult(result{tc.id, tc.tool, "L1", st, el, note})
  64. }
  65. }
  66. // L2 只读冒烟:预设关键用例 + 对无必填参数的只读工具自动冒烟
  67. func runL2(ctx context.Context, c *client.Client, tools []mcp.Tool, byName map[string]mcp.Tool) {
  68. type l2 struct {
  69. id, tool string
  70. args map[string]any
  71. }
  72. preset := []l2{
  73. // Redis
  74. {"L2-R1", "data_redis_exists", map[string]any{"key": "nonexistent_test_key_xyz"}},
  75. {"L2-R2", "data_redis_get", map[string]any{"key": "nonexistent_test_key_xyz"}},
  76. // Mongo
  77. {"L2-M1", "data_mongo_count", map[string]any{"collection": "SiteInfoManageData"}},
  78. {"L2-M2", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "limit": 1}},
  79. {"L2-M3", "data_mongo_count", map[string]any{"collection": "StatisticsAlarm"}},
  80. // MinIO / 文件
  81. {"L2-S1", "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "limit": 5}},
  82. {"L2-S3", "storage_file_list", map[string]any{}},
  83. // Plane
  84. {"L2-PL1", "plane_list_workspaces", map[string]any{}},
  85. {"L2-PL2", "plane_list_projects", map[string]any{}},
  86. {"L2-PL4", "plane_list_labels", map[string]any{}},
  87. {"L2-PL5", "plane_list_states", map[string]any{}},
  88. {"L2-PL6", "service_feedback_history", map[string]any{}},
  89. // 业务层(远程 MySQL)
  90. {"L2-B1", "service_site_search", map[string]any{"page": 1, "size": 3}},
  91. {"L2-B2", "service_sim_search", map[string]any{"page": 1, "size": 3}},
  92. {"L2-B3", "service_monitor_alarm_stats", map[string]any{}},
  93. {"L2-B4", "service_monitor_shutdown_stats", map[string]any{}},
  94. {"L2-B5", "service_dashboard_shutdown_date", map[string]any{}},
  95. {"L2-B6", "service_dashboard_shutdown_detail", map[string]any{}},
  96. {"L2-B7", "service_dashboard_alarm_detail", map[string]any{}},
  97. {"L2-B8", "service_tou_template_list", map[string]any{"page": 1, "size": 3}},
  98. {"L2-B9", "service_background_task_list", map[string]any{}},
  99. {"L2-B10", "service_site_monitor_config_list", map[string]any{}},
  100. {"L2-B11", "service_site_monitor_name_list", map[string]any{}},
  101. {"L2-B12", "service_site_info_update_status", map[string]any{}},
  102. {"L2-B13", "service_site_info_tags_list", map[string]any{"page": 1, "size": 3}},
  103. {"L2-B14", "service_auto_update_get_options", map[string]any{}},
  104. {"L2-B15", "service_auto_update_get_apower_version", map[string]any{}},
  105. {"L2-B16", "service_auto_update_file_list", map[string]any{"page": 1, "size": 3}},
  106. {"L2-B17", "service_batch_download_list", map[string]any{"page": 1, "size": 3}},
  107. {"L2-B18", "service_site_analysis_list", map[string]any{"page": 1, "size": 3}},
  108. // emspara
  109. {"L2-F8a", "data_emspara_vid_info", map[string]any{"id": "1"}},
  110. {"L2-F8b", "data_emspara_pid_info", map[string]any{"id": "1"}},
  111. }
  112. seen := map[string]bool{}
  113. for _, tc := range preset {
  114. if _, ok := byName[tc.tool]; !ok {
  115. addResult(result{tc.id, tc.tool, "L2", SKIP, 0, "工具未注册"})
  116. continue
  117. }
  118. seen[tc.tool] = true
  119. runOneL2(ctx, c, tc.id, tc.tool, tc.args)
  120. }
  121. // 自动冒烟:无必填参数、只读(非写操作、无 [写操作] 标记)、且未在 preset 覆盖的工具
  122. auto := 0
  123. for _, t := range tools {
  124. if seen[t.Name] {
  125. continue
  126. }
  127. if isWriteTool(t) {
  128. continue // 写操作跳过
  129. }
  130. if len(t.InputSchema.Required) > 0 {
  131. // 需要必填参数(多为需真实样本 SN/GICCID/ID),本轮无样本 → SKIP
  132. addResult(result{"L2-auto", t.Name, "L2", SKIP, 0, "需必填参数,缺测试样本: " + strings.Join(t.InputSchema.Required, ",")})
  133. continue
  134. }
  135. auto++
  136. runOneL2(ctx, c, fmt.Sprintf("L2-auto%d", auto), t.Name, map[string]any{})
  137. }
  138. }
  139. func runOneL2(ctx context.Context, c *client.Client, id, tool string, args map[string]any) {
  140. t0 := time.Now()
  141. text, isErr, cErr := callText(ctx, c, tool, args)
  142. el := time.Since(t0)
  143. if cErr != nil {
  144. // 传输层错误 → FAIL
  145. addResult(result{id, tool, "L2", FAIL, el, "调用异常: " + cErr.Error()})
  146. return
  147. }
  148. if isErr {
  149. // 判定是连接类(SKIP)还是真实错误(FAIL)
  150. if looksLikeDependencyError(text) {
  151. addResult(result{id, tool, "L2", SKIP, el, "依赖不可用/凭据: " + truncate(text, 120)})
  152. } else {
  153. // 参数校验类错误(如需真实样本)也归 SKIP,其它归 FAIL
  154. addResult(result{id, tool, "L2", SKIP, el, "返回错误: " + truncate(text, 120)})
  155. }
  156. return
  157. }
  158. // 成功:结构合法即 PASS(含"查询结果为空"等业务空结果)
  159. addResult(result{id, tool, "L2", PASS, el, "返回合法结果 " + fmt.Sprintf("(%dB)", len(text))})
  160. }
  161. // L4 敏感数据脱敏(端到端,用生产库真实数据做负向/正向断言)
  162. func runL4(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  163. type l4 struct {
  164. id, tool string
  165. args map[string]any
  166. keep []string // 正向断言:应保留的字段名(出现即视为保留生效)
  167. }
  168. cases := []l4{
  169. {"L4-2", "service_site_search", map[string]any{"page": 1, "size": 5}, []string{"sn"}},
  170. {"L4-4", "service_sim_search", map[string]any{"page": 1, "size": 5}, []string{"giccid"}},
  171. {"L4-5", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "limit": 3}, []string{"_id"}},
  172. {"L4-6", "data_mongo_find", map[string]any{"collection": "SimCardManage", "limit": 3}, []string{"_id"}},
  173. }
  174. for _, tc := range cases {
  175. if _, ok := byName[tc.tool]; !ok {
  176. piis = append(piis, piiFinding{tc.id, tc.tool, SKIP, nil, "工具未注册", ""})
  177. addResult(result{tc.id, tc.tool, "L4", SKIP, 0, "工具未注册"})
  178. continue
  179. }
  180. t0 := time.Now()
  181. text, isErr, cErr := callText(ctx, c, tc.tool, tc.args)
  182. el := time.Since(t0)
  183. if cErr != nil {
  184. piis = append(piis, piiFinding{tc.id, tc.tool, FAIL, nil, "调用异常", cErr.Error()})
  185. addResult(result{tc.id, tc.tool, "L4", FAIL, el, "调用异常: " + cErr.Error()})
  186. continue
  187. }
  188. if isErr || looksLikeDependencyError(text) {
  189. piis = append(piis, piiFinding{tc.id, tc.tool, SKIP, nil, "依赖不可用或空数据", truncate(text, 200)})
  190. addResult(result{tc.id, tc.tool, "L4", SKIP, el, "依赖不可用/空: " + truncate(text, 100)})
  191. continue
  192. }
  193. // 若结果落盘,读取本地文件内容一起检测
  194. full := text
  195. if lp := extractLocalPath(text); lp != "" {
  196. if b, err := os.ReadFile(lp); err == nil {
  197. full = text + "\n" + string(b)
  198. }
  199. }
  200. leaks := detectLeaks(full)
  201. // 正向断言
  202. pos := "n/a"
  203. if len(tc.keep) > 0 {
  204. hit := []string{}
  205. for _, k := range tc.keep {
  206. if strings.Contains(full, "\""+k+"\"") {
  207. hit = append(hit, k)
  208. }
  209. }
  210. pos = "保留字段命中: " + strings.Join(hit, ",")
  211. }
  212. st := PASS
  213. note := "无明文 PII"
  214. if len(leaks) > 0 {
  215. st = FAIL
  216. note = "检出明文 PII: " + strings.Join(leaks, "; ")
  217. }
  218. piis = append(piis, piiFinding{tc.id, tc.tool, st, leaks, pos, truncate(full, 600)})
  219. addResult(result{tc.id, tc.tool, "L4", st, el, note + "; " + pos})
  220. }
  221. }
  222. // runSampleCases 用真实样本补测之前 SKIP 的只读工具(含 PII 端到端检测)。
  223. func runSampleCases(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  224. type sc struct {
  225. id, tool string
  226. args map[string]any
  227. pii bool // 是否做明文 PII 负向断言
  228. keep []string // 正向断言:保留字段
  229. }
  230. cases := []sc{
  231. // 站点(FWH 远程 + PII)
  232. {"L2S-1", "service_site_query_info", map[string]any{"sn": sampleSN}, true, []string{"sn"}},
  233. {"L2S-2", "service_site_power_flow", map[string]any{"sn": sampleSN, "date": "2026-08-24"}, false, nil},
  234. {"L2S-3", "data_fwh_query_site_mode_info", map[string]any{"sn": sampleSN}, false, nil},
  235. {"L2S-4", "data_fwh_query_site_alarm_list", map[string]any{"sn": sampleSN}, false, nil},
  236. {"L2S-5", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, false, nil},
  237. {"L2S-6", "service_site_monitor_mode_info", map[string]any{"sn": sampleSN}, false, nil},
  238. {"L2S-7", "service_site_monitor_alarm_list", map[string]any{"sn": sampleSN}, false, nil},
  239. {"L2S-8", "service_site_monitor_base_info", map[string]any{"site_list": `["` + sampleSN + `"]`}, false, nil},
  240. // SIM(PII)
  241. {"L2S-10", "service_sim_query", map[string]any{"giccid": sampleGICCID}, true, []string{"giccid"}},
  242. {"L2S-11", "service_sim_monthly_data", map[string]any{"giccid": sampleGICCID, "month": "2026-08"}, false, nil},
  243. {"L2S-12", "data_sim_smarthub_card_info", map[string]any{"giccid": sampleGICCID}, true, nil},
  244. // Plane(真实 slug/project)
  245. {"L2S-20", "plane_list_projects", map[string]any{"workspace_slug": samplePlaneSlug}, false, nil},
  246. {"L2S-21", "plane_get_project", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  247. {"L2S-22", "plane_list_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  248. {"L2S-23", "plane_list_labels", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  249. {"L2S-24", "plane_list_states", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  250. {"L2S-25", "plane_list_cycles", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  251. {"L2S-26", "plane_list_modules", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  252. {"L2S-27", "plane_list_intake_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  253. {"L2S-28", "plane_list_project_members", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil},
  254. {"L2S-29", "plane_list_workspace_members", map[string]any{"workspace_slug": samplePlaneSlug}, false, nil},
  255. }
  256. for _, tc := range cases {
  257. if _, ok := byName[tc.tool]; !ok {
  258. addResult(result{tc.id, tc.tool, "L2S", SKIP, 0, "工具未注册"})
  259. continue
  260. }
  261. t0 := time.Now()
  262. text, isErr, cErr := callText(ctx, c, tc.tool, tc.args)
  263. el := time.Since(t0)
  264. if cErr != nil {
  265. addResult(result{tc.id, tc.tool, "L2S", FAIL, el, "调用异常: " + cErr.Error()})
  266. continue
  267. }
  268. if isErr {
  269. // 样本用例:仅在 isError 时判定;区分依赖问题与真实错误
  270. if looksLikeDependencyError(text) {
  271. addResult(result{tc.id, tc.tool, "L2S", SKIP, el, "依赖不可用/空: " + truncate(text, 120)})
  272. } else {
  273. addResult(result{tc.id, tc.tool, "L2S", FAIL, el, "返回错误: " + truncate(text, 120)})
  274. }
  275. continue
  276. }
  277. // PII 检测
  278. if tc.pii {
  279. full := text
  280. if lp := extractLocalPath(text); lp != "" {
  281. if b, err := os.ReadFile(lp); err == nil {
  282. full = text + "\n" + string(b)
  283. }
  284. }
  285. leaks := detectLeaks(full)
  286. pos := ""
  287. for _, k := range tc.keep {
  288. if strings.Contains(full, "\""+k+"\"") {
  289. pos += k + " "
  290. }
  291. }
  292. st, note := PASS, "无明文 PII; 保留:"+pos
  293. if len(leaks) > 0 {
  294. st, note = FAIL, "检出明文 PII: "+strings.Join(leaks, "; ")
  295. }
  296. piis = append(piis, piiFinding{tc.id, tc.tool, st, leaks, "保留字段: " + pos, truncate(full, 600)})
  297. addResult(result{tc.id, tc.tool, "L2S", st, el, note})
  298. continue
  299. }
  300. addResult(result{tc.id, tc.tool, "L2S", PASS, el, fmt.Sprintf("返回合法结果 (%dB)", len(text))})
  301. }
  302. }
  303. // runWriteLoop L3RW:MinIO/文件 写→读→列 闭环(用户已授权 MinIO 写 + file 读)。
  304. func runWriteLoop(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  305. if _, ok := byName["storage_file_write"]; !ok {
  306. addResult(result{"L3RW-1", "storage_file_write", "L3RW", SKIP, 0, "工具未注册"})
  307. return
  308. }
  309. testPath := fmt.Sprintf("/uploads/mcp_test_%d.txt", time.Now().Unix())
  310. testContent := fmt.Sprintf("MCP write-read loop test @ %s\nno-pii-content", time.Now().Format(time.RFC3339))
  311. // 1) write
  312. t0 := time.Now()
  313. wText, wErr, cErr := callText(ctx, c, "storage_file_write", map[string]any{
  314. "path": testPath, "content": testContent, "userId": "mcp-test", "sessionId": "mcp-test",
  315. })
  316. if cErr != nil {
  317. addResult(result{"L3RW-1", "storage_file_write", "L3RW", FAIL, time.Since(t0), "调用异常: " + cErr.Error()})
  318. return
  319. }
  320. if wErr {
  321. if looksLikeDependencyError(wText) {
  322. addResult(result{"L3RW-1", "storage_file_write", "L3RW", SKIP, time.Since(t0), "依赖不可用: " + truncate(wText, 120)})
  323. return
  324. }
  325. addResult(result{"L3RW-1", "storage_file_write", "L3RW", FAIL, time.Since(t0), "写入失败: " + truncate(wText, 120)})
  326. return
  327. }
  328. // 从返回中解析实际存储 path(handler 会加 /uploads/ 前缀)
  329. storedPath := testPath
  330. if p := extractJSONString(wText, "path"); p != "" {
  331. storedPath = p
  332. }
  333. addResult(result{"L3RW-1", "storage_file_write", "L3RW", PASS, time.Since(t0), "写入成功 path=" + storedPath})
  334. // 2) list(应含刚写入的文件)
  335. t0 = time.Now()
  336. lText, lErr, _ := callText(ctx, c, "storage_file_list", map[string]any{})
  337. st, note := PASS, "列表返回"
  338. if lErr {
  339. st, note = SKIP, "列表返回错误: "+truncate(lText, 100)
  340. } else if !strings.Contains(lText, "mcp_test_") {
  341. st, note = FAIL, "列表未包含刚写入的文件"
  342. }
  343. addResult(result{"L3RW-2", "storage_file_list", "L3RW", st, time.Since(t0), note})
  344. // 3) read(读回并校验内容)
  345. if _, ok := byName["storage_file_read"]; ok {
  346. t0 = time.Now()
  347. rText, rErr, rcErr := callText(ctx, c, "storage_file_read", map[string]any{"path": storedPath})
  348. rst, rnote := PASS, "读回内容一致"
  349. if rcErr != nil {
  350. rst, rnote = FAIL, "调用异常: "+rcErr.Error()
  351. } else if rErr {
  352. rst, rnote = SKIP, "读取错误: "+truncate(rText, 100)
  353. } else if !strings.Contains(rText, "no-pii-content") {
  354. rst, rnote = FAIL, "读回内容与写入不一致: "+truncate(rText, 100)
  355. }
  356. addResult(result{"L3RW-3", "storage_file_read", "L3RW", rst, time.Since(t0), rnote})
  357. }
  358. // 4) minio list(验证对象确实落到 MinIO)
  359. if _, ok := byName["storage_minio_list_objects"]; ok {
  360. t0 = time.Now()
  361. mText, mErr, _ := callText(ctx, c, "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "prefix": "", "limit": 50})
  362. mst, mnote := PASS, "MinIO 可列出对象"
  363. if mErr {
  364. mst, mnote = SKIP, "MinIO 列出错误: "+truncate(mText, 100)
  365. }
  366. addResult(result{"L3RW-4", "storage_minio_list_objects", "L3RW", mst, time.Since(t0), mnote})
  367. }
  368. }
  369. // runCascadeCases L2C:从已 PASS 的列表接口提取 id/key/name,再喂给详情接口。
  370. // source: 先调用的列表工具;srcArgs: 其参数;extract: 从返回 JSON 中取值的路径。
  371. // target: 待补测的详情工具;argKey: 目标工具接收该值的参数名;extraArgs: 目标工具的其它固定参数。
  372. func runCascadeCases(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  373. type cascade struct {
  374. id string
  375. source string
  376. srcArgs map[string]any
  377. extract string // 值提取路径(见 jsonExtract)
  378. target string
  379. argKey string
  380. extraArgs map[string]any
  381. }
  382. cs := []cascade{
  383. // auto_update: options → package / config
  384. {"L2C-1", "service_auto_update_get_options", map[string]any{}, "packages.system[0]", "service_auto_update_get_package", "file_name", nil},
  385. {"L2C-2", "service_auto_update_get_options", map[string]any{}, "aGateVersions[0]", "service_auto_update_get_config", "agate_version", nil},
  386. // tou: list → detail
  387. {"L2C-3", "service_tou_template_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_tou_template_detail", "id", nil},
  388. {"L2C-4", "service_tou_template_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "data_fwh_query_tou_template_detail", "id", nil},
  389. // site_analysis: list → get_config / data
  390. {"L2C-5", "service_site_analysis_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_site_analysis_get_config", "id", nil},
  391. {"L2C-6", "service_site_analysis_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_site_analysis_data", "id", nil},
  392. // batch_download: list → path / file_tree
  393. {"L2C-7", "service_batch_download_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_batch_download_path", "id", nil},
  394. // site_monitor: name_list → config_get
  395. {"L2C-8", "service_site_monitor_name_list", map[string]any{}, "[0]", "service_site_monitor_config_get", "name", nil},
  396. // acceleronix: card_list → card_info;data_pool_list → data_pool_sim_info
  397. {"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},
  398. {"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},
  399. // system_update_log(sample SN) → detail(order_id=id)
  400. {"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}},
  401. // minio: list_objects → read_object
  402. {"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"}},
  403. // plane issues: list → get_issue / sub_issues / activity(若 results 非空)
  404. {"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}},
  405. {"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}},
  406. {"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}},
  407. {"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}},
  408. {"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}},
  409. }
  410. for _, cc := range cs {
  411. if _, ok := byName[cc.target]; !ok {
  412. addResult(result{cc.id, cc.target, "L2C", SKIP, 0, "目标工具未注册"})
  413. continue
  414. }
  415. // 1) 调用源列表接口
  416. srcText, srcErr, cErr := callText(ctx, c, cc.source, cc.srcArgs)
  417. if cErr != nil || srcErr {
  418. addResult(result{cc.id, cc.target, "L2C", SKIP, 0, "源接口不可用: " + cc.source + " " + truncate(srcText, 80)})
  419. continue
  420. }
  421. // 落盘情况:读取完整内容
  422. full := srcText
  423. if lp := extractLocalPath(srcText); lp != "" {
  424. if b, err := os.ReadFile(lp); err == nil {
  425. full = string(b)
  426. }
  427. }
  428. // 2) 提取值
  429. val := jsonExtract(full, cc.extract)
  430. if val == "" {
  431. addResult(result{cc.id, cc.target, "L2C", SKIP, 0, fmt.Sprintf("源 %s 无可用数据(路径 %s 为空)", cc.source, cc.extract)})
  432. continue
  433. }
  434. // 3) 调用目标详情接口
  435. args := map[string]any{cc.argKey: val}
  436. for k, v := range cc.extraArgs {
  437. args[k] = v
  438. }
  439. t0 := time.Now()
  440. tText, tErr, tcErr := callText(ctx, c, cc.target, args)
  441. el := time.Since(t0)
  442. if tcErr != nil {
  443. addResult(result{cc.id, cc.target, "L2C", FAIL, el, "调用异常: " + tcErr.Error()})
  444. continue
  445. }
  446. if tErr {
  447. // storage_minio_read_object 的"文件过大/非文本"是工具正确的校验行为,判 PASS
  448. if cc.target == "storage_minio_read_object" && (strings.Contains(tText, "文件过大") || strings.Contains(tText, "非文本")) {
  449. addResult(result{cc.id, cc.target, "L2C", PASS, el, "校验行为正常(拒绝大/非文本文件): " + truncate(tText, 60)})
  450. continue
  451. }
  452. if looksLikeDependencyError(tText) {
  453. addResult(result{cc.id, cc.target, "L2C", SKIP, el, fmt.Sprintf("%s=%s 依赖/空: %s", cc.argKey, truncate(val, 20), truncate(tText, 80))})
  454. } else {
  455. addResult(result{cc.id, cc.target, "L2C", FAIL, el, fmt.Sprintf("%s=%s 返回错误: %s", cc.argKey, truncate(val, 20), truncate(tText, 80))})
  456. }
  457. continue
  458. }
  459. addResult(result{cc.id, cc.target, "L2C", PASS, el, fmt.Sprintf("级联 %s=%s → %dB", cc.argKey, truncate(val, 24), len(tText))})
  460. }
  461. }
  462. // jsonExtract 从 JSON 文本按简易路径取标量值,返回其字符串形式。
  463. // 支持:对象键 a.b、数组下标 a[0]、根数组 [0]、混合 data.list[0].iccid。
  464. func jsonExtract(jsonText, path string) string {
  465. var root any
  466. if err := json.Unmarshal([]byte(jsonText), &root); err != nil {
  467. return ""
  468. }
  469. cur := root
  470. // 将 [n] 归一化为 .n 便于按 . 分段
  471. norm := strings.ReplaceAll(path, "[", ".")
  472. norm = strings.ReplaceAll(norm, "]", "")
  473. for _, seg := range strings.Split(norm, ".") {
  474. if seg == "" {
  475. continue
  476. }
  477. switch node := cur.(type) {
  478. case map[string]any:
  479. cur = node[seg]
  480. case []any:
  481. idx := 0
  482. fmt.Sscanf(seg, "%d", &idx)
  483. if idx < 0 || idx >= len(node) {
  484. return ""
  485. }
  486. cur = node[idx]
  487. default:
  488. return ""
  489. }
  490. if cur == nil {
  491. return ""
  492. }
  493. }
  494. switch v := cur.(type) {
  495. case string:
  496. return v
  497. case float64:
  498. // 整数去掉小数点
  499. if v == float64(int64(v)) {
  500. return fmt.Sprintf("%d", int64(v))
  501. }
  502. return fmt.Sprintf("%v", v)
  503. case json.Number:
  504. return v.String()
  505. case bool:
  506. return fmt.Sprintf("%v", v)
  507. default:
  508. return ""
  509. }
  510. }
  511. // runExtraCascades L2X:处理需递归级联或自造输入的补测。
  512. func runExtraCascades(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  513. // 1) data_fwh_query_site_power_flow —— 直接用样本 SN+date
  514. runOneL2X(ctx, c, byName, "L2X-1", "data_fwh_query_site_power_flow",
  515. map[string]any{"sn": sampleSN, "date": "2026-08-24"})
  516. // 2) plane_get_workspace —— slug=fwh
  517. runOneL2X(ctx, c, byName, "L2X-2", "plane_get_workspace",
  518. map[string]any{"workspace_slug": samplePlaneSlug})
  519. // 3) plane_list_pages / plane_list_stickies —— slug/project
  520. runOneL2X(ctx, c, byName, "L2X-3", "plane_list_pages",
  521. map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject})
  522. runOneL2X(ctx, c, byName, "L2X-4", "plane_list_stickies",
  523. map[string]any{"workspace_slug": samplePlaneSlug})
  524. // 4) service_batch_download_file_tree —— 从 batch list → path 树递归取第一个叶子文件
  525. runFileTreeCascade(ctx, c, byName)
  526. // 5) storage_file_read_to_local —— 先写一个文件再 read_to_local
  527. runReadToLocalCascade(ctx, c, byName)
  528. // 6) storage_file_upload —— 自造本地临时文件再上传
  529. runUploadCascade(ctx, c, byName)
  530. }
  531. // runOneL2X 调用单个工具并按三态判定(样本已知有效,只区分 isErr 与依赖/空)。
  532. func runOneL2X(ctx context.Context, c *client.Client, byName map[string]mcp.Tool, id, tool string, args map[string]any) {
  533. if _, ok := byName[tool]; !ok {
  534. addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
  535. return
  536. }
  537. t0 := time.Now()
  538. text, isErr, cErr := callText(ctx, c, tool, args)
  539. el := time.Since(t0)
  540. if cErr != nil {
  541. addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + cErr.Error()})
  542. return
  543. }
  544. if isErr {
  545. if looksLikeDependencyError(text) {
  546. addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(text, 100)})
  547. } else {
  548. addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(text, 100)})
  549. }
  550. return
  551. }
  552. addResult(result{id, tool, "L2X", PASS, el, fmt.Sprintf("返回合法结果 (%dB)", len(text))})
  553. }
  554. // runFileTreeCascade batch list → path 树 → 递归取第一个 isFile 叶子 → file_tree。
  555. func runFileTreeCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  556. const id, tool = "L2X-5", "service_batch_download_file_tree"
  557. if _, ok := byName[tool]; !ok {
  558. addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
  559. return
  560. }
  561. // 取 batch id
  562. lText, lErr, _ := callText(ctx, c, "service_batch_download_list", map[string]any{"page": 1, "size": 1})
  563. if lErr {
  564. addResult(result{id, tool, "L2X", SKIP, 0, "batch list 不可用"})
  565. return
  566. }
  567. bid := jsonExtract(lText, "data[0].id")
  568. if bid == "" {
  569. addResult(result{id, tool, "L2X", SKIP, 0, "无 batch 任务可用"})
  570. return
  571. }
  572. // 取 path 树
  573. pText, pErr, _ := callText(ctx, c, "service_batch_download_path", map[string]any{"id": bid})
  574. if pErr {
  575. addResult(result{id, tool, "L2X", SKIP, 0, "batch path 不可用"})
  576. return
  577. }
  578. full := pText
  579. if lp := extractLocalPath(pText); lp != "" {
  580. if b, err := os.ReadFile(lp); err == nil {
  581. full = string(b)
  582. }
  583. }
  584. leaf := findFirstFileLeaf(full)
  585. if leaf == "" {
  586. addResult(result{id, tool, "L2X", SKIP, 0, "path 树中无文件叶子(tar 文件路径)"})
  587. return
  588. }
  589. t0 := time.Now()
  590. tText, tErr, tcErr := callText(ctx, c, tool, map[string]any{"file_path": leaf})
  591. el := time.Since(t0)
  592. if tcErr != nil {
  593. addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + tcErr.Error()})
  594. return
  595. }
  596. if tErr {
  597. if looksLikeDependencyError(tText) {
  598. addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(tText, 100)})
  599. } else {
  600. addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(tText, 100)})
  601. }
  602. return
  603. }
  604. addResult(result{id, tool, "L2X", PASS, el, "级联 file_path=" + truncate(leaf, 40) + fmt.Sprintf(" → %dB", len(tText))})
  605. }
  606. // findFirstFileLeaf 递归查找 tree JSON 中第一个 isFile==true 节点的 name。
  607. func findFirstFileLeaf(jsonText string) string {
  608. var root any
  609. if err := json.Unmarshal([]byte(jsonText), &root); err != nil {
  610. return ""
  611. }
  612. var dfs func(n any) string
  613. dfs = func(n any) string {
  614. switch node := n.(type) {
  615. case map[string]any:
  616. if f, ok := node["isFile"].(bool); ok && f {
  617. if name, ok := node["name"].(string); ok {
  618. return name
  619. }
  620. }
  621. if ch, ok := node["children"]; ok {
  622. if r := dfs(ch); r != "" {
  623. return r
  624. }
  625. }
  626. case []any:
  627. for _, e := range node {
  628. if r := dfs(e); r != "" {
  629. return r
  630. }
  631. }
  632. }
  633. return ""
  634. }
  635. return dfs(root)
  636. }
  637. // runReadToLocalCascade 写一个文件 → storage_file_read_to_local 读到本地。
  638. func runReadToLocalCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  639. const id, tool = "L2X-6", "storage_file_read_to_local"
  640. if _, ok := byName[tool]; !ok {
  641. addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
  642. return
  643. }
  644. path := fmt.Sprintf("/uploads/mcp_test_r2l_%d.txt", time.Now().UnixNano())
  645. wText, wErr, _ := callText(ctx, c, "storage_file_write", map[string]any{
  646. "path": path, "content": "read-to-local test", "userId": "mcp-test", "sessionId": "mcp-test",
  647. })
  648. if wErr {
  649. addResult(result{id, tool, "L2X", SKIP, 0, "前置写入失败: " + truncate(wText, 80)})
  650. return
  651. }
  652. stored := path
  653. if p := extractJSONString(wText, "path"); p != "" {
  654. stored = p
  655. }
  656. t0 := time.Now()
  657. rText, rErr, rcErr := callText(ctx, c, tool, map[string]any{"path": stored})
  658. el := time.Since(t0)
  659. if rcErr != nil {
  660. addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + rcErr.Error()})
  661. return
  662. }
  663. if rErr {
  664. addResult(result{id, tool, "L2X", SKIP, el, "返回错误: " + truncate(rText, 80)})
  665. return
  666. }
  667. addResult(result{id, tool, "L2X", PASS, el, "写入后下载到本地成功"})
  668. }
  669. // runUploadCascade 自造本地临时文件 → storage_file_upload。
  670. func runUploadCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) {
  671. const id, tool = "L2X-7", "storage_file_upload"
  672. if _, ok := byName[tool]; !ok {
  673. addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"})
  674. return
  675. }
  676. tmp := fmt.Sprintf("/tmp/mcp_test_upload_%d.txt", time.Now().UnixNano())
  677. if err := os.WriteFile(tmp, []byte("upload test content\nno-pii"), 0644); err != nil {
  678. addResult(result{id, tool, "L2X", SKIP, 0, "无法创建本地临时文件: " + err.Error()})
  679. return
  680. }
  681. defer os.Remove(tmp)
  682. t0 := time.Now()
  683. uText, uErr, ucErr := callText(ctx, c, tool, map[string]any{
  684. "localPath": tmp, "fileName": "mcp_test_upload.txt", "userId": "mcp-test", "sessionId": "mcp-test",
  685. })
  686. el := time.Since(t0)
  687. if ucErr != nil {
  688. addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + ucErr.Error()})
  689. return
  690. }
  691. if uErr {
  692. if looksLikeDependencyError(uText) {
  693. addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(uText, 80)})
  694. } else {
  695. addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(uText, 80)})
  696. }
  697. return
  698. }
  699. addResult(result{id, tool, "L2X", PASS, el, "本地文件上传成功"})
  700. }
  701. // ---- 判定辅助 ----
  702. func isWriteTool(t mcp.Tool) bool {
  703. return strings.Contains(t.Description, "[写操作]") || strings.Contains(t.Description, "写操作")
  704. }
  705. func looksLikeDependencyError(text string) bool {
  706. kw := []string{"connection", "connect", "timeout", "refused", "no reachable",
  707. "dial ", "EOF", "登录", "login", "unauthorized", "authentication",
  708. "i/o timeout", "context deadline", "无法连接", "失败", "server selection"}
  709. lt := strings.ToLower(text)
  710. for _, k := range kw {
  711. if strings.Contains(lt, strings.ToLower(k)) {
  712. return true
  713. }
  714. }
  715. return false
  716. }
  717. func extractLocalPath(text string) string {
  718. return extractJSONString(text, "localPath")
  719. }
  720. // extractJSONString 粗略提取 "key": "value" 中的 value(用于报告展示,不做完整 JSON 解析)。
  721. func extractJSONString(text, key string) string {
  722. idx := strings.Index(text, "\""+key+"\"")
  723. if idx < 0 {
  724. return ""
  725. }
  726. rest := text[idx+len(key)+2:]
  727. c1 := strings.Index(rest, ":")
  728. if c1 < 0 {
  729. return ""
  730. }
  731. rest = rest[c1+1:]
  732. q1 := strings.Index(rest, "\"")
  733. if q1 < 0 {
  734. return ""
  735. }
  736. rest = rest[q1+1:]
  737. q2 := strings.Index(rest, "\"")
  738. if q2 < 0 {
  739. return ""
  740. }
  741. return rest[:q2]
  742. }
  743. // ---- 报告 ----
  744. func writeReports(outDir string) {
  745. writeReport(filepath.Join(outDir, "report.md"))
  746. writePiiReport(filepath.Join(outDir, "pii_report.md"))
  747. }
  748. func countByStatus(level string) (p, f, s int) {
  749. for _, r := range results {
  750. if level != "" && r.level != level {
  751. continue
  752. }
  753. switch r.status {
  754. case PASS:
  755. p++
  756. case FAIL:
  757. f++
  758. case SKIP:
  759. s++
  760. }
  761. }
  762. return
  763. }
  764. // foldByTool 将每个工具的多条执行记录折叠为一条终态。
  765. // 终态优先级:FAIL > PASS > SKIP(FAIL 必须暴露;有任一 PASS 即视为已覆盖;全 SKIP 才算未覆盖)。
  766. // 返回 工具→终态 的映射。
  767. func foldByTool() map[string]status {
  768. rank := map[status]int{SKIP: 0, PASS: 1, FAIL: 2}
  769. final := map[string]status{}
  770. for _, r := range results {
  771. if r.tool == "-" || r.tool == "" {
  772. continue
  773. }
  774. if cur, ok := final[r.tool]; !ok || rank[r.status] > rank[cur] {
  775. final[r.tool] = r.status
  776. }
  777. }
  778. return final
  779. }
  780. // toolCoverage 基于折叠后的终态统计工具级覆盖率。
  781. func toolCoverage() (total, passed, failed, uncovered int) {
  782. for _, st := range foldByTool() {
  783. total++
  784. switch st {
  785. case PASS:
  786. passed++
  787. case FAIL:
  788. failed++
  789. case SKIP:
  790. uncovered++
  791. }
  792. }
  793. return
  794. }
  795. func writeReport(path string) {
  796. var b strings.Builder
  797. b.WriteString("# FWH MCP Server 非写操作接口测试报告\n\n")
  798. b.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
  799. b.WriteString("> SKIP / FAIL 的逐条根因分析、代码定位与修复建议见 **ANALYSIS.md**。\n\n")
  800. // 主口径:按工具去重的覆盖率(每个工具只算一次终态,FAIL>PASS>SKIP)
  801. tt, tpass, tfail, tuncov := toolCoverage()
  802. b.WriteString("## 工具覆盖率(按工具去重,主口径)\n\n")
  803. b.WriteString(fmt.Sprintf("被测工具 **%d** 个 | ✅ 已覆盖(PASS) **%d** | ❌ FAIL **%d** | ⏭️ 未覆盖(SKIP) **%d**\n\n",
  804. tt, tpass, tfail, tuncov))
  805. if tt > 0 {
  806. b.WriteString(fmt.Sprintf("覆盖率: **%.1f%%**(%d/%d 工具至少有一次成功调用)\n\n", float64(tpass)/float64(tt)*100, tpass, tt))
  807. }
  808. b.WriteString("> 说明:一个工具可能在多个阶段被调用(自动冒烟因缺参 SKIP、样本/级联阶段 PASS)。\n")
  809. b.WriteString("> 上表按工具去重,只要该工具有任一次 PASS 即计为“已覆盖”,避免同一工具重复计数。\n")
  810. b.WriteString("> 下方“执行记录”为全部调用流水(含重复),供追溯过程。\n\n")
  811. // 次口径:执行记录流水
  812. tp, tf, ts := countByStatus("")
  813. b.WriteString("## 执行记录统计(流水口径,含重复)\n\n")
  814. b.WriteString(fmt.Sprintf("总执行 %d 次 | PASS %d | FAIL %d | SKIP %d\n\n", len(results), tp, tf, ts))
  815. b.WriteString("## 分层汇总(执行流水)\n\n| 层级 | PASS | FAIL | SKIP |\n|------|------|------|------|\n")
  816. for _, lv := range []string{"L0", "L1", "L2", "L2S", "L2C", "L2X", "L3RW", "L4"} {
  817. p, f, s := countByStatus(lv)
  818. b.WriteString(fmt.Sprintf("| %s | %d | %d | %d |\n", lv, p, f, s))
  819. }
  820. b.WriteString("\n")
  821. b.WriteString("## 执行明细\n\n| 编号 | 层级 | 工具 | 结果 | 耗时 | 备注 |\n|------|------|------|------|------|------|\n")
  822. for _, r := range results {
  823. note := strings.ReplaceAll(r.note, "|", "\\|")
  824. note = strings.ReplaceAll(note, "\n", " ")
  825. b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %dms | %s |\n",
  826. r.id, r.level, r.tool, r.status, r.elapsed.Milliseconds(), truncate(note, 160)))
  827. }
  828. // FAIL 汇总
  829. b.WriteString("\n## FAIL 用例(需关注)\n\n")
  830. anyFail := false
  831. for _, r := range results {
  832. if r.status == FAIL {
  833. anyFail = true
  834. b.WriteString(fmt.Sprintf("- **%s** %s: %s\n", r.id, r.tool, strings.ReplaceAll(r.note, "\n", " ")))
  835. }
  836. }
  837. if !anyFail {
  838. b.WriteString("无\n")
  839. }
  840. b.WriteString("\n## SKIP 逐条分析(每个工具缺什么 / 如何补)\n\n")
  841. writeSkipAnalysis(&b)
  842. os.WriteFile(path, []byte(b.String()), 0644)
  843. }
  844. // skipNeed 描述某工具 SKIP 的具体缺失与补法。
  845. type skipNeed struct {
  846. group string // A=需样本(可级联) / B=需有数据样本 / C=环境或代码 / D=数据为空
  847. need string // 缺什么、如何补
  848. }
  849. // skipNeeds 映射每个可能 SKIP 的工具 → 缺失说明。
  850. var skipNeeds = map[string]skipNeed{
  851. // —— C:环境/代码,非样本 ——
  852. "service_background_task_list": {"C", "缺 asynq 任务队列 inspector 初始化。需在 cmd/mcp/main.go 初始化队列 inspector(代码改动),或该能力在 MCP 场景不启用。"},
  853. "service_monitor_alarm_stats": {"D", "缺当日告警统计数据(StatisticsAlarm 集合无文档)。需等有数据的日期或造数据;工具逻辑正常(同族 shutdown 已 PASS)。"},
  854. "service_dashboard_alarm_detail": {"D", "同上,缺 StatisticsAlarm 当日文档。"},
  855. // —— B:需“确定有数据”的样本 ——
  856. "data_pgsql_query_snapshot_file_record": {"B", "缺一个已知存在的快照 file_name(PgSQL 记录,列表接口无法级联获取)。"},
  857. "data_fwh_query_alarm_snapshot": {"B", "缺“确实有告警快照”的 sn+date 组合(普通 SN 当天不一定有快照)。"},
  858. "data_fwh_query_alarm_statistics_from_code": {"B", "缺一个真实的 alarm_code(告警码)。"},
  859. "data_fwh_query_mqtt_log_list": {"B", "缺有效的 cmd_type 取值 + 有 MQTT 日志的 sn 与时间范围。"},
  860. "data_fwh_query_ems_log_record": {"B", "缺“有 EMS 日志”的 sn(现有样本 SN 试过为空)。"},
  861. "data_fwh_query_event_alarm_list": {"B", "缺“该时段确有事件/告警”的 sn+start_time+end_time。"},
  862. "data_fwh_query_site_alarm_list": {"B", "缺“确有告警”的 sn(现有样本 SN 无告警)。"},
  863. "data_fwh_query_shutdown_list": {"B", "缺“该日期确有停机记录”的 date。"},
  864. "data_fwh_query_ahub_update_log": {"B", "缺“有 aHub 升级记录”的 sn。"},
  865. "data_fwh_query_fault_record_list": {"B", "缺“确有故障录波”的 sn+时间范围(现有 SN 上游返回 Response failed=无记录)。"},
  866. "data_fwh_query_fault_record_callable_list": {"B", "缺“有可召唤故障录波”的 sn。"},
  867. "data_fwh_query_fault_record_sub_list": {"B", "缺 fault_id(需先有 fault_record_list 返回数据才能级联)。"},
  868. "data_fwh_query_system_update_log_detail": {"B", "order_id 已能级联获取,但上游对该 order 无详情(Response failed)。需一个确有升级详情的 order。"},
  869. "data_sim_acceleronix_batch_data_info": {"B", "iccid 可级联,但需配一个“确有用量数据”的 month。"},
  870. // —— Plane:列表为空导致无法级联(环境数据缺失)——
  871. "plane_get_issue": {"D", "缺可用 issue:plane_list_issues 当前返回空(该 project 无工作项)。需项目里有 issue。"},
  872. "plane_get_sub_issues": {"D", "同上,需存在带子项的 issue。"},
  873. "plane_get_issue_activity": {"D", "同上,需存在 issue。"},
  874. "plane_get_cycle": {"D", "缺 cycle:plane_list_cycles 返回空。需项目里有周期。"},
  875. "plane_get_module": {"D", "缺 module:plane_list_modules 返回空。需项目里有模块。"},
  876. "plane_get_page": {"D", "缺 page:plane_list_pages 返回空。需项目里有页面。"},
  877. "plane_get_page_description": {"D", "同上,需存在 page。"},
  878. "plane_get_workspace": {"A", "仅需 workspace_slug=fwh,驱动未覆盖直接调用(可级联/固定值补测)。"},
  879. "plane_list_pages": {"A", "仅需 slug/project(有样本),驱动未覆盖(可补测)。"},
  880. "plane_list_stickies": {"A", "仅需 slug(有样本),驱动未覆盖(可补测)。"},
  881. // —— A:需 localPath(可自造)——
  882. "storage_file_upload": {"A", "缺 localPath。测试可自造一个本地临时文件再上传(无需外部样本)。"},
  883. "storage_file_read_to_local": {"A", "缺 path。可用 L3RW 写入的 path 级联补测。"},
  884. }
  885. // writeSkipAnalysis 渲染当前仍为 SKIP、且未在其它阶段 PASS 的工具的逐条说明。
  886. func writeSkipAnalysis(b *strings.Builder) {
  887. passed := map[string]bool{}
  888. for _, r := range results {
  889. if r.status == PASS {
  890. passed[r.tool] = true
  891. }
  892. }
  893. // 收集仍 SKIP 且从未 PASS 的工具(去重)
  894. seen := map[string]bool{}
  895. type row struct{ tool, group, need string }
  896. var rows []row
  897. for _, r := range results {
  898. if r.status != SKIP || passed[r.tool] || seen[r.tool] {
  899. continue
  900. }
  901. seen[r.tool] = true
  902. n, ok := skipNeeds[r.tool]
  903. if !ok {
  904. n = skipNeed{"A", "需必填参数样本(未归类,可级联或提供样本补测)"}
  905. }
  906. rows = append(rows, row{r.tool, n.group, n.need})
  907. }
  908. b.WriteString(fmt.Sprintf("以下为**排除已在其它阶段 PASS 后**、仍未覆盖的 %d 个工具。已在别处 PASS 的工具(如 service_site_query_info、plane_list_* 等)不再列出。\n\n", len(rows)))
  909. b.WriteString("分组:A=可级联/自造补测(无需外部样本)|B=需“确有数据”的样本|C=需环境或代码改动|D=环境数据为空。\n\n")
  910. for _, g := range []string{"A", "B", "C", "D"} {
  911. var gr []row
  912. for _, r := range rows {
  913. if r.group == g {
  914. gr = append(gr, r)
  915. }
  916. }
  917. if len(gr) == 0 {
  918. continue
  919. }
  920. labels := map[string]string{
  921. "A": "A 组 · 可级联/自造补测(无需外部样本)",
  922. "B": "B 组 · 需“确有数据”的样本(要你提供)",
  923. "C": "C 组 · 需环境或代码改动",
  924. "D": "D 组 · 环境数据为空(非缺陷,需造/等数据)",
  925. }
  926. b.WriteString(fmt.Sprintf("### %s\n\n| 工具 | 缺什么 / 如何补 |\n|------|------|\n", labels[g]))
  927. for _, r := range gr {
  928. need := strings.ReplaceAll(r.need, "|", "\\|")
  929. b.WriteString(fmt.Sprintf("| `%s` | %s |\n", r.tool, need))
  930. }
  931. b.WriteString("\n")
  932. }
  933. }
  934. func writePiiReport(path string) {
  935. var b strings.Builder
  936. b.WriteString("# FWH MCP Server 敏感数据脱敏专项报告 (L4)\n\n")
  937. b.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
  938. b.WriteString("说明: 负向断言=返回内容不得出现明文 PII(手机号/邮箱/IMEI/MAC/IP);正向断言=保留字段(sn/giccid等)应保持原值。\n")
  939. b.WriteString("L4-0 单元测试 (go test ./cmd/mcp/tools/helper/...) 已单独执行。\n\n")
  940. pp, pf, ps := 0, 0, 0
  941. for _, f := range piis {
  942. switch f.status {
  943. case PASS:
  944. pp++
  945. case FAIL:
  946. pf++
  947. case SKIP:
  948. ps++
  949. }
  950. }
  951. b.WriteString(fmt.Sprintf("端到端脱敏用例: PASS %d | FAIL %d | SKIP %d\n\n", pp, pf, ps))
  952. for _, f := range piis {
  953. b.WriteString(fmt.Sprintf("## %s — %s [%s]\n\n", f.id, f.tool, f.status))
  954. if len(f.leaks) > 0 {
  955. b.WriteString("**检出明文 PII(安全缺陷):**\n")
  956. for _, l := range f.leaks {
  957. b.WriteString("- " + l + "\n")
  958. }
  959. b.WriteString("\n")
  960. } else {
  961. b.WriteString("负向断言: 无明文 PII\n\n")
  962. }
  963. b.WriteString("正向断言: " + f.positive + "\n\n")
  964. if f.sample != "" {
  965. b.WriteString("脱敏后样本片段:\n```\n" + f.sample + "\n```\n\n")
  966. }
  967. }
  968. os.WriteFile(path, []byte(b.String()), 0644)
  969. }