Răsfoiți Sursa

fix:优化agent大数据落盘,避免大数据走模型烧token

Jerry Liang 1 lună în urmă
părinte
comite
2fc1f214fc

+ 1 - 1
deploy/kiro/skills/fwh-toolbox-workflow/SKILL.md

@@ -170,7 +170,7 @@ MCP 工具按访问层级分为五类,**选择工具时严格遵循以下优
 仅在 `service_*` 无法满足时使用,全部只读。详细流程见 references:
 
 - **FWH Pro 上游 API**:`data_fwh_query_*`(站点列表/基本信息/功率流/工作模式、当前告警、告警统计、告警快照、事件/告警日志、MQTT 日志、EMS 日志召唤记录、故障录波列表/子记录/可召唤列表/召唤记录、aHub/系统升级记录、Shutdown 列表、TOU 模板)→ references/fwh-pro-api.md
-- **MongoDB / Redis / PgSQL / EMS 参数 / SIM 上游**:`data_mongo_find`、`data_mongo_count`、`data_redis_get`、`data_redis_exists`、`data_pgsql_query_snapshot_data`、`data_pgsql_query_snapshot_file_record`、`data_emspara_pid_info`、`data_emspara_vid_info`、`data_sim_*` → references/data-direct-query.md
+- **MongoDB / Redis / PgSQL / EMS 参数 / SIM 上游**:`data_mongo_find`、`data_mongo_count`、`data_mongo_export`(全量流式导出)、`data_mongo_aggregate`(聚合管道,支持 `$lookup` 跨集合关联/分组统计,在 DB 侧一步完成"过滤+JOIN+聚合",大数据量关联首选)、`data_redis_get`、`data_redis_exists`、`data_pgsql_query_snapshot_data`、`data_pgsql_query_snapshot_file_record`、`data_emspara_pid_info`、`data_emspara_vid_info`、`data_sim_*` → references/data-direct-query.md(含字段字典、JOIN 关系图谱与聚合配方)
 - **Plane 直查(V1 封装)**:`data_plane_*` → references/plane-collaboration.md
 
 ---

+ 93 - 7
deploy/kiro/skills/fwh-toolbox-workflow/references/data-direct-query.md

@@ -6,21 +6,107 @@
 
 | 工具 | 参数 | 说明 |
 |------|------|------|
-| `data_mongo_find` | `collection`(必填)、`filter`(JSON)、`limit`(默认10,最大100)、`skip`、`sort`(JSON) | 查询集合文档 |
+| `data_mongo_find` | `collection`(必填)、`filter`(JSON)、`fields`(字段投影数组)、`limit`(默认10,最大100)、`skip`、`sort`(JSON) | 查询集合文档 |
 | `data_mongo_count` | `collection`(必填)、`filter`(JSON,可空) | 统计符合条件的文档数 |
+| `data_mongo_export` | `collection`(必填)、`filter`(JSON)、`fields`(数组)、`sort`(JSON)、`max_docs` | 全量导出到 NDJSON 文件,游标流式、内存常数级;仅回 localPath+count+summary |
+| `data_mongo_aggregate` | `collection`(必填)、`pipeline`(JSON数组字符串,必填)、`max_docs` | **聚合管道**:在数据库侧完成过滤+关联(JOIN)+分组统计,结果流式落盘 |
 
-**collection 白名单**(仅以下集合可查):
-`SiteInfoManageData`、`SiteInfoManageRecord`、`SimCardManage`、`SimCardMonthlyData`、`BatchDownloadConfig`、`SiteAnalysisConfig`、`StatisticsShutdown`、`StatisticsAlarm`、`SiteMonitorConfig`、`UserInfo`、`AutoUpdateConfigOption`、`AutoUpdateConfig`、`AiChatSessions`
+**collection 白名单**(仅以下集合可查;`$lookup`/`$graphLookup`/`$unionWith` 引用的集合也必须在此列表内):
+`SiteInfoManageData`、`SiteInfoManageRecord`、`SimCardManage`、`SimCardMonthlyData`、`BatchDownloadConfig`、`SiteAnalysisConfig`、`StatisticsShutdown`、`StatisticsAlarm`、`SiteMonitorConfig`、`AutoUpdateConfigOption`、`AutoUpdateConfig`
 
-示例:
+> 注意:`UserInfo`(内部用户身份数据)与 `AiChatSessions`(历史对话自由文本)已从白名单**移除**,不可直查,也不可作为 `$lookup`/`$unionWith` 的目标。
+
+find/export 示例:
 ```json
 { "collection": "SiteInfoManageData", "filter": "{\"sn\": \"xxx\"}", "limit": 10, "sort": "{\"createdAt\": -1}" }
 ```
 
 用法提示:
-- `filter` / `sort` 是 JSON **字符串**(如 `"{\"sn\":\"xxx\"}"`)。
-- 分页用 `skip` + `limit`。
-- 优先用对应的 `service_*` 工具(如站点分析用 `service_site_analysis_*`),直查仅用于灵活筛选/聚合计数。
+- `filter` / `sort` / `pipeline` 都是 JSON **字符串**(如 `"{\"sn\":\"xxx\"}"`)。
+- 分页用 `skip` + `limit`;大数据量导出用 `data_mongo_export`(不受 100 条上限)。
+- 优先用对应的 `service_*` 工具;直查仅用于灵活筛选/聚合/关联。
+- **所有 `data_mongo_*` 返回都无条件落盘**:仅返回 `localPath` + `summary`,需用文件读取工具按 `localPath` 读取完整数据。
+
+### data_mongo_aggregate(聚合 / 跨集合 JOIN)
+
+在**数据库侧**一次性完成"过滤 + 关联(JOIN) + 分组统计",避免多次取数与 Agent 侧拼接,是大数据量关联任务的首选。
+
+**允许的只读阶段**:`$match`、`$project`、`$addFields`/`$set`、`$unset`、`$group`、`$sort`、`$limit`、`$skip`、`$count`、`$unwind`、`$lookup`、`$graphLookup`、`$unionWith`、`$facet`、`$bucket`/`$bucketAuto`、`$sortByCount`、`$replaceRoot`/`$replaceWith`、`$sample`、`$redact`、`$setWindowFields`。
+
+**禁止的阶段**(会被拒绝):`$out`、`$merge`(写集合)、`$function`、`$accumulator`、`$where`(执行 JS)。
+
+**安全约束**:
+- `collection` 及 `$lookup`/`$graphLookup`/`$unionWith` 引用的集合都必须在白名单内(含嵌套 pipeline,递归校验)。
+- 每个阶段必须恰好一个顶层操作符。
+- 结果超过 `max_docs`(默认 50000)会被截断。
+
+#### 字段字典(写 pipeline 必读)
+
+**`SiteInfoManageData`(站点信息,`_id` = 站点 SN)关键字段**:
+- `_id`(站点 SN,20位)、`iccId`(SIM 卡 ICCID,20位;**前 19 位 = GICCID**)
+- `province`(州/省,如 `"Puerto Rico"`)、`city`、`country`(`"United States"`)、`countryId`、`provinceId`
+- `activeStatus`(1=已激活)、`status`(在线状态)、`runStatus`、`connType`、`signal`、`batterySoc`
+- `installerName`、`version`、`realSysHdVersion`、`site`(站点编码)、`siteId`
+- 敏感字段(会被脱敏):`address`/`deviceAddress`、`longitude`/`latitude`、`contactNumber`、`userName`/`firstName`/`lastName`、`account`、`mac`/`deviceIp`/`imei`
+
+**`SimCardManage`(SIM 卡,`_id` = GICCID 19位)关键字段**:
+- `_id`(GICCID,19位)、`status`(UNKNOWN/NOT_ACTIVATED/ACTIVATED/DEACTIVATED/RETIRED)、`platform`(SmartHub/Acceleronix)
+- `allDataUsage`(累计用量字节)、`month`(yyyy-MM)、`expirationTime`、`updateTime`
+- 敏感字段(会被脱敏):`imei`、`imsi`、`msisdn`
+
+**`SimCardMonthlyData`(SIM 月用量)**:`simId`(GICCID)、`month`、`allDataUsage`、`dailyUsage`。
+
+#### JOIN 关系图谱(跨集合关联键)
+
+- **站点 ↔ SIM**:`SiteInfoManageData.iccId` 取**前 19 位** = `SimCardManage._id`(GICCID)。
+  在 pipeline 中先用 `$addFields` 计算 `giccid = $substrBytes(iccId, 0, 19)`,再 `$lookup` 关联。
+- **SIM ↔ 月用量**:`SimCardManage._id` = `SimCardMonthlyData.simId`。
+
+#### 配方:波多黎各站点关联 SIM 信息(一步完成)
+
+```json
+[
+  { "$match": { "province": "Puerto Rico" } },
+  { "$addFields": { "giccid": { "$substrBytes": ["$iccId", 0, 19] } } },
+  { "$lookup": {
+      "from": "SimCardManage",
+      "localField": "giccid",
+      "foreignField": "_id",
+      "as": "sim"
+  }},
+  { "$project": {
+      "sn": "$_id", "city": 1, "giccid": 1,
+      "simStatus": { "$arrayElemAt": ["$sim.status", 0] },
+      "simPlatform": { "$arrayElemAt": ["$sim.platform", 0] },
+      "dataUsage": { "$arrayElemAt": ["$sim.allDataUsage", 0] }
+  }}
+]
+```
+调用(pipeline 作为 JSON 字符串传入):
+```json
+{ "collection": "SiteInfoManageData", "pipeline": "[{\"$match\":{\"province\":\"Puerto Rico\"}}, ...]" }
+```
+
+#### 配方:按州统计站点数与激活率
+
+```json
+[
+  { "$group": {
+      "_id": "$province",
+      "total": { "$sum": 1 },
+      "active": { "$sum": { "$cond": [{ "$eq": ["$activeStatus", 1] }, 1, 0] } }
+  }},
+  { "$sort": { "total": -1 } }
+]
+```
+
+#### 约束与反模式
+
+- **不要**用 `$project`/`$group` 把敏感字段(`contactNumber`/`imei`/`longitude` 等)**重命名**成新字段名或拼接进字符串——脱敏按字段名匹配,改名可能绕过脱敏,导致隐私外泄。保留敏感字段原名或直接不输出。
+- `$lookup` 关联大集合时注意被关联集合的关联字段应有索引(`SimCardManage._id`、`SimCardMonthlyData.simId` 已有)。
+- 只需计数/分组时优先 `$group`,不要把明细 `$push` 出来再在 Agent 侧数。
+- 需要把结果交给用户或后续脚本时,按 `localPath` 读取 NDJSON 文件处理。
+
 
 ## Redis
 

+ 6 - 1
service/cmd/mcp/main.go

@@ -14,6 +14,7 @@ import (
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/repositories"
 	minioUtil "fwh-toolbox-server/utils/minio"
+	"fwh-toolbox-server/utils/agentfiles"
 
 	"fwh-toolbox-server/cmd/mcp/tools"
 	"fwh-toolbox-server/cmd/mcp/tools/middleware"
@@ -65,6 +66,10 @@ func main() {
 	// 注册所有 Tools(唯一入口)
 	tools.RegisterAll(s)
 
+	// A 方案:进程启动时清理一次过期的本地落盘文件(results/ 与 downloads/)。
+	// MCP 随会话频繁拉起,启动清理能覆盖大多数场景;与后端 cron 定时清理(B 方案)互为补充。
+	agentfiles.Cleanup(agentfiles.DefaultMaxAge)
+
 	// 启动 stdio 通信
 	zap.L().Info("[fwh-mcp-server] started", zap.String("version", serverVersion))
 	fmt.Fprintln(os.Stderr, "[fwh-mcp-server] started (stdio) v"+serverVersion)
@@ -154,5 +159,5 @@ const serverInstructions = `FranklinWH 工具箱 MCP Server — 内部工具平
 5. 查询 MongoDB 时注意 collection 白名单限制
 6. MinIO 操作受 bucket 白名单限制
 7. 描述中标注 [写操作] 的工具由 Agent 权限管控,执行前会请求用户确认
-8. 大结果落盘:当工具返回的 JSON 中包含 "spilled": true 时,表示完整结果已写入本地文件,请使用文件读取工具按返回的 "localPath" 读取完整内容;"preview" 字段仅为前部片段,不代表全部数据。落盘文件位于 /tmp/fwh-toolbox-agent/ 目录下。返回中若含 "summary" 字段(数据规模与字段名摘要),可据此判断是否真的需要读回完整文件,尽量节省 token。
+8. 带数据的结果一律落盘:查询/导出类工具(返回数据集合、记录、详情等)的返回体不含数据正文,而是一个 JSON 摘要——包含 "localPath"(完整结果已写入的本地文件路径)与 "summary"(数据规模与字段名摘要)。请使用文件读取工具按 "localPath" 读取完整内容;可先据 "summary" 判断是否真的需要读回完整文件,尽量节省 token。落盘文件位于 /tmp/fwh-toolbox-agent/ 目录下。分页类工具(PageResult)会直接返回 total/pageNum/pageSize/hasMore 等分页元信息,本页数据正文则落盘在 "data.localPath"。触发动作/任务或返回状态提示的工具(如创建/更新/删除、"xxx成功")仍直接返回文本,不落盘。
 9. 大数据量导出:需要一次性拉取超过分页上限的大量数据时(如导出某集合的全部记录),优先使用 data_mongo_export 等 *_export 工具。它们流式将结果写入 NDJSON 文件(每行一个 JSON 对象,"format":"ndjson"),仅返回 "exported": true + "localPath" + "count" + "summary"(不返回数据正文),请使用文件读取工具按 localPath 逐行读取解析。为防误导出全表,无 filter 且数据量过大时会被拒绝,此时应补充 filter 或显式调高 max_docs`

+ 230 - 0
service/cmd/mcp/tools/data/mongo.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"time"
 
 	"github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
@@ -80,6 +81,57 @@ func registerMongoTools(s *server.MCPServer) {
 		),
 		handleMongoExport,
 	)
+
+	s.AddTool(
+		mcp.NewTool("data_mongo_aggregate",
+			mcp.WithDescription("对 MongoDB 集合执行聚合管道(aggregation pipeline,只读)并将结果流式导出到本地文件。"+
+				"支持 $match/$project/$group/$addFields/$lookup(跨集合关联/JOIN)/$sort/$limit/$unwind/$facet 等只读阶段,"+
+				"可在数据库侧一次性完成过滤+关联+聚合,避免多次取数与 Agent 侧拼接。"+
+				"结果边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),仅返回 localPath+count+summary(不返回数据正文)。"+
+				"安全约束:collection 与 $lookup/$unionWith/$graphLookup 引用的集合都必须在白名单内;"+
+				"禁止 $out/$merge/$function/$accumulator/$where 等写入或执行代码的阶段。"),
+			mcp.WithString("collection", mcp.Required(), mcp.Description(
+				fmt.Sprintf("聚合起点集合名,可选值: %v", mongoAllowedCollections),
+			)),
+			mcp.WithString("pipeline", mcp.Required(), mcp.Description(
+				"聚合管道,JSON 数组字符串,每个元素是一个聚合阶段。"+
+					"例:[{\"$match\":{\"province\":\"Puerto Rico\"}},{\"$group\":{\"_id\":\"$province\",\"n\":{\"$sum\":1}}}]")),
+			mcp.WithNumber("max_docs", mcp.Description(fmt.Sprintf(
+				"最大输出条数上限,默认 %d,硬上限 %d。聚合结果超过上限会被截断并在返回中标注。", mongoAggregateDefaultMax, mongoExportHardMax))),
+		),
+		handleMongoAggregate,
+	)
+}
+
+// mongoAggregateDefaultMax 聚合输出默认条数上限(软保护,可由 max_docs 覆盖)。
+const mongoAggregateDefaultMax = 50000
+
+// mongoAllowedStages 聚合管道允许的只读阶段白名单。
+// 明确排除写入/执行代码的阶段:$out、$merge(写集合)、$function、$accumulator、$where(执行 JS)。
+var mongoAllowedStages = map[string]bool{
+	"$match":         true,
+	"$project":       true,
+	"$addFields":     true,
+	"$set":           true,
+	"$unset":         true,
+	"$group":         true,
+	"$sort":          true,
+	"$limit":         true,
+	"$skip":          true,
+	"$count":         true,
+	"$unwind":        true,
+	"$lookup":        true,
+	"$graphLookup":   true,
+	"$unionWith":     true,
+	"$facet":         true,
+	"$bucket":        true,
+	"$bucketAuto":    true,
+	"$sortByCount":   true,
+	"$replaceRoot":   true,
+	"$replaceWith":   true,
+	"$sample":        true,
+	"$redact":        true,
+	"$setWindowFields": true,
 }
 
 // mongoExportDefaultMax 全量导出默认上限(防误导出全表的软保护,可由 max_docs 覆盖)
@@ -288,6 +340,184 @@ func handleMongoExport(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
 	return res, nil
 }
 
+func handleMongoAggregate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	collection, err := req.RequireString("collection")
+	if err != nil {
+		return helper.ParamError("collection"), nil
+	}
+	if !isAllowedCollection(collection) {
+		return helper.ParamInvalidError("collection", fmt.Sprintf("不允许查询此集合,可选: %v", mongoAllowedCollections)), nil
+	}
+
+	// 解析 pipeline(JSON 数组)
+	pipelineStr, err := req.RequireString("pipeline")
+	if err != nil {
+		return helper.ParamError("pipeline"), nil
+	}
+	var pipeline []bson.M
+	if err := json.Unmarshal([]byte(pipelineStr), &pipeline); err != nil {
+		return helper.ParamInvalidError("pipeline", fmt.Sprintf("需要 JSON 数组,每个元素是一个聚合阶段: %v", err)), nil
+	}
+	if len(pipeline) == 0 {
+		return helper.ParamInvalidError("pipeline", "pipeline 不能为空"), nil
+	}
+
+	// 安全校验:阶段白名单 + 引用集合白名单(递归)
+	if verr := validatePipeline(pipeline); verr != "" {
+		return helper.ParamInvalidError("pipeline", verr), nil
+	}
+
+	// max_docs 输出上限
+	maxDocs := int64(req.GetFloat("max_docs", float64(mongoAggregateDefaultMax)))
+	if maxDocs < 1 {
+		maxDocs = mongoAggregateDefaultMax
+	}
+	if maxDocs > mongoExportHardMax {
+		maxDocs = mongoExportHardMax
+	}
+
+	// 聚合可能较重:设置超时 + allowDiskUse 防止内存溢出。
+	aggCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
+	defer cancel()
+
+	coll := database.GetCollection(collection)
+	allowDisk := true
+	cursor, err := coll.Aggregate(aggCtx, pipeline, &options.AggregateOptions{AllowDiskUse: &allowDisk})
+	if err != nil {
+		return helper.ServiceError("MongoDB聚合", err), nil
+	}
+	defer cursor.Close(aggCtx)
+
+	// 流式:逐条 decode → 脱敏 → 写 NDJSON,内存常数级;达到 maxDocs 即停止。
+	var emitted int64
+	res := helper.ExportStreamNDJSON(func() (any, bool, error) {
+		if emitted >= maxDocs {
+			return nil, false, nil // 达到上限,正常结束(截断)
+		}
+		if !cursor.Next(aggCtx) {
+			return nil, false, cursor.Err()
+		}
+		var doc bson.M
+		if err := cursor.Decode(&doc); err != nil {
+			return nil, false, err
+		}
+		emitted++
+		return doc, true, nil
+	}, "mongo_aggregate_"+collection)
+	return res, nil
+}
+
+// validatePipeline 对聚合管道做安全校验,返回空字符串表示通过,否则返回错误说明。
+//   - 每个阶段的顶层操作符必须在 mongoAllowedStages 白名单内(排除 $out/$merge/$function 等)。
+//   - 递归收集 $lookup/$graphLookup/$unionWith 引用的集合,全部必须在集合白名单内,
+//     防止借跨集合阶段绕过白名单读取被禁集合。
+func validatePipeline(pipeline []bson.M) string {
+	for i, stage := range pipeline {
+		if len(stage) != 1 {
+			return fmt.Sprintf("第 %d 个阶段必须恰好包含一个聚合操作符", i+1)
+		}
+		for op, spec := range stage {
+			if !mongoAllowedStages[op] {
+				return fmt.Sprintf("第 %d 个阶段使用了不允许的操作符 %q(仅允许只读阶段,禁止 $out/$merge/$function 等)", i+1, op)
+			}
+			if refs := collectStageCollectionRefs(op, spec); len(refs) > 0 {
+				for _, c := range refs {
+					if !isAllowedCollection(c) {
+						return fmt.Sprintf("第 %d 个阶段 %s 引用了不在白名单内的集合 %q,可选: %v", i+1, op, c, mongoAllowedCollections)
+					}
+				}
+			}
+			// $unionWith 可能携带嵌套 pipeline,需递归校验
+			if op == "$unionWith" {
+				if sub := extractUnionWithPipeline(spec); sub != nil {
+					if verr := validatePipeline(sub); verr != "" {
+						return fmt.Sprintf("第 %d 个阶段 $unionWith 的嵌套 pipeline 校验失败: %s", i+1, verr)
+					}
+				}
+			}
+			// $lookup 的子 pipeline 形式(带 pipeline 字段)也需递归校验
+			if op == "$lookup" {
+				if sub := extractLookupPipeline(spec); sub != nil {
+					if verr := validatePipeline(sub); verr != "" {
+						return fmt.Sprintf("第 %d 个阶段 $lookup 的嵌套 pipeline 校验失败: %s", i+1, verr)
+					}
+				}
+			}
+		}
+	}
+	return ""
+}
+
+// collectStageCollectionRefs 提取某个阶段引用的外部集合名(用于白名单校验)。
+//   - $lookup:      spec.from(字符串)
+//   - $graphLookup: spec.from(字符串)
+//   - $unionWith:   字符串本身,或 spec.coll(字符串)
+func collectStageCollectionRefs(op string, spec any) []string {
+	var refs []string
+	switch op {
+	case "$lookup", "$graphLookup":
+		if m, ok := toMap(spec); ok {
+			if from, ok := m["from"].(string); ok && from != "" {
+				refs = append(refs, from)
+			}
+		}
+	case "$unionWith":
+		if s, ok := spec.(string); ok && s != "" {
+			refs = append(refs, s)
+		} else if m, ok := toMap(spec); ok {
+			if coll, ok := m["coll"].(string); ok && coll != "" {
+				refs = append(refs, coll)
+			}
+		}
+	}
+	return refs
+}
+
+// extractUnionWithPipeline 提取 $unionWith 的嵌套 pipeline(若有)。
+func extractUnionWithPipeline(spec any) []bson.M {
+	m, ok := toMap(spec)
+	if !ok {
+		return nil
+	}
+	return toPipeline(m["pipeline"])
+}
+
+// extractLookupPipeline 提取 $lookup 的子 pipeline(若有)。
+func extractLookupPipeline(spec any) []bson.M {
+	m, ok := toMap(spec)
+	if !ok {
+		return nil
+	}
+	return toPipeline(m["pipeline"])
+}
+
+// toMap 将 bson.M / map[string]any 统一转为 map[string]any。
+func toMap(v any) (map[string]any, bool) {
+	switch t := v.(type) {
+	case bson.M:
+		return map[string]any(t), true
+	case map[string]any:
+		return t, true
+	default:
+		return nil, false
+	}
+}
+
+// toPipeline 将任意值尽力转换为 []bson.M(聚合子管道)。
+func toPipeline(v any) []bson.M {
+	arr, ok := v.([]any)
+	if !ok {
+		return nil
+	}
+	out := make([]bson.M, 0, len(arr))
+	for _, e := range arr {
+		if m, ok := toMap(e); ok {
+			out = append(out, bson.M(m))
+		}
+	}
+	return out
+}
+
 func isAllowedCollection(name string) bool {
 	for _, c := range mongoAllowedCollections {
 		if c == name {

+ 34 - 6
service/cmd/mcp/tools/helper/helper.go

@@ -1,6 +1,7 @@
 package helper
 
 import (
+	"encoding/json"
 	"fmt"
 	"reflect"
 
@@ -85,12 +86,13 @@ func EmptyResult(msg string) *mcp.CallToolResult {
 	return mcp.NewToolResultText(msg)
 }
 
-// PageResult 将分页查询结果包装为 AI 友好的结构:在原始 data 之外,附带
+// PageResult 将分页查询结果包装为 AI 友好的结构:分页元信息
 // total(符合条件的总数)、pageSize、pageNum、returned(本页返回条数)与
-// hasMore(是否还有下一页),便于 Agent 判断是否翻完、如何继续翻页。
+// hasMore(是否还有下一页)直接返回,便于 Agent 判断是否翻完、如何继续翻页;
+// data 正文(带数据)经 PII 脱敏后无条件落盘,只在返回里给出 localPath 与 summary
+// (不含数据正文),避免分页数据进入模型上下文。
 //
 // hasMore = pageNum*pageSize < total(pageNum 从 1 开始)。
-// data 会经过 PII 脱敏并按大小决定内联或落盘(复用 JSONResultOrFile)。
 // hint 用于落盘文件名前缀。
 func PageResult(total, pageSize, pageNum uint, data any, hint string) *mcp.CallToolResult {
 	returned := 0
@@ -106,14 +108,40 @@ func PageResult(total, pageSize, pageNum uint, data any, hint string) *mcp.CallT
 
 	hasMore := uint(pageNum)*uint(pageSize) < uint(total)
 
-	return JSONResultOrFile(map[string]any{
+	meta := map[string]any{
 		"total":    total,
 		"pageSize": pageSize,
 		"pageNum":  pageNum,
 		"returned": returned,
 		"hasMore":  hasMore,
-		"data":     data,
-	}, hint)
+	}
+
+	// data 正文无条件落盘:脱敏 → 写文件 → 只回 localPath + summary。
+	if returned == 0 {
+		// 本页无数据:无需落盘,直接标注空数据。
+		meta["data"] = nil
+		meta["note"] = "本页无数据。"
+	} else if b, err := marshalSanitized(data); err != nil {
+		return mcp.NewToolResultError(err.Error())
+	} else if path, werr := writeSpillFile(hint, b); werr != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("分页数据落盘失败: %v", werr))
+	} else {
+		dataMeta := map[string]any{
+			"localPath": path,
+			"bytes":     len(b),
+		}
+		if summary := summarizeJSON(b); summary != nil {
+			dataMeta["summary"] = summary
+		}
+		meta["data"] = dataMeta
+		meta["note"] = "本页数据已写入本地文件,请使用文件读取工具按 data.localPath 读取完整内容。"
+	}
+
+	mb, err := json.MarshalIndent(meta, "", "  ")
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("分页元信息序列化失败: %v", err))
+	}
+	return mcp.NewToolResultText(string(mb))
 }
 
 // toSliceLen 若 v 是切片或数组,返回其长度与 true;否则返回 (0,false)。

+ 123 - 0
service/cmd/mcp/tools/helper/list_source.go

@@ -0,0 +1,123 @@
+package helper
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+)
+
+// StringListSource 表示一批字符串(如 SN / GICCID 列表)的来源解析结果,
+// 用于统一"数组入参"与"文件入参"两种传参方式。
+type StringListSource struct {
+	Items    []string // 解析出的字符串列表
+	FromFile bool     // 是否来自文件入参
+	FilePath string   // 当 FromFile 为 true 时的文件绝对路径
+}
+
+// ResolveStringListOrFile 统一解析"数组或文件"两种批量入参,优先级与安全规则:
+//
+//   - 若提供了 fileKey(文件入参):优先使用文件,忽略数组入参。
+//     文件路径必须通过 ValidateSourcePath 校验(仅允许 AgentBaseDir 下),
+//     否则返回错误结果。文件内容按扩展名解析:
+//   - .json  → JSON 字符串数组,如 ["A","B"]
+//   - 其它(.ndjson/.txt/无扩展名)→ 每行一个值(忽略空行、去除首尾空白)
+//   - 否则若提供了 arrayKey(结构化数组 schema,WithArray+WithStringItems):使用数组。
+//   - 二者都未提供:返回 (nil-source, errResult),由调用方作为参数错误返回。
+//
+// 返回 (source, errResult):errResult 非 nil 时表示解析失败,调用方应直接 return 该结果。
+func ResolveStringListOrFile(req mcp.CallToolRequest, arrayKey, fileKey string) (*StringListSource, *mcp.CallToolResult) {
+	// 1) 文件入参优先
+	if fileKey != "" {
+		if raw := strings.TrimSpace(req.GetString(fileKey, "")); raw != "" {
+			abs, err := ValidateSourcePath(raw)
+			if err != nil {
+				return nil, ParamInvalidError(fileKey, err.Error())
+			}
+			items, perr := parseStringListFile(abs)
+			if perr != nil {
+				return nil, ParamInvalidError(fileKey, perr.Error())
+			}
+			return &StringListSource{Items: items, FromFile: true, FilePath: abs}, nil
+		}
+	}
+
+	// 2) 数组入参:兼容两种历史风格
+	//    a) WithArray 结构化数组(WithStringItems)→ GetStringSlice
+	//    b) JSON 字符串数组(WithString + 手动 unmarshal)→ req.GetString 后解析
+	if arrayKey != "" {
+		if items, ok := GetStringSlice(req, arrayKey); ok {
+			return &StringListSource{Items: items, FromFile: false}, nil
+		}
+		if s := strings.TrimSpace(req.GetString(arrayKey, "")); s != "" {
+			if items, ok := tryParseJSONStringArray([]byte(s)); ok {
+				return &StringListSource{Items: items, FromFile: false}, nil
+			}
+			return nil, ParamInvalidError(arrayKey, "需要 JSON 字符串数组,如 [\"A\",\"B\"]")
+		}
+	}
+
+	// 3) 都没提供
+	hint := arrayKey
+	if fileKey != "" {
+		hint = fmt.Sprintf("%s(数组)或 %s(文件路径)", arrayKey, fileKey)
+	}
+	return nil, ParamInvalidError(arrayKey, fmt.Sprintf("必须提供 %s 之一", hint))
+}
+
+// parseStringListFile 按扩展名解析本地文件为字符串列表。
+//   - .json:解析为 JSON 字符串数组;也兼容 {"items":[...]} 或 NDJSON 回退。
+//   - 其它:按行解析,每行一个值,忽略空行、去除首尾空白。
+//
+// 传入的 path 应已通过 ValidateSourcePath 校验。
+func parseStringListFile(path string) ([]string, error) {
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("读取文件失败: %v", err)
+	}
+	if len(strings.TrimSpace(string(b))) == 0 {
+		return nil, fmt.Errorf("文件为空")
+	}
+
+	ext := strings.ToLower(filepath.Ext(path))
+	if ext == ".json" {
+		if items, ok := tryParseJSONStringArray(b); ok {
+			return items, nil
+		}
+		// .json 但不是纯字符串数组:回退按行(可能是 NDJSON 的 .json 命名)
+	}
+
+	// 按行解析
+	items := parseLines(b)
+	if len(items) == 0 {
+		return nil, fmt.Errorf("未从文件解析出任何有效值")
+	}
+	return items, nil
+}
+
+// tryParseJSONStringArray 尝试把字节解析为 JSON 字符串数组 ["a","b"]。
+// 成功返回 (items, true);不是该形态则返回 (nil, false)。
+func tryParseJSONStringArray(b []byte) ([]string, bool) {
+	var arr []string
+	if err := json.Unmarshal(b, &arr); err == nil {
+		return arr, true
+	}
+	return nil, false
+}
+
+// parseLines 按行解析,每行一个值,忽略空行、去除首尾空白。
+func parseLines(b []byte) []string {
+	lines := strings.Split(string(b), "\n")
+	out := make([]string, 0, len(lines))
+	for _, ln := range lines {
+		s := strings.TrimSpace(ln)
+		if s == "" {
+			continue
+		}
+		out = append(out, s)
+	}
+	return out
+}

+ 39 - 68
service/cmd/mcp/tools/helper/spill.go

@@ -12,22 +12,28 @@ import (
 	"time"
 
 	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/utils/agentfiles"
 )
 
 // AgentBaseDir 是 MCP Agent 允许读写的本地根目录。
 // 输入侧 source_path 校验、输出侧结果落盘都限制在此目录及其子目录内。
 const AgentBaseDir = "/tmp/fwh-toolbox-agent"
 
+// init 断言:本包 AgentBaseDir 必须与 agentfiles.AgentBaseDir 一致,
+// 否则落盘目录与清理目录不匹配会导致文件永不被清理。不一致时 MCP 启动即 panic,
+// 让问题在部署时立刻暴露,而非静默漂移。
+func init() {
+	if AgentBaseDir != agentfiles.AgentBaseDir {
+		panic(fmt.Sprintf(
+			"AgentBaseDir 不一致: helper=%q agentfiles=%q(两处必须相同)",
+			AgentBaseDir, agentfiles.AgentBaseDir))
+	}
+}
+
 // spillDir 大结果落盘目录(AgentBaseDir 的子目录)
 var spillDir = filepath.Join(AgentBaseDir, "results")
 
-// InlineMaxBytes 结果内联返回的最大字节数,超过则落盘到文件。
-// 约 32KB,兼顾避免烧 token 与减少不必要的"读文件"往返。
-const InlineMaxBytes = 32 * 1024
-
-// previewMaxBytes 落盘时返回给 Agent 的预览内容最大字节数
-const previewMaxBytes = 2000
-
 // hintSanitizer 用于清洗文件名 hint,只保留安全字符
 var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
 
@@ -36,9 +42,8 @@ var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
 //   - 其余类型:序列化后走流式 token 脱敏(优化 B)。
 //   - 脱敏失败:返回错误,拒绝输出(fail-closed,绝不泄露原始数据)。
 //
-// 脱敏完成后:
-//   - 小结果(<= InlineMaxBytes)直接内联返回文本
-//   - 大结果写入本地文件,返回包含 localPath + 预览 + 元信息的摘要
+// 脱敏完成后无条件落盘(数据类返回统一策略):无论数据大小都写入本地文件,
+// 返回体只含 localPath + bytes + summary(不含数据正文),避免数据进入模型上下文。
 //
 // hint 用于生成可读的文件名前缀(如 "plane_work_items")。
 func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
@@ -78,15 +83,14 @@ func JSONResultOrFileAuto(data any) *mcp.CallToolResult {
 	return JSONResultOrFile(data, "result")
 }
 
-// TextResultOrFile 针对可能较大的纯文本返回值做落盘判断:
-//   - 小文本直接内联返回
-//   - 大文本写入本地文件,返回 localPath + 预览
+// TextResultOrFile 针对纯文本返回值无条件落盘(数据类返回统一策略):
+// 无论文本大小都写入本地文件,返回体只含 localPath + summary(不含正文)。
 func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
 	return BytesResultOrFile([]byte(text), hint)
 }
 
 // BytesResultOrFileMasked 针对上游返回的原始 JSON 字节(如 Plane API 响应)
-// 先做流式 PII 脱敏(优化 B),再走落盘判断。
+// 先做流式 PII 脱敏(优化 B),再无条件落盘(数据类返回统一策略)。
 //   - 脱敏失败(含无法解析的非法 JSON):返回错误,拒绝输出(fail-closed)。
 //
 // 相比"unmarshal 成 any 再 JSONResultOrFile",此路径省去一次反序列化+序列化往返。
@@ -98,24 +102,29 @@ func BytesResultOrFileMasked(b []byte, hint string) *mcp.CallToolResult {
 	return BytesResultOrFile(indentJSON(masked), hint)
 }
 
-// BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做落盘判断。
+// BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做无条件落盘。
+//
+// 语义(数据类返回):无论数据大小都写入本地文件,返回结果只包含
+// localPath + bytes + summary(不含数据正文),供 Agent 按 localPath 读取完整内容。
+// 这样任意"带数据"的查询/导出结果都不会进入模型上下文,从根本上避免上下文膨胀。
+//
+// 落盘失败时返回错误(不再退化为内联,避免大结果灌爆上下文)。
 func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
-	if len(b) <= InlineMaxBytes {
-		return mcp.NewToolResultText(string(b))
-	}
+	return spillResult(b, hint)
+}
 
+// spillResult 是"带数据结果一律落盘"的统一实现:写盘 + 组装 localPath/summary 元信息。
+// 供 BytesResultOrFile / ExportToFile 等数据类返回路径复用。
+func spillResult(b []byte, hint string) *mcp.CallToolResult {
 	path, werr := writeSpillFile(hint, b)
 	if werr != nil {
-		// 落盘失败则退化为直接内联返回,保证功能可用(仅牺牲上下文占用)
-		return mcp.NewToolResultText(string(b))
+		return mcp.NewToolResultError(fmt.Sprintf("结果落盘失败: %v", werr))
 	}
 
 	meta := map[string]any{
-		"spilled":   true,
 		"localPath": path,
 		"bytes":     len(b),
-		"preview":   truncate(string(b), previewMaxBytes),
-		"note":      "结果较大已写入本地文件,请使用文件读取工具按 localPath 读取完整内容;preview 仅为前部片段。",
+		"note":      "结果数据已写入本地文件,请使用文件读取工具按 localPath 读取完整内容。",
 	}
 	// 附带结构化摘要:帮助 Agent 在不读回完整文件的情况下了解数据规模与字段,节省 token。
 	if summary := summarizeJSON(b); summary != nil {
@@ -123,42 +132,23 @@ func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
 	}
 	mb, err := json.MarshalIndent(meta, "", "  ")
 	if err != nil {
-		return mcp.NewToolResultText(string(b))
+		return mcp.NewToolResultError(fmt.Sprintf("落盘元信息序列化失败: %v", err))
 	}
 	return mcp.NewToolResultText(string(mb))
 }
 
-// ExportToFile 用于"全量导出"场景:无论数据大小都落盘为文件,绝不内联。
-// 先做 A+B PII 脱敏(与其它返回路径一致,fail-closed),再写入落盘目录,
-// 返回始终包含 localPath + summary + bytes 的结构化结果(不含数据正文),
-// 供 Agent 按 localPath 读取完整导出内容。
+// ExportToFile 用于"全量导出"场景:将数据一次性序列化后落盘。
+// 先做 A+B PII 脱敏(与其它返回路径一致,fail-closed),再走统一的落盘返回,
+// 结果只包含 localPath + bytes + summary(不含数据正文)。
 //
-// 落盘失败时返回错误(导出场景不做内联退化,避免大结果灌爆上下文)。
+// 注:数据类返回现已统一为无条件落盘,本函数与 JSONResultOrFile 行为一致,
+// 保留独立入口以表达"全量导出"语义、便于调用点阅读。
 func ExportToFile(data any, hint string) *mcp.CallToolResult {
 	b, err := marshalSanitized(data)
 	if err != nil {
 		return mcp.NewToolResultError(err.Error())
 	}
-
-	path, werr := writeSpillFile(hint, b)
-	if werr != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("导出落盘失败: %v", werr))
-	}
-
-	meta := map[string]any{
-		"exported":  true,
-		"localPath": path,
-		"bytes":     len(b),
-		"note":      "全量导出结果已写入本地文件,请使用文件读取工具按 localPath 读取完整内容。",
-	}
-	if summary := summarizeJSON(b); summary != nil {
-		meta["summary"] = summary
-	}
-	mb, err := json.MarshalIndent(meta, "", "  ")
-	if err != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("导出元信息序列化失败: %v", err))
-	}
-	return mcp.NewToolResultText(string(mb))
+	return spillResult(b, hint)
 }
 
 // marshalSanitizedCompact 对单条数据做 A+B 脱敏并返回紧凑(无缩进/无换行)的 JSON 字节,
@@ -369,25 +359,6 @@ func sanitizeHint(hint string) string {
 	return hint
 }
 
-// truncate 截断字符串到指定字节数(按 rune 边界安全截断)
-func truncate(s string, max int) string {
-	if len(s) <= max {
-		return s
-	}
-	// 从 max 处向前回退到 rune 边界
-	end := max
-	for end > 0 && !utf8RuneStart(s[end]) {
-		end--
-	}
-	return s[:end] + "\n...(truncated)"
-}
-
-// utf8RuneStart 判断字节是否为 UTF-8 编码的起始字节
-func utf8RuneStart(b byte) bool {
-	// 0xxxxxxx (ASCII) 或 11xxxxxx (多字节起始);10xxxxxx 为续字节
-	return b&0xC0 != 0x80
-}
-
 // ValidateSourcePath 校验输入侧的本地文件路径:
 //   - 必须位于 AgentBaseDir(/tmp/fwh-toolbox-agent)目录或其子目录内
 //   - 清理 ".." 等路径穿越

+ 0 - 107
service/cmd/mcp/tools/helper/spill_test.go

@@ -1,107 +0,0 @@
-package helper
-
-import (
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	"github.com/mark3labs/mcp-go/mcp"
-)
-
-func TestJSONResultOrFile_SmallInline(t *testing.T) {
-	res := JSONResultOrFile(map[string]any{"a": 1}, "small")
-	if res == nil || len(res.Content) == 0 {
-		t.Fatal("empty result")
-	}
-	txt := textOf(t, res)
-	if strings.Contains(txt, "spilled") {
-		t.Fatalf("small result should be inline, got: %s", txt)
-	}
-	if !strings.Contains(txt, "\"a\": 1") {
-		t.Fatalf("unexpected inline content: %s", txt)
-	}
-}
-
-func TestJSONResultOrFile_LargeSpill(t *testing.T) {
-	// 构造超过 InlineMaxBytes 的数据
-	big := make([]map[string]string, 0, 5000)
-	for i := 0; i < 5000; i++ {
-		big = append(big, map[string]string{"k": "value-value-value-value"})
-	}
-	res := JSONResultOrFile(big, "large_test")
-	txt := textOf(t, res)
-
-	var meta map[string]any
-	if err := json.Unmarshal([]byte(txt), &meta); err != nil {
-		t.Fatalf("expected JSON meta, got: %s", txt)
-	}
-	if meta["spilled"] != true {
-		t.Fatalf("expected spilled=true, got: %v", meta["spilled"])
-	}
-	path, _ := meta["localPath"].(string)
-	if path == "" {
-		t.Fatal("missing localPath")
-	}
-	// 落盘路径必须在 AgentBaseDir 内
-	if !strings.HasPrefix(path, AgentBaseDir) {
-		t.Fatalf("spill path outside base dir: %s", path)
-	}
-	// 文件应真实存在且内容为完整 JSON
-	data, err := os.ReadFile(path)
-	if err != nil {
-		t.Fatalf("spill file not readable: %v", err)
-	}
-	var parsed []map[string]string
-	if err := json.Unmarshal(data, &parsed); err != nil {
-		t.Fatalf("spill file not valid JSON: %v", err)
-	}
-	if len(parsed) != 5000 {
-		t.Fatalf("spill file content incomplete: %d", len(parsed))
-	}
-	_ = os.Remove(path)
-}
-
-func TestValidateSourcePath(t *testing.T) {
-	okDir := filepath.Join(AgentBaseDir, "downloads")
-	_ = os.MkdirAll(okDir, 0755)
-
-	cases := []struct {
-		name    string
-		in      string
-		wantErr bool
-	}{
-		{"base itself", AgentBaseDir, false},
-		{"subdir file", filepath.Join(AgentBaseDir, "downloads", "a.json"), false},
-		{"nested subdir", filepath.Join(AgentBaseDir, "x", "y", "z.txt"), false},
-		{"traversal escape", filepath.Join(AgentBaseDir, "..", "etc", "passwd"), true},
-		{"outside abs", "/etc/passwd", true},
-		{"sibling prefix trick", "/tmp/fwh-toolbox-agent-evil/x", true},
-		{"empty", "", true},
-	}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			_, err := ValidateSourcePath(c.in)
-			if c.wantErr && err == nil {
-				t.Fatalf("expected error for %q", c.in)
-			}
-			if !c.wantErr && err != nil {
-				t.Fatalf("unexpected error for %q: %v", c.in, err)
-			}
-		})
-	}
-}
-
-func textOf(t *testing.T, res *mcp.CallToolResult) string {
-	t.Helper()
-	if res == nil || len(res.Content) == 0 {
-		t.Fatal("empty result content")
-	}
-	tc, ok := res.Content[0].(mcp.TextContent)
-	if !ok {
-		t.Fatalf("content is not text: %T", res.Content[0])
-	}
-	return tc.Text
-}

+ 2 - 2
service/cmd/mcp/tools/middleware/logging.go

@@ -145,8 +145,8 @@ func resultText(result *mcp.CallToolResult) string {
 func resultMeta(result *mcp.CallToolResult) (size int, spilled bool) {
 	text := resultText(result)
 	size = len(text)
-	// 落盘结果的返回体是包含 "spilled": true 的 JSON 摘要
-	if strings.Contains(text, `"spilled": true`) || strings.Contains(text, `"spilled":true`) {
+	// 落盘结果的返回体是包含 "localPath" 的 JSON 摘要(数据类返回已统一为无条件落盘)。
+	if strings.Contains(text, `"localPath"`) {
 		spilled = true
 	}
 	return size, spilled

+ 11 - 5
service/cmd/mcp/tools/service/batch_download.go

@@ -29,8 +29,10 @@ func registerBatchDownloadTools(s *server.MCPServer) {
 	s.AddTool(
 		mcp.NewTool("service_batch_download_create",
 			mcp.WithDescription("创建批量下载任务(自动入队执行)[写操作]"),
-			mcp.WithArray("sn", mcp.Required(), mcp.WithStringItems(),
-				mcp.Description("站点SN列表,字符串数组,每个SN标准长度20位")),
+			mcp.WithArray("sn", mcp.WithStringItems(),
+				mcp.Description("站点SN列表,字符串数组,每个SN标准长度20位。与 sn_file 二选一,大批量建议用 sn_file。")),
+			mcp.WithString("sn_file",
+				mcp.Description("站点SN列表文件路径(须位于 /tmp/fwh-toolbox-agent/ 下)。.json 为JSON字符串数组,其它按行每行一个SN。提供时优先于 sn,适合大批量避免占用上下文。")),
 			mcp.WithString("time_period", mcp.Required(), mcp.Description("时间范围,JSON数组 [\"2024-01-01\",\"2024-01-31\"]")),
 			mcp.WithString("options", mcp.Required(), mcp.Description("下载选项 JSON,字段: powerFlow(bool), mqttLogs(string[]), eventAlarm(bool), alarmSnapshot(bool), emsLogs(string[]), faultRecord(bool), updateLog(bool), settings(bool)")),
 			mcp.WithString("description", mcp.Description("任务描述")),
@@ -103,9 +105,13 @@ func handleBatchDownloadList(ctx context.Context, req mcp.CallToolRequest) (*mcp
 }
 
 func handleBatchDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	snList, ok := helper.GetStringSlice(req, "sn")
-	if !ok || len(snList) == 0 {
-		return helper.ParamInvalidError("sn", "需要非空的字符串数组,如 [\"SN1\",\"SN2\"]"), nil
+	src, errRes := helper.ResolveStringListOrFile(req, "sn", "sn_file")
+	if errRes != nil {
+		return errRes, nil
+	}
+	snList := src.Items
+	if len(snList) == 0 {
+		return helper.ParamInvalidError("sn", "站点SN列表为空"), nil
 	}
 	timePeriodStr, err := req.RequireString("time_period")
 	if err != nil {

+ 62 - 0
service/cmd/mcp/tools/service/sim.go

@@ -19,6 +19,17 @@ func registerSimTools(s *server.MCPServer) {
 		handleSimQuery,
 	)
 
+	s.AddTool(
+		mcp.NewTool("service_sim_query_batch",
+			mcp.WithDescription("批量查询多张 SIM 卡的详细信息(按 GICCID 列表一次性查询,含关联 SN、状态、平台、用量)。适合大批量场景:结果无条件落盘,仅返回 localPath+summary。GICCID 列表可用数组或文件传入。"),
+			mcp.WithArray("giccid", mcp.WithStringItems(),
+				mcp.Description("GICCID 列表(19位),字符串数组。与 giccid_file 二选一,大批量建议用 giccid_file。")),
+			mcp.WithString("giccid_file",
+				mcp.Description("GICCID 列表文件路径(须位于 /tmp/fwh-toolbox-agent/ 下)。.json 为JSON字符串数组,其它按行每行一个GICCID。提供时优先于 giccid,适合大批量避免占用上下文。")),
+		),
+		handleSimQueryBatch,
+	)
+
 	s.AddTool(
 		mcp.NewTool("service_sim_search",
 			mcp.WithDescription("搜索 SIM 卡列表,支持按状态和平台过滤"),
@@ -55,6 +66,57 @@ func handleSimQuery(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 	return helper.JSONResultOrFileAuto(data), nil
 }
 
+func handleSimQueryBatch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	src, errRes := helper.ResolveStringListOrFile(req, "giccid", "giccid_file")
+	if errRes != nil {
+		return errRes, nil
+	}
+	if len(src.Items) == 0 {
+		return helper.ParamInvalidError("giccid", "GICCID 列表为空"), nil
+	}
+
+	// 去重,避免重复 GICCID 造成冗余查询
+	seen := make(map[string]struct{}, len(src.Items))
+	uniq := make([]string, 0, len(src.Items))
+	for _, g := range src.Items {
+		if _, ok := seen[g]; ok {
+			continue
+		}
+		seen[g] = struct{}{}
+		uniq = append(uniq, g)
+	}
+
+	list, err := services.GetSimCardBatchByIDs(uniq)
+	if err != nil {
+		return helper.ServiceError("批量查询SIM卡信息", err), nil
+	}
+
+	// 统计未命中的 GICCID(仅给数量与少量样例,避免把大列表塞回上下文)
+	found := make(map[string]struct{}, len(list))
+	for _, info := range list {
+		found[info.GICCID] = struct{}{}
+	}
+	var notFoundSample []string
+	notFoundCount := 0
+	for _, g := range uniq {
+		if _, ok := found[g]; !ok {
+			notFoundCount++
+			if len(notFoundSample) < 20 {
+				notFoundSample = append(notFoundSample, g)
+			}
+		}
+	}
+
+	// 结果无条件落盘(数据类返回统一策略):附带命中/未命中统计,便于 Agent 判断。
+	return helper.JSONResultOrFileAuto(map[string]any{
+		"requested":      len(uniq),
+		"matched":        len(list),
+		"notFound":       notFoundCount,
+		"notFoundSample": notFoundSample,
+		"items":          list,
+	}), nil
+}
+
 func handleSimMonthlyData(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 	giccid, err := req.RequireString("giccid")
 	if err != nil {

+ 29 - 22
service/cmd/mcp/tools/service/site_monitor.go

@@ -2,7 +2,6 @@ package service
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 
 	"github.com/mark3labs/mcp-go/mcp"
@@ -42,8 +41,10 @@ func registerSiteMonitorTools(s *server.MCPServer) {
 			mcp.WithDescription("创建站点监控配置(包含监控名、描述、站点SN列表)[写操作]"),
 			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
 			mcp.WithString("description", mcp.Description("配置描述")),
-			mcp.WithArray("site_list", mcp.Required(), mcp.WithStringItems(),
-				mcp.Description("站点SN列表,字符串数组,如 [\"SN1\",\"SN2\"]")),
+			mcp.WithArray("site_list", mcp.WithStringItems(),
+				mcp.Description("站点SN列表,字符串数组,如 [\"SN1\",\"SN2\"]。与 site_list_file 二选一,大批量建议用 site_list_file。")),
+			mcp.WithString("site_list_file",
+				mcp.Description("站点SN列表文件路径(须位于 /tmp/fwh-toolbox-agent/ 下)。.json 为JSON字符串数组,其它按行每行一个SN。提供时优先于 site_list,适合大批量避免占用上下文。")),
 		),
 		handleSiteMonitorConfigCreate,
 	)
@@ -53,7 +54,9 @@ func registerSiteMonitorTools(s *server.MCPServer) {
 			mcp.WithDescription("更新指定名称的站点监控配置 [写操作]"),
 			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
 			mcp.WithString("description", mcp.Description("配置描述")),
-			mcp.WithString("site_list", mcp.Description("站点SN列表,JSON数组格式")),
+			mcp.WithString("site_list", mcp.Description("站点SN列表,JSON数组格式。与 site_list_file 二选一。")),
+			mcp.WithString("site_list_file",
+				mcp.Description("站点SN列表文件路径(须位于 /tmp/fwh-toolbox-agent/ 下)。.json 为JSON字符串数组,其它按行每行一个SN。提供时优先于 site_list。")),
 		),
 		handleSiteMonitorConfigUpdate,
 	)
@@ -69,7 +72,9 @@ func registerSiteMonitorTools(s *server.MCPServer) {
 	s.AddTool(
 		mcp.NewTool("service_site_monitor_base_info",
 			mcp.WithDescription("查询指定站点列表的基本信息(激活状态、国家、版本等)"),
-			mcp.WithString("site_list", mcp.Required(), mcp.Description("站点SN列表,JSON数组格式")),
+			mcp.WithString("site_list", mcp.Description("站点SN列表,JSON数组格式。与 site_list_file 二选一。")),
+			mcp.WithString("site_list_file",
+				mcp.Description("站点SN列表文件路径(须位于 /tmp/fwh-toolbox-agent/ 下)。.json 为JSON字符串数组,其它按行每行一个SN。提供时优先于 site_list,适合大批量避免占用上下文。")),
 			mcp.WithNumber("page", mcp.Description("页码,默认1")),
 			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
 		),
@@ -129,15 +134,18 @@ func handleSiteMonitorConfigCreate(ctx context.Context, req mcp.CallToolRequest)
 	}
 	description := req.GetString("description", "")
 
-	siteList, ok := helper.GetStringSlice(req, "site_list")
-	if !ok || len(siteList) == 0 {
-		return helper.ParamInvalidError("site_list", "需要非空的字符串数组,如 [\"SN1\",\"SN2\"]"), nil
+	src, errRes := helper.ResolveStringListOrFile(req, "site_list", "site_list_file")
+	if errRes != nil {
+		return errRes, nil
+	}
+	if len(src.Items) == 0 {
+		return helper.ParamInvalidError("site_list", "站点SN列表为空"), nil
 	}
 
 	config := domain.SitesMonitorConfig{
 		Name:        name,
 		Description: description,
-		SiteList:    siteList,
+		SiteList:    src.Items,
 	}
 
 	if err := services.InsertSiteMonitorConfig(config); err != nil {
@@ -166,12 +174,13 @@ func handleSiteMonitorConfigUpdate(ctx context.Context, req mcp.CallToolRequest)
 	if desc := req.GetString("description", ""); desc != "" {
 		config.Description = desc
 	}
-	if siteListStr := req.GetString("site_list", ""); siteListStr != "" {
-		var siteList []string
-		if err := json.Unmarshal([]byte(siteListStr), &siteList); err != nil {
-			return helper.ParamInvalidError("site_list", "需要JSON数组格式"), nil
+	// site_list / site_list_file 均为可选:仅当提供了其一时才更新站点列表。
+	if req.GetString("site_list", "") != "" || req.GetString("site_list_file", "") != "" {
+		src, errRes := helper.ResolveStringListOrFile(req, "site_list", "site_list_file")
+		if errRes != nil {
+			return errRes, nil
 		}
-		config.SiteList = siteList
+		config.SiteList = src.Items
 	}
 
 	if err := services.UpdateSiteMonitorConfig(name, config); err != nil {
@@ -193,21 +202,19 @@ func handleSiteMonitorConfigDelete(ctx context.Context, req mcp.CallToolRequest)
 }
 
 func handleSiteMonitorBaseInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	siteListStr, err := req.RequireString("site_list")
-	if err != nil {
-		return helper.ParamError("site_list"), nil
+	src, errRes := helper.ResolveStringListOrFile(req, "site_list", "site_list_file")
+	if errRes != nil {
+		return errRes, nil
 	}
-
-	var siteList []string
-	if err := json.Unmarshal([]byte(siteListStr), &siteList); err != nil {
-		return helper.ParamInvalidError("site_list", "需要JSON数组格式"), nil
+	if len(src.Items) == 0 {
+		return helper.ParamInvalidError("site_list", "站点SN列表为空"), nil
 	}
 
 	page := uint(req.GetFloat("page", 1))
 	size := uint(req.GetFloat("size", 10))
 
 	para := dto.SiteBaseInfoPara{
-		SiteList: siteList,
+		SiteList: src.Items,
 		PageSize: size,
 		PageNum:  page,
 	}

+ 24 - 0
service/repositories/mongo/simCardManageRepo.go

@@ -112,6 +112,30 @@ func GetSimCardInfo(id string) (*domain.SimCardInfo, error) {
 	return &info, nil
 }
 
+// GetSimCardInfoByIDs 按 GICCID 列表批量查询 SIM 卡信息(_id $in)。
+// 返回命中的记录(未命中的 GICCID 不会出现在结果中),按 allDataUsage 降序。
+func GetSimCardInfoByIDs(ids []string) ([]domain.SimCardInfo, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SimCardManage)
+
+	filter := bson.M{"_id": bson.M{"$in": ids}}
+	findOptions := options.Find().SetSort(bson.D{{Key: "allDataUsage", Value: -1}})
+
+	cursor, err := collection.Find(ctx, filter, findOptions)
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+
+	var infoList []domain.SimCardInfo
+	if err := cursor.All(ctx, &infoList); err != nil {
+		return nil, err
+	}
+	return infoList, nil
+}
+
 // 查询SIM卡月数据(单卡)
 func GetSimCardMonthlyData(month string, id string) (*domain.SimCardMonthlyData, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

+ 1 - 0
service/repositories/repositories.go

@@ -390,6 +390,7 @@ var UpdateSiteInfoTag = mongo.UpdateSiteInfoTag
 // SimCardManage
 var GetSimCardBatchInfo = mongo.GetSimCardBatchInfo
 var GetSimCardInfo = mongo.GetSimCardInfo
+var GetSimCardInfoByIDs = mongo.GetSimCardInfoByIDs
 var GetSimCardMonthlyData = mongo.GetSimCardMonthlyData
 var UpdateSimCardInfo = mongo.UpdateSimCardInfo
 var UpdateSimCardBatchInfo = mongo.UpdateSimCardBatchInfo

+ 15 - 0
service/scheduler/scheduler.go

@@ -6,6 +6,8 @@ import (
 
 	"github.com/robfig/cron/v3"
 	"go.uber.org/zap"
+
+	"fwh-toolbox-server/utils/agentfiles"
 )
 
 func Init() *cron.Cron {
@@ -65,6 +67,19 @@ func Init() *cron.Cron {
 		// })
 	})
 
+	// 每小时清理 MCP Agent 本地落盘目录(/tmp/fwh-toolbox-agent 下 results/、downloads/)
+	// 中 mtime 超过 24h 的过期文件。MCP 是本机子进程、与后端共享 /tmp,故可在此统一清理。
+	// 与 MCP 进程启动时的清理(A 方案)互为补充,覆盖长期不重启的常驻场景。
+	c.AddFunc("@every 1h", func() {
+		res := agentfiles.Cleanup(agentfiles.DefaultMaxAge)
+		zap.L().Debug("agent 落盘目录清理",
+			zap.Int("scanned", res.Scanned),
+			zap.Int("removed", res.Removed),
+			zap.Int("failed", res.Failed),
+			zap.Int64("freedBytes", res.Freed),
+		)
+	})
+
 	c.Start()
 	// 打印任务计划表
 	entries := c.Entries()

+ 40 - 0
service/services/simCardManageServices.go

@@ -57,6 +57,46 @@ func GetSimCardInfo(id string) (*domain.SimCardInfo, error) {
 	return repositories.GetSimCardInfo(id)
 }
 
+// GetSimCardBatchByIDs 按 GICCID 列表批量查询 SIM 卡信息(含关联 SN),
+// 返回富信息列表(与 GetSimCardBatchInfo 的 Data 元素结构一致)。
+// 未命中的 GICCID 不会出现在结果中。
+func GetSimCardBatchByIDs(ids []string) ([]dto.SimCardInfo, error) {
+	infoList, err := repositories.GetSimCardInfoByIDs(ids)
+	if err != nil {
+		zap.L().Error("repositories.GetSimCardInfoByIDs failed.", zap.String("err", err.Error()), zap.Int("idCount", len(ids)))
+		return nil, err
+	}
+
+	result := make([]dto.SimCardInfo, 0, len(infoList))
+	for _, info := range infoList {
+		snList := []string{}
+		siteList, err := repositories.GetSiteInfoManageListFromGiccid(info.GICCID)
+		if err != nil {
+			zap.L().Error("repositories.GetSiteInfoManageListFromGiccid failed.", zap.String("err", err.Error()), zap.String("GICCID", info.GICCID))
+		} else {
+			for _, site := range siteList {
+				snList = append(snList, site.Sn)
+			}
+		}
+
+		result = append(result, dto.SimCardInfo{
+			GICCID:         info.GICCID,
+			SN:             snList,
+			IMEI:           info.IMEI,
+			IMSI:           info.IMSI,
+			MSISDN:         info.MSISDN,
+			Status:         info.Status,
+			Platform:       info.Platform,
+			ExpirationTime: info.ExpirationTime,
+			Month:          info.Month,
+			AllDataUsage:   info.AllDataUsage,
+			UpdateTime:     info.UpdateTime,
+		})
+	}
+
+	return result, nil
+}
+
 // 查询SIM卡月数据(单卡)
 func GetSimCardMonthlyData(month string, id string) (*domain.SimCardMonthlyData, error) {
 	return repositories.GetSimCardMonthlyData(month, id)

+ 102 - 0
service/utils/agentfiles/cleanup.go

@@ -0,0 +1,102 @@
+// Package agentfiles 提供 MCP Agent 本地落盘目录(/tmp/fwh-toolbox-agent)的清理能力。
+//
+// 背景:MCP 工具的查询/导出结果会无条件落盘到 results/ 目录,storage_file_read_to_local
+// 下载的文件会落到 downloads/ 目录。这些都是会话内的中间产物,需要定期清理避免无限堆积。
+//
+// 本包被设计为独立、无外部依赖(仅标准库 + zap 日志),以便:
+//   - MCP 进程(cmd/mcp)在启动时调用一次(A 方案:进程启动清理);
+//   - 后端主服务的 cron scheduler 定时调用(B 方案:定时清理)。
+//
+// MCP 是后端通过 kiro-cli 拉起的本机子进程,与后端共享同一 /tmp 文件系统,
+// 因此后端 cron 能清理到 MCP 写入的这些目录。
+package agentfiles
+
+import (
+	"os"
+	"path/filepath"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+// AgentBaseDir 是 MCP Agent 本地落盘的根目录。
+// 注意:需与 cmd/mcp/tools/helper.AgentBaseDir 保持一致。
+const AgentBaseDir = "/tmp/fwh-toolbox-agent"
+
+// DefaultMaxAge 默认过期阈值:文件 mtime 超过此时长即视为可清理。
+// 取 24h 以覆盖单个会话的生命周期,避免清掉会话内稍后仍需读回的落盘文件。
+const DefaultMaxAge = 24 * time.Hour
+
+// cleanupSubDirs 需要清理的子目录(相对 AgentBaseDir)。
+// results:查询/导出结果落盘;downloads:read_to_local 下载中转。
+var cleanupSubDirs = []string{"results", "downloads"}
+
+// CleanupResult 描述一次清理的结果统计。
+type CleanupResult struct {
+	Scanned int   // 扫描到的文件总数
+	Removed int   // 成功删除的文件数
+	Failed  int   // 删除失败的文件数
+	Freed   int64 // 释放的字节数(仅统计成功删除的文件)
+}
+
+// Cleanup 清理 AgentBaseDir 下各子目录中 mtime 超过 maxAge 的文件。
+// maxAge <= 0 时使用 DefaultMaxAge。
+//
+// 清理为"尽力而为":单个文件删除失败只记 warn 并继续,不返回错误、不中断调用方。
+// 仅删除普通文件,跳过子目录(当前落盘不产生嵌套目录)。
+func Cleanup(maxAge time.Duration) CleanupResult {
+	if maxAge <= 0 {
+		maxAge = DefaultMaxAge
+	}
+	cutoff := time.Now().Add(-maxAge)
+
+	var res CleanupResult
+	for _, sub := range cleanupSubDirs {
+		dir := filepath.Join(AgentBaseDir, sub)
+		entries, err := os.ReadDir(dir)
+		if err != nil {
+			// 目录不存在是正常情况(尚未产生过任何落盘文件),忽略。
+			if !os.IsNotExist(err) {
+				zap.L().Warn("[agentfiles] 读取清理目录失败",
+					zap.String("dir", dir), zap.Error(err))
+			}
+			continue
+		}
+
+		for _, e := range entries {
+			if e.IsDir() {
+				continue
+			}
+			info, err := e.Info()
+			if err != nil {
+				zap.L().Warn("[agentfiles] 获取文件信息失败",
+					zap.String("dir", dir), zap.String("name", e.Name()), zap.Error(err))
+				continue
+			}
+			res.Scanned++
+			if info.ModTime().After(cutoff) {
+				continue // 未过期,保留
+			}
+			path := filepath.Join(dir, e.Name())
+			if err := os.Remove(path); err != nil {
+				res.Failed++
+				zap.L().Warn("[agentfiles] 删除过期文件失败",
+					zap.String("path", path), zap.Error(err))
+				continue
+			}
+			res.Removed++
+			res.Freed += info.Size()
+		}
+	}
+
+	if res.Scanned > 0 {
+		zap.L().Info("[agentfiles] 清理完成",
+			zap.Int("scanned", res.Scanned),
+			zap.Int("removed", res.Removed),
+			zap.Int("failed", res.Failed),
+			zap.Int64("freedBytes", res.Freed),
+			zap.Duration("maxAge", maxAge),
+		)
+	}
+	return res
+}