Parcourir la source

refactor:删除旧AI助手(Kiro)代码:顶栏AiChat、/ai/*接口、ACP客户端、旧MCP服务cmd/mcp

- 前端删除AiChat抽屉与aiChat接口,移除仅其使用的marked、dompurify依赖
- 后端删除aiChat控制器/服务/仓储、utils/acp、filestore、agentfiles及定时清理、KIRO配置
- 删除旧MCP服务cmd/mcp与deploy_mcp.sh、only_build_mcp.sh,cmd/deploy.sh只部署主服务

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang il y a 5 heures
Parent
commit
5404efa9b0
99 fichiers modifiés avec 8 ajouts et 18085 suppressions
  1. 0 53
      deploy/config.mcp.env.example
  2. 0 93
      deploy/kiro/agents/fwh-toolbox-agent.json
  3. 0 80
      deploy/kiro/bin/fwh-pyrun
  4. 0 27
      deploy/kiro/skills-sh.list
  5. BIN
      deploy/kiro/templates/company-template.pptx
  6. 0 506
      deploy/scripts/deploy_kiro_cli.sh
  7. 0 192
      deploy/scripts/install_kiro_cli.sh
  8. 0 293
      deploy/scripts/sync_kiro_config.sh
  9. 0 3
      package.json
  10. 0 31
      pnpm-lock.yaml
  11. 0 8
      service/.env
  12. 0 31
      service/.kiro/settings/mcp.json
  13. 1 1
      service/agentmcp/bootstrap.go
  14. 1 1
      service/agentmcp/server.go
  15. 1 1
      service/agentmcp/tools/misc.go
  16. 0 5
      service/cmd/deploy.sh
  17. 1 1
      service/cmd/deploy_agent_mcp.sh
  18. 0 18
      service/cmd/deploy_mcp.sh
  19. 0 587
      service/cmd/mcp/DESIGN.md
  20. 0 170
      service/cmd/mcp/main.go
  21. 0 347
      service/cmd/mcp/test/ANALYSIS.md
  22. 0 269
      service/cmd/mcp/test/TEST_CASES.md
  23. 0 376
      service/cmd/mcp/test/TEST_PLAN.md
  24. 0 238
      service/cmd/mcp/test/pii_report.md
  25. 0 259
      service/cmd/mcp/test/report.md
  26. 0 1022
      service/cmd/mcp/test/runner/cases.go
  27. 0 331
      service/cmd/mcp/test/runner/main.go
  28. 0 79
      service/cmd/mcp/tools/data/emspara.go
  29. 0 651
      service/cmd/mcp/tools/data/fwh_api.go
  30. 0 528
      service/cmd/mcp/tools/data/mongo.go
  31. 0 71
      service/cmd/mcp/tools/data/pgsql.go
  32. 0 207
      service/cmd/mcp/tools/data/plane_api.go
  33. 0 237
      service/cmd/mcp/tools/data/plane_api_handlers.go
  34. 0 216
      service/cmd/mcp/tools/data/plane_v2_api.go
  35. 0 283
      service/cmd/mcp/tools/data/plane_v2_api_handlers.go
  36. 0 97
      service/cmd/mcp/tools/data/plane_v2_api_write.go
  37. 0 461
      service/cmd/mcp/tools/data/redis.go
  38. 0 16
      service/cmd/mcp/tools/data/register.go
  39. 0 189
      service/cmd/mcp/tools/data/sim_api.go
  40. 0 156
      service/cmd/mcp/tools/helper/helper.go
  41. 0 123
      service/cmd/mcp/tools/helper/list_source.go
  42. 0 141
      service/cmd/mcp/tools/helper/pii_rules.go
  43. 0 241
      service/cmd/mcp/tools/helper/pii_stream.go
  44. 0 165
      service/cmd/mcp/tools/helper/pii_struct.go
  45. 0 334
      service/cmd/mcp/tools/helper/pii_test.go
  46. 0 384
      service/cmd/mcp/tools/helper/spill.go
  47. 0 165
      service/cmd/mcp/tools/middleware/logging.go
  48. 0 140
      service/cmd/mcp/tools/middleware/logging_test.go
  49. 0 24
      service/cmd/mcp/tools/register.go
  50. 0 256
      service/cmd/mcp/tools/service/auto_update.go
  51. 0 33
      service/cmd/mcp/tools/service/background_task.go
  52. 0 258
      service/cmd/mcp/tools/service/batch_download.go
  53. 0 79
      service/cmd/mcp/tools/service/dashboard.go
  54. 0 95
      service/cmd/mcp/tools/service/data_download.go
  55. 0 75
      service/cmd/mcp/tools/service/feedback.go
  56. 0 61
      service/cmd/mcp/tools/service/monitor.go
  57. 0 20
      service/cmd/mcp/tools/service/register.go
  58. 0 160
      service/cmd/mcp/tools/service/sim.go
  59. 0 99
      service/cmd/mcp/tools/service/site.go
  60. 0 203
      service/cmd/mcp/tools/service/site_analysis.go
  61. 0 91
      service/cmd/mcp/tools/service/site_info.go
  62. 0 253
      service/cmd/mcp/tools/service/site_monitor.go
  63. 0 169
      service/cmd/mcp/tools/service/tou_template.go
  64. 0 231
      service/cmd/mcp/tools/storage/file.go
  65. 0 172
      service/cmd/mcp/tools/storage/minio.go
  66. 0 9
      service/cmd/mcp/tools/storage/register.go
  67. 0 83
      service/cmd/mcp/tools/util/format.go
  68. 0 8
      service/cmd/mcp/tools/util/register.go
  69. 0 1
      service/cmd/only_build.sh
  70. 0 8
      service/cmd/only_build_mcp.sh
  71. 0 68
      service/cmd/test_acp/main.go
  72. 1 22
      service/config/config.go
  73. 0 483
      service/controllers/aiChatControllers.go
  74. 0 14
      service/domain/aiChatDomain.go
  75. 0 2
      service/go.sum
  76. 0 3
      service/main.go
  77. 0 1
      service/repositories/mongo/agentSessionRepo.go
  78. 0 138
      service/repositories/mongo/aiChatRepo.go
  79. 0 114
      service/repositories/redis/aiChatRepo.go
  80. 0 23
      service/repositories/repositories.go
  81. 1 1
      service/routes/agentRoutes.go
  82. 0 48
      service/routes/aiChatRoutes.go
  83. 0 1
      service/routes/routes.go
  84. 0 14
      service/scheduler/scheduler.go
  85. 1 1
      service/services/agentServices.go
  86. 0 650
      service/services/aiChatServices.go
  87. 0 1569
      service/utils/acp/acp.go
  88. 0 192
      service/utils/acp/toolLabel.go
  89. 0 102
      service/utils/agentfiles/cleanup.go
  90. 0 270
      service/utils/filestore/filestore.go
  91. 1 1
      service/utils/pii/stream.go
  92. 0 538
      src/layout/header/Actions/AiChat/ChatPanel.vue
  93. 0 391
      src/layout/header/Actions/AiChat/MessageItem.vue
  94. 0 64
      src/layout/header/Actions/AiChat/chatDB.ts
  95. 0 38
      src/layout/header/Actions/AiChat/index.vue
  96. 0 102
      src/layout/header/Actions/AiChat/interface.ts
  97. 0 916
      src/layout/header/Actions/AiChat/useAiChat.ts
  98. 0 2
      src/layout/header/Actions/index.vue
  99. 0 102
      src/service/aiChat/index.ts

+ 0 - 53
deploy/config.mcp.env.example

@@ -1,53 +0,0 @@
-# =============================================================================
-# FranklinWH Toolbox MCP Server 配置示例
-# 使用方式:复制为 config.mcp.env 后按需修改,切勿把真实密钥提交到仓库。
-# 读取路径:MCP 进程仅读取工作目录下的 ./config.mcp.env(与后端 config.env 独立)。
-#           环境变量可覆盖文件内配置。
-#
-# 说明:MCP Server 仅使用其运行所需的配置子集(数据库/缓存/MinIO、FWH 生产 API、
-#       Plane Web)。后端主服务专属的配置(MQTT、Plane API、Kiro、测试环境等)
-#       不在此文件内。
-# =============================================================================
-
-# ---------- APP(数据库 / 缓存 / 对象存储)----------
-# 运行环境:pro=生产 / dev=开发
-APP_ENV="dev"
-
-# PostgreSQL 连接串
-POSTGRES_DSN="host=localhost user=fwh_toolbox password=CHANGE_ME dbname=fwh_toolbox_db port=5432 sslmode=disable"
-# MongoDB 连接串——生产用 FwhToolboxDb,开发用 FwhToolboxDbDev
-MONGO_DSN="mongodb://127.0.0.1:27017/FwhToolboxDb"
-# Redis 地址(缓存)
-REDIS_ADDR="localhost:6379"
-# 远端只读 MySQL(FWH 生产库),用到站点信息查询时必填
-MYSQL_DSN="USER:PASSWORD@tcp(HOST:PORT)/home_energy_storage_server?charset=utf8mb4&parseTime=True&loc=Local"
-
-# ---------- MinIO(本地对象存储)----------
-# MCP 使用 AI 专用账号 fwh-toolbox-ai(由 provision_minio.sh 创建):
-#   对 fwh-toolbox-ai* 桶可读写、对其余桶只读;MCP 工具本身仅做只读访问。
-#   须与 provision_minio.sh 的 MINIO_AI_ACCESS_KEY/MINIO_AI_SECRET_KEY 一致。
-MINIO_ENDPOINT="localhost:9198"
-MINIO_ACCESS_KEY="fwh-toolbox-ai"
-MINIO_SECRET_KEY="fwh-toolbox-ai"
-MINIO_BUCKET_NAME="fwh-toolbox"
-
-# ---------- FWH API(生产)----------
-FWH_ENERGY="energy.franklinwh.com"
-FWH_OSS="oss.franklinwh.com"
-FWH_USER="FMU006"
-# 后端会自动做 MD5,此处填明文
-FWH_PASSWORD="CHANGE_ME"
-# 若 MCP 使用的 FWH 账号启用 TOTP,填写认证器提供的 Base32 密钥。
-FWH_TOTP_SECRET=""
-# 登录接口接收验证码的 JSON 字段名;启用 TOTP 时必填。
-FWH_TOTP_FIELD=""
-
-# ---------- Plane Web(反馈协作)----------
-PLANE_WEB_HOST="http://localhost:8600"
-PLANE_WEB_EMAIL="CHANGE_ME"
-PLANE_WEB_PASSWORD="CHANGE_ME"
-PLANE_WEB_WORKSPACE_SLUG="fwh"
-PLANE_WEB_PROJECT_ID="CHANGE_ME"
-PLANE_WEB_LABEL_BUG="CHANGE_ME"
-PLANE_WEB_LABEL_FEATURE="CHANGE_ME"
-PLANE_WEB_LABEL_FEEDBACK="CHANGE_ME"

+ 0 - 93
deploy/kiro/agents/fwh-toolbox-agent.json

@@ -1,93 +0,0 @@
-{
-  "name": "fwh-toolbox-agent",
-  "description": "fwh-toolbox平台AI助手",
-  "prompt": "你是一个本地平台专业分析工具。使用本地知识库和MCP server读取数据,实现数据分析。不可创建/修改文件,只能通过MCP将文件生成到Minio服务器。",
-  "tools": [
-    "read",
-    "write",
-    "grep",
-    "glob",
-    "code",
-    "web_fetch",
-    "web_search",
-    "knowledge",
-    "subagent",
-    "todo_list",
-    "introspect",
-    "shell",
-    "@fwh-tools",
-    "@drawio",
-    "@officecli"
-  ],
-  "allowedTools": [
-    "read",
-    "grep",
-    "glob",
-    "code",
-    "web_fetch",
-    "web_search",
-    "knowledge",
-    "subagent",
-    "todo_list",
-    "introspect",
-    "shell",
-    "@drawio",
-    "@officecli",
-    "@fwh-tools"
-  ],
-  "toolsSettings": {
-    "read": {
-      "allowedPaths": ["./**", "/tmp/fwh-toolbox-agent/**", "__WIKI_DOCS__/**"],
-      "deniedPaths": []
-    },
-    "write": {
-      "allowedPaths": ["/tmp/fwh-toolbox-agent/**"],
-      "deniedPaths": []
-    },
-    "shell": {
-      "allowedCommands": [
-        "^npx -y @firecrawl/anydoc(\\s.*)?$",
-        "^base64(\\s.*)?$",
-        "^mkdir\\s+-p\\s+/tmp/fwh-toolbox-agent(/[^\\s;|&]*)?$",
-        "^cp\\s+__KIRO_HOME__/templates/[^\\s;|&]+\\s+/tmp/fwh-toolbox-agent/[^\\s;|&]+$",
-        "^__KIRO_HOME__/bin/fwh-pyrun\\s+/tmp/fwh-toolbox-agent/[^\\s;|&]+\\.py(\\s+[^\\s;|&]+)*$"
-      ],
-      "denyByDefault": true
-    }
-  },
-  "resources": [
-    {
-      "type": "knowledgeBase",
-      "source": "file://__WIKI_DOCS__",
-      "name": "FranklinWHProductDocs",
-      "description": "产品文档和指南,包括产品的软件、硬件、系统功能详细说明",
-      "include": ["**/*.md"],
-      "indexType": "best",
-      "autoUpdate": true
-    }
-  ],
-  "hooks": {},
-  "mcpServers": {
-    "fwh-tools": {
-      "command": "./fwh-mcp-server",
-      "args": [],
-      "disabled": false
-    },
-    "drawio": {
-      "command": "npx",
-      "args": ["@next-ai-drawio/mcp-server@latest"],
-      "disabled": false
-    },
-    "officecli": {
-      "command": "__OFFICECLI_BIN__",
-      "args": ["mcp"],
-      "disabled": false
-    },
-    "markitdown": {
-      "command": "uvx",
-      "args": ["markitdown-mcp@latest"],
-      "disabled": false
-    }
-  },
-  "welcomeMessage": "欢迎使用fwh-toolbox平台AI助手"
-}

+ 0 - 80
deploy/kiro/bin/fwh-pyrun

@@ -1,80 +0,0 @@
-#!/usr/bin/env bash
-# =============================================================================
-# fwh-pyrun —— 在 bwrap 沙箱中运行 Python3,供 fwh-toolbox-agent 安全调用
-# =============================================================================
-# 用法:
-#   fwh-pyrun /tmp/fwh-toolbox-agent/<脚本>.py [args...]
-#
-# 安全模型(三层):
-#   B(强制隔离,bwrap):只读挂载系统目录以支撑 Python 运行;仅沙箱目录
-#     /tmp/fwh-toolbox-agent 可写;--unshare-net 断网;--unshare-all 降权。
-#     脚本在笼子里“看不见” /home、项目目录、凭据等,越界访问会直接失败。
-#   A(准入校验):脚本必须位于沙箱目录内、以 .py 结尾、真实存在;解析真实
-#     路径以防 ../ 与软链逃逸。(agent.json 的 allowedCommands 已在更外层拦截)
-#   C(资源限额,ulimit + timeout):限制 CPU / 内存 / 单文件大小 / 进程数,
-#     外层 timeout 兜底防死循环。
-#
-# 退出码:
-#   2  入参非法 / 路径越界 / 脚本不存在
-#   124 运行超时(timeout 触发)
-#   其它 透传 Python 退出码
-# =============================================================================
-set -euo pipefail
-
-SANDBOX_ROOT="/tmp/fwh-toolbox-agent"
-PY="/usr/bin/python3"
-
-# --- 依赖自检(部署脚本应已确保安装;此处兜底给出清晰报错) ---
-command -v bwrap >/dev/null 2>&1 || { echo "fwh-pyrun: 未找到 bwrap(bubblewrap),无法建立沙箱" >&2; exit 2; }
-[ -x "$PY" ] || { echo "fwh-pyrun: 未找到 python3($PY)" >&2; exit 2; }
-
-# --- A:入参与路径校验 ---
-script="${1:-}"
-[ -n "$script" ] || { echo "fwh-pyrun: 缺少脚本路径。用法: fwh-pyrun $SANDBOX_ROOT/<脚本>.py [args...]" >&2; exit 2; }
-shift || true
-
-# 解析真实绝对路径,防止 ../ 或软链逃逸
-real_script="$(readlink -f -- "$script" 2>/dev/null)" || { echo "fwh-pyrun: 无法解析路径:$script" >&2; exit 2; }
-case "$real_script" in
-  "$SANDBOX_ROOT"/*.py) : ;;   # 合法:沙箱内 .py
-  *) echo "fwh-pyrun: 拒绝——脚本必须位于 $SANDBOX_ROOT 内且以 .py 结尾(实际:$real_script)" >&2; exit 2 ;;
-esac
-[ -f "$real_script" ] || { echo "fwh-pyrun: 脚本不存在:$real_script" >&2; exit 2; }
-
-# 确保沙箱目录存在(幂等)
-mkdir -p "$SANDBOX_ROOT"
-
-# --- C:资源限额 ---
-ulimit -t 30          # CPU 时间上限 30 秒
-ulimit -v 1048576     # 虚拟内存上限 1GB(KB)
-ulimit -f 262144      # 单文件写入上限 ~128MB(blocks,512B/block)
-ulimit -c 0           # 不产生 core dump
-# 注:不在此处收紧 ulimit -u(进程数按整个用户计,过紧会导致 timeout/bwrap
-# 自身 fork 失败);fork 炸弹由 bwrap 的 PID namespace 隔离 + CPU/内存限额抑制。
-
-# --- B:bwrap 强制隔离 + 外层 timeout 兜底(总运行 ≤ 60s) ---
-# 只读挂载 Python 运行所需系统目录;仅沙箱目录可写;无网络;降权。
-# 用数组构造参数,避免路径含特殊字符时被拆分。
-bwrap_args=(
-  --ro-bind /usr /usr
-  --ro-bind /etc /etc
-  --bind "$SANDBOX_ROOT" "$SANDBOX_ROOT"
-  --proc /proc
-  --dev /dev
-  --unshare-all
-  --unshare-net
-  --die-with-parent
-  --new-session
-  --setenv HOME "$SANDBOX_ROOT"
-  --setenv TMPDIR "$SANDBOX_ROOT"
-  --setenv PYTHONDONTWRITEBYTECODE 1
-  --setenv PYTHONPATH ""
-  --chdir "$SANDBOX_ROOT"
-)
-# /lib /lib64 /bin /sbin 在多数发行版是指向 /usr 的符号链接;存在时才只读挂载
-for d in /lib /lib64 /bin /sbin; do
-  [ -e "$d" ] && bwrap_args+=( --ro-bind "$d" "$d" )
-done
-
-exec timeout --kill-after=5s 60s \
-  bwrap "${bwrap_args[@]}" -- "$PY" -I -B "$real_script" "$@"

+ 0 - 27
deploy/kiro/skills-sh.list

@@ -1,27 +0,0 @@
-# =============================================================================
-# skills.sh 生态 skill 安装清单
-# =============================================================================
-# 本文件列出需要通过 skills CLI 命令安装的 skill(来自 skills.sh 生态)。
-# 部署脚本 deploy_kiro_cli.sh 会读取本文件逐行安装。
-#
-# 格式:每行一个 skill,写为  <owner>/<repo>@<skill>
-#   - 以 # 开头的行是注释,空行忽略
-#   - @<skill> 用于 skills CLI 的 --skill 参数(指定仓库内要装的 skill 名)
-#
-# 安装命令(部署脚本对每行执行的等价命令):
-#   npx --yes skills add <owner>/<repo> --skill <skill> --agent kiro-cli --yes
-#
-# 手动安装单个示例:
-#   npx skills add graphify-labs/graphify --skill graphify --agent kiro-cli
-#
-# 查询某 skill 的准确 owner/repo:
-#   npx skills find <关键字>
-#
-# 新增/调整 skills.sh skill:在下方增删行即可,无需改动部署脚本。
-# =============================================================================
-
-firecrawl/anydoc@convert-documents-to-markdown
-graphify-labs/graphify@graphify
-iofficeai/officecli@officecli
-larashero3-dotcom/lieflat-charts@lieflat-charts
-github/awesome-copilot@excalidraw-diagram-generator

BIN
deploy/kiro/templates/company-template.pptx


+ 0 - 506
deploy/scripts/deploy_kiro_cli.sh

@@ -1,506 +0,0 @@
-#!/usr/bin/env bash
-# =============================================================================
-# FranklinWH Toolbox - Kiro CLI 配置部署脚本
-# =============================================================================
-# 作用:把版本库内的 kiro-cli 配置(agents / steering / skills)部署到
-#   后端运行用户的 HOME 下的 .kiro 目录,供后端以 ACP 子进程方式拉起的 kiro-cli
-#   加载使用。
-#
-#   注意:settings/ 由 kiro-cli 自动生成,不纳入部署(不覆盖目标机自身的设置)。
-#
-#   配置来源(版本库内,权威配置):
-#     deploy/kiro/
-#       ├── agents/     Agent 定义(*.json)
-#       ├── steering/   Steering 规则(*.md)
-#       └── skills/     仅自定义 skill(skills.sh 生态的通过命令安装,不入此目录)
-#
-#   部署目标(与 install_kiro_cli.sh 的 KIRO_HOME 保持一致):
-#     ${KIRO_HOME}/.kiro           默认 /var/www/fwh-toolbox/server/.kiro
-#       ├── agents/                ← 拷贝同步
-#       ├── steering/              ← 拷贝同步
-#       ├── settings/              ← 不部署(kiro-cli 自动生成)
-#       └── skills/
-#             ├── <skills.sh 生态>  ← 用 `npx skills add` 命令安装(可控、可更新)
-#             └── <自定义 skill>    ← 直接拷贝(skills.sh 上不存在,无法命令安装)
-#
-# Skill 分类原则:
-#   * 来自 skills.sh 生态的 skill → 命令安装。清单外置在 ${KIRO_CONFIG_SRC}/skills-sh.list
-#     (每行 "<owner>/<repo>@<skill>",# 注释/空行忽略;文件内含安装命令备注)。
-#     新增/调整只需改该清单文件,无需改本脚本。
-#   * 非 skills.sh 的自定义 skill → 直接拷贝。**不再硬编码清单**,脚本扫描
-#     ${KIRO_CONFIG_SRC}/skills/ 下实际存在的目录逐个拷贝。新增只需把目录放进去。
-#
-# 前置条件:
-#   * 目标用户存在,且 kiro-cli 已安装(见 install_kiro_cli.sh)。
-#   * 命令安装 skill 需要 Node.js / npx(skills CLI 通过 npx 调用)。
-#   * rsync(可选,用于同步;缺失时回退到 cp)。
-#
-# 环境变量:
-#   KIRO_RUN_AS_USER   部署并运行 kiro-cli 的系统用户            (默认当前用户)
-#   KIRO_HOME          目标用户 HOME(决定 .kiro 位置)          (默认 /var/www/fwh-toolbox/server)
-#   KIRO_CONFIG_SRC    配置来源目录                              (默认 <repo>/deploy/kiro)
-#   KIRO_SKILLS_LIST   skills.sh 安装清单文件                    (默认 ${KIRO_CONFIG_SRC}/skills-sh.list)
-#   KIRO_AGENT         skills CLI 的目标 agent                   (默认 kiro-cli)
-#   KIRO_SKILLS_INSTALL  是否命令安装 skills.sh skill (y/n)       (默认 y)
-#   KIRO_WIKI_DOCS     产品文档知识库目录(替换 __WIKI_DOCS__)  (默认当前 Wiki docs 路径)
-#   KIRO_OFFICECLI_BIN officecli 可执行文件(替换 __OFFICECLI_BIN__)(默认当前 officecli 路径)
-#   KIRO_DRY_RUN       只打印将执行的动作,不落盘 (y/n)          (默认 n)
-# =============================================================================
-set -euo pipefail
-
-# ---------------------------------------------------------------------------
-# 路径与默认值
-# ---------------------------------------------------------------------------
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"          # deploy/
-PROJECT_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"        # 仓库根目录
-
-KIRO_RUN_AS_USER="${KIRO_RUN_AS_USER:-$(id -un)}"
-KIRO_HOME="${KIRO_HOME:-/var/www/fwh-toolbox/server}"
-KIRO_CONFIG_SRC="${KIRO_CONFIG_SRC:-${DEPLOY_DIR}/kiro}"
-KIRO_AGENT="${KIRO_AGENT:-kiro-cli}"
-KIRO_SKILLS_INSTALL="${KIRO_SKILLS_INSTALL:-y}"
-KIRO_DRY_RUN="${KIRO_DRY_RUN:-n}"
-
-# agent.json 占位符替换值(部署时可覆盖;默认取当前环境路径)
-#   __KIRO_HOME__     → ${KIRO_HOME}(fwh-pyrun、templates 的父路径)
-#   __WIKI_DOCS__     → ${KIRO_WIKI_DOCS}(产品文档知识库目录)
-#   __OFFICECLI_BIN__ → ${KIRO_OFFICECLI_BIN}(officecli 可执行文件)
-KIRO_WIKI_DOCS="${KIRO_WIKI_DOCS:-/home/jerry-remote/web/FranklinWH-Wiki/client/docs}"
-KIRO_OFFICECLI_BIN="${KIRO_OFFICECLI_BIN:-/home/jerry-remote/.local/bin/officecli}"
-
-# 文件系统操作是否使用 sudo(默认 y,保持原行为)。当目标目录当前用户可写时可设 n。
-KIRO_USE_SUDO="${KIRO_USE_SUDO:-y}"
-if [[ "${KIRO_USE_SUDO}" == "y" ]]; then SUDO="sudo "; else SUDO=""; fi
-
-KIRO_DEST="${KIRO_HOME%/}/.kiro"
-
-# ---------------------------------------------------------------------------
-# Skill 来源
-# ---------------------------------------------------------------------------
-# skills.sh 生态:从清单文件读取(每行 "<owner>/<repo>@<skill>",# 注释/空行忽略)
-KIRO_SKILLS_LIST="${KIRO_SKILLS_LIST:-${KIRO_CONFIG_SRC}/skills-sh.list}"
-# 自定义 skill:不再硬编码,直接扫描 ${KIRO_CONFIG_SRC}/skills/ 下实际存在的目录进行拷贝
-
-# 拷贝同步的配置子目录(settings 由 kiro-cli 自动生成,不部署)
-CONFIG_SUBDIRS=(agents steering)
-
-# ---------------------------------------------------------------------------
-# 日志
-# ---------------------------------------------------------------------------
-c_reset='\033[0m'; c_blue='\033[1;34m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'
-info() { echo -e "${c_blue}[INFO]${c_reset} $*"; }
-ok()   { echo -e "${c_green}[ OK ]${c_reset} $*"; }
-warn() { echo -e "${c_yellow}[WARN]${c_reset} $*"; }
-err()  { echo -e "${c_red}[FAIL]${c_reset} $*" >&2; }
-
-has() { command -v "$1" >/dev/null 2>&1; }
-is_dry() { [[ "${KIRO_DRY_RUN}" == "y" ]]; }
-
-# 解析目标用户 HOME(仅用于日志/一致性校验;实际目标由 KIRO_HOME 决定)
-resolve_user_home() {
-    getent passwd "${KIRO_RUN_AS_USER}" | cut -d: -f6
-}
-
-# 以目标用户身份执行命令(设置 HOME=KIRO_HOME,保证 .kiro 落到目标位置)
-# dry-run 时只打印。
-as_target_user() {
-    local cmd="$*"
-    if is_dry; then
-        echo -e "${c_yellow}[DRY]${c_reset} (as ${KIRO_RUN_AS_USER}, HOME=${KIRO_HOME}) ${cmd}"
-        return 0
-    fi
-    if [[ "${KIRO_RUN_AS_USER}" == "$(id -un)" ]]; then
-        HOME="${KIRO_HOME}" bash -lc "${cmd}"
-    else
-        sudo -u "${KIRO_RUN_AS_USER}" -H env HOME="${KIRO_HOME}" bash -lc "${cmd}"
-    fi
-}
-
-# 以目标用户身份 / 或 sudo 执行文件系统操作(run_fs "<shell>")
-run_fs() {
-    local cmd="$*"
-    if is_dry; then
-        echo -e "${c_yellow}[DRY]${c_reset} ${cmd}"
-        return 0
-    fi
-    bash -lc "${cmd}"
-}
-
-# 检查并(尽力)安装沙箱运行 Python 所需依赖:bwrap、python3。
-# 自动安装需要 sudo 与受支持的包管理器(apt/dnf/yum/zypper/pacman)。
-# 失败时给出告警但不中断部署(与 npx 缺失的处理一致)。
-ensure_sandbox_deps() {
-    local missing=()
-    has bwrap   || missing+=("bwrap")
-    has python3 || missing+=("python3")
-    if [[ ${#missing[@]} -eq 0 ]]; then
-        ok "沙箱依赖已就绪:bwrap、python3"
-        return 0
-    fi
-
-    warn "缺少沙箱依赖:${missing[*]},尝试自动安装 ..."
-
-    # 将逻辑包名映射到各发行版的实际包名
-    local pkgs_apt=() pkgs_rpm=() pkgs_pacman=()
-    local m
-    for m in "${missing[@]}"; do
-        case "$m" in
-            bwrap)   pkgs_apt+=("bubblewrap");  pkgs_rpm+=("bubblewrap"); pkgs_pacman+=("bubblewrap") ;;
-            python3) pkgs_apt+=("python3");      pkgs_rpm+=("python3");    pkgs_pacman+=("python") ;;
-        esac
-    done
-
-    if is_dry; then
-        echo -e "${c_yellow}[DRY]${c_reset} 将安装:${missing[*]}"
-        return 0
-    fi
-
-    if has apt-get; then
-        run_fs "sudo apt-get update -y && sudo apt-get install -y ${pkgs_apt[*]}" || true
-    elif has dnf; then
-        run_fs "sudo dnf install -y ${pkgs_rpm[*]}" || true
-    elif has yum; then
-        run_fs "sudo yum install -y ${pkgs_rpm[*]}" || true
-    elif has zypper; then
-        run_fs "sudo zypper install -y ${pkgs_rpm[*]}" || true
-    elif has pacman; then
-        run_fs "sudo pacman -Sy --noconfirm ${pkgs_pacman[*]}" || true
-    else
-        err "未识别的包管理器,无法自动安装:${missing[*]}"
-        err "请手动安装后重试(bwrap 对应包名通常为 bubblewrap)。"
-        return 1
-    fi
-
-    # 复检
-    local still=()
-    has bwrap   || still+=("bwrap")
-    has python3 || still+=("python3")
-    if [[ ${#still[@]} -gt 0 ]]; then
-        err "以下依赖安装后仍不可用:${still[*]}。Python3 沙箱能力将不可用。"
-        return 1
-    fi
-    ok "沙箱依赖安装完成:${missing[*]}"
-}
-
-# ---------------------------------------------------------------------------
-# 0. 前置检查
-# ---------------------------------------------------------------------------
-ensure_prereqs() {
-    if [[ ! -d "${KIRO_CONFIG_SRC}" ]]; then
-        err "配置来源不存在:${KIRO_CONFIG_SRC}"
-        return 1
-    fi
-    if ! getent passwd "${KIRO_RUN_AS_USER}" >/dev/null; then
-        err "系统用户不存在:${KIRO_RUN_AS_USER}"
-        return 1
-    fi
-    if [[ "${KIRO_SKILLS_INSTALL}" == "y" ]] && ! has npx; then
-        warn "未找到 npx(Node.js)。skills.sh skill 的命令安装将被跳过。"
-        warn "如需命令安装,请先安装 Node.js(提供 npx),或设 KIRO_SKILLS_INSTALL=n。"
-    fi
-    # Python3 沙箱依赖(bwrap + python3),供 fwh-toolbox-agent 的 fwh-pyrun 使用
-    ensure_sandbox_deps || warn "沙箱依赖未完全就绪,fwh-pyrun 可能无法运行。"
-}
-
-# ---------------------------------------------------------------------------
-# 1. 准备目标目录(属主为目标用户)
-# ---------------------------------------------------------------------------
-prepare_dest() {
-    info "准备目标目录:${KIRO_DEST}(属主 ${KIRO_RUN_AS_USER})"
-    run_fs "${SUDO}mkdir -p '${KIRO_DEST}/skills'"
-    run_fs "${SUDO}chown -R '${KIRO_RUN_AS_USER}' '${KIRO_HOME%/}/.kiro'"
-}
-
-# ---------------------------------------------------------------------------
-# 2. 同步配置子目录(agents / settings / steering)
-# ---------------------------------------------------------------------------
-sync_config_dirs() {
-    local sub src
-    for sub in "${CONFIG_SUBDIRS[@]}"; do
-        src="${KIRO_CONFIG_SRC}/${sub}"
-        if [[ ! -d "${src}" ]]; then
-            warn "跳过(源不存在):${src}"
-            continue
-        fi
-        info "同步配置:${sub}/ → ${KIRO_DEST}/${sub}/"
-        run_fs "${SUDO}mkdir -p '${KIRO_DEST}/${sub}'"
-        if has rsync; then
-            run_fs "${SUDO}rsync -a --delete '${src}/' '${KIRO_DEST}/${sub}/'"
-        else
-            run_fs "${SUDO}rm -rf '${KIRO_DEST}/${sub}'/* && ${SUDO}cp -rf '${src}/.' '${KIRO_DEST}/${sub}/'"
-        fi
-    done
-    run_fs "${SUDO}chown -R '${KIRO_RUN_AS_USER}' '${KIRO_HOME%/}/.kiro'"
-    ok "配置目录同步完成"
-}
-
-# ---------------------------------------------------------------------------
-# 3. 命令安装 skills.sh 生态的 skill(从清单文件读取)
-# ---------------------------------------------------------------------------
-# 解析清单文件:去掉 # 注释与空行,输出每行 "<owner>/<repo>@<skill>"
-read_skills_list() {
-    [[ -f "${KIRO_SKILLS_LIST}" ]] || return 0
-    sed -E 's/#.*$//; s/[[:space:]]+$//; s/^[[:space:]]+//' "${KIRO_SKILLS_LIST}" \
-        | grep -vE '^[[:space:]]*$'
-}
-
-install_skills_sh() {
-    if [[ "${KIRO_SKILLS_INSTALL}" != "y" ]]; then
-        info "跳过 skills.sh skill 命令安装(KIRO_SKILLS_INSTALL=${KIRO_SKILLS_INSTALL})"
-        return 0
-    fi
-    if [[ ! -f "${KIRO_SKILLS_LIST}" ]]; then
-        warn "未找到 skills.sh 清单文件:${KIRO_SKILLS_LIST},跳过命令安装"
-        return 0
-    fi
-    if ! has npx; then
-        warn "无 npx,跳过 skills.sh skill 命令安装"
-        return 0
-    fi
-    info "从清单安装 skills.sh skill:${KIRO_SKILLS_LIST}"
-    local entry repo skill ok_count=0 fail_count=0 failed_list=""
-    while IFS= read -r entry; do
-        [[ -z "${entry}" ]] && continue
-        if [[ "${entry}" != *"@"* ]]; then
-            warn "清单行格式不符(应为 owner/repo@skill),跳过:${entry}"
-            continue
-        fi
-        repo="${entry%@*}"
-        skill="${entry##*@}"
-        if install_one_skill "${repo}" "${skill}"; then
-            ok_count=$((ok_count+1))
-        else
-            fail_count=$((fail_count+1))
-            failed_list+=" ${entry}"
-        fi
-    done < <(read_skills_list)
-
-    if [[ ${fail_count} -eq 0 ]]; then
-        ok "skills.sh skill 安装完成(成功 ${ok_count} 个)"
-    else
-        warn "skills.sh skill 安装:成功 ${ok_count} 个,失败 ${fail_count} 个 →${failed_list}"
-        warn "可重跑本脚本重试,或手动执行:npx skills add <owner>/<repo> --skill <skill> --agent ${KIRO_AGENT} --yes"
-    fi
-}
-
-# 安装单个 skill 并校验是否真正落地;失败自动重试一次。
-# 参数:$1=<owner>/<repo>  $2=<skill>
-# 校验方式:安装后检查目标 skills 目录下是否出现该 skill 目录(含 SKILL.md)。
-install_one_skill() {
-    local repo="$1" skill="$2"
-    local dest_skill="${KIRO_DEST}/skills/${skill}"
-    local attempt
-    for attempt in 1 2; do
-        info "命令安装 skill:${repo} --skill ${skill} → agent ${KIRO_AGENT}(第 ${attempt} 次)"
-        if is_dry; then
-            as_target_user "cd '${KIRO_HOME}' && npx --yes skills add '${repo}' --skill '${skill}' --agent '${KIRO_AGENT}' --yes"
-            return 0
-        fi
-        # 逐条独立执行;stdin 接 /dev/null,避免 TUI 在管道/循环中争用输入导致后续条目静默失败
-        as_target_user "cd '${KIRO_HOME}' && npx --yes skills add '${repo}' --skill '${skill}' --agent '${KIRO_AGENT}' --yes < /dev/null" || true
-        # 安装后校验:目标目录出现且含 SKILL.md 视为成功
-        if [[ -f "${dest_skill}/SKILL.md" ]]; then
-            ok "skill 就绪:${skill}"
-            return 0
-        fi
-        warn "skill 未检测到落地:${skill}(第 ${attempt} 次),准备重试 ..."
-        sleep 1
-    done
-    err "skill 安装失败:${repo}@${skill}"
-    return 1
-}
-
-# ---------------------------------------------------------------------------
-# 4. 拷贝自定义 skill(扫描 ${KIRO_CONFIG_SRC}/skills/ 实际存在的目录)
-# ---------------------------------------------------------------------------
-copy_custom_skills() {
-    local skills_src="${KIRO_CONFIG_SRC}/skills"
-    if [[ ! -d "${skills_src}" ]]; then
-        info "无自定义 skill 目录(${skills_src}),跳过拷贝"
-        return 0
-    fi
-    local dir name dest count=0
-    for dir in "${skills_src}"/*/; do
-        [[ -d "${dir}" ]] || continue
-        name="$(basename "${dir}")"
-        dest="${KIRO_DEST}/skills/${name}"
-        info "拷贝自定义 skill:${name} → ${dest}"
-        if has rsync; then
-            run_fs "${SUDO}rsync -a --delete '${skills_src}/${name}/' '${dest}/'"
-        else
-            run_fs "${SUDO}rm -rf '${dest}' && ${SUDO}mkdir -p '${dest}' && ${SUDO}cp -rf '${skills_src}/${name}/.' '${dest}/'"
-        fi
-        count=$((count+1))
-    done
-    run_fs "${SUDO}chown -R '${KIRO_RUN_AS_USER}' '${KIRO_DEST}/skills'"
-    ok "自定义 skill 拷贝完成(共 ${count} 个)"
-}
-
-# ---------------------------------------------------------------------------
-# 渲染占位符(部署后按目标环境替换 agents 与 skills 中的路径)
-# ---------------------------------------------------------------------------
-# 版本库中的 agents/*.json 与 skills/**/*.md 用占位符表示环境相关路径,
-# 部署到目标后在此统一替换为实值:
-#   __KIRO_HOME__     → ${KIRO_HOME}       (fwh-pyrun、templates 的父路径)
-#   __WIKI_DOCS__     → ${KIRO_WIKI_DOCS}   (产品文档知识库目录)
-#   __OFFICECLI_BIN__ → ${KIRO_OFFICECLI_BIN}(officecli 可执行文件)
-# 注意:需在 agents / skills 全部拷贝到目标之后调用。
-render_placeholders() {
-    info "替换占位符:__KIRO_HOME__=${KIRO_HOME%/}  __WIKI_DOCS__=${KIRO_WIKI_DOCS}  __OFFICECLI_BIN__=${KIRO_OFFICECLI_BIN}"
-    # 用 # 作为 sed 分隔符(路径含 /);当前替换值不含 #
-    local sed_expr="s#__KIRO_HOME__#${KIRO_HOME%/}#g; s#__WIKI_DOCS__#${KIRO_WIKI_DOCS%/}#g; s#__OFFICECLI_BIN__#${KIRO_OFFICECLI_BIN}#g"
-
-    # agents/*.json
-    if [[ -d "${KIRO_DEST}/agents" ]]; then
-        run_fs "${SUDO}find '${KIRO_DEST}/agents' -maxdepth 1 -type f -name '*.json' -exec sed -i -E '${sed_expr}' {} +"
-    fi
-    # skills/**/*.md
-    if [[ -d "${KIRO_DEST}/skills" ]]; then
-        run_fs "${SUDO}find '${KIRO_DEST}/skills' -type f -name '*.md' -exec sed -i -E '${sed_expr}' {} +"
-    fi
-    run_fs "${SUDO}chown -R '${KIRO_RUN_AS_USER}' '${KIRO_HOME%/}/.kiro'"
-    ok "占位符替换完成(agents + skills)"
-}
-
-# ---------------------------------------------------------------------------
-# 4.2 部署 PPT 公司模板(随部署路径走)
-# ---------------------------------------------------------------------------
-# 源:${KIRO_CONFIG_SRC}/templates/*
-# 目标:${KIRO_HOME}/templates/(agent.json 中以 __KIRO_HOME__/templates 引用)
-install_templates() {
-    local src="${KIRO_CONFIG_SRC}/templates"
-    local dest="${KIRO_HOME%/}/templates"
-    if [[ ! -d "${src}" ]]; then
-        info "无模板目录(${src}),跳过模板部署"
-        return 0
-    fi
-    info "部署模板:${src}/ → ${dest}/"
-    run_fs "${SUDO}mkdir -p '${dest}'"
-    if has rsync; then
-        run_fs "${SUDO}rsync -a '${src}/' '${dest}/'"
-    else
-        run_fs "${SUDO}cp -rf '${src}/.' '${dest}/'"
-    fi
-    run_fs "${SUDO}chown -R '${KIRO_RUN_AS_USER}' '${dest}'"
-    ok "模板部署完成:${dest}"
-}
-
-# ---------------------------------------------------------------------------
-# 4.5 部署 fwh-pyrun 包装脚本(Python3 沙箱执行器,供 fwh-toolbox-agent 调用)
-# ---------------------------------------------------------------------------
-# 源:${KIRO_CONFIG_SRC}/bin/fwh-pyrun
-# 目标:${KIRO_HOME}/bin/fwh-pyrun(须与 agent.json 中 allowedCommands 的路径一致)
-# 同时确保沙箱目录 /tmp/fwh-toolbox-agent 存在(属主为运行用户)。
-install_pyrun() {
-    local src="${KIRO_CONFIG_SRC}/bin/fwh-pyrun"
-    local dest_dir="${KIRO_HOME%/}/bin"
-    local dest="${dest_dir}/fwh-pyrun"
-    if [[ ! -f "${src}" ]]; then
-        warn "未找到 fwh-pyrun 源(${src}),跳过部署(Python3 沙箱能力不可用)"
-        return 0
-    fi
-    info "部署 fwh-pyrun:${src} → ${dest}"
-    run_fs "${SUDO}mkdir -p '${dest_dir}'"
-    run_fs "${SUDO}cp -f '${src}' '${dest}'"
-    run_fs "${SUDO}chmod 0755 '${dest}'"
-    run_fs "${SUDO}chown '${KIRO_RUN_AS_USER}' '${dest}'"
-    # 沙箱可写目录(唯一持久写入点),属主为运行用户
-    run_fs "${SUDO}mkdir -p /tmp/fwh-toolbox-agent && ${SUDO}chown '${KIRO_RUN_AS_USER}' /tmp/fwh-toolbox-agent"
-    ok "fwh-pyrun 部署完成:${dest}"
-}
-
-# ---------------------------------------------------------------------------
-# 5. 校验
-# ---------------------------------------------------------------------------
-verify() {
-    info "校验部署结果 ..."
-    if is_dry; then
-        info "(dry-run) 跳过实际校验"
-        return 0
-    fi
-    local sub
-    for sub in "${CONFIG_SUBDIRS[@]}" skills; do
-        if [[ -d "${KIRO_DEST}/${sub}" ]]; then
-            ok "存在:${KIRO_DEST}/${sub}"
-        else
-            warn "缺失:${KIRO_DEST}/${sub}"
-        fi
-    done
-    local name dir
-    for dir in "${KIRO_CONFIG_SRC}/skills"/*/; do
-        [[ -d "${dir}" ]] || continue
-        name="$(basename "${dir}")"
-        [[ -f "${KIRO_DEST}/skills/${name}/SKILL.md" ]] \
-            && ok "自定义 skill 就绪:${name}" \
-            || warn "自定义 skill 缺 SKILL.md:${name}"
-    done
-    if has npx && [[ "${KIRO_SKILLS_INSTALL}" == "y" ]]; then
-        info "已安装 skill(项目级,HOME=${KIRO_HOME}):"
-        as_target_user "cd '${KIRO_HOME}' && npx --yes skills list 2>/dev/null" || true
-    fi
-
-    # fwh-pyrun 沙箱执行器校验
-    local pyrun="${KIRO_HOME%/}/bin/fwh-pyrun"
-    if [[ -x "${pyrun}" ]]; then
-        ok "fwh-pyrun 就绪:${pyrun}"
-    else
-        warn "fwh-pyrun 缺失或不可执行:${pyrun}(Python3 沙箱能力不可用)"
-    fi
-    has bwrap   && ok "bwrap 可用:$(command -v bwrap)"   || warn "bwrap 不可用(Python3 沙箱能力不可用)"
-    has python3 && ok "python3 可用:$(command -v python3)" || warn "python3 不可用(Python3 沙箱能力不可用)"
-
-    # 模板校验
-    if [[ -d "${KIRO_HOME%/}/templates" ]]; then
-        ok "模板目录就绪:${KIRO_HOME%/}/templates"
-    else
-        warn "模板目录缺失:${KIRO_HOME%/}/templates"
-    fi
-
-    # 占位符残留校验(替换后不应再出现 __XXX__)
-    if grep -rlE '__(KIRO_HOME|WIKI_DOCS|OFFICECLI_BIN)__' "${KIRO_DEST}/agents" "${KIRO_DEST}/skills" >/dev/null 2>&1; then
-        warn "agents/skills 中仍存在未替换的占位符,请检查 render_placeholders"
-    else
-        ok "占位符已全部替换(agents + skills)"
-    fi
-}
-
-# ---------------------------------------------------------------------------
-# main
-# ---------------------------------------------------------------------------
-main() {
-    info "== 部署 Kiro CLI 配置 =="
-    info "运行用户   = ${KIRO_RUN_AS_USER}(passwd HOME=$(resolve_user_home))"
-    info "配置来源   = ${KIRO_CONFIG_SRC}"
-    info "部署目标   = ${KIRO_DEST}"
-    info "目标 agent = ${KIRO_AGENT}"
-    info "命令安装   = ${KIRO_SKILLS_INSTALL}   dry-run = ${KIRO_DRY_RUN}"
-    if [[ "$(resolve_user_home)" != "${KIRO_HOME%/}" ]]; then
-        warn "KIRO_HOME(${KIRO_HOME}) 与该用户 passwd 家目录($(resolve_user_home)) 不一致;"
-        warn "请确认 config.env 的 KIRO_HOME 与后端拉起 kiro-cli 时的 HOME 一致。"
-    fi
-
-    ensure_prereqs
-    prepare_dest
-    sync_config_dirs
-    install_skills_sh
-    copy_custom_skills
-    install_templates
-    install_pyrun
-    render_placeholders
-    verify
-
-    echo
-    echo -e "${c_green}Kiro CLI 配置部署结束${c_reset}"
-    cat <<EOF
-  目标目录   : ${KIRO_DEST}
-  已同步     : ${CONFIG_SUBDIRS[*]}
-  命令安装源 : ${KIRO_SKILLS_LIST}
-  拷贝源     : ${KIRO_CONFIG_SRC}/skills/(扫描实际目录)
-  模板目录   : ${KIRO_HOME%/}/templates(公司 PPT 模板等)
-  沙箱执行器 : ${KIRO_HOME%/}/bin/fwh-pyrun(bwrap 隔离 Python3)
-  占位符替换 : __KIRO_HOME__=${KIRO_HOME%/}  __WIKI_DOCS__=${KIRO_WIKI_DOCS}  __OFFICECLI_BIN__=${KIRO_OFFICECLI_BIN}
-EOF
-}
-
-main "$@"

+ 0 - 192
deploy/scripts/install_kiro_cli.sh

@@ -1,192 +0,0 @@
-#!/usr/bin/env bash
-# =============================================================================
-# FranklinWH Toolbox - Kiro CLI 安装脚本
-# =============================================================================
-# 作用:为后端 AI 对话功能安装 kiro-cli。后端通过 ACP 协议以子进程方式拉起
-#   `kiro-cli`,并降权到 config.env 中的 KIRO_RUN_AS_USER(HOME=KIRO_HOME)。
-#   因此 kiro-cli 必须:
-#     1. 安装到可被该用户执行的位置(默认装到该用户的 ~/.local/bin);
-#     2. 该用户完成 `kiro-cli login`(AWS Builder ID,交互式)后方可使用。
-#
-# 安装方式(优先级):
-#   1. ZIP 安装包(离线/可控,默认):下载官方 kirocli-<arch>-linux[-musl].zip,
-#      解压后以目标用户身份运行 install.sh --no-confirm。
-#   2. 官方一键脚本(KIRO_INSTALL_METHOD=curl):curl -fsSL https://cli.kiro.dev/install | bash
-#
-# 前置条件:curl、unzip(缺失会尝试用 apt 安装)
-#
-# 环境变量:
-#   KIRO_RUN_AS_USER      安装并运行 kiro-cli 的系统用户 (默认当前用户)
-#   KIRO_HOME             该用户 HOME(决定 ~/.kiro 配置位置) (默认该用户家目录)
-#   KIRO_INSTALL_METHOD   zip | curl                          (默认 zip)
-#   KIRO_LIBC             gnu | musl(zip 方式的 C 库变体)     (默认 gnu)
-#   KIRO_ZIP_URL          自定义 ZIP 下载地址(覆盖自动推断)   (默认空)
-#   KIRO_DO_LOGIN         安装后是否立即交互式登录 (y/n)        (默认 n)
-# =============================================================================
-set -euo pipefail
-
-KIRO_RUN_AS_USER="${KIRO_RUN_AS_USER:-$(id -un)}"
-KIRO_HOME="${KIRO_HOME:-}"
-KIRO_INSTALL_METHOD="${KIRO_INSTALL_METHOD:-zip}"
-KIRO_LIBC="${KIRO_LIBC:-gnu}"
-KIRO_ZIP_URL="${KIRO_ZIP_URL:-}"
-KIRO_DO_LOGIN="${KIRO_DO_LOGIN:-n}"
-
-c_reset='\033[0m'; c_blue='\033[1;34m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'
-info() { echo -e "${c_blue}[INFO]${c_reset} $*"; }
-ok()   { echo -e "${c_green}[ OK ]${c_reset} $*"; }
-warn() { echo -e "${c_yellow}[WARN]${c_reset} $*"; }
-err()  { echo -e "${c_red}[FAIL]${c_reset} $*" >&2; }
-
-has() { command -v "$1" >/dev/null 2>&1; }
-
-# 解析目标用户 HOME(KIRO_HOME 优先,否则取该用户 passwd 家目录)
-resolve_home() {
-    if [[ -n "${KIRO_HOME}" ]]; then
-        echo "${KIRO_HOME}"; return 0
-    fi
-    getent passwd "${KIRO_RUN_AS_USER}" | cut -d: -f6
-}
-
-# 以目标用户身份执行命令(设置正确的 HOME/USER),当前用户即目标用户时直接执行
-as_target_user() {
-    local home; home="$(resolve_home)"
-    if [[ "${KIRO_RUN_AS_USER}" == "$(id -un)" ]]; then
-        HOME="${home}" bash -lc "$*"
-    else
-        sudo -u "${KIRO_RUN_AS_USER}" -H env HOME="${home}" bash -lc "$*"
-    fi
-}
-
-# ---------------------------------------------------------------------------
-# 0. 前置依赖
-# ---------------------------------------------------------------------------
-ensure_prereqs() {
-    local need=()
-    has curl  || need+=(curl)
-    has unzip || need+=(unzip)
-    if [[ ${#need[@]} -gt 0 ]]; then
-        info "安装依赖:${need[*]}"
-        if has apt-get; then
-            sudo apt-get update -y
-            sudo apt-get install -y "${need[@]}"
-        else
-            err "缺少 ${need[*]},且系统无 apt-get,请手动安装后重试"
-            return 1
-        fi
-    fi
-    if ! getent passwd "${KIRO_RUN_AS_USER}" >/dev/null; then
-        err "系统用户不存在:${KIRO_RUN_AS_USER}(请先创建,或修正 KIRO_RUN_AS_USER)"
-        return 1
-    fi
-}
-
-# 已装则跳过(检测目标用户可见的 kiro-cli)
-already_installed() {
-    if as_target_user 'command -v kiro-cli >/dev/null 2>&1'; then
-        local ver; ver="$(as_target_user 'kiro-cli version 2>/dev/null | head -n1' || true)"
-        ok "kiro-cli 已安装(用户 ${KIRO_RUN_AS_USER}):${ver:-unknown}"
-        return 0
-    fi
-    return 1
-}
-
-# 推断 ZIP 下载地址
-resolve_zip_url() {
-    if [[ -n "${KIRO_ZIP_URL}" ]]; then echo "${KIRO_ZIP_URL}"; return 0; fi
-    local arch; arch="$(uname -m)"
-    local platform
-    case "${arch}" in
-        x86_64|amd64)  platform="x86_64-linux" ;;
-        aarch64|arm64) platform="aarch64-linux" ;;
-        *) err "未知架构:${arch}(可用 KIRO_ZIP_URL 手动指定下载地址)"; return 1 ;;
-    esac
-    [[ "${KIRO_LIBC}" == "musl" ]] && platform="${platform}-musl"
-    echo "https://desktop-release.q.us-east-1.amazonaws.com/latest/kirocli-${platform}.zip"
-}
-
-# ---------------------------------------------------------------------------
-# 1a. ZIP 方式安装
-# ---------------------------------------------------------------------------
-install_via_zip() {
-    local url; url="$(resolve_zip_url)" || return 1
-    info "下载 Kiro CLI 安装包:${url}"
-    local tmp; tmp="$(mktemp -d)"
-    # 以目标用户身份下载/解压/安装,确保产物落到其家目录且属主正确
-    local home; home="$(resolve_home)"
-    sudo chown "${KIRO_RUN_AS_USER}" "${tmp}" 2>/dev/null || true
-    as_target_user "cd '${tmp}' && \
-        curl --proto '=https' --tlsv1.2 -sSf '${url}' -o kirocli.zip && \
-        unzip -q kirocli.zip && \
-        bash ./kirocli/install.sh --no-confirm"
-    rm -rf "${tmp}"
-    ok "Kiro CLI 安装完成(ZIP 方式)"
-}
-
-# ---------------------------------------------------------------------------
-# 1b. 官方一键脚本安装
-# ---------------------------------------------------------------------------
-install_via_curl() {
-    info "使用官方一键脚本安装 Kiro CLI ..."
-    as_target_user "curl -fsSL https://cli.kiro.dev/install | bash"
-    ok "Kiro CLI 安装完成(curl 方式)"
-}
-
-# ---------------------------------------------------------------------------
-# 2. 校验
-# ---------------------------------------------------------------------------
-verify() {
-    info "校验 kiro-cli 可用性(用户 ${KIRO_RUN_AS_USER})..."
-    if as_target_user 'command -v kiro-cli >/dev/null 2>&1'; then
-        local ver; ver="$(as_target_user 'kiro-cli version 2>/dev/null | head -n1' || true)"
-        ok "kiro-cli 可用:${ver:-installed}"
-    else
-        warn "在登录 shell 中未找到 kiro-cli。安装脚本可能已把它装到 ~/.local/bin,"
-        warn "需确保该目录在 PATH 中(安装脚本一般会写入 shell 配置,重登后生效)。"
-        warn "后端拉起 kiro-cli 时依赖 PATH/HOME,请确认 KIRO_HOME 与该用户一致。"
-    fi
-}
-
-# ---------------------------------------------------------------------------
-# 3. 登录(可选,交互式)
-# ---------------------------------------------------------------------------
-login() {
-    [[ "${KIRO_DO_LOGIN}" == "y" ]] || return 0
-    info "启动 kiro-cli 登录(AWS Builder ID,按提示在浏览器完成认证)..."
-    as_target_user "kiro-cli login" || warn "登录未完成,可稍后以 ${KIRO_RUN_AS_USER} 身份运行 'kiro-cli login'"
-}
-
-main() {
-    info "== 安装 Kiro CLI(AI 对话依赖)=="
-    info "运行用户=${KIRO_RUN_AS_USER}  HOME=$(resolve_home)  方式=${KIRO_INSTALL_METHOD}"
-    ensure_prereqs
-
-    if already_installed; then
-        verify; login
-        return 0
-    fi
-
-    case "${KIRO_INSTALL_METHOD}" in
-        zip)  install_via_zip ;;
-        curl) install_via_curl ;;
-        *) err "未知 KIRO_INSTALL_METHOD:${KIRO_INSTALL_METHOD}(可选 zip|curl)"; exit 1 ;;
-    esac
-
-    verify
-    login
-
-    cat <<EOF
-
-${c_green}Kiro CLI 安装步骤结束${c_reset}
-  运行用户    : ${KIRO_RUN_AS_USER}
-  HOME        : $(resolve_home)
-  配置目录    : $(resolve_home)/.kiro
-  重要后续    :
-    1) 若尚未登录,请以该用户身份运行: sudo -u ${KIRO_RUN_AS_USER} -H kiro-cli login
-       (AWS Builder ID,交互式浏览器认证;远程无浏览器时按提示复制 URL 完成)
-    2) 确认 config.env 的 KIRO_RUN_AS_USER=${KIRO_RUN_AS_USER}、KIRO_HOME 与此处一致
-    3) 确认 kiro-cli 在该用户 PATH 中(一般为 ~/.local/bin),后端方能成功拉起
-EOF
-}
-
-main "$@"

+ 0 - 293
deploy/scripts/sync_kiro_config.sh

@@ -1,293 +0,0 @@
-#!/usr/bin/env bash
-# =============================================================================
-# FranklinWH Toolbox - Kiro CLI 配置单向同步脚本(A → B)
-# =============================================================================
-# 作用:把「版本库配置源」单向推送到「线上部署目标」。
-#
-#   A 端(版本库源):deploy/kiro/
-#   B 端(部署目标):${KIRO_HOME}/.kiro   默认 /var/www/fwh-toolbox/server/.kiro
-#
-# 同步内容(一律 A → B,不回写):
-#   * steering/            覆盖同步(rsync --delete,B 端该目录与 A 端一致)
-#   * agents/              覆盖同步(rsync --delete)
-#   * 自定义 skill          扫描 A 端 skills/ 下的目录,逐个覆盖同步到 B 端
-#                          (每个 skill 目录内部 --delete,不影响 B 端其它 skill)
-#   * skills.sh 生态 skill  读取 skills-sh.list,在 B 端用 `npx skills add` 安装
-#                          (要装新的:先在 skills-sh.list 加一行,再跑本脚本)
-#
-# 不同步:settings/(kiro-cli 自动生成)。
-# 安全:顶层 skills/ 不做 --delete,命令安装的 skill 目录不会被误删。
-#      另:禁止把配置同步到用户级 ~/.kiro(B 端不能落在任何真实用户 HOME 下),
-#      命中即报错退出;确需解除请设 KIRO_ALLOW_USER_HOME=y。
-#
-# 用法:
-#   ./sync_kiro_config.sh                    # 执行同步
-#   KIRO_HOME=/path ./sync_kiro_config.sh    # 指定 B 端 HOME
-#   KIRO_DRY_RUN=y ./sync_kiro_config.sh     # 预演,只打印动作不落盘
-#   KIRO_SKILLS_INSTALL=n ./sync_kiro_config.sh  # 跳过 skills.sh 命令安装,只同步文件
-#
-# 环境变量:
-#   KIRO_HOME            B 端 HOME(决定 .kiro 位置)   (默认 /var/www/fwh-toolbox/server)
-#   KIRO_RUN_AS_USER     B 端属主 / 运行 kiro-cli 的用户 (默认当前用户)
-#   A_DIR                A 端目录                       (默认 <repo>/deploy/kiro)
-#   KIRO_SKILLS_LIST     skills.sh 安装清单文件         (默认 ${A_DIR}/skills-sh.list)
-#   KIRO_AGENT           skills CLI 目标 agent          (默认 kiro-cli)
-#   KIRO_SKILLS_INSTALL  是否命令安装 skills.sh skill    (默认 y)
-#   KIRO_DRY_RUN         只打印不落盘 (y/n)             (默认 n)
-#   KIRO_ALLOW_USER_HOME 解除「禁止同步到用户级 ~/.kiro」保护 (y/n) (默认 n)
-# =============================================================================
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"           # deploy/
-
-KIRO_HOME="${KIRO_HOME:-/var/www/fwh-toolbox/server}"
-KIRO_RUN_AS_USER="${KIRO_RUN_AS_USER:-$(id -un)}"
-A_DIR="${A_DIR:-${DEPLOY_DIR}/kiro}"
-B_DIR="${KIRO_HOME%/}/.kiro"
-KIRO_SKILLS_LIST="${KIRO_SKILLS_LIST:-${A_DIR}/skills-sh.list}"
-KIRO_AGENT="${KIRO_AGENT:-kiro-cli}"
-KIRO_SKILLS_INSTALL="${KIRO_SKILLS_INSTALL:-y}"
-KIRO_DRY_RUN="${KIRO_DRY_RUN:-n}"
-
-# 占位符替换目标值(须与 deploy_kiro_cli.sh 保持一致):
-#   __KIRO_HOME__     → ${KIRO_HOME}         (fwh-pyrun、templates 的父路径)
-#   __WIKI_DOCS__     → ${KIRO_WIKI_DOCS}     (产品文档知识库目录)
-#   __OFFICECLI_BIN__ → ${KIRO_OFFICECLI_BIN} (officecli 可执行文件)
-KIRO_WIKI_DOCS="${KIRO_WIKI_DOCS:-/home/jerry-remote/web/FranklinWH-Wiki/client/docs}"
-KIRO_OFFICECLI_BIN="${KIRO_OFFICECLI_BIN:-/home/jerry-remote/.local/bin/officecli}"
-
-# A→B 覆盖同步的配置子目录
-SYNC_SUBDIRS=(steering agents)
-
-c_reset='\033[0m'; c_blue='\033[1;34m'; c_green='\033[1;32m'; c_yellow='\033[1;33m'; c_red='\033[1;31m'
-info() { echo -e "${c_blue}[INFO]${c_reset} $*"; }
-ok()   { echo -e "${c_green}[ OK ]${c_reset} $*"; }
-warn() { echo -e "${c_yellow}[WARN]${c_reset} $*"; }
-err()  { echo -e "${c_red}[FAIL]${c_reset} $*" >&2; }
-
-has() { command -v "$1" >/dev/null 2>&1; }
-is_dry() { [[ "${KIRO_DRY_RUN}" == "y" ]]; }
-
-# 执行文件系统命令(dry-run 只打印)
-run_fs() {
-    if is_dry; then echo -e "${c_yellow}[DRY]${c_reset} $*"; return 0; fi
-    bash -lc "$*"
-}
-
-# 以 B 端运行用户身份执行(HOME=KIRO_HOME),用于 npx skills add
-as_target_user() {
-    local cmd="$*"
-    if is_dry; then
-        echo -e "${c_yellow}[DRY]${c_reset} (as ${KIRO_RUN_AS_USER}, HOME=${KIRO_HOME}) ${cmd}"
-        return 0
-    fi
-    if [[ "${KIRO_RUN_AS_USER}" == "$(id -un)" ]]; then
-        HOME="${KIRO_HOME}" bash -lc "${cmd}"
-    else
-        sudo -u "${KIRO_RUN_AS_USER}" -H env HOME="${KIRO_HOME}" bash -lc "${cmd}"
-    fi
-}
-
-# 扫描 A 端 skills/ 下实际存在的自定义 skill 目录名
-custom_skill_names() {
-    local d
-    [[ -d "${A_DIR}/skills" ]] || return 0
-    for d in "${A_DIR}/skills"/*/; do
-        [[ -d "${d}" ]] && basename "${d}"
-    done
-}
-
-# 读取 skills.sh 清单:去注释/空行,输出每行 "<owner>/<repo>@<skill>"
-read_skills_list() {
-    [[ -f "${KIRO_SKILLS_LIST}" ]] || return 0
-    sed -E 's/#.*$//; s/[[:space:]]+$//; s/^[[:space:]]+//' "${KIRO_SKILLS_LIST}" \
-        | grep -vE '^[[:space:]]*$'
-}
-
-# rsync A→B,一个子路径(带 --delete,作用范围仅该子目录)
-rsync_dir() {
-    local rel="$1"                       # 如 steering、skills/fwh-toolbox-rules
-    local src="${A_DIR}/${rel}"
-    local dst="${B_DIR}/${rel}"
-    [[ -d "${src}" ]] || { warn "跳过(源不存在):${src}"; return 0; }
-    run_fs "sudo mkdir -p '${dst}'"
-    if has rsync; then
-        run_fs "sudo rsync -a --delete '${src}/' '${dst}/'"
-    else
-        run_fs "sudo rm -rf '${dst}'/* && sudo cp -rf '${src}/.' '${dst}/'"
-    fi
-}
-
-# ---------------------------------------------------------------------------
-# 1. 同步 steering / agents(覆盖)
-# ---------------------------------------------------------------------------
-sync_config_dirs() {
-    local sub
-    for sub in "${SYNC_SUBDIRS[@]}"; do
-        info "同步 ${sub}/  (A→B 覆盖)"
-        rsync_dir "${sub}"
-    done
-}
-
-# ---------------------------------------------------------------------------
-# 2. 同步自定义 skill(逐个目录覆盖,扫描 A 端)
-# ---------------------------------------------------------------------------
-sync_custom_skills() {
-    local name count=0
-    while IFS= read -r name; do
-        [[ -z "${name}" ]] && continue
-        info "同步自定义 skill:skills/${name}/  (A→B 覆盖)"
-        rsync_dir "skills/${name}"
-        count=$((count+1))
-    done < <(custom_skill_names)
-    [[ ${count} -eq 0 ]] && info "A 端无自定义 skill" || ok "自定义 skill 同步完成(共 ${count} 个)"
-}
-
-# ---------------------------------------------------------------------------
-# 3. 命令安装 skills.sh 生态 skill(读清单,在 B 端安装)
-# ---------------------------------------------------------------------------
-install_skills_sh() {
-    if [[ "${KIRO_SKILLS_INSTALL}" != "y" ]]; then
-        info "跳过 skills.sh 命令安装(KIRO_SKILLS_INSTALL=${KIRO_SKILLS_INSTALL})"
-        return 0
-    fi
-    if [[ ! -f "${KIRO_SKILLS_LIST}" ]]; then
-        warn "未找到清单文件:${KIRO_SKILLS_LIST},跳过命令安装"
-        return 0
-    fi
-    if ! has npx; then
-        warn "无 npx,跳过 skills.sh 命令安装"
-        return 0
-    fi
-    info "从清单安装 skills.sh skill:${KIRO_SKILLS_LIST}"
-    local entry repo skill count=0
-    while IFS= read -r entry; do
-        [[ -z "${entry}" ]] && continue
-        if [[ "${entry}" != *"@"* ]]; then
-            warn "清单行格式不符(应为 owner/repo@skill),跳过:${entry}"
-            continue
-        fi
-        repo="${entry%@*}"
-        skill="${entry##*@}"
-        info "安装 skill:${repo} --skill ${skill} → agent ${KIRO_AGENT}"
-        as_target_user "cd '${KIRO_HOME}' && npx --yes skills add '${repo}' --skill '${skill}' --agent '${KIRO_AGENT}' --yes" \
-            || warn "skill 安装失败(可稍后重试):${entry}"
-        count=$((count+1))
-    done < <(read_skills_list)
-    ok "skills.sh skill 命令安装完成(共 ${count} 个)"
-}
-
-# ---------------------------------------------------------------------------
-# 4. 替换占位符(与 deploy_kiro_cli.sh 一致)
-#    A 端源文件存的是带占位符的模板,rsync 到 B 端后必须替换为真实路径,
-#    否则 agents/*.json 的命令白名单、officecli 路径、skills 文档会残留 __XXX__。
-#    作用范围:B 端 agents/*.json 与 skills/**/*.md。
-# ---------------------------------------------------------------------------
-replace_placeholders() {
-    info "替换占位符:__KIRO_HOME__=${KIRO_HOME%/}  __WIKI_DOCS__=${KIRO_WIKI_DOCS%/}  __OFFICECLI_BIN__=${KIRO_OFFICECLI_BIN}"
-    # 用 # 作为 sed 分隔符(路径含 /);替换值不含 #
-    local sed_expr="s#__KIRO_HOME__#${KIRO_HOME%/}#g; s#__WIKI_DOCS__#${KIRO_WIKI_DOCS%/}#g; s#__OFFICECLI_BIN__#${KIRO_OFFICECLI_BIN}#g"
-    if [[ -d "${B_DIR}/agents" ]]; then
-        run_fs "sudo find '${B_DIR}/agents' -maxdepth 1 -type f -name '*.json' -exec sed -i -E '${sed_expr}' {} +"
-    fi
-    if [[ -d "${B_DIR}/skills" ]]; then
-        run_fs "sudo find '${B_DIR}/skills' -type f -name '*.md' -exec sed -i -E '${sed_expr}' {} +"
-    fi
-    ok "占位符替换完成"
-}
-
-# ---------------------------------------------------------------------------
-# 安全守卫:禁止把配置同步到「用户级目录」
-#   本脚本是 A→B 的部署同步,B 端应为服务账户的部署目录(如
-#   /var/www/fwh-toolbox/server/.kiro),绝不能指向某个真实用户的 HOME
-#   下的 ~/.kiro,否则 rsync --delete 会覆盖/删除用户自己的 steering、
-#   agents、skills(曾因此丢失用户级配置)。
-#
-#   判定规则(任一命中即拒绝):
-#     * B_DIR 解析后正好等于 “<某 HOME>/.kiro”,或位于其下;
-#     * 该 HOME 来源:当前执行用户 $HOME、KIRO_RUN_AS_USER 的 HOME、
-#       以及常见用户家目录前缀 /home/*、/Users/*、/root。
-#   用物理路径比较,避免 ..、软链接、结尾斜杠绕过。可用
-#   KIRO_ALLOW_USER_HOME=y 显式解除(仅限明确知道后果时)。
-# ---------------------------------------------------------------------------
-abspath() {
-    # 输出物理绝对路径;路径可能尚不存在,故对父目录逐级回退解析
-    local p="$1"
-    if command -v realpath >/dev/null 2>&1; then
-        realpath -m -- "${p}" 2>/dev/null && return 0
-    fi
-    if command -v readlink >/dev/null 2>&1; then
-        readlink -f -- "${p}" 2>/dev/null && return 0
-    fi
-    # 兜底:去掉结尾斜杠,原样返回
-    echo "${p%/}"
-}
-
-guard_not_user_home() {
-    if [[ "${KIRO_ALLOW_USER_HOME:-n}" == "y" ]]; then
-        warn "已设置 KIRO_ALLOW_USER_HOME=y,跳过用户级目录保护(请自行确认后果)"
-        return 0
-    fi
-
-    local b_abs; b_abs="$(abspath "${B_DIR}")"
-
-    # 收集需要保护的 HOME 列表
-    local -a homes=()
-    [[ -n "${HOME:-}" ]] && homes+=("${HOME}")
-    local run_home
-    run_home="$(getent passwd "${KIRO_RUN_AS_USER}" 2>/dev/null | cut -d: -f6)"
-    [[ -n "${run_home}" ]] && homes+=("${run_home}")
-
-    local h h_abs
-    for h in "${homes[@]}"; do
-        [[ -z "${h}" || "${h}" == "/" ]] && continue
-        h_abs="$(abspath "${h}")"
-        # B_DIR 恰为 <HOME>/.kiro,或位于 <HOME>/ 之下
-        if [[ "${b_abs}" == "${h_abs}/.kiro" || "${b_abs}" == "${h_abs}/.kiro/"* ]]; then
-            err "拒绝同步:B 端目标 ${b_abs} 位于用户 HOME(${h_abs})下,属于用户级 .kiro 目录。"
-            err "本脚本只允许同步到服务部署目录。若确需覆盖用户级目录,请设 KIRO_ALLOW_USER_HOME=y。"
-            exit 1
-        fi
-    done
-
-    # 额外拦截:B_DIR 直接落在常见「用户家目录」前缀下(覆盖非当前用户的情况)
-    case "${b_abs}" in
-        /home/*/.kiro|/home/*/.kiro/*|/Users/*/.kiro|/Users/*/.kiro/*|/root/.kiro|/root/.kiro/*)
-            err "拒绝同步:B 端目标 ${b_abs} 看起来是用户级 .kiro 目录(位于 /home、/Users 或 /root 下)。"
-            err "本脚本只允许同步到服务部署目录。若确需覆盖用户级目录,请设 KIRO_ALLOW_USER_HOME=y。"
-            exit 1
-            ;;
-    esac
-
-    ok "用户级目录保护检查通过:B 端 ${b_abs} 非用户级 .kiro"
-}
-
-# ---------------------------------------------------------------------------
-# main
-# ---------------------------------------------------------------------------
-main() {
-    info "== Kiro 配置单向同步(A → B)=="
-    info "A 端(版本库)  :${A_DIR}"
-    info "B 端(部署目标):${B_DIR}"
-    info "自定义 skill    :$(custom_skill_names | tr '\n' ' ')"
-    info "skills.sh 清单  :${KIRO_SKILLS_LIST}(命令安装=${KIRO_SKILLS_INSTALL})"
-    info "dry-run=${KIRO_DRY_RUN}"
-
-    if [[ ! -d "${A_DIR}" ]]; then err "A 端不存在:${A_DIR}"; exit 1; fi
-
-    # 写操作前的安全守卫:禁止同步到用户级 ~/.kiro
-    guard_not_user_home
-
-    sync_config_dirs
-    sync_custom_skills
-    install_skills_sh
-    replace_placeholders
-
-    # 属主归还给 B 端运行用户
-    run_fs "sudo chown -R '${KIRO_RUN_AS_USER}' '${B_DIR}'"
-
-    ok "同步结束"
-}
-
-main "$@"

+ 0 - 3
package.json

@@ -48,12 +48,10 @@
     "casdoor-vue-sdk": "^1.6.0",
     "chroma-js": "^3.2.0",
     "dayjs": "^1.11.20",
-    "dompurify": "^3.4.12",
     "es-toolkit": "^1.45.1",
     "file-saver": "^2.0.5",
     "idb": "^8.0.3",
     "jwt-decode": "^4.0.0",
-    "marked": "^18.0.7",
     "monaco-editor": "^0.55.1",
     "monaco-editor-vue3": "^1.0.5",
     "naive-ui": "^2.44.1",
@@ -72,7 +70,6 @@
     "@playwright/test": "^1.58.2",
     "@tsconfig/node22": "^22.0.5",
     "@types/chroma-js": "^3.1.2",
-    "@types/dompurify": "^3.2.0",
     "@types/file-saver": "^2.0.7",
     "@types/jsdom": "^28.0.1",
     "@types/node": "^25.5.0",

+ 0 - 31
pnpm-lock.yaml

@@ -89,9 +89,6 @@ importers:
       dayjs:
         specifier: ^1.11.20
         version: 1.11.20
-      dompurify:
-        specifier: ^3.4.12
-        version: 3.4.12
       es-toolkit:
         specifier: ^1.45.1
         version: 1.45.1
@@ -104,9 +101,6 @@ importers:
       jwt-decode:
         specifier: ^4.0.0
         version: 4.0.0
-      marked:
-        specifier: ^18.0.7
-        version: 18.0.7
       monaco-editor:
         specifier: ^0.55.1
         version: 0.55.1
@@ -156,9 +150,6 @@ importers:
       '@types/chroma-js':
         specifier: ^3.1.2
         version: 3.1.2
-      '@types/dompurify':
-        specifier: ^3.2.0
-        version: 3.2.0
       '@types/file-saver':
         specifier: ^2.0.7
         version: 2.0.7
@@ -1791,10 +1782,6 @@ packages:
   '@types/deep-eql@4.0.2':
     resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
 
-  '@types/dompurify@3.2.0':
-    resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
-    deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
-
   '@types/esrecurse@4.3.1':
     resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
 
@@ -2941,9 +2928,6 @@ packages:
   dompurify@3.2.7:
     resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
 
-  dompurify@3.4.12:
-    resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
-
   domutils@2.8.0:
     resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
 
@@ -3821,11 +3805,6 @@ packages:
     engines: {node: '>= 18'}
     hasBin: true
 
-  marked@18.0.7:
-    resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==}
-    engines: {node: '>= 20'}
-    hasBin: true
-
   mdast-util-find-and-replace@3.0.2:
     resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
 
@@ -6613,10 +6592,6 @@ snapshots:
 
   '@types/deep-eql@4.0.2': {}
 
-  '@types/dompurify@3.2.0':
-    dependencies:
-      dompurify: 3.4.12
-
   '@types/esrecurse@4.3.1': {}
 
   '@types/estree@1.0.8': {}
@@ -7996,10 +7971,6 @@ snapshots:
     optionalDependencies:
       '@types/trusted-types': 2.0.7
 
-  dompurify@3.4.12:
-    optionalDependencies:
-      '@types/trusted-types': 2.0.7
-
   domutils@2.8.0:
     dependencies:
       dom-serializer: 1.4.1
@@ -8912,8 +8883,6 @@ snapshots:
 
   marked@14.0.0: {}
 
-  marked@18.0.7: {}
-
   mdast-util-find-and-replace@3.0.2:
     dependencies:
       '@types/mdast': 4.0.4

+ 0 - 8
service/.env

@@ -60,11 +60,3 @@ PLANE_WEB_PROJECT_ID="55efdd5f-214d-43b3-bed5-a6b71bd04673"
 PLANE_WEB_LABEL_BUG="415ede7f-a698-426d-b385-ab875a5725dd"
 PLANE_WEB_LABEL_FEATURE="11c51a92-6a76-40d0-aa6c-82f6b52b27eb"
 PLANE_WEB_LABEL_FEEDBACK="f85c7c59-bb11-4035-bce1-3bc417655b70"
-
-# KIRO CONFIG
-KIRO_RUN_AS_USER=jerry-remote
-KIRO_HOME=/home/jerry-remote
-# 调试日志宏开关:true 开启后,拉起的 kiro-cli 子进程会注入 KIRO_LOG_LEVEL=debug,
-# 输出 debug 级日志用于问题排查。默认关闭,排查完请改回 false。
-# 日志输出路径(Linux,kiro-cli acp 默认):/tmp/kiro-log/logs/kiro-chat.log
-KIRO_DEBUG_LOG=false

+ 0 - 31
service/.kiro/settings/mcp.json

@@ -1,31 +0,0 @@
-{
-  "mcpServers": {
-    "fwh-tools": {
-      "command": "./fwh-mcp-server",
-      "args": [],
-      "env": {
-        "MONGO_DSN": "${MONGO_DSN}",
-        "REDIS_ADDR": "${REDIS_ADDR}",
-        "PLANE_WEB_HOST": "${PLANE_WEB_HOST}",
-        "PLANE_WEB_EMAIL": "${PLANE_WEB_EMAIL}",
-        "PLANE_WEB_PASSWORD": "${PLANE_WEB_PASSWORD}",
-        "PLANE_WEB_WORKSPACE_SLUG": "${PLANE_WEB_WORKSPACE_SLUG}",
-        "PLANE_WEB_PROJECT_ID": "${PLANE_WEB_PROJECT_ID}"
-      }
-    },
-    "team-knowledge": {
-      "url": "http://knowledge.internal:4100/mcp"
-    },
-    "aws-docs": {
-      "command": "npx",
-      "args": ["-y", "@aws/aws-documentation-mcp-server"]
-    },
-    "web-search": {
-      "command": "npx",
-      "args": ["-y", "@modelcontextprotocol/server-bravesearch"],
-      "env": {
-        "BRAVE_API_KEY": "${BRAVE_API_KEY}"
-      }
-    }
-  }
-}

+ 1 - 1
service/agentmcp/bootstrap.go

@@ -24,7 +24,7 @@ import (
 	"fwh-toolbox-server/utils/minio"
 )
 
-// DefaultConfigFile fwh-agent-mcp 的独立配置文件(相对工作目录),与主服务的 config.env、旧 MCP 的 config.mcp.env 互不共用。
+// DefaultConfigFile fwh-agent-mcp 的独立配置文件(相对工作目录),与主服务的 config.env 互不共用。
 // 示例见 deploy/config.agent-mcp.env.example。
 const DefaultConfigFile = "./config.agent-mcp.env"
 

+ 1 - 1
service/agentmcp/server.go

@@ -1,7 +1,7 @@
 // Package agentmcp 是新 AI 助手使用的业务数据 MCP 服务(fwh-agent-mcp)。
 //
 // 无用户状态:工具不接收、不推断调用者;由 AgentServer 后台登记为远程 HTTP 服务,经 MCP Gateway 转接给 Agent。
-// 设计见 docs/ai-assistant-agentserver/agent-mcp.md。与旧助手的 cmd/mcp 相互独立。
+// 设计见 docs/ai-assistant-agentserver/agent-mcp.md。
 package agentmcp
 
 import (

+ 1 - 1
service/agentmcp/tools/misc.go

@@ -195,7 +195,7 @@ var SnParse = kit.Def[snParseArgs]{
 	},
 }
 
-// ParseSN 按 SN 规则拆分字段(规则来自旧 MCP 的 util_parse_sn)。
+// ParseSN 按 SN 规则拆分字段。
 func ParseSN(sn string) (map[string]any, error) {
 	sn = strings.TrimSpace(sn)
 	if len(sn) < 20 {

+ 0 - 5
service/cmd/deploy.sh

@@ -9,10 +9,5 @@ echo ""
 # 部署主程序
 bash "$SCRIPT_DIR/deploy_toolbox.sh"
 
-echo ""
-
-# 部署 MCP Server
-bash "$SCRIPT_DIR/deploy_mcp.sh"
-
 echo ""
 echo "===== 全部部署完成 ====="

+ 1 - 1
service/cmd/deploy_agent_mcp.sh

@@ -1,7 +1,7 @@
 #!/bin/bash
 set -e
 
-# 部署新 AI 助手的业务数据 MCP 服务 fwh-agent-mcp(与旧 MCP 的 deploy_mcp.sh 相互独立)
+# 部署新 AI 助手的业务数据 MCP 服务 fwh-agent-mcp
 # 用法:在 service/ 目录下执行 ./cmd/deploy_agent_mcp.sh
 
 # ===== 配置部分 =====

+ 0 - 18
service/cmd/deploy_mcp.sh

@@ -1,18 +0,0 @@
-#!/bin/bash
-set -e
-
-# ===== 配置部分 =====
-MCP_NAME="fwh-mcp-server"
-APP_DIR="/var/www/fwh-toolbox/server"
-MCP_PATH="$APP_DIR/$MCP_NAME"
-
-# ===== 编译程序 =====
-echo "🚀 Building $MCP_NAME..."
-GOOS=linux GOARCH=amd64 go build -o $MCP_NAME ./cmd/mcp/
-
-# ===== 复制程序到目标路径 =====
-echo "📦 Copying binary to $MCP_PATH..."
-sudo mv $MCP_NAME $MCP_PATH
-sudo chmod +x $MCP_PATH
-
-echo "✅ $MCP_NAME deployed to $MCP_PATH"

+ 0 - 587
service/cmd/mcp/DESIGN.md

@@ -1,587 +0,0 @@
-# FranklinWH MCP Server 设计文档
-
-## 1. 概述
-
-FranklinWH MCP Server (`fwh-tools`) 是基于 [Model Context Protocol](https://modelcontextprotocol.io) 实现的内部工具服务器,为 AI Agent(Kiro CLI)提供对 FranklinWH 内部工具平台数据和服务的结构化访问能力。
-
-| 项目 | 说明 |
-|------|------|
-| 服务名 | `fwh-tools` |
-| 版本 | `2.1.0` |
-| 语言 | Go |
-| SDK | `github.com/mark3labs/mcp-go` |
-| 传输协议 | stdio (JSON-RPC 2.0 over stdin/stdout) |
-| 构建产物 | 单二进制 `fwh-mcp-server` |
-| 调用方 | Kiro CLI(通过 ACP 协议启动子进程) |
-
----
-
-## 2. 架构设计
-
-### 2.1 整体架构
-
-```
-┌─────────────────────────────────────────────────────┐
-│                   Kiro CLI (AI Agent)                 │
-│              通过 ACP 协议管理 MCP Server              │
-└────────────────────────┬────────────────────────────┘
-                         │ stdio (JSON-RPC 2.0)
-                         ▼
-┌─────────────────────────────────────────────────────┐
-│              fwh-mcp-server (本进程)                  │
-│                                                      │
-│  ┌────────────────────────────────────────────────┐  │
-│  │              MCP Protocol Layer                 │  │
-│  │         (mcp-go SDK: 请求路由、序列化)          │  │
-│  └─────────────────────┬──────────────────────────┘  │
-│                        │                             │
-│  ┌─────────┬───────────┼───────────┬─────────────┐  │
-│  │service/ │  data/    │  storage/ │    util/     │  │
-│  │业务逻辑 │ 数据直查   │ 对象存储  │   辅助工具   │  │
-│  └────┬────┴─────┬─────┴─────┬─────┴──────┬──────┘  │
-│       │          │           │            │          │
-└───────┼──────────┼───────────┼────────────┼──────────┘
-        │          │           │            │
-        ▼          ▼           ▼            │ (纯函数)
-┌──────────┐ ┌──────────────────────┐ ┌──────────┐
-│ services/│ │  repositories/       │ │  MinIO   │
-│层(复用后 │ │  ├─ mongo (MongoDB)  │ │          │
-│端业务代 │ │  ├─ redis (Redis)    │ │          │
-│码)       │ │  ├─ pgsql (PgSQL)   │ │          │
-│          │ │  ├─ api/fwh_pro     │ │          │
-│          │ │  ├─ api/plane_v1+v2 │ │          │
-│          │ │  └─ api/sim (SH/AC) │ │          │
-└──────────┘ └──────────────────────┘ └──────────┘
-```
-
-### 2.2 与现有后端的关系
-
-MCP Server 是现有后端 Service 层的**另一个入口**,不是独立系统:
-
-| 对比项 | HTTP Server (`main.go`) | MCP Server (`cmd/mcp/main.go`) |
-|--------|------------------------|-------------------------------|
-| 入口 | Fiber HTTP 路由 | stdio JSON-RPC |
-| 调用方 | 前端浏览器 | Kiro CLI (AI Agent) |
-| 认证 | Casdoor JWT | 无需(本地 stdio 进程) |
-| 复用层 | controllers → services → repositories | **直接调用 services/repositories** |
-| 输出格式 | JSON HTTP Response | MCP Tool Result (text/JSON) |
-
-### 2.3 代码目录结构
-
-```
-service/cmd/mcp/
-├── main.go                      # 入口:初始化基础设施 + 创建 MCP Server + 注册工具
-├── tools/
-│   ├── register.go              # RegisterAll:唯一对外注册入口
-│   ├── helper/
-│   │   └── helper.go            # 统一响应工具函数
-│   ├── service/                 # 业务逻辑层工具
-│   │   ├── register.go          # RegisterServiceTools
-│   │   ├── site.go              # 站点相关工具
-│   │   ├── sim.go               # SIM 卡相关工具
-│   │   ├── monitor.go           # 监控统计工具
-│   │   └── feedback.go          # 反馈协作工具
-│   ├── data/                    # 数据直查层工具
-│   │   ├── register.go          # RegisterDataTools
-│   │   ├── mongo.go             # MongoDB 通用直查
-│   │   ├── redis.go             # Redis 缓存读取
-│   │   ├── pgsql.go             # PostgreSQL 直查
-│   │   ├── emspara.go           # EMS 参数查询
-│   │   ├── fwh_api.go           # FWH Pro API 读操作
-│   │   ├── plane_api.go         # Plane API 读操作(工具注册)
-│   │   ├── plane_api_handlers.go # Plane API 读操作(handler 实现)
-│   │   └── sim_api.go           # SmartHub/Acceleronix SIM 卡 API
-│   ├── storage/                 # 对象存储层工具
-│   │   ├── register.go          # RegisterStorageTools
-│   │   ├── file.go              # 用户上传文件读取
-│   │   └── minio.go             # MinIO 对象浏览/读取
-│   └── util/                    # 辅助工具层
-│       ├── register.go          # RegisterUtilTools
-│       └── format.go            # SN 解析等纯函数
-```
-
----
-
-## 3. 工具分层架构
-
-采用四层分类设计,按访问层级递进:
-
-```
-┌───────────────────────────────────────────────────┐
-│  Layer 1: service_*  — 业务逻辑层(优先使用)        │
-│  调用后端 Service 层函数,包含业务校验和数据聚合      │
-├───────────────────────────────────────────────────┤
-│  Layer 2: data_*     — 数据直查层(灵活查询)        │
-│  直接调用 repositories 层,跳过业务逻辑,仅限只读     │
-│  覆盖: DB(Mongo/Redis/PgSQL) + 外部API(FWH/Plane/SIM) │
-├───────────────────────────────────────────────────┤
-│  Layer 3: storage_*  — 对象存储层(文件操作)        │
-│  file_* 支持读写,minio_* 仅只读浏览和读取           │
-├───────────────────────────────────────────────────┤
-│  Layer 4: util_*     — 辅助工具层(纯函数)          │
-│  格式解析等不涉及数据持久化的工具                     │
-└───────────────────────────────────────────────────┘
-```
-
-**命名规范:** `{layer}_{domain}_{action}`
-
-示例:`service_site_query_info`、`data_fwh_query_site_alarm_list`、`data_plane_list_work_items`、`storage_minio_list_objects`、`util_parse_sn`
-
----
-
-## 4. 工具清单
-
-### 4.1 Service 层(业务逻辑)
-
-| 工具名 | 说明 | 参数 |
-|--------|------|------|
-| `service_site_query_info` | 查询站点设备详情 | `sn` (必填) |
-| `service_site_search` | 搜索站点列表 | `page`, `size`, `keyword` |
-| `service_site_power_flow` | 查询功率流数据 | `sn` (必填), `date` (必填, yyyy-MM-dd) |
-| `service_sim_query` | 查询 SIM 卡详情 | `giccid` (必填) |
-| `service_sim_search` | 搜索 SIM 卡列表 | `page`, `size`, `search`, `status`, `platform` |
-| `service_monitor_alarm_stats` | 查询告警统计 | `date` |
-| `service_monitor_shutdown_stats` | 查询停机统计 | `date` |
-| `service_feedback_create` | 创建 Plane Intake Issue | `title` (必填), `type` (必填), `priority`, `description`, `category` |
-| `service_feedback_history` | 查询反馈历史 | (无参数) |
-
-### 4.2 Data 层(数据直查)
-
-#### 4.2.1 通用数据库直查
-
-| 工具名 | 说明 | 参数 |
-|--------|------|------|
-| `data_mongo_find` | MongoDB 集合查询 | `collection` (必填), `filter`, `limit`, `skip`, `sort` |
-| `data_mongo_count` | MongoDB 文档计数 | `collection` (必填), `filter` |
-| `data_redis_get` | 读取 Redis key | `key` (必填), `type` |
-| `data_redis_exists` | 检查 key 存在性及 TTL | `key` (必填) |
-| `data_pgsql_query_snapshot_file_record` | 查询快照文件解析记录 | `file_name` (必填) |
-| `data_pgsql_query_snapshot_data` | 查询告警快照数据 | `sn` (必填), `date` (必填) |
-| `data_emspara_vid_info` | 查询 EMS VID 参数信息 | `id` (必填) |
-| `data_emspara_pid_info` | 查询 EMS PID 参数信息 | `id` (必填) |
-
-#### 4.2.2 FWH Pro API 直查
-
-| 工具名 | 说明 | 参数 |
-|--------|------|------|
-| `data_fwh_query_site_base_info_list` | 查询站点基本信息列表 | `sn`, `page`, `size` |
-| `data_fwh_query_site_list` | 查询站点列表 | `sn`, `page`, `size` |
-| `data_fwh_query_site_mode_info` | 查询站点工作模式 | `sn` (必填) |
-| `data_fwh_query_site_power_flow` | 查询站点功率流 | `sn` (必填), `date` (必填) |
-| `data_fwh_query_site_alarm_list` | 查询站点告警列表 | `sn` (必填) |
-| `data_fwh_query_event_alarm_list` | 查询事件/告警日志 | `sn` (必填), `start_time` (必填), `end_time` (必填), `type` |
-| `data_fwh_query_alarm_snapshot` | 查询告警快照 | `sn` (必填), `date` (必填) |
-| `data_fwh_query_alarm_statistics` | 查询实时告警统计 | `country`, `page` |
-| `data_fwh_query_alarm_statistics_from_code` | 按告警码查询站点 | `alarm_code` (必填), `country`, `page` |
-| `data_fwh_query_mqtt_log_list` | 查询 MQTT 日志 | `sn` (必填), `cmd_type` (必填), `start_time` (必填), `end_time` (必填) |
-| `data_fwh_query_shutdown_list` | 查询停机站点列表 | `date` (必填), `country` |
-| `data_fwh_query_ems_log_record` | 查询 EMS 日志记录 | `sn` (必填) |
-| `data_fwh_query_fault_record_list` | 查询故障录波列表 | `sn` (必填), `start_time` (必填), `end_time` (必填) |
-| `data_fwh_query_fault_record_callable_list` | 查询可召唤故障录波 | `sn` (必填), `dev_type` |
-| `data_fwh_query_fault_record_call_record_list` | 查询录波召唤记录 | (无参数) |
-| `data_fwh_query_fault_record_sub_list` | 查询录波子记录 | `fault_id` (必填) |
-| `data_fwh_query_system_update_log` | 查询系统升级记录 | `sn` (必填) |
-| `data_fwh_query_system_update_log_detail` | 查询升级记录详情 | `order_id` (必填) |
-| `data_fwh_query_ahub_update_log` | 查询 aHub 升级记录 | `sn` (必填) |
-| `data_fwh_query_tou_template_list` | 查询 TOU 模板列表 | `search`, `page`, `size` |
-| `data_fwh_query_tou_template_detail` | 查询 TOU 模板详情 | `id` (必填) |
-
-#### 4.2.3 Plane API 直查
-
-| 工具名 | 说明 | 参数 |
-|--------|------|------|
-| `data_plane_list_projects` | 列出项目 | (无参数) |
-| `data_plane_get_project` | 获取项目详情 | `project_id` (必填) |
-| `data_plane_get_project_summary` | 获取项目摘要 | `project_id` (必填), `fields` |
-| `data_plane_list_work_items` | 列出工作项 | `state`, `priority`, `assignee`, `label` |
-| `data_plane_get_work_item` | 获取工作项详情 | `work_item_id` (必填) |
-| `data_plane_get_work_item_by_identifier` | 通过标识符获取工作项 | `identifier` (必填), `sequence_id` (必填) |
-| `data_plane_search_work_items` | 搜索工作项 | `query` (必填), `limit` |
-| `data_plane_list_cycles` | 列出迭代 | (无参数) |
-| `data_plane_get_cycle` | 获取迭代详情 | `cycle_id` (必填) |
-| `data_plane_list_cycle_issues` | 列出迭代工作项 | `cycle_id` (必填) |
-| `data_plane_list_modules` | 列出模块 | (无参数) |
-| `data_plane_get_module` | 获取模块详情 | `module_id` (必填) |
-| `data_plane_list_module_issues` | 列出模块工作项 | `module_id` (必填) |
-| `data_plane_list_states` | 列出状态 | (无参数) |
-| `data_plane_list_labels` | 列出标签 | (无参数) |
-| `data_plane_list_workspace_members` | 列出工作空间成员 | (无参数) |
-| `data_plane_list_project_members` | 列出项目成员 | (无参数) |
-| `data_plane_list_work_item_comments` | 列出评论 | `work_item_id` (必填) |
-| `data_plane_list_work_item_activities` | 列出活动历史 | `work_item_id` (必填) |
-| `data_plane_list_work_item_links` | 列出链接 | `work_item_id` (必填) |
-| `data_plane_list_work_item_attachments` | 列出附件 | `work_item_id` (必填) |
-| `data_plane_list_work_item_relations` | 列出关系 | `work_item_id` (必填) |
-| `data_plane_get_intake_issue_list` | 查询 Intake 列表 | (无参数) |
-| `data_plane_get_intake_issue_detail` | 获取 Intake 详情 | `intake_id` (必填) |
-| `data_plane_list_pages` | 列出页面 (V2) | (无参数) |
-| `data_plane_get_page` | 获取页面详情 (V2) | `page_id` (必填) |
-| `data_plane_get_page_description` | 获取页面描述 (V2) | `page_id` (必填) |
-
-#### 4.2.4 SIM 卡 API 直查
-
-| 工具名 | 说明 | 参数 |
-|--------|------|------|
-| `data_sim_smarthub_batch_info` | SmartHub 批量查询 SIM 卡 | `page`, `size` |
-| `data_sim_smarthub_card_info` | SmartHub 单卡详情 | `giccid` (必填) |
-| `data_sim_acceleronix_card_list` | Acceleronix SIM 卡列表 | `page`, `size` |
-| `data_sim_acceleronix_card_info` | Acceleronix 单卡详情 | `iccid` (必填) |
-| `data_sim_acceleronix_batch_data_info` | Acceleronix 批量数据用量 | `iccid_list` (必填), `month` (必填) |
-| `data_sim_acceleronix_data_pool_list` | Acceleronix 流量池列表 | `page`, `size` |
-| `data_sim_acceleronix_data_pool_sim_info` | Acceleronix 流量池 SIM 详情 | `data_pool_id` (必填), `page`, `size` |
-
-### 4.3 Storage 层(对象存储)
-
-| 工具名 | 说明 | 参数 | 读写 |
-|--------|------|------|------|
-| `storage_file_read` | 读取用户上传文件 | `path` (必填) | 读 |
-| `storage_file_write` | 写入文件到存储 | `path` (必填), `content` (必填) | 写 |
-| `storage_file_list` | 列出用户上传文件 | (无参数) | 读 |
-| `storage_minio_list_objects` | 列出 MinIO 对象 | `bucket` (必填), `prefix`, `limit` | 读 |
-| `storage_minio_read_object` | 读取 MinIO 文本文件 | `bucket` (必填), `key` (必填) | 读 |
-
-### 4.4 Util 层(辅助工具)
-
-| 工具名 | 说明 | 参数 |
-|--------|------|------|
-| `util_parse_sn` | 解析设备 SN 序列号含义 | `sn` (必填) |
-
----
-
-## 5. 安全设计
-
-### 5.1 访问控制
-
-| 机制 | 说明 |
-|------|------|
-| 传输层 | stdio 本地进程通信,无网络暴露 |
-| 认证 | 无需(由 ACP 管理器控制进程生命周期) |
-| Collection 白名单 | MongoDB 查询限制在指定集合内 |
-| Bucket 白名单 | MinIO 访问限制在指定 bucket 内 |
-| 只读限制 | data_* 层所有操作均为只读(排除写、更新操作) |
-| 外部 API | data_fwh_*、data_plane_*、data_sim_* 仅暴露查询接口,不暴露写操作 |
-| 文件大小限制 | MinIO 读取限制 1MB,用户上传限制 2MB |
-| 查询结果限制 | MongoDB 单次最多返回 100 条 |
-| Redis 安全 | 不支持通配符 key,防止 KEYS * 类操作 |
-| SIM 卡批量限制 | Acceleronix 批量查询最多 50 个 ICCID |
-
-### 5.2 白名单配置
-
-**MongoDB Collections:**
-```go
-var mongoAllowedCollections = []string{
-    "SiteInfoManageData",
-    "SiteInfoManageRecord",
-    "SimCardManage",
-    "SimCardMonthlyData",
-    "BatchDownloadConfig",
-    "SiteAnalysisConfig",
-    "StatisticsShutdown",
-    "StatisticsAlarm",
-    "SiteMonitorConfig",
-    "UserInfo",
-    "AutoUpdateConfigOption",
-    "AutoUpdateConfig",
-    "AiChatSessions",
-}
-```
-
-**MinIO Buckets:**
-```go
-var minioAllowedBuckets = []string{
-    "fwh-toolbox-batch-download",
-    "fwh-toolbox-ai-file",
-}
-```
-
-### 5.3 写操作控制
-
-| 层级 | 写操作 | 说明 |
-|------|--------|------|
-| service | `service_feedback_create` | 创建 Plane Intake Issue |
-| data | 无 | 全部只读 |
-| storage | `storage_file_write` | 写入文件到 MCP FileStore(MinIO),最大 2MB |
-| storage | `storage_minio_*` | 仅只读(列出、读取),不支持写入 |
-| util | 无 | 纯函数 |
-
----
-
-## 6. 文件存储设计
-
-### 6.1 FileStore 架构
-
-```
-MinIO Bucket: fwh-toolbox-ai-file
-├── upload/                        ← 用户上传的文件
-│   └── {userId}/{sessionId}/{uuid}_{fileName}
-│
-└── download/                      ← AI 生成的下载文件
-    └── {userId}/{sessionId}/{uuid}_{fileName}
-```
-
-### 6.2 文件生命周期
-
-| 阶段 | 操作 | 说明 |
-|------|------|------|
-| 上传 | `POST /ai/files/upload` | 前端上传 → 后端存入 MinIO(prefix=acp) |
-| 读取 | Agent `readTextFile` 或 `storage_file_read` | AI 请求读取 → FileStore 从 MinIO 获取 |
-| 过期 | 自动清理 | 文件设置 7 天过期 |
-
-### 6.3 共享模块
-
-通用 FileStore 位于 `service/utils/filestore/filestore.go`,ACP 和 MCP 通过不同 prefix 实例化:
-
-```go
-// ACP 使用
-acpFS := filestore.NewFileStore("acp")
-
-// MCP 使用
-mcpFS := filestore.NewFileStore("download")
-```
-
----
-
-## 7. 响应格式
-
-### 7.1 统一响应工具 (`helper/helper.go`)
-
-| 函数 | 用途 | 返回类型 |
-|------|------|----------|
-| `JSONResult(data)` | 序列化为格式化 JSON | `CallToolResult` (text) |
-| `TextResult(text)` | 直接返回文本 | `CallToolResult` (text) |
-| `ParamError(name)` | 参数缺失错误 | `CallToolResult` (error) |
-| `ParamInvalidError(name, detail)` | 参数格式错误 | `CallToolResult` (error) |
-| `ServiceError(action, err)` | 业务调用失败 | `CallToolResult` (error) |
-| `EmptyResult(msg)` | 空结果提示 | `CallToolResult` (text) |
-
-### 7.2 错误响应格式
-
-```
-参数 {name} 必填
-参数 {name} 格式错误: {detail}
-{action}失败: {error}
-```
-
----
-
-## 8. 初始化流程
-
-```go
-func main() {
-    // 1. 加载配置(与 HTTP Server 共享)
-    cfg := config.LoadConfig()
-
-    // 2. 初始化基础设施
-    logger.Init(cfg.App.AppEnv)
-    database.Init(&cfg.App)          // MongoDB
-    cache.Init(cfg.App.RedisAddr)    // Redis
-    repositories.PlaneWebHttpInit()  // Plane API
-
-    // 3. 初始化 MCP FileStore (prefix="download")
-    mcpFS := filestore.NewFileStore("download")
-    storage.InitFileStore(mcpFS)
-
-    // 4. 创建 MCP Server
-    s := server.NewMCPServer("fwh-tools", "2.0.0",
-        server.WithToolCapabilities(false),
-        server.WithRecovery(),
-        server.WithInstructions(serverInstructions),
-    )
-
-    // 5. 注册所有 Tools(单一入口)
-    tools.RegisterAll(s)
-
-    // 6. 启动 stdio 通信
-    server.ServeStdio(s)
-}
-```
-
----
-
-## 9. 部署配置
-
-### 9.1 构建
-
-```bash
-cd service
-go build -o fwh-mcp-server ./cmd/mcp
-```
-
-### 9.2 Kiro MCP 配置
-
-**文件:`service/.kiro/settings/mcp.json`**
-
-```json
-{
-  "mcpServers": {
-    "fwh-tools": {
-      "command": "./fwh-mcp-server",
-      "args": [],
-      "env": {
-        "MONGO_DSN": "${MONGO_DSN}",
-        "REDIS_ADDR": "${REDIS_ADDR}",
-        "MINIO_ENDPOINT": "${MINIO_ENDPOINT}",
-        "MINIO_ACCESS_KEY": "${MINIO_ACCESS_KEY}",
-        "MINIO_SECRET_KEY": "${MINIO_SECRET_KEY}",
-        "PLANE_WEB_HOST": "${PLANE_WEB_HOST}",
-        "PLANE_WEB_EMAIL": "${PLANE_WEB_EMAIL}",
-        "PLANE_WEB_PASSWORD": "${PLANE_WEB_PASSWORD}",
-        "PLANE_WEB_WORKSPACE_SLUG": "${PLANE_WEB_WORKSPACE_SLUG}",
-        "PLANE_WEB_PROJECT_ID": "${PLANE_WEB_PROJECT_ID}"
-      }
-    }
-  }
-}
-```
-
-### 9.3 部署目录
-
-```
-/var/www/fwh-toolbox/server/
-├── fwh-toolbox-server         # HTTP Server
-├── fwh-mcp-server             # MCP Server
-├── .kiro/
-│   ├── settings/mcp.json      # MCP 配置
-│   ├── agents/                # Agent 配置
-│   └── steering/              # Steering 文件
-└── .env                       # 环境变量
-```
-
----
-
-## 10. Instructions 系统提示
-
-MCP Server 通过 `server.WithInstructions()` 向 AI Agent 提供使用指南:
-
-```
-FranklinWH 工具箱 MCP Server — 内部工具平台的 AI 接口层。
-
-使用原则:
-1. 优先使用 service_* 工具,它们包含完整的业务逻辑
-2. 仅当 service 层无法满足时才使用 data_* 直查
-3. data_* 层覆盖 repositories 全部只读操作(DB + 外部 API)
-4. 所有 data_* 和 storage_* 工具仅支持只读操作
-5. 查询 MongoDB 时注意 collection 白名单限制
-```
-
----
-
-## 11. 扩展指南
-
-### 11.1 添加新的 Service 层工具
-
-1. 在 `tools/service/` 下创建新文件(如 `newdomain.go`)
-2. 实现 `registerXxxTools(s *server.MCPServer)` 函数
-3. 在 `tools/service/register.go` 的 `RegisterServiceTools` 中调用
-4. 工具命名遵循 `service_{domain}_{action}` 格式
-
-```go
-// tools/service/newdomain.go
-package service
-
-func registerNewDomainTools(s *server.MCPServer) {
-    s.AddTool(
-        mcp.NewTool("service_newdomain_query",
-            mcp.WithDescription("..."),
-            mcp.WithString("id", mcp.Required(), mcp.Description("...")),
-        ),
-        handleNewDomainQuery,
-    )
-}
-
-func handleNewDomainQuery(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-    id, err := req.RequireString("id")
-    if err != nil {
-        return helper.ParamError("id"), nil
-    }
-    data, err := services.GetSomething(id)
-    if err != nil {
-        return helper.ServiceError("查询", err), nil
-    }
-    return helper.JSONResult(data), nil
-}
-```
-
-### 11.2 添加新的 MongoDB Collection 访问
-
-在 `tools/data/mongo.go` 的 `mongoAllowedCollections` 白名单中添加新集合名:
-
-```go
-var mongoAllowedCollections = []string{
-    // ... 现有集合
-    "new_collection_name",  // ← 添加这一行
-}
-```
-
-### 11.3 添加新的 MinIO Bucket 访问
-
-在 `tools/storage/minio.go` 的 `minioAllowedBuckets` 白名单中添加:
-
-```go
-var minioAllowedBuckets = []string{
-    // ... 现有 bucket
-    "new-bucket-name",  // ← 添加这一行
-}
-```
-
----
-
-## 12. 测试
-
-### 12.1 本地测试
-
-```bash
-# 构建
-cd service && go build -o fwh-mcp-server ./cmd/mcp
-
-# 测试 tools/list
-echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./fwh-mcp-server
-
-# 测试单个工具调用
-echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"util_parse_sn","arguments":{"sn":"10060006A03X25310091"}}}' | ./fwh-mcp-server
-```
-
-### 12.2 通过 Kiro CLI 测试
-
-```bash
-# 在 service/ 目录下(含 .kiro/ 配置)
-kiro-cli chat
-
-# 对话中使用工具
-> 帮我查一下 SN 为 10060006A03X25310091 的站点信息
-> 统计一下 site_info 集合中有多少条记录
-> 解析一下这个 SN: 10050004B02X24450023
-```
-
----
-
-## 13. 依赖关系
-
-| 依赖 | 版本 | 用途 |
-|------|------|------|
-| `github.com/mark3labs/mcp-go` | latest | MCP 协议 SDK |
-| `go.mongodb.org/mongo-driver` | - | MongoDB 驱动 |
-| `github.com/minio/minio-go/v7` | - | MinIO 客户端 |
-| `github.com/google/uuid` | - | UUID 生成 |
-| `go.uber.org/zap` | - | 日志 |
-
-共享后端模块:
-- `fwh-toolbox-server/services` — 业务逻辑层
-- `fwh-toolbox-server/database` — 数据库连接
-- `fwh-toolbox-server/cache` — Redis 缓存
-- `fwh-toolbox-server/repositories` — 数据访问层(Mongo/Redis/PgSQL/API)
-- `fwh-toolbox-server/domain` — 领域对象定义
-- `fwh-toolbox-server/utils/filestore` — 文件存储
-- `fwh-toolbox-server/utils/minio` — MinIO 客户端
-- `fwh-toolbox-server/config` — 配置加载
-- `fwh-toolbox-server/dto` — 数据传输对象
-
----
-
-## 14. 变更历史
-
-| 版本 | 日期 | 变更内容 |
-|------|------|----------|
-| 1.0.0 | 2026-08-03 | 初版:6 个 service 层工具(site/sim/alarm/shutdown/plane) |
-| 2.0.0 | 2026-08-06 | 重构为四层架构,新增 data/storage/util 层,命名规范化 |
-| 2.1.0 | 2026-08-10 | data 层全面覆盖 repositories 只读操作:新增 fwh_api(21)、plane_api(25)、sim_api(7)、pgsql(2)、emspara(2) 共 57 个工具;修正 MongoDB 白名单为实际集合名 |

+ 0 - 170
service/cmd/mcp/main.go

@@ -1,170 +0,0 @@
-package main
-
-import (
-	"context"
-	"fmt"
-	"os"
-
-	"github.com/hibiken/asynq"
-	"github.com/mark3labs/mcp-go/server"
-	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
-
-	"fwh-toolbox-server/cache"
-	"fwh-toolbox-server/config"
-	"fwh-toolbox-server/database"
-	"fwh-toolbox-server/logger"
-	"fwh-toolbox-server/repositories"
-	"fwh-toolbox-server/task/queue/dataDownloadQueue"
-	"fwh-toolbox-server/utils/agentfiles"
-	minioUtil "fwh-toolbox-server/utils/minio"
-
-	"fwh-toolbox-server/cmd/mcp/tools"
-	"fwh-toolbox-server/cmd/mcp/tools/middleware"
-)
-
-const (
-	serverName    = "fwh-tools"
-	serverVersion = "2.0.0"
-	logDir        = "/var/log/fwh-mcp-server"
-	logFile       = "/var/log/fwh-mcp-server/mcp.log"
-)
-
-func main() {
-	// 初始化 MCP 专用 logger(写入文件,不污染 stdio)
-	initMCPLogger()
-	defer zap.L().Sync()
-
-	// 加载配置(MCP 使用独立配置文件,与后端主服务分开)
-	cfg, err := config.LoadConfig("./config.mcp.env")
-	if err != nil {
-		zap.L().Fatal("加载配置失败", zap.Error(err))
-	}
-
-	// 初始化基础设施(与 HTTP Server 相同)
-	if err := database.Init(&cfg.App); err != nil {
-		zap.L().Fatal("数据库初始化失败", zap.Error(err))
-	}
-	cache.Init(cfg.App.RedisAddr)
-	minioUtil.InitClient(&cfg.App)
-	if err := repositories.Init(context.Background()); err != nil {
-		zap.L().Fatal("Repository 初始化失败", zap.Error(err))
-	}
-	dataDownloadQueue.InitClient(asynq.RedisClientOpt{Addr: cfg.App.RedisAddr, DB: 12})
-
-	// 初始化 FWH Pro API 客户端(注入 host/账号,否则请求 URL 无 host)
-	repositories.FwhHttpInit(&cfg.FwhApi)
-
-	// 初始化 Plane(含底层 client + Feedback 专用实例 repositories.Plane)
-	// 注意:必须用 InitPlane 而非仅 InitPlaneClient,否则 repositories.Plane 为 nil,
-	// service_feedback_* 会空指针 panic。
-	repositories.InitPlane(&cfg.PlaneWebApi)
-
-	// 创建 MCP Server
-	s := server.NewMCPServer(
-		serverName,
-		serverVersion,
-		server.WithToolCapabilities(false),
-		server.WithRecovery(),
-		server.WithToolHandlerMiddleware(middleware.LoggingMiddleware()),
-		server.WithInstructions(serverInstructions),
-	)
-
-	// 注册所有 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)
-	if err := server.ServeStdio(s); err != nil {
-		zap.L().Fatal("[fwh-mcp-server] error", zap.Error(err))
-	}
-}
-
-// initMCPLogger 初始化 MCP Server 专用 logger
-// 日志写入文件,不输出到 stdout/stderr(避免污染 stdio JSON-RPC 通道)
-func initMCPLogger() {
-	// 确保日志目录存在
-	if err := os.MkdirAll(logDir, 0755); err != nil {
-		fmt.Fprintf(os.Stderr, "[fwh-mcp-server] 创建日志目录失败: %v\n", err)
-	}
-
-	config := zap.Config{
-		Level:       zap.NewAtomicLevelAt(zap.InfoLevel),
-		Development: false,
-		Encoding:    "json",
-		EncoderConfig: zapcore.EncoderConfig{
-			TimeKey:        "ts",
-			LevelKey:       "level",
-			NameKey:        "logger",
-			CallerKey:      "caller",
-			MessageKey:     "msg",
-			StacktraceKey:  "stacktrace",
-			LineEnding:     zapcore.DefaultLineEnding,
-			EncodeLevel:    zapcore.LowercaseLevelEncoder,
-			EncodeTime:     zapcore.ISO8601TimeEncoder,
-			EncodeDuration: zapcore.SecondsDurationEncoder,
-			EncodeCaller:   zapcore.ShortCallerEncoder,
-		},
-		OutputPaths:      []string{logFile},
-		ErrorOutputPaths: []string{logFile},
-	}
-
-	zapLogger, err := config.Build(zap.AddStacktrace(zap.ErrorLevel))
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "[fwh-mcp-server] logger 初始化失败: %v\n", err)
-		// fallback: 使用 nop logger
-		zapLogger = zap.NewNop()
-	}
-
-	// 设置全局 logger(zap.L() 和 zap.S())
-	zap.ReplaceGlobals(zapLogger)
-
-	// 同时设置 logger 包变量(database 等模块直接引用 logger.Sugar)
-	logger.Log = zapLogger
-	logger.Sugar = zapLogger.Sugar()
-	logger.Std = zap.NewStdLog(zapLogger)
-}
-
-// serverInstructions MCP Server 使用说明,帮助 AI 理解工具分类和使用优先级
-const serverInstructions = `FranklinWH 工具箱 MCP Server — 内部工具平台的 AI 接口层。
-
-## 工具分类(按访问层级)
-
-### service_* — 业务逻辑层(优先使用)
-调用后端 Service 层函数,包含业务校验、数据聚合和格式化。
-适用场景:常规的业务数据查询和操作。
-- service_site_*: 站点设备管理(查详情、搜索、功率流)
-- service_sim_*: SIM 卡管理(查详情、搜索)
-- service_monitor_*: 运维监控(告警统计、停机统计)
-- service_feedback_*: 反馈协作(创建/查询 Plane Issue)
-
-### data_* — 数据直查层(灵活查询)
-直接读取数据库或缓存,跳过业务逻辑层。仅限只读操作。
-适用场景:当 service 层工具无法满足查询需求时(自定义 filter、聚合统计等)。
-- data_mongo_*: MongoDB 集合查询
-- data_redis_*: Redis 缓存查看
-
-### storage_* — 对象存储层
-文件和对象存储操作。
-- storage_file_*: 用户上传文件的读取和写入
-- storage_minio_*: MinIO bucket 对象浏览和读取(只读)
-
-### util_* — 辅助工具
-纯函数工具,不涉及数据持久化。
-- util_parse_sn: 解析设备 SN 序列号含义
-
-## 使用原则
-1. 优先使用 service_* 工具,它们包含完整的业务逻辑
-2. 仅当 service 层无法满足时才使用 data_* 直查
-3. data_* 工具仅支持只读操作
-4. storage_file_* 支持读写,storage_minio_* 仅只读
-5. 查询 MongoDB 时注意 collection 白名单限制
-6. MinIO 操作受 bucket 白名单限制
-7. 描述中标注 [写操作] 的工具由 Agent 权限管控,执行前会请求用户确认
-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`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 0 - 79
service/cmd/mcp/tools/data/emspara.go

@@ -1,79 +0,0 @@
-package data
-
-import (
-	"context"
-	"fmt"
-	"strconv"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/repositories"
-)
-
-func registerEmsparaTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("data_emspara_vid_info",
-			mcp.WithDescription("查询 EMS 参数 VID 信息(通过 VID ID 获取宏名和描述)"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("VID ID(数字)")),
-		),
-		handleEmsparaVidInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_emspara_pid_info",
-			mcp.WithDescription("查询 EMS 参数 PID 信息(通过 PID ID 获取宏名和描述)"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("PID ID(数字)")),
-		),
-		handleEmsparaPidInfo,
-	)
-}
-
-func handleEmsparaVidInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := strconv.ParseUint(idStr, 10, 64)
-	if err != nil {
-		return helper.ParamInvalidError("id", "必须是数字"), nil
-	}
-
-	name, desc, err := repositories.GetEmsparaVidInfo(uint(id))
-	if err != nil {
-		return helper.EmptyResult(fmt.Sprintf("未找到 VID %d 的信息: %v", id, err)), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"id":          id,
-		"type":        "VID",
-		"macro_name":  name,
-		"description": desc,
-	}), nil
-}
-
-func handleEmsparaPidInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := strconv.ParseUint(idStr, 10, 64)
-	if err != nil {
-		return helper.ParamInvalidError("id", "必须是数字"), nil
-	}
-
-	name, desc, err := repositories.GetEmsparaPidInfo(uint(id))
-	if err != nil {
-		return helper.EmptyResult(fmt.Sprintf("未找到 PID %d 的信息: %v", id, err)), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"id":          id,
-		"type":        "PID",
-		"macro_name":  name,
-		"description": desc,
-	}), nil
-}

+ 0 - 651
service/cmd/mcp/tools/data/fwh_api.go

@@ -1,651 +0,0 @@
-package data
-
-import (
-	"context"
-	"fmt"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/repositories"
-)
-
-func registerFwhApiTools(s *server.MCPServer) {
-	// 站点信息
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_site_base_info_list",
-			mcp.WithDescription("查询站点基本信息列表(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Description("站点SN,多个用逗号分隔")),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleFwhQuerySiteBaseInfoList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_site_list",
-			mcp.WithDescription("查询站点列表(FWH Pro API,支持SN搜索)"),
-			mcp.WithString("sn", mcp.Description("站点SN搜索")),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleFwhQuerySiteList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_site_mode_info",
-			mcp.WithDescription("查询站点工作模式信息(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-		),
-		handleFwhQuerySiteModeInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_site_power_flow",
-			mcp.WithDescription("查询站点指定日期的功率流数据(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-			mcp.WithString("date", mcp.Required(), mcp.Description("日期,格式 yyyy-MM-dd")),
-		),
-		handleFwhQuerySitePowerFlow,
-	)
-
-	// 告警相关
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_site_alarm_list",
-			mcp.WithDescription("查询站点当前告警列表(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-		),
-		handleFwhQuerySiteAlarmList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_event_alarm_list",
-			mcp.WithDescription("查询站点时间范围内的事件/告警日志(FWH Pro API)。注意:大时间范围查询可能较慢"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-			mcp.WithString("start_time", mcp.Required(), mcp.Description("开始时间,格式 yyyy-MM-dd HH:mm:ss")),
-			mcp.WithString("end_time", mcp.Required(), mcp.Description("结束时间,格式 yyyy-MM-dd HH:mm:ss")),
-			mcp.WithString("type", mcp.Description("类型:all(默认)、event、alarm")),
-		),
-		handleFwhQueryEventAlarmList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_alarm_snapshot",
-			mcp.WithDescription("查询站点告警快照信息(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-			mcp.WithString("date", mcp.Required(), mcp.Description("日期,格式 yyyy-MM-dd")),
-		),
-		handleFwhQueryAlarmSnapshot,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_alarm_statistics",
-			mcp.WithDescription("查询实时告警统计数据(FWH Pro API)"),
-			mcp.WithNumber("country", mcp.Description("国家ID: 0-全部(默认), 2-美国, 3-澳大利亚")),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-		),
-		handleFwhQueryAlarmStatistics,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_alarm_statistics_from_code",
-			mcp.WithDescription("按告警码查询触发站点列表(FWH Pro API)"),
-			mcp.WithString("alarm_code", mcp.Required(), mcp.Description("告警码")),
-			mcp.WithNumber("country", mcp.Description("国家ID: 0-全部(默认), 2-美国, 3-澳大利亚")),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-		),
-		handleFwhQueryAlarmStatisticsFromCode,
-	)
-
-	// MQTT日志
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_mqtt_log_list",
-			mcp.WithDescription("查询站点时间范围内的MQTT日志(FWH Pro API)。注意:大时间范围查询可能较慢"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-			mcp.WithString("cmd_type", mcp.Required(), mcp.Description("MQTT命令类型(如 0, 101, 201 等)")),
-			mcp.WithString("start_time", mcp.Required(), mcp.Description("开始时间,格式 yyyy-MM-dd HH:mm:ss")),
-			mcp.WithString("end_time", mcp.Required(), mcp.Description("结束时间,格式 yyyy-MM-dd HH:mm:ss")),
-		),
-		handleFwhQueryMqttLogList,
-	)
-
-	// 停机列表
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_shutdown_list",
-			mcp.WithDescription("查询Shutdown站点列表(FWH Pro API)"),
-			mcp.WithString("date", mcp.Required(), mcp.Description("日期,格式 yyyy-MM-dd")),
-			mcp.WithNumber("country", mcp.Description("国家ID: 0-全部(默认), 2-美国, 3-澳大利亚")),
-		),
-		handleFwhQueryShutdownList,
-	)
-
-	// EMS日志
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_ems_log_record",
-			mcp.WithDescription("查询EMS日志召唤记录(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-		),
-		handleFwhQueryEmsLogRecord,
-	)
-
-	// 故障录波
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_fault_record_list",
-			mcp.WithDescription("查询故障录波日志列表(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-			mcp.WithString("start_time", mcp.Required(), mcp.Description("开始时间,格式 yyyy-MM-dd HH:mm:ss")),
-			mcp.WithString("end_time", mcp.Required(), mcp.Description("结束时间,格式 yyyy-MM-dd HH:mm:ss")),
-		),
-		handleFwhQueryFaultRecordList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_fault_record_callable_list",
-			mcp.WithDescription("查询设备可召唤的故障录波数据列表(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("设备SN(aGate/aPower/aHub)")),
-			mcp.WithNumber("dev_type", mcp.Description("设备类型: 0-aPower, 1-aGate, 2-MPPT, 3-aHub(默认1)")),
-		),
-		handleFwhQueryFaultRecordCallableList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_fault_record_call_record_list",
-			mcp.WithDescription("查询故障录波召唤记录(FWH Pro API)"),
-		),
-		handleFwhQueryFaultRecordCallRecordList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_fault_record_sub_list",
-			mcp.WithDescription("查询故障录波子记录(FWH Pro API)"),
-			mcp.WithString("fault_id", mcp.Required(), mcp.Description("故障录波记录ID")),
-		),
-		handleFwhQueryFaultRecordSubList,
-	)
-
-	// 系统升级
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_system_update_log",
-			mcp.WithDescription("查询系统升级记录(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-		),
-		handleFwhQuerySystemUpdateLog,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_system_update_log_detail",
-			mcp.WithDescription("查询系统升级记录详细信息(FWH Pro API)"),
-			mcp.WithString("order_id", mcp.Required(), mcp.Description("升级单ID")),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-		),
-		handleFwhQuerySystemUpdateLogDetail,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_ahub_update_log",
-			mcp.WithDescription("查询aHub升级记录(FWH Pro API)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-		),
-		handleFwhQueryAHubUpdateLog,
-	)
-
-	// TOU模板
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_tou_template_list",
-			mcp.WithDescription("查询TOU模板列表(FWH Pro API)"),
-			mcp.WithString("search", mcp.Description("搜索关键字")),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleFwhQueryTouTemplateList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_fwh_query_tou_template_detail",
-			mcp.WithDescription("查询TOU模板详情(FWH Pro API)"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("模板ID")),
-		),
-		handleFwhQueryTouTemplateDetail,
-	)
-}
-
-// === Handlers ===
-
-func handleFwhQuerySiteBaseInfoList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn := req.GetString("sn", "")
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	para := dto.SiteBaseInfoPara{
-		PageNum:  page,
-		PageSize: size,
-	}
-	if sn != "" {
-		snList := strings.Split(sn, ",")
-		para.SiteList = snList
-	}
-
-	data, err := repositories.QuerySiteBaseInfoList(para)
-	if err != nil {
-		return helper.ServiceError("查询站点基本信息列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQuerySiteList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn := req.GetString("sn", "")
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	para := dto.SiteInfoPara{
-		PageNum:  page,
-		PageSize: size,
-	}
-	if sn != "" {
-		para.SN = &sn
-	}
-
-	data, err := repositories.QuerySiteInfoList(para)
-	if err != nil {
-		return helper.ServiceError("查询站点列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQuerySiteModeInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := repositories.QuerySiteModeInfo(sn)
-	if err != nil {
-		return helper.ServiceError("查询站点工作模式", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQuerySitePowerFlow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	dateStr, err := req.RequireString("date")
-	if err != nil {
-		return helper.ParamError("date"), nil
-	}
-
-	day, err := time.Parse("2006-01-02", dateStr)
-	if err != nil {
-		return helper.ParamInvalidError("date", fmt.Sprintf("需要 yyyy-MM-dd 格式: %v", err)), nil
-	}
-
-	data, err := repositories.QuerySitePowerFlow(sn, day)
-	if err != nil {
-		return helper.ServiceError("查询功率流", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQuerySiteAlarmList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := repositories.QuerySiteAlarmList(sn)
-	if err != nil {
-		return helper.ServiceError("查询站点告警列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryEventAlarmList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	startTimeStr, err := req.RequireString("start_time")
-	if err != nil {
-		return helper.ParamError("start_time"), nil
-	}
-	endTimeStr, err := req.RequireString("end_time")
-	if err != nil {
-		return helper.ParamError("end_time"), nil
-	}
-
-	startTime, err := time.Parse(time.DateTime, startTimeStr)
-	if err != nil {
-		return helper.ParamInvalidError("start_time", fmt.Sprintf("需要 yyyy-MM-dd HH:mm:ss 格式: %v", err)), nil
-	}
-	endTime, err := time.Parse(time.DateTime, endTimeStr)
-	if err != nil {
-		return helper.ParamInvalidError("end_time", fmt.Sprintf("需要 yyyy-MM-dd HH:mm:ss 格式: %v", err)), nil
-	}
-
-	typeStr := req.GetString("type", "all")
-	var eventAlarmType repositories.EventAlarmType
-	switch typeStr {
-	case "event":
-		eventAlarmType = repositories.Event
-	case "alarm":
-		eventAlarmType = repositories.Alarm
-	default:
-		eventAlarmType = repositories.EventAndAlarm
-	}
-
-	data, err := repositories.QueryEventOrAlarmListOnTimeRange(sn, startTime, endTime, eventAlarmType)
-	if err != nil {
-		return helper.ServiceError("查询事件告警日志", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryAlarmSnapshot(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	dateStr, err := req.RequireString("date")
-	if err != nil {
-		return helper.ParamError("date"), nil
-	}
-
-	// 验证日期格式
-	if _, err := time.Parse("2006-01-02", dateStr); err != nil {
-		return helper.ParamInvalidError("date", fmt.Sprintf("需要 yyyy-MM-dd 格式: %v", err)), nil
-	}
-
-	data, err := repositories.QueryAlarmSnapshotInfo(sn, dateStr)
-	if err != nil {
-		return helper.ServiceError("查询告警快照", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryAlarmStatistics(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	country := domain.CountryType(int(req.GetFloat("country", 0)))
-	page := int(req.GetFloat("page", 1))
-
-	data, err := repositories.QueryAlarmStatistics(country, page)
-	if err != nil {
-		return helper.ServiceError("查询实时告警统计", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryAlarmStatisticsFromCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	alarmCode, err := req.RequireString("alarm_code")
-	if err != nil {
-		return helper.ParamError("alarm_code"), nil
-	}
-	country := domain.CountryType(int(req.GetFloat("country", 0)))
-	page := int(req.GetFloat("page", 1))
-
-	data, err := repositories.QueryAlarmStatisticsFromCode(country, alarmCode, page)
-	if err != nil {
-		return helper.ServiceError("查询告警码统计", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryMqttLogList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	cmdType, err := req.RequireString("cmd_type")
-	if err != nil {
-		return helper.ParamError("cmd_type"), nil
-	}
-	startTimeStr, err := req.RequireString("start_time")
-	if err != nil {
-		return helper.ParamError("start_time"), nil
-	}
-	endTimeStr, err := req.RequireString("end_time")
-	if err != nil {
-		return helper.ParamError("end_time"), nil
-	}
-
-	startTime, err := time.Parse(time.DateTime, startTimeStr)
-	if err != nil {
-		return helper.ParamInvalidError("start_time", fmt.Sprintf("需要 yyyy-MM-dd HH:mm:ss 格式: %v", err)), nil
-	}
-	endTime, err := time.Parse(time.DateTime, endTimeStr)
-	if err != nil {
-		return helper.ParamInvalidError("end_time", fmt.Sprintf("需要 yyyy-MM-dd HH:mm:ss 格式: %v", err)), nil
-	}
-
-	data, err := repositories.QueryMqttLogListOnTimeRange(sn, domain.MqttCmdType(cmdType), startTime, endTime)
-	if err != nil {
-		return helper.ServiceError("查询MQTT日志", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryShutdownList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	dateStr, err := req.RequireString("date")
-	if err != nil {
-		return helper.ParamError("date"), nil
-	}
-	country := int(req.GetFloat("country", 0))
-
-	// 验证日期格式
-	if _, err := time.Parse("2006-01-02", dateStr); err != nil {
-		return helper.ParamInvalidError("date", fmt.Sprintf("需要 yyyy-MM-dd 格式: %v", err)), nil
-	}
-
-	data, err := repositories.QuerySiteShutdownList(dateStr, country)
-	if err != nil {
-		return helper.ServiceError("查询停机列表", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryEmsLogRecord(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	// 使用空日期和0类型查询所有记录
-	data, err := repositories.QueryEmsLogCallRecord(sn, "", 0)
-	if err != nil {
-		// 如果是"没有召唤日志"的错误,返回空结果
-		return helper.EmptyResult(fmt.Sprintf("未找到EMS日志记录: %v", err)), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryFaultRecordList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	startTimeStr, err := req.RequireString("start_time")
-	if err != nil {
-		return helper.ParamError("start_time"), nil
-	}
-	endTimeStr, err := req.RequireString("end_time")
-	if err != nil {
-		return helper.ParamError("end_time"), nil
-	}
-
-	data, err := repositories.QueryFaultRecordList(sn, startTimeStr, endTimeStr)
-	if err != nil {
-		return helper.ServiceError("查询故障录波列表", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryFaultRecordCallableList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	devType := int(req.GetFloat("dev_type", 1))
-
-	data, err := repositories.QueryFaultRecordCallableList(devType, sn)
-	if err != nil {
-		return helper.ServiceError("查询可召唤故障录波列表", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("无可召唤的故障录波数据"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryFaultRecordCallRecordList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.QueryFaultRecordCallRecordList()
-	if err != nil {
-		return helper.ServiceError("查询故障录波召唤记录", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryFaultRecordSubList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	faultIdStr, err := req.RequireString("fault_id")
-	if err != nil {
-		return helper.ParamError("fault_id"), nil
-	}
-
-	faultId, err := strconv.ParseInt(faultIdStr, 10, 64)
-	if err != nil {
-		return helper.ParamInvalidError("fault_id", "必须是数字"), nil
-	}
-
-	data, err := repositories.QueryFaultRecordSubList(faultId)
-	if err != nil {
-		return helper.ServiceError("查询故障录波子记录", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQuerySystemUpdateLog(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := repositories.QuerySystemUpdateLog(sn)
-	if err != nil {
-		return helper.ServiceError("查询系统升级记录", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQuerySystemUpdateLogDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	orderIdStr, err := req.RequireString("order_id")
-	if err != nil {
-		return helper.ParamError("order_id"), nil
-	}
-
-	orderId, err := strconv.ParseInt(orderIdStr, 10, 64)
-	if err != nil {
-		return helper.ParamInvalidError("order_id", "必须是数字"), nil
-	}
-
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := repositories.QuerySystemUpdateLogDetail(orderId, sn)
-	if err != nil {
-		return helper.ServiceError("查询升级详情", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryAHubUpdateLog(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := repositories.QueryAHubUpdateLog(sn)
-	if err != nil {
-		return helper.ServiceError("查询aHub升级记录", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryTouTemplateList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	search := req.GetString("search", "")
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	data, err := repositories.QueryTouTemplateInfoList(search, size, page, nil, nil)
-	if err != nil {
-		return helper.ServiceError("查询TOU模板列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleFwhQueryTouTemplateDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := strconv.ParseUint(idStr, 10, 64)
-	if err != nil {
-		return helper.ParamInvalidError("id", "必须是数字"), nil
-	}
-
-	data, err := repositories.QueryTouTemplateDetailInfo(uint(id))
-	if err != nil {
-		return helper.ServiceError("查询TOU模板详情", err), nil
-	}
-	if data == nil {
-		return helper.EmptyResult("模板不存在"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}

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

@@ -1,528 +0,0 @@
-package data
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"time"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-	"go.mongodb.org/mongo-driver/bson"
-	"go.mongodb.org/mongo-driver/mongo/options"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/database"
-)
-
-// mongoAllowedCollections 允许查询的 collection 白名单
-var mongoAllowedCollections = []string{
-	// 站点信息管理
-	"SiteInfoManageData",
-	"SiteInfoManageRecord",
-	// SIM卡管理
-	"SimCardManage",
-	"SimCardMonthlyData",
-	// 批量下载
-	"BatchDownloadConfig",
-	// 站点分析
-	"SiteAnalysisConfig",
-	// 统计
-	"StatisticsShutdown",
-	"StatisticsAlarm",
-	// 站点监控
-	"SiteMonitorConfig",
-	// 自动升级
-	"AutoUpdateConfigOption",
-	"AutoUpdateConfig",
-	// 注意:UserInfo(内部用户身份数据)与 AiChatSessions(历史对话自由文本,
-	// 字段级脱敏无效)已从白名单移除,禁止通过 data_mongo_* 直查以防隐私泄露。
-}
-
-func registerMongoTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("data_mongo_find",
-			mcp.WithDescription("直接查询 MongoDB 集合数据(只读)。用于 service 层无法满足的灵活查询场景。"),
-			mcp.WithString("collection", mcp.Required(), mcp.Description(
-				fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
-			)),
-			mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter(如 {\"sn\": \"xxx\"}),不传则查全部")),
-			mcp.WithArray("fields", mcp.WithStringItems(),
-				mcp.Description("字段投影:要返回的字段名数组(如 [\"sn\",\"status\"]),留空返回全部字段。用于大数据量场景下减少返回体积、节省 token。")),
-			mcp.WithNumber("limit", mcp.Description("返回数量限制,默认10,最大100")),
-			mcp.WithNumber("skip", mcp.Description("跳过数量(分页偏移),默认0")),
-			mcp.WithString("sort", mcp.Description("排序条件,JSON 格式(如 {\"createdAt\": -1})")),
-		),
-		handleMongoFind,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_mongo_count",
-			mcp.WithDescription("统计 MongoDB 集合中符合条件的文档数量"),
-			mcp.WithString("collection", mcp.Required(), mcp.Description(
-				fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
-			)),
-			mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter,不传则统计全部")),
-		),
-		handleMongoCount,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_mongo_export",
-			mcp.WithDescription("全量导出 MongoDB 集合数据到本地文件(只读)。用于大数据量导出场景:不受 find 的 100 条上限限制,游标流式拉取符合条件的全部文档,边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),内存占用为常数级。仅返回 localPath、count 与结构化摘要(不返回数据正文,避免占用上下文)。为防止误导出全表,无 filter 时若总数超过上限会被拒绝。"),
-			mcp.WithString("collection", mcp.Required(), mcp.Description(
-				fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
-			)),
-			mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter,不传则导出全部(受上限保护)")),
-			mcp.WithArray("fields", mcp.WithStringItems(),
-				mcp.Description("字段投影:只导出这些字段(如 [\"sn\",\"status\"]),留空导出全部字段。")),
-			mcp.WithString("sort", mcp.Description("排序条件,JSON 格式(如 {\"createdAt\": -1})")),
-			mcp.WithNumber("max_docs", mcp.Description(fmt.Sprintf("最大导出条数上限,默认 %d,硬上限 %d。超过则拒绝,请缩小 filter。", mongoExportDefaultMax, mongoExportHardMax))),
-		),
-		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 覆盖)
-const mongoExportDefaultMax = 50000
-
-// mongoExportHardMax 全量导出硬上限。流式导出内存为常数级,此上限仅用于防止
-// 误导出超大规模数据,而非内存保护,故设置较高。
-const mongoExportHardMax = 5000000
-
-func handleMongoFind(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
-	}
-
-	// 解析 filter
-	filterStr := req.GetString("filter", "{}")
-	var filter bson.M
-	if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
-		return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
-	}
-
-	// limit
-	limit := int64(req.GetFloat("limit", 10))
-	if limit > 100 {
-		limit = 100
-	}
-	if limit < 1 {
-		limit = 10
-	}
-
-	// skip
-	skip := int64(req.GetFloat("skip", 0))
-	if skip < 0 {
-		skip = 0
-	}
-
-	// sort
-	var sort bson.M
-	sortStr := req.GetString("sort", "")
-	if sortStr != "" {
-		if err := json.Unmarshal([]byte(sortStr), &sort); err != nil {
-			return helper.ParamInvalidError("sort", fmt.Sprintf("JSON 解析失败: %v", err)), nil
-		}
-	}
-
-	// 查询
-	coll := database.GetCollection(collection)
-	findOpts := options.Find().SetLimit(limit).SetSkip(skip)
-	if sort != nil {
-		findOpts.SetSort(sort)
-	}
-
-	// fields 字段投影:留空返回全部字段;指定则只返回这些字段(源头减量,节省 token)
-	if fields, ok := helper.GetStringSlice(req, "fields"); ok && len(fields) > 0 {
-		projection := bson.M{}
-		for _, f := range fields {
-			projection[f] = 1
-		}
-		findOpts.SetProjection(projection)
-	}
-
-	cursor, err := coll.Find(ctx, filter, findOpts)
-	if err != nil {
-		return helper.ServiceError("MongoDB查询", err), nil
-	}
-	defer cursor.Close(ctx)
-
-	var results []bson.M
-	if err := cursor.All(ctx, &results); err != nil {
-		return helper.ServiceError("读取MongoDB结果", err), nil
-	}
-
-	// total:符合 filter 的文档总数(不受 limit/skip 影响),便于大数据量遍历时判断进度
-	total, err := coll.CountDocuments(ctx, filter)
-	if err != nil {
-		return helper.ServiceError("统计MongoDB总数", err), nil
-	}
-
-	returned := int64(len(results))
-	hasMore := skip+returned < total
-
-	// 结构化返回:附带 total/returned/skip/limit/hasMore,便于 AI 判断是否翻完、如何翻页
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"total":    total,
-		"returned": returned,
-		"skip":     skip,
-		"limit":    limit,
-		"hasMore":  hasMore,
-		"items":    results,
-	}), nil
-}
-
-func handleMongoCount(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
-	}
-
-	filterStr := req.GetString("filter", "{}")
-	var filter bson.M
-	if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
-		return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
-	}
-
-	coll := database.GetCollection(collection)
-	count, err := coll.CountDocuments(ctx, filter)
-	if err != nil {
-		return helper.ServiceError("MongoDB统计", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"collection": collection,
-		"count":      count,
-	}), nil
-}
-
-func handleMongoExport(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
-	}
-
-	// 解析 filter
-	filterStr := req.GetString("filter", "{}")
-	var filter bson.M
-	if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
-		return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
-	}
-	hasFilter := len(filter) > 0
-
-	// max_docs 上限
-	maxDocs := int64(req.GetFloat("max_docs", float64(mongoExportDefaultMax)))
-	if maxDocs < 1 {
-		maxDocs = mongoExportDefaultMax
-	}
-	if maxDocs > mongoExportHardMax {
-		maxDocs = mongoExportHardMax
-	}
-
-	coll := database.GetCollection(collection)
-
-	// 先统计总量,做防误导出保护
-	total, err := coll.CountDocuments(ctx, filter)
-	if err != nil {
-		return helper.ServiceError("统计导出总数", err), nil
-	}
-	if total == 0 {
-		return helper.EmptyResult("符合条件的文档为 0,无需导出"), nil
-	}
-	if total > maxDocs {
-		hint := "请缩小 filter 或调高 max_docs"
-		if !hasFilter {
-			hint = "未提供 filter 且数据量过大,请提供 filter 缩小范围,或显式调高 max_docs"
-		}
-		return helper.ParamInvalidError("max_docs", fmt.Sprintf(
-			"符合条件的文档数 %d 超过上限 %d,%s(硬上限 %d)", total, maxDocs, hint, mongoExportHardMax)), nil
-	}
-
-	// 组装查询选项
-	findOpts := options.Find()
-	if sortStr := req.GetString("sort", ""); sortStr != "" {
-		var sort bson.M
-		if err := json.Unmarshal([]byte(sortStr), &sort); err != nil {
-			return helper.ParamInvalidError("sort", fmt.Sprintf("JSON 解析失败: %v", err)), nil
-		}
-		findOpts.SetSort(sort)
-	}
-	if fields, ok := helper.GetStringSlice(req, "fields"); ok && len(fields) > 0 {
-		projection := bson.M{}
-		for _, f := range fields {
-			projection[f] = 1
-		}
-		findOpts.SetProjection(projection)
-	}
-
-	// 游标流式导出:逐条 decode → 脱敏 → 写 NDJSON,内存常数级
-	cursor, err := coll.Find(ctx, filter, findOpts)
-	if err != nil {
-		return helper.ServiceError("MongoDB导出查询", err), nil
-	}
-	defer cursor.Close(ctx)
-
-	res := helper.ExportStreamNDJSON(func() (any, bool, error) {
-		if !cursor.Next(ctx) {
-			// Next 返回 false:可能是正常结束,也可能是出错
-			return nil, false, cursor.Err()
-		}
-		var doc bson.M
-		if err := cursor.Decode(&doc); err != nil {
-			return nil, false, err
-		}
-		return doc, true, nil
-	}, "mongo_export_"+collection)
-	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 {
-			return true
-		}
-	}
-	return false
-}

+ 0 - 71
service/cmd/mcp/tools/data/pgsql.go

@@ -1,71 +0,0 @@
-package data
-
-import (
-	"context"
-	"fmt"
-	"time"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/repositories"
-)
-
-func registerPgsqlTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("data_pgsql_query_snapshot_file_record",
-			mcp.WithDescription("查询 PostgreSQL 中的告警快照文件解析记录"),
-			mcp.WithString("file_name", mcp.Required(), mcp.Description("快照文件名")),
-		),
-		handlePgsqlQuerySnapshotFileRecord,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_pgsql_query_snapshot_data",
-			mcp.WithDescription("查询 PostgreSQL 中的告警快照数据(按SN和日期)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN")),
-			mcp.WithString("date", mcp.Required(), mcp.Description("日期,格式 yyyy-MM-dd")),
-		),
-		handlePgsqlQuerySnapshotData,
-	)
-}
-
-func handlePgsqlQuerySnapshotFileRecord(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	fileName, err := req.RequireString("file_name")
-	if err != nil {
-		return helper.ParamError("file_name"), nil
-	}
-
-	data, err := repositories.QuerySnapshotFileRecord(ctx, fileName)
-	if err != nil {
-		return helper.EmptyResult(fmt.Sprintf("未找到文件记录: %v", err)), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handlePgsqlQuerySnapshotData(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	dateStr, err := req.RequireString("date")
-	if err != nil {
-		return helper.ParamError("date"), nil
-	}
-
-	// 验证日期格式
-	if _, err := time.Parse("2006-01-02", dateStr); err != nil {
-		return helper.ParamInvalidError("date", fmt.Sprintf("需要 yyyy-MM-dd 格式: %v", err)), nil
-	}
-
-	data, err := repositories.QuerySnapshotData(ctx, sn, dateStr)
-	if err != nil {
-		return helper.ServiceError("查询快照数据", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 207
service/cmd/mcp/tools/data/plane_api.go

@@ -1,207 +0,0 @@
-package data
-
-import (
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-)
-
-func registerPlaneApiTools(s *server.MCPServer) {
-	// === Project ===
-	s.AddTool(
-		mcp.NewTool("data_plane_list_projects",
-			mcp.WithDescription("列出 Plane 所有项目(V1 API)"),
-		),
-		handlePlaneListProjects,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_project",
-			mcp.WithDescription("获取 Plane 项目详情(V1 API)"),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneGetProject,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_project_summary",
-			mcp.WithDescription("获取 Plane 项目摘要统计(V1 API)"),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("fields", mcp.Description("要获取的字段,逗号分隔")),
-		),
-		handlePlaneGetProjectSummary,
-	)
-
-	// === Work Item ===
-	s.AddTool(
-		mcp.NewTool("data_plane_list_work_items",
-			mcp.WithDescription("列出 Plane 工作项列表(V1 API)"),
-			mcp.WithString("state", mcp.Description("状态ID过滤")),
-			mcp.WithString("priority", mcp.Description("优先级过滤")),
-			mcp.WithString("assignee", mcp.Description("指派人ID过滤")),
-			mcp.WithString("label", mcp.Description("标签ID过滤")),
-		),
-		handlePlaneListWorkItems,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_work_item",
-			mcp.WithDescription("获取 Plane 工作项详情(V1 API)"),
-			mcp.WithString("work_item_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneGetWorkItem,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_work_item_by_identifier",
-			mcp.WithDescription("通过标识符获取工作项(如 PROJ-42)(V1 API)"),
-			mcp.WithString("identifier", mcp.Required(), mcp.Description("项目标识符(如 PROJ)")),
-			mcp.WithNumber("sequence_id", mcp.Required(), mcp.Description("序号(如 42)")),
-		),
-		handlePlaneGetWorkItemByIdentifier,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_search_work_items",
-			mcp.WithDescription("搜索 Plane 工作项(V1 API)"),
-			mcp.WithString("query", mcp.Required(), mcp.Description("搜索关键字")),
-			mcp.WithNumber("limit", mcp.Description("返回数量限制,默认20")),
-		),
-		handlePlaneSearchWorkItems,
-	)
-
-	// === Cycle ===
-	s.AddTool(
-		mcp.NewTool("data_plane_list_cycles",
-			mcp.WithDescription("列出 Plane 迭代列表(V1 API)"),
-		),
-		handlePlaneListCycles,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_cycle",
-			mcp.WithDescription("获取 Plane 迭代详情(V1 API)"),
-			mcp.WithString("cycle_id", mcp.Required(), mcp.Description("迭代 UUID")),
-		),
-		handlePlaneGetCycle,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_cycle_issues",
-			mcp.WithDescription("列出 Plane 迭代中的工作项(V1 API)"),
-			mcp.WithString("cycle_id", mcp.Required(), mcp.Description("迭代 UUID")),
-		),
-		handlePlaneListCycleIssues,
-	)
-
-	// === Module ===
-	s.AddTool(
-		mcp.NewTool("data_plane_list_modules",
-			mcp.WithDescription("列出 Plane 模块列表(V1 API)"),
-		),
-		handlePlaneListModules,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_module",
-			mcp.WithDescription("获取 Plane 模块详情(V1 API)"),
-			mcp.WithString("module_id", mcp.Required(), mcp.Description("模块 UUID")),
-		),
-		handlePlaneGetModule,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_module_issues",
-			mcp.WithDescription("列出 Plane 模块中的工作项(V1 API)"),
-			mcp.WithString("module_id", mcp.Required(), mcp.Description("模块 UUID")),
-		),
-		handlePlaneListModuleIssues,
-	)
-
-	// === State / Label / Member ===
-	s.AddTool(
-		mcp.NewTool("data_plane_list_states",
-			mcp.WithDescription("列出 Plane 项目状态列表(V1 API)"),
-		),
-		handlePlaneListStates,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_labels",
-			mcp.WithDescription("列出 Plane 项目标签列表(V1 API)"),
-		),
-		handlePlaneListLabels,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_workspace_members",
-			mcp.WithDescription("列出 Plane 工作空间成员(V1 API)"),
-		),
-		handlePlaneListWorkspaceMembers,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_project_members",
-			mcp.WithDescription("列出 Plane 项目成员(V1 API)"),
-		),
-		handlePlaneListProjectMembers,
-	)
-
-	// === Comment / Activity / Attachment / Relation ===
-	s.AddTool(
-		mcp.NewTool("data_plane_list_work_item_comments",
-			mcp.WithDescription("列出 Plane 工作项评论(V1 API)"),
-			mcp.WithString("work_item_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneListWorkItemComments,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_work_item_activities",
-			mcp.WithDescription("列出 Plane 工作项活动历史(V1 API)"),
-			mcp.WithString("work_item_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneListWorkItemActivities,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_work_item_links",
-			mcp.WithDescription("列出 Plane 工作项链接(V1 API)"),
-			mcp.WithString("work_item_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneListWorkItemLinks,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_work_item_attachments",
-			mcp.WithDescription("列出 Plane 工作项附件(V1 API)"),
-			mcp.WithString("work_item_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneListWorkItemAttachments,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_list_work_item_relations",
-			mcp.WithDescription("列出 Plane 工作项关系(V1 API)"),
-			mcp.WithString("work_item_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneListWorkItemRelations,
-	)
-
-	// === Intake ===
-	s.AddTool(
-		mcp.NewTool("data_plane_get_intake_issue_list",
-			mcp.WithDescription("查询 Plane Intake Issue 列表(V1 API)"),
-		),
-		handlePlaneGetIntakeIssueList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_plane_get_intake_issue_detail",
-			mcp.WithDescription("获取 Plane Intake Issue 详情(V1 API)"),
-			mcp.WithString("intake_id", mcp.Required(), mcp.Description("Intake Issue UUID")),
-		),
-		handlePlaneGetIntakeIssueDetail,
-	)
-
-	// === V2 Web API: Page (已迁移到 plane_v2_api.go) ===
-}

+ 0 - 237
service/cmd/mcp/tools/data/plane_api_handlers.go

@@ -1,237 +0,0 @@
-package data
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/repositories"
-)
-
-// planeRawResult 将 Plane API 返回的原始 JSON bytes 转为 MCP 结果。
-// 原始字节先经流式 PII 脱敏(优化 B),脱敏失败(含非法 JSON)则返回错误、拒绝输出。
-func planeRawResult(data []byte, err error, action string) (*mcp.CallToolResult, error) {
-	if err != nil {
-		return helper.ServiceError(action, err), nil
-	}
-	return helper.BytesResultOrFileMasked(data, "plane_"+action), nil
-}
-
-// === Project Handlers ===
-
-func handlePlaneListProjects(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListProjects()
-	return planeRawResult(data, err, "列出项目")
-}
-
-func handlePlaneGetProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	projectID, err := req.RequireString("project_id")
-	if err != nil {
-		return helper.ParamError("project_id"), nil
-	}
-	data, apiErr := repositories.PlaneGetProject(projectID)
-	return planeRawResult(data, apiErr, "获取项目详情")
-}
-
-func handlePlaneGetProjectSummary(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	projectID, err := req.RequireString("project_id")
-	if err != nil {
-		return helper.ParamError("project_id"), nil
-	}
-	fields := req.GetString("fields", "")
-	data, apiErr := repositories.PlaneGetProjectSummary(projectID, fields)
-	return planeRawResult(data, apiErr, "获取项目摘要")
-}
-
-// === Work Item Handlers ===
-
-func handlePlaneListWorkItems(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	params := make(map[string]string)
-	if v := req.GetString("state", ""); v != "" {
-		params["state"] = v
-	}
-	if v := req.GetString("priority", ""); v != "" {
-		params["priority"] = v
-	}
-	if v := req.GetString("assignee", ""); v != "" {
-		params["assignee"] = v
-	}
-	if v := req.GetString("label", ""); v != "" {
-		params["label"] = v
-	}
-	data, err := repositories.PlaneListWorkItems(params)
-	return planeRawResult(data, err, "列出工作项")
-}
-
-func handlePlaneGetWorkItem(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("work_item_id")
-	if err != nil {
-		return helper.ParamError("work_item_id"), nil
-	}
-	data, apiErr := repositories.PlaneGetWorkItem(id)
-	return planeRawResult(data, apiErr, "获取工作项详情")
-}
-
-func handlePlaneGetWorkItemByIdentifier(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	identifier, err := req.RequireString("identifier")
-	if err != nil {
-		return helper.ParamError("identifier"), nil
-	}
-	seqID := int(req.GetFloat("sequence_id", 0))
-	if seqID <= 0 {
-		return helper.ParamError("sequence_id"), nil
-	}
-	data, apiErr := repositories.PlaneGetWorkItemByIdentifier(identifier, seqID)
-	return planeRawResult(data, apiErr, "通过标识符获取工作项")
-}
-
-func handlePlaneSearchWorkItems(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	query, err := req.RequireString("query")
-	if err != nil {
-		return helper.ParamError("query"), nil
-	}
-	limit := int(req.GetFloat("limit", 20))
-	data, apiErr := repositories.PlaneSearchWorkItems(query, limit, "")
-	return planeRawResult(data, apiErr, "搜索工作项")
-}
-
-// === Cycle Handlers ===
-
-func handlePlaneListCycles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListCycles()
-	return planeRawResult(data, err, "列出迭代")
-}
-
-func handlePlaneGetCycle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("cycle_id")
-	if err != nil {
-		return helper.ParamError("cycle_id"), nil
-	}
-	data, apiErr := repositories.PlaneGetCycle(id)
-	return planeRawResult(data, apiErr, "获取迭代详情")
-}
-
-func handlePlaneListCycleIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("cycle_id")
-	if err != nil {
-		return helper.ParamError("cycle_id"), nil
-	}
-	data, apiErr := repositories.PlaneListCycleIssues(id)
-	return planeRawResult(data, apiErr, "列出迭代工作项")
-}
-
-// === Module Handlers ===
-
-func handlePlaneListModules(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListModules()
-	return planeRawResult(data, err, "列出模块")
-}
-
-func handlePlaneGetModule(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("module_id")
-	if err != nil {
-		return helper.ParamError("module_id"), nil
-	}
-	data, apiErr := repositories.PlaneGetModule(id)
-	return planeRawResult(data, apiErr, "获取模块详情")
-}
-
-func handlePlaneListModuleIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("module_id")
-	if err != nil {
-		return helper.ParamError("module_id"), nil
-	}
-	data, apiErr := repositories.PlaneListModuleIssues(id)
-	return planeRawResult(data, apiErr, "列出模块工作项")
-}
-
-// === State / Label / Member Handlers ===
-
-func handlePlaneListStates(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListStates()
-	return planeRawResult(data, err, "列出状态")
-}
-
-func handlePlaneListLabels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListLabels()
-	return planeRawResult(data, err, "列出标签")
-}
-
-func handlePlaneListWorkspaceMembers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListWorkspaceMembers()
-	return planeRawResult(data, err, "列出工作空间成员")
-}
-
-func handlePlaneListProjectMembers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneListProjectMembers()
-	return planeRawResult(data, err, "列出项目成员")
-}
-
-// === Comment / Activity / Link / Attachment / Relation Handlers ===
-
-func handlePlaneListWorkItemComments(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("work_item_id")
-	if err != nil {
-		return helper.ParamError("work_item_id"), nil
-	}
-	data, apiErr := repositories.PlaneListWorkItemComments(id)
-	return planeRawResult(data, apiErr, "列出评论")
-}
-
-func handlePlaneListWorkItemActivities(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("work_item_id")
-	if err != nil {
-		return helper.ParamError("work_item_id"), nil
-	}
-	data, apiErr := repositories.PlaneListWorkItemActivities(id)
-	return planeRawResult(data, apiErr, "列出活动历史")
-}
-
-func handlePlaneListWorkItemLinks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("work_item_id")
-	if err != nil {
-		return helper.ParamError("work_item_id"), nil
-	}
-	data, apiErr := repositories.PlaneListWorkItemLinks(id)
-	return planeRawResult(data, apiErr, "列出链接")
-}
-
-func handlePlaneListWorkItemAttachments(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("work_item_id")
-	if err != nil {
-		return helper.ParamError("work_item_id"), nil
-	}
-	data, apiErr := repositories.PlaneListWorkItemAttachments(id)
-	return planeRawResult(data, apiErr, "列出附件")
-}
-
-func handlePlaneListWorkItemRelations(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("work_item_id")
-	if err != nil {
-		return helper.ParamError("work_item_id"), nil
-	}
-	data, apiErr := repositories.PlaneListWorkItemRelations(id)
-	return planeRawResult(data, apiErr, "列出关系")
-}
-
-// === Intake Handlers ===
-
-func handlePlaneGetIntakeIssueList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := repositories.PlaneGetIntakeIssueList()
-	if err != nil {
-		return helper.ServiceError("查询Intake列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handlePlaneGetIntakeIssueDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireString("intake_id")
-	if err != nil {
-		return helper.ParamError("intake_id"), nil
-	}
-	data, apiErr := repositories.PlaneGetIntakeIssueDetail(id)
-	return planeRawResult(data, apiErr, "获取Intake详情")
-}
-
-// === V2 Web API: Page Handlers (已迁移到 plane_v2_api_handlers.go) ===

+ 0 - 216
service/cmd/mcp/tools/data/plane_v2_api.go

@@ -1,216 +0,0 @@
-package data
-
-import (
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	plane_v2 "fwh-toolbox-server/repositories/api/plane_v2"
-)
-
-// planeV2 通用 PlaneService 实例
-var planeV2Svc *plane_v2.PlaneService
-
-func registerPlaneV2ApiTools(s *server.MCPServer) {
-	planeV2Svc = plane_v2.NewPlaneService()
-
-	// ============ Workspace (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_workspaces",
-			mcp.WithDescription("列出当前用户有权访问的所有工作空间"),
-		),
-		handlePlaneV2ListWorkspaces,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_workspace",
-			mcp.WithDescription("获取工作空间详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-		),
-		handlePlaneV2GetWorkspace,
-	)
-
-	// ============ Project (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_projects",
-			mcp.WithDescription("列出工作空间下的所有项目"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-		),
-		handlePlaneV2ListProjects,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_project",
-			mcp.WithDescription("获取项目详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2GetProject,
-	)
-
-	// ============ Issue (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_issues",
-			mcp.WithDescription("列出项目工作项列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListIssues,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_issue",
-			mcp.WithDescription("获取工作项详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneV2GetIssue,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_sub_issues",
-			mcp.WithDescription("获取子工作项列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("issue_id", mcp.Required(), mcp.Description("父工作项 UUID")),
-		),
-		handlePlaneV2GetSubIssues,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_issue_activity",
-			mcp.WithDescription("获取工作项活动历史"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneV2GetIssueActivity,
-	)
-
-	// ============ Intake (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_intake_issues",
-			mcp.WithDescription("列出收件箱工作项"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("status", mcp.Description("状态过滤,如 -2,-1,0,1,2")),
-		),
-		handlePlaneV2ListIntakeIssues,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_intake_issue",
-			mcp.WithDescription("获取收件箱工作项详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项 UUID")),
-		),
-		handlePlaneV2GetIntakeIssue,
-	)
-
-	// ============ Cycle (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_cycles",
-			mcp.WithDescription("列出项目周期列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListCycles,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_cycle",
-			mcp.WithDescription("获取周期详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期 UUID")),
-		),
-		handlePlaneV2GetCycle,
-	)
-
-	// ============ Module (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_modules",
-			mcp.WithDescription("列出项目模块列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListModules,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_module",
-			mcp.WithDescription("获取模块详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("module_id", mcp.Required(), mcp.Description("模块 UUID")),
-		),
-		handlePlaneV2GetModule,
-	)
-
-	// ============ Label (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_labels",
-			mcp.WithDescription("列出项目标签列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListLabels,
-	)
-
-	// ============ State (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_states",
-			mcp.WithDescription("列出项目状态列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListStates,
-	)
-
-	// ============ Page (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_pages",
-			mcp.WithDescription("列出项目页面列表"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListPages,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_page",
-			mcp.WithDescription("获取页面详情"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("page_id", mcp.Required(), mcp.Description("页面 UUID")),
-		),
-		handlePlaneV2GetPage,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_get_page_description",
-			mcp.WithDescription("获取页面描述内容"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-			mcp.WithString("page_id", mcp.Required(), mcp.Description("页面 UUID")),
-		),
-		handlePlaneV2GetPageDescription,
-	)
-
-	// ============ Member (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_workspace_members",
-			mcp.WithDescription("列出工作空间成员"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-		),
-		handlePlaneV2ListWorkspaceMembers,
-	)
-	s.AddTool(
-		mcp.NewTool("plane_list_project_members",
-			mcp.WithDescription("列出项目成员"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-			mcp.WithString("project_id", mcp.Required(), mcp.Description("项目 UUID")),
-		),
-		handlePlaneV2ListProjectMembers,
-	)
-
-	// ============ Sticky (读) ============
-	s.AddTool(
-		mcp.NewTool("plane_list_stickies",
-			mcp.WithDescription("列出工作空间便签"),
-			mcp.WithString("workspace_slug", mcp.Required(), mcp.Description("工作空间 slug")),
-		),
-		handlePlaneV2ListStickies,
-	)
-}

+ 0 - 283
service/cmd/mcp/tools/data/plane_v2_api_handlers.go

@@ -1,283 +0,0 @@
-package data
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-)
-
-// planeV2RawResult 将 Plane V2 API 返回的原始 JSON bytes 转为 MCP 结果。
-// 原始字节先经流式 PII 脱敏(优化 B),脱敏失败(含非法 JSON)则返回错误、拒绝输出。
-// 结果较大时自动落盘到本地文件(/tmp/fwh-toolbox-agent/results/),
-// 返回 localPath + 预览,避免大数据占用 LLM 上下文。
-func planeV2RawResult(data []byte, err error, action string) (*mcp.CallToolResult, error) {
-	if err != nil {
-		return helper.ServiceError(action, err), nil
-	}
-	return helper.BytesResultOrFileMasked(data, "plane_v2_"+action), nil
-}
-
-// requireSlugProject 校验并返回必填的 workspace_slug 与 project_id。
-// 缺失时返回对应的 ParamError(errResult 非 nil,调用方应直接返回它)。
-func requireSlugProject(req mcp.CallToolRequest) (slug, projectId string, errResult *mcp.CallToolResult) {
-	slug, err := req.RequireString("workspace_slug")
-	if err != nil {
-		return "", "", helper.ParamError("workspace_slug")
-	}
-	projectId, err = req.RequireString("project_id")
-	if err != nil {
-		return "", "", helper.ParamError("project_id")
-	}
-	return slug, projectId, nil
-}
-
-// === Workspace Handlers ===
-
-func handlePlaneV2ListWorkspaces(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := planeV2Svc.ListWorkspaces()
-	return planeV2RawResult(data, err, "列出工作空间")
-}
-
-func handlePlaneV2GetWorkspace(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, err := req.RequireString("workspace_slug")
-	if err != nil {
-		return helper.ParamError("workspace_slug"), nil
-	}
-	data, apiErr := planeV2Svc.GetWorkspace(slug)
-	return planeV2RawResult(data, apiErr, "获取工作空间详情")
-}
-
-// === Project Handlers ===
-
-func handlePlaneV2ListProjects(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, err := req.RequireString("workspace_slug")
-	if err != nil {
-		return helper.ParamError("workspace_slug"), nil
-	}
-	data, apiErr := planeV2Svc.ListProjects(slug)
-	return planeV2RawResult(data, apiErr, "列出项目")
-}
-
-func handlePlaneV2GetProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, apiErr := planeV2Svc.GetProject(slug, projectId)
-	return planeV2RawResult(data, apiErr, "获取项目详情")
-}
-
-// === Issue Handlers ===
-
-func handlePlaneV2ListIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListIssues(slug, projectId)
-	return planeV2RawResult(data, err, "列出工作项")
-}
-
-func handlePlaneV2GetIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	issueId, err := req.RequireString("issue_id")
-	if err != nil {
-		return helper.ParamError("issue_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetIssue(slug, projectId, issueId)
-	return planeV2RawResult(data, apiErr, "获取工作项详情")
-}
-
-func handlePlaneV2GetSubIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	issueId, err := req.RequireString("issue_id")
-	if err != nil {
-		return helper.ParamError("issue_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetSubIssues(slug, projectId, issueId)
-	return planeV2RawResult(data, apiErr, "获取子工作项")
-}
-
-func handlePlaneV2GetIssueActivity(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	issueId, err := req.RequireString("issue_id")
-	if err != nil {
-		return helper.ParamError("issue_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetIssueActivity(slug, projectId, issueId)
-	return planeV2RawResult(data, apiErr, "获取工作项活动历史")
-}
-
-// === Intake Handlers ===
-
-func handlePlaneV2ListIntakeIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	status := req.GetString("status", "")
-	data, err := planeV2Svc.ListIntakeIssues(slug, projectId, status)
-	return planeV2RawResult(data, err, "列出收件箱工作项")
-}
-
-func handlePlaneV2GetIntakeIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	issueId, err := req.RequireString("issue_id")
-	if err != nil {
-		return helper.ParamError("issue_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetIntakeIssue(slug, projectId, issueId)
-	return planeV2RawResult(data, apiErr, "获取收件箱工作项详情")
-}
-
-// === Cycle Handlers ===
-
-func handlePlaneV2ListCycles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListCycles(slug, projectId)
-	return planeV2RawResult(data, err, "列出周期")
-}
-
-func handlePlaneV2GetCycle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	cycleId, err := req.RequireString("cycle_id")
-	if err != nil {
-		return helper.ParamError("cycle_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetCycle(slug, projectId, cycleId)
-	return planeV2RawResult(data, apiErr, "获取周期详情")
-}
-
-// === Module Handlers ===
-
-func handlePlaneV2ListModules(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListModules(slug, projectId)
-	return planeV2RawResult(data, err, "列出模块")
-}
-
-func handlePlaneV2GetModule(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	moduleId, err := req.RequireString("module_id")
-	if err != nil {
-		return helper.ParamError("module_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetModule(slug, projectId, moduleId)
-	return planeV2RawResult(data, apiErr, "获取模块详情")
-}
-
-// === Label Handlers ===
-
-func handlePlaneV2ListLabels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListLabels(slug, projectId)
-	return planeV2RawResult(data, err, "列出标签")
-}
-
-// === State Handlers ===
-
-func handlePlaneV2ListStates(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListStates(slug, projectId)
-	return planeV2RawResult(data, err, "列出状态")
-}
-
-// === Page Handlers ===
-
-func handlePlaneV2ListPages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListPages(slug, projectId)
-	return planeV2RawResult(data, err, "列出页面")
-}
-
-func handlePlaneV2GetPage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	pageId, err := req.RequireString("page_id")
-	if err != nil {
-		return helper.ParamError("page_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetPage(slug, projectId, pageId)
-	return planeV2RawResult(data, apiErr, "获取页面详情")
-}
-
-func handlePlaneV2GetPageDescription(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	pageId, err := req.RequireString("page_id")
-	if err != nil {
-		return helper.ParamError("page_id"), nil
-	}
-	data, apiErr := planeV2Svc.GetPageDescription(slug, projectId, pageId)
-	return planeV2RawResult(data, apiErr, "获取页面描述内容")
-}
-
-// === Member Handlers ===
-
-func handlePlaneV2ListWorkspaceMembers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, err := req.RequireString("workspace_slug")
-	if err != nil {
-		return helper.ParamError("workspace_slug"), nil
-	}
-	data, apiErr := planeV2Svc.ListWorkspaceMembers(slug)
-	return planeV2RawResult(data, apiErr, "列出工作空间成员")
-}
-
-func handlePlaneV2ListProjectMembers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, projectId, errRes := requireSlugProject(req)
-	if errRes != nil {
-		return errRes, nil
-	}
-	data, err := planeV2Svc.ListProjectMembers(slug, projectId)
-	return planeV2RawResult(data, err, "列出项目成员")
-}
-
-// === Sticky Handlers ===
-
-func handlePlaneV2ListStickies(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	slug, err := req.RequireString("workspace_slug")
-	if err != nil {
-		return helper.ParamError("workspace_slug"), nil
-	}
-	data, apiErr := planeV2Svc.ListStickies(slug)
-	return planeV2RawResult(data, apiErr, "列出便签")
-}

+ 0 - 97
service/cmd/mcp/tools/data/plane_v2_api_write.go

@@ -1,97 +0,0 @@
-package data
-
-// ============================================================
-// Plane V2 写操作工具(暂未启用,需要时取消注释并实现 handler)
-// ============================================================
-
-// --- Workspace 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_workspace", mcp.WithDescription("创建工作空间(V2 Web API)"), mcp.WithString("name", mcp.Required(), mcp.Description("工作空间名称")), mcp.WithString("slug", mcp.Required(), mcp.Description("工作空间标识"))), handlePlaneV2CreateWorkspace)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_workspace", mcp.WithDescription("更新工作空间(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateWorkspace)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_workspace", mcp.WithDescription("删除工作空间(V2 Web API)")), handlePlaneV2DeleteWorkspace)
-
-// --- Project 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_project", mcp.WithDescription("创建项目(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("项目数据JSON"))), handlePlaneV2CreateProject)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_project", mcp.WithDescription("更新项目(V2 Web API)"), mcp.WithString("project_id", mcp.Required(), mcp.Description("项目UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateProject)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_project", mcp.WithDescription("删除项目(V2 Web API)"), mcp.WithString("project_id", mcp.Required(), mcp.Description("项目UUID"))), handlePlaneV2DeleteProject)
-// s.AddTool(mcp.NewTool("data_plane_v2_archive_project", mcp.WithDescription("归档项目(V2 Web API)"), mcp.WithString("project_id", mcp.Required(), mcp.Description("项目UUID"))), handlePlaneV2ArchiveProject)
-// s.AddTool(mcp.NewTool("data_plane_v2_unarchive_project", mcp.WithDescription("取消归档项目(V2 Web API)"), mcp.WithString("project_id", mcp.Required(), mcp.Description("项目UUID"))), handlePlaneV2UnarchiveProject)
-
-// --- Issue 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_issue", mcp.WithDescription("创建工作项(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("工作项数据JSON"))), handlePlaneV2CreateIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_issue", mcp.WithDescription("更新工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_issue", mcp.WithDescription("删除工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2DeleteIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_archive_issue", mcp.WithDescription("归档工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2ArchiveIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_unarchive_issue", mcp.WithDescription("取消归档工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2UnarchiveIssue)
-
-// --- Cycle 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_cycle", mcp.WithDescription("创建周期(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("周期数据JSON"))), handlePlaneV2CreateCycle)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_cycle", mcp.WithDescription("更新周期(V2 Web API)"), mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateCycle)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_cycle", mcp.WithDescription("删除周期(V2 Web API)"), mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期UUID"))), handlePlaneV2DeleteCycle)
-// s.AddTool(mcp.NewTool("data_plane_v2_add_issues_to_cycle", mcp.WithDescription("添加工作项到周期(V2 Web API)"), mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期UUID")), mcp.WithString("issue_ids", mcp.Required(), mcp.Description("工作项UUID列表,逗号分隔"))), handlePlaneV2AddIssuesToCycle)
-// s.AddTool(mcp.NewTool("data_plane_v2_remove_issue_from_cycle", mcp.WithDescription("从周期移除工作项(V2 Web API)"), mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期UUID")), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2RemoveIssueFromCycle)
-// s.AddTool(mcp.NewTool("data_plane_v2_archive_cycle", mcp.WithDescription("归档周期(V2 Web API)"), mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期UUID"))), handlePlaneV2ArchiveCycle)
-// s.AddTool(mcp.NewTool("data_plane_v2_unarchive_cycle", mcp.WithDescription("取消归档周期(V2 Web API)"), mcp.WithString("cycle_id", mcp.Required(), mcp.Description("周期UUID"))), handlePlaneV2UnarchiveCycle)
-
-// --- Module 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_module", mcp.WithDescription("创建模块(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("模块数据JSON"))), handlePlaneV2CreateModule)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_module", mcp.WithDescription("更新模块(V2 Web API)"), mcp.WithString("module_id", mcp.Required(), mcp.Description("模块UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateModule)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_module", mcp.WithDescription("删除模块(V2 Web API)"), mcp.WithString("module_id", mcp.Required(), mcp.Description("模块UUID"))), handlePlaneV2DeleteModule)
-// s.AddTool(mcp.NewTool("data_plane_v2_add_issues_to_module", mcp.WithDescription("添加工作项到模块(V2 Web API)"), mcp.WithString("module_id", mcp.Required(), mcp.Description("模块UUID")), mcp.WithString("issue_ids", mcp.Required(), mcp.Description("工作项UUID列表,逗号分隔"))), handlePlaneV2AddIssuesToModule)
-// s.AddTool(mcp.NewTool("data_plane_v2_remove_issue_from_module", mcp.WithDescription("从模块移除工作项(V2 Web API)"), mcp.WithString("module_id", mcp.Required(), mcp.Description("模块UUID")), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2RemoveIssueFromModule)
-// s.AddTool(mcp.NewTool("data_plane_v2_archive_module", mcp.WithDescription("归档模块(V2 Web API)"), mcp.WithString("module_id", mcp.Required(), mcp.Description("模块UUID"))), handlePlaneV2ArchiveModule)
-// s.AddTool(mcp.NewTool("data_plane_v2_unarchive_module", mcp.WithDescription("取消归档模块(V2 Web API)"), mcp.WithString("module_id", mcp.Required(), mcp.Description("模块UUID"))), handlePlaneV2UnarchiveModule)
-
-// --- Label 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_label", mcp.WithDescription("创建标签(V2 Web API)"), mcp.WithString("name", mcp.Required(), mcp.Description("标签名称")), mcp.WithString("color", mcp.Required(), mcp.Description("标签颜色"))), handlePlaneV2CreateLabel)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_label", mcp.WithDescription("更新标签(V2 Web API)"), mcp.WithString("label_id", mcp.Required(), mcp.Description("标签UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateLabel)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_label", mcp.WithDescription("删除标签(V2 Web API)"), mcp.WithString("label_id", mcp.Required(), mcp.Description("标签UUID"))), handlePlaneV2DeleteLabel)
-
-// --- State 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_state", mcp.WithDescription("创建状态(V2 Web API)"), mcp.WithString("name", mcp.Required(), mcp.Description("状态名称")), mcp.WithString("color", mcp.Required(), mcp.Description("状态颜色")), mcp.WithString("group", mcp.Required(), mcp.Description("状态分组"))), handlePlaneV2CreateState)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_state", mcp.WithDescription("更新状态(V2 Web API)"), mcp.WithString("state_id", mcp.Required(), mcp.Description("状态UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateState)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_state", mcp.WithDescription("删除状态(V2 Web API)"), mcp.WithString("state_id", mcp.Required(), mcp.Description("状态UUID"))), handlePlaneV2DeleteState)
-// s.AddTool(mcp.NewTool("data_plane_v2_mark_state_default", mcp.WithDescription("标记状态为默认(V2 Web API)"), mcp.WithString("state_id", mcp.Required(), mcp.Description("状态UUID"))), handlePlaneV2MarkStateDefault)
-
-// --- Comment 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_comment", mcp.WithDescription("创建评论(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("comment_html", mcp.Required(), mcp.Description("评论HTML内容"))), handlePlaneV2CreateComment)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_comment", mcp.WithDescription("更新评论(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("comment_id", mcp.Required(), mcp.Description("评论UUID")), mcp.WithString("comment_html", mcp.Required(), mcp.Description("评论HTML内容"))), handlePlaneV2UpdateComment)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_comment", mcp.WithDescription("删除评论(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("comment_id", mcp.Required(), mcp.Description("评论UUID"))), handlePlaneV2DeleteComment)
-
-// --- Link 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_issue_link", mcp.WithDescription("创建工作项链接(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("url", mcp.Required(), mcp.Description("链接URL")), mcp.WithString("title", mcp.Required(), mcp.Description("链接标题"))), handlePlaneV2CreateIssueLink)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_issue_link", mcp.WithDescription("更新工作项链接(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("link_id", mcp.Required(), mcp.Description("链接UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateIssueLink)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_issue_link", mcp.WithDescription("删除工作项链接(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("link_id", mcp.Required(), mcp.Description("链接UUID"))), handlePlaneV2DeleteIssueLink)
-
-// --- Relation 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_issue_relation", mcp.WithDescription("创建工作项关联(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("relation_type", mcp.Required(), mcp.Description("关联类型")), mcp.WithString("related_ids", mcp.Required(), mcp.Description("关联工作项UUID列表,逗号分隔"))), handlePlaneV2CreateIssueRelation)
-// s.AddTool(mcp.NewTool("data_plane_v2_remove_issue_relation", mcp.WithDescription("移除工作项关联(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("relation_type", mcp.Required(), mcp.Description("关联类型")), mcp.WithString("related_id", mcp.Required(), mcp.Description("关联工作项UUID"))), handlePlaneV2RemoveIssueRelation)
-
-// --- Sticky 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_sticky", mcp.WithDescription("创建便签(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("便签数据JSON"))), handlePlaneV2CreateSticky)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_sticky", mcp.WithDescription("更新便签(V2 Web API)"), mcp.WithString("sticky_id", mcp.Required(), mcp.Description("便签UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateSticky)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_sticky", mcp.WithDescription("删除便签(V2 Web API)"), mcp.WithString("sticky_id", mcp.Required(), mcp.Description("便签UUID"))), handlePlaneV2DeleteSticky)
-
-// --- Intake 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_intake_issue", mcp.WithDescription("创建收件箱工作项(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("工作项数据JSON"))), handlePlaneV2CreateIntakeIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_intake_issue", mcp.WithDescription("更新收件箱工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdateIntakeIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_accept_intake_issue", mcp.WithDescription("接受收件箱工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2AcceptIntakeIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_reject_intake_issue", mcp.WithDescription("拒绝收件箱工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2RejectIntakeIssue)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_intake_issue", mcp.WithDescription("删除收件箱工作项(V2 Web API)"), mcp.WithString("issue_id", mcp.Required(), mcp.Description("工作项UUID"))), handlePlaneV2DeleteIntakeIssue)
-
-// --- Page 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_create_page", mcp.WithDescription("创建页面(V2 Web API)"), mcp.WithString("data", mcp.Required(), mcp.Description("页面数据JSON"))), handlePlaneV2CreatePage)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_page", mcp.WithDescription("更新页面(V2 Web API)"), mcp.WithString("page_id", mcp.Required(), mcp.Description("页面UUID")), mcp.WithString("data", mcp.Required(), mcp.Description("更新数据JSON"))), handlePlaneV2UpdatePage)
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_page", mcp.WithDescription("删除页面(V2 Web API)"), mcp.WithString("page_id", mcp.Required(), mcp.Description("页面UUID"))), handlePlaneV2DeletePage)
-// s.AddTool(mcp.NewTool("data_plane_v2_archive_page", mcp.WithDescription("归档页面(V2 Web API)"), mcp.WithString("page_id", mcp.Required(), mcp.Description("页面UUID"))), handlePlaneV2ArchivePage)
-// s.AddTool(mcp.NewTool("data_plane_v2_unarchive_page", mcp.WithDescription("取消归档页面(V2 Web API)"), mcp.WithString("page_id", mcp.Required(), mcp.Description("页面UUID"))), handlePlaneV2UnarchivePage)
-
-// --- Member 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_update_workspace_member_role", mcp.WithDescription("更新工作空间成员角色(V2 Web API)"), mcp.WithString("member_id", mcp.Required(), mcp.Description("成员UUID")), mcp.WithNumber("role", mcp.Required(), mcp.Description("角色值"))), handlePlaneV2UpdateWorkspaceMemberRole)
-// s.AddTool(mcp.NewTool("data_plane_v2_remove_workspace_member", mcp.WithDescription("移除工作空间成员(V2 Web API)"), mcp.WithString("member_id", mcp.Required(), mcp.Description("成员UUID"))), handlePlaneV2RemoveWorkspaceMember)
-// s.AddTool(mcp.NewTool("data_plane_v2_add_project_member", mcp.WithDescription("添加项目成员(V2 Web API)"), mcp.WithString("member_id", mcp.Required(), mcp.Description("成员UUID")), mcp.WithNumber("role", mcp.Required(), mcp.Description("角色值"))), handlePlaneV2AddProjectMember)
-// s.AddTool(mcp.NewTool("data_plane_v2_update_project_member_role", mcp.WithDescription("更新项目成员角色(V2 Web API)"), mcp.WithString("member_id", mcp.Required(), mcp.Description("成员UUID")), mcp.WithNumber("role", mcp.Required(), mcp.Description("角色值"))), handlePlaneV2UpdateProjectMemberRole)
-// s.AddTool(mcp.NewTool("data_plane_v2_remove_project_member", mcp.WithDescription("移除项目成员(V2 Web API)"), mcp.WithString("member_id", mcp.Required(), mcp.Description("成员UUID"))), handlePlaneV2RemoveProjectMember)
-
-// --- Asset 写操作 ---
-// s.AddTool(mcp.NewTool("data_plane_v2_delete_project_asset", mcp.WithDescription("删除项目资源(V2 Web API)"), mcp.WithString("asset_id", mcp.Required(), mcp.Description("资源UUID"))), handlePlaneV2DeleteProjectAsset)
-// s.AddTool(mcp.NewTool("data_plane_v2_bind_assets_to_entity", mcp.WithDescription("绑定资源到实体(V2 Web API)"), mcp.WithString("entity_id", mcp.Required(), mcp.Description("实体UUID")), mcp.WithString("asset_ids", mcp.Required(), mcp.Description("资源UUID列表,逗号分隔"))), handlePlaneV2BindAssetsToEntity)

+ 0 - 461
service/cmd/mcp/tools/data/redis.go

@@ -1,461 +0,0 @@
-package data
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cache/redisx"
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-)
-
-// Redis 只读工具集。
-// 说明:本工具集仅提供只读命令,不包含任何写操作。数据均位于业务约定的 Redis DB 12
-//(业务缓存 + asynq 任务队列),由 MCP 进程启动时的 cache.Init 固定连接。
-
-func registerRedisTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("data_redis_get",
-			mcp.WithDescription("读取 Redis 中指定 key 的值(只读)。支持 string/hash/list/set/zset;type=auto 时自动探测类型。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
-			mcp.WithString("type", mcp.Description("数据类型:auto(默认,自动探测)、string、hash、list、set、zset")),
-			mcp.WithNumber("limit", mcp.Description("list/set/zset 返回的最大元素数,默认100,最大1000。string/hash 忽略。")),
-		),
-		handleRedisGet,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_mget",
-			mcp.WithDescription("批量读取多个 string key 的值(只读)。"),
-			mcp.WithString("keys", mcp.Required(), mcp.Description("多个 key,用英文逗号分隔")),
-		),
-		handleRedisMGet,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_hget",
-			mcp.WithDescription("读取 hash 中指定 field 的值(只读)。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Redis hash key")),
-			mcp.WithString("field", mcp.Required(), mcp.Description("hash field 名")),
-		),
-		handleRedisHGet,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_type",
-			mcp.WithDescription("查看 key 的类型及规模概览(只读):类型、TTL、以及对应类型的长度/元素数。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
-		),
-		handleRedisType,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_exists",
-			mcp.WithDescription("检查 Redis key 是否存在,并返回 TTL 信息(只读)。key 支持精确匹配。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
-		),
-		handleRedisExists,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_ttl",
-			mcp.WithDescription("查看 key 的存活时间 TTL(只读)。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
-		),
-		handleRedisTTL,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_scan",
-			mcp.WithDescription("游标遍历 key(只读,非阻塞,推荐用于扫描)。返回本批 keys 与下一个游标(cursor=0 表示结束)。"),
-			mcp.WithString("match", mcp.Description("匹配模式,如 user:* ;为空则匹配全部")),
-			mcp.WithNumber("cursor", mcp.Description("起始游标,首次传 0,后续传上次返回的 next_cursor")),
-			mcp.WithNumber("count", mcp.Description("每批返回数量提示,默认100,最大1000")),
-		),
-		handleRedisScan,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_keys",
-			mcp.WithDescription("按 pattern 返回匹配的 key(只读)。注意:KEYS 为阻塞命令,大库慎用,建议优先用 data_redis_scan。"),
-			mcp.WithString("pattern", mcp.Required(), mcp.Description("匹配模式,如 user:* 或 * ")),
-			mcp.WithNumber("limit", mcp.Description("返回的最大 key 数,默认1000,最大10000")),
-		),
-		handleRedisKeys,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_stream_range",
-			mcp.WithDescription("读取 Stream 指定范围内的消息(只读)。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Stream key")),
-			mcp.WithString("start", mcp.Description("起始 ID,默认 '-'(最早)")),
-			mcp.WithString("stop", mcp.Description("结束 ID,默认 '+'(最新)")),
-			mcp.WithNumber("count", mcp.Description("返回的最大消息数,默认100,最大1000")),
-		),
-		handleRedisStreamRange,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_redis_stream_info",
-			mcp.WithDescription("查看 Stream 元信息与消费者组信息(只读):长度、首末消息、消费者组列表。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("Stream key")),
-		),
-		handleRedisStreamInfo,
-	)
-}
-
-// clampLimit 将 limit 限制在 [1, max],非法值回退到 def。
-func clampLimit(v, def, max int) int {
-	if v < 1 {
-		return def
-	}
-	if v > max {
-		return max
-	}
-	return v
-}
-
-func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-
-	keyType := req.GetString("type", "auto")
-	limit := clampLimit(int(req.GetFloat("limit", 100)), 100, 1000)
-
-	// auto:先探测类型
-	if keyType == "auto" {
-		t, terr := redisx.Type(ctx, key)
-		if terr != nil {
-			return helper.ServiceError("探测Redis key类型", terr), nil
-		}
-		if t == "none" {
-			return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
-		}
-		keyType = t
-	}
-
-	switch keyType {
-	case "string":
-		val, err := redisx.Get(ctx, key)
-		if err != nil {
-			return helper.EmptyResult(fmt.Sprintf("key 不存在或读取失败: %s (%v)", key, err)), nil
-		}
-		return helper.JSONResultOrFileAuto(map[string]any{
-			"key": key, "type": "string", "value": val,
-		}), nil
-
-	case "hash":
-		val, err := redisx.HGetAll(ctx, key)
-		if err != nil {
-			return helper.ServiceError("读取Redis hash", err), nil
-		}
-		if len(val) == 0 {
-			return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
-		}
-		return helper.JSONResultOrFileAuto(map[string]any{
-			"key": key, "type": "hash", "count": len(val), "value": val,
-		}), nil
-
-	case "list":
-		val, err := redisx.LRange(ctx, key, 0, int64(limit-1))
-		if err != nil {
-			return helper.ServiceError("读取Redis list", err), nil
-		}
-		if len(val) == 0 {
-			return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
-		}
-		return helper.JSONResultOrFileAuto(map[string]any{
-			"key": key, "type": "list", "count": len(val),
-			"limit": limit, "truncated": len(val) == limit, "value": val,
-		}), nil
-
-	case "set":
-		val, err := redisx.SMembers(ctx, key)
-		if err != nil {
-			return helper.ServiceError("读取Redis set", err), nil
-		}
-		if len(val) == 0 {
-			return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
-		}
-		total := len(val)
-		truncated := false
-		if total > limit {
-			val = val[:limit]
-			truncated = true
-		}
-		return helper.JSONResultOrFileAuto(map[string]any{
-			"key": key, "type": "set", "total": total, "count": len(val),
-			"limit": limit, "truncated": truncated, "value": val,
-		}), nil
-
-	case "zset":
-		val, err := redisx.ZRangeWithScores(ctx, key, 0, int64(limit-1))
-		if err != nil {
-			return helper.ServiceError("读取Redis zset", err), nil
-		}
-		if len(val) == 0 {
-			return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
-		}
-		members := make([]map[string]any, 0, len(val))
-		for _, z := range val {
-			members = append(members, map[string]any{"member": z.Member, "score": z.Score})
-		}
-		return helper.JSONResultOrFileAuto(map[string]any{
-			"key": key, "type": "zset", "count": len(members),
-			"limit": limit, "truncated": len(members) == limit, "value": members,
-		}), nil
-
-	default:
-		return helper.ParamInvalidError("type", "可选值: auto, string, hash, list, set, zset"), nil
-	}
-}
-
-func handleRedisMGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	raw, err := req.RequireString("keys")
-	if err != nil {
-		return helper.ParamError("keys"), nil
-	}
-	keys := make([]string, 0)
-	for _, k := range strings.Split(raw, ",") {
-		if s := strings.TrimSpace(k); s != "" {
-			keys = append(keys, s)
-		}
-	}
-	if len(keys) == 0 {
-		return helper.ParamError("keys"), nil
-	}
-	vals, err := redisx.MGet(ctx, keys...)
-	if err != nil {
-		return helper.ServiceError("批量读取Redis string", err), nil
-	}
-	result := make(map[string]any, len(keys))
-	for i, k := range keys {
-		if i < len(vals) {
-			result[k] = vals[i] // nil 表示 key 不存在
-		}
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"type": "mget", "count": len(keys), "value": result,
-	}), nil
-}
-
-func handleRedisHGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-	field, err := req.RequireString("field")
-	if err != nil {
-		return helper.ParamError("field"), nil
-	}
-	val, err := redisx.HGet(ctx, key, field)
-	if err != nil {
-		return helper.EmptyResult(fmt.Sprintf("field 不存在或读取失败: %s.%s (%v)", key, field, err)), nil
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"key": key, "type": "hash", "field": field, "value": val,
-	}), nil
-}
-
-func handleRedisType(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-	t, err := redisx.Type(ctx, key)
-	if err != nil {
-		return helper.ServiceError("获取Redis key类型", err), nil
-	}
-	if t == "none" {
-		return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
-	}
-
-	out := map[string]any{"key": key, "type": t}
-
-	// TTL
-	if ttl, e := redisx.TTL(ctx, key); e == nil {
-		out["ttl"] = ttlString(ttl.Seconds(), ttl == -1, ttl == -2)
-	}
-
-	// 各类型规模概览
-	switch t {
-	case "string":
-		if n, e := redisx.StrLen(ctx, key); e == nil {
-			out["length"] = n
-		}
-	case "hash":
-		if n, e := redisx.HLen(ctx, key); e == nil {
-			out["fields"] = n
-		}
-	case "list":
-		if n, e := redisx.LLen(ctx, key); e == nil {
-			out["length"] = n
-		}
-	case "set":
-		if n, e := redisx.SCard(ctx, key); e == nil {
-			out["members"] = n
-		}
-	case "zset":
-		if n, e := redisx.ZCard(ctx, key); e == nil {
-			out["members"] = n
-		}
-	case "stream":
-		if n, e := redisx.XLen(ctx, key); e == nil {
-			out["entries"] = n
-		}
-	}
-	return helper.JSONResultOrFileAuto(out), nil
-}
-
-func handleRedisExists(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-
-	exists, err := redisx.Exists(ctx, key)
-	if err != nil {
-		return helper.ServiceError("检查Redis key", err), nil
-	}
-	if !exists {
-		return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
-	}
-
-	ttl, err := redisx.TTL(ctx, key)
-	if err != nil {
-		return helper.ServiceError("获取TTL", err), nil
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"key": key, "exists": true,
-		"ttl": ttlString(ttl.Seconds(), ttl == -1, ttl == -2),
-	}), nil
-}
-
-func handleRedisTTL(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-	ttl, err := redisx.TTL(ctx, key)
-	if err != nil {
-		return helper.ServiceError("获取TTL", err), nil
-	}
-	if ttl == -2 {
-		return helper.EmptyResult(fmt.Sprintf("key 不存在: %s", key)), nil
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"key":         key,
-		"ttl":         ttlString(ttl.Seconds(), ttl == -1, ttl == -2),
-		"ttl_seconds": int64(ttl.Seconds()),
-	}), nil
-}
-
-func handleRedisScan(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	cursor := uint64(req.GetFloat("cursor", 0))
-	match := req.GetString("match", "")
-	count := int64(clampLimit(int(req.GetFloat("count", 100)), 100, 1000))
-
-	keys, next, err := redisx.Scan(ctx, cursor, match, count)
-	if err != nil {
-		return helper.ServiceError("SCAN 遍历", err), nil
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"type": "scan", "match": match, "count": len(keys),
-		"next_cursor": next, "done": next == 0, "keys": keys,
-	}), nil
-}
-
-func handleRedisKeys(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	pattern, err := req.RequireString("pattern")
-	if err != nil {
-		return helper.ParamError("pattern"), nil
-	}
-	limit := clampLimit(int(req.GetFloat("limit", 1000)), 1000, 10000)
-
-	keys, err := redisx.Keys(ctx, pattern)
-	if err != nil {
-		return helper.ServiceError("KEYS 匹配", err), nil
-	}
-	total := len(keys)
-	truncated := false
-	if total > limit {
-		keys = keys[:limit]
-		truncated = true
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"type": "keys", "pattern": pattern, "total": total,
-		"count": len(keys), "limit": limit, "truncated": truncated, "keys": keys,
-	}), nil
-}
-
-func handleRedisStreamRange(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-	start := req.GetString("start", "-")
-	stop := req.GetString("stop", "+")
-	count := int64(clampLimit(int(req.GetFloat("count", 100)), 100, 1000))
-
-	msgs, err := redisx.XRangeN(ctx, key, start, stop, count)
-	if err != nil {
-		return helper.ServiceError("读取Stream消息", err), nil
-	}
-	if len(msgs) == 0 {
-		return helper.EmptyResult(fmt.Sprintf("Stream 无消息或不存在: %s", key)), nil
-	}
-	items := make([]map[string]any, 0, len(msgs))
-	for _, m := range msgs {
-		items = append(items, map[string]any{"id": m.ID, "values": m.Values})
-	}
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"key": key, "type": "stream", "count": len(items),
-		"limit": count, "truncated": int64(len(items)) == count, "value": items,
-	}), nil
-}
-
-func handleRedisStreamInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-
-	xlen, err := redisx.XLen(ctx, key)
-	if err != nil {
-		return helper.ServiceError("获取Stream长度", err), nil
-	}
-
-	out := map[string]any{"key": key, "type": "stream", "length": xlen}
-
-	if groups, e := redisx.XInfoGroups(ctx, key); e == nil {
-		gs := make([]map[string]any, 0, len(groups))
-		for _, g := range groups {
-			gs = append(gs, map[string]any{
-				"name":              g.Name,
-				"consumers":         g.Consumers,
-				"pending":           g.Pending,
-				"last_delivered_id": g.LastDeliveredID,
-			})
-		}
-		out["groups"] = gs
-	}
-
-	return helper.JSONResultOrFileAuto(out), nil
-}
-
-// ttlString 将 TTL 秒数格式化为可读字符串。
-// persist 表示无过期时间(-1),missing 表示 key 不存在(-2)。
-func ttlString(seconds float64, persist, missing bool) string {
-	if missing {
-		return "key 不存在"
-	}
-	if persist || seconds < 0 {
-		return "永不过期"
-	}
-	return fmt.Sprintf("%ds", int64(seconds))
-}

+ 0 - 16
service/cmd/mcp/tools/data/register.go

@@ -1,16 +0,0 @@
-package data
-
-import "github.com/mark3labs/mcp-go/server"
-
-// RegisterDataTools 注册所有数据直查层 MCP Tools
-func RegisterDataTools(s *server.MCPServer) {
-	registerMongoTools(s)
-	registerRedisTools(s)
-	registerPgsqlTools(s)
-	registerEmsparaTools(s)
-	registerFwhApiTools(s)
-	// 暂时停用Plane V1 Api
-	// registerPlaneApiTools(s)
-	registerPlaneV2ApiTools(s)
-	registerSimApiTools(s)
-}

+ 0 - 189
service/cmd/mcp/tools/data/sim_api.go

@@ -1,189 +0,0 @@
-package data
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/repositories"
-)
-
-func registerSimApiTools(s *server.MCPServer) {
-	// SmartHub
-	s.AddTool(
-		mcp.NewTool("data_sim_smarthub_batch_info",
-			mcp.WithDescription("批量查询 SmartHub 平台 SIM 卡信息"),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleSimSmartHubBatchInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_sim_smarthub_card_info",
-			mcp.WithDescription("查询 SmartHub 平台单张 SIM 卡详细信息"),
-			mcp.WithString("giccid", mcp.Required(), mcp.Description("SIM 卡 GICCID")),
-		),
-		handleSimSmartHubCardInfo,
-	)
-
-	// Acceleronix
-	s.AddTool(
-		mcp.NewTool("data_sim_acceleronix_card_list",
-			mcp.WithDescription("查询 Acceleronix 平台 SIM 卡列表"),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleSimAcceleronixCardList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_sim_acceleronix_card_info",
-			mcp.WithDescription("查询 Acceleronix 平台单张 SIM 卡详情"),
-			mcp.WithString("iccid", mcp.Required(), mcp.Description("SIM 卡 ICCID")),
-		),
-		handleSimAcceleronixCardInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_sim_acceleronix_batch_data_info",
-			mcp.WithDescription("批量获取 Acceleronix SIM 卡数据用量信息"),
-			mcp.WithString("iccid_list", mcp.Required(), mcp.Description("ICCID 列表,逗号分隔")),
-			mcp.WithString("month", mcp.Required(), mcp.Description("月份,格式 YYYYMM(如 202601)")),
-		),
-		handleSimAcceleronixBatchDataInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_sim_acceleronix_data_pool_list",
-			mcp.WithDescription("查询 Acceleronix 流量池列表"),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleSimAcceleronixDataPoolList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("data_sim_acceleronix_data_pool_sim_info",
-			mcp.WithDescription("查询 Acceleronix 流量池中的 SIM 卡详情"),
-			mcp.WithString("data_pool_id", mcp.Required(), mcp.Description("流量池ID")),
-			mcp.WithNumber("page", mcp.Description("页码,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		),
-		handleSimAcceleronixDataPoolSimInfo,
-	)
-}
-
-// === Handlers ===
-
-func handleSimSmartHubBatchInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	data, err := repositories.QuerySmartHubBatchSimCardInfo(page, size)
-	if err != nil {
-		return helper.ServiceError("查询SmartHub SIM卡列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimSmartHubCardInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	giccid, err := req.RequireString("giccid")
-	if err != nil {
-		return helper.ParamError("giccid"), nil
-	}
-
-	data, err := repositories.QuerySmartHubSimCardInfo(giccid)
-	if err != nil {
-		return helper.ServiceError("查询SmartHub SIM卡详情", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimAcceleronixCardList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	data, err := repositories.QueryAcceleronixSimCardList(page, size)
-	if err != nil {
-		return helper.ServiceError("查询Acceleronix SIM卡列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimAcceleronixCardInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	iccid, err := req.RequireString("iccid")
-	if err != nil {
-		return helper.ParamError("iccid"), nil
-	}
-
-	data, err := repositories.QueryAcceleronixSimCardInfo(iccid)
-	if err != nil {
-		return helper.ServiceError("查询Acceleronix SIM卡详情", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimAcceleronixBatchDataInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	iccidListStr, err := req.RequireString("iccid_list")
-	if err != nil {
-		return helper.ParamError("iccid_list"), nil
-	}
-	month, err := req.RequireString("month")
-	if err != nil {
-		return helper.ParamError("month"), nil
-	}
-
-	// 验证月份格式
-	if len(month) != 6 {
-		return helper.ParamInvalidError("month", "需要 YYYYMM 格式,如 202601"), nil
-	}
-
-	iccidList := strings.Split(iccidListStr, ",")
-	for i := range iccidList {
-		iccidList[i] = strings.TrimSpace(iccidList[i])
-	}
-
-	if len(iccidList) == 0 {
-		return helper.ParamInvalidError("iccid_list", "至少需要一个ICCID"), nil
-	}
-	if len(iccidList) > 50 {
-		return helper.ParamInvalidError("iccid_list", fmt.Sprintf("最多50个ICCID,当前%d个", len(iccidList))), nil
-	}
-
-	data, err := repositories.QueryAcceleronixBatchSimCardDataInfo(iccidList, month)
-	if err != nil {
-		return helper.ServiceError("批量查询Acceleronix数据用量", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimAcceleronixDataPoolList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	data, err := repositories.QueryAcceleronixDataPoolList(page, size)
-	if err != nil {
-		return helper.ServiceError("查询Acceleronix流量池列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimAcceleronixDataPoolSimInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	poolID, err := req.RequireString("data_pool_id")
-	if err != nil {
-		return helper.ParamError("data_pool_id"), nil
-	}
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 20))
-
-	data, err := repositories.QueryAcceleronixDataPoolSimInfo(poolID, page, size)
-	if err != nil {
-		return helper.ServiceError("查询流量池SIM详情", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 156
service/cmd/mcp/tools/helper/helper.go

@@ -1,156 +0,0 @@
-package helper
-
-import (
-	"encoding/json"
-	"fmt"
-	"reflect"
-
-	"github.com/mark3labs/mcp-go/mcp"
-)
-
-// GetStringSlice 从请求参数中读取字符串数组(结构化数组 schema,如 WithArray+WithStringItems)。
-//   - 参数缺失或为 nil:返回 (nil, false)
-//   - 参数为 []any:逐元素转字符串(仅接受 string 元素,遇到非字符串元素返回 ok=false)
-//   - 其余类型:返回 ok=false
-//
-// 注意:本函数不接受 JSON 字符串形式的入参,参数应通过结构化数组 schema 传入。
-func GetStringSlice(req mcp.CallToolRequest, key string) ([]string, bool) {
-	args := req.GetArguments()
-	raw, exists := args[key]
-	if !exists || raw == nil {
-		return nil, false
-	}
-
-	arr, ok := raw.([]any)
-	if !ok {
-		return nil, false
-	}
-
-	out := make([]string, 0, len(arr))
-	for _, item := range arr {
-		s, ok := item.(string)
-		if !ok {
-			return nil, false
-		}
-		out = append(out, s)
-	}
-	return out, true
-}
-
-// JSONResult 将任意数据序列化为 JSON 文本返回,序列化前完成 PII 脱敏(A+B)。
-// 脱敏失败时返回错误,拒绝输出(fail-closed)。
-func JSONResult(data any) *mcp.CallToolResult {
-	b, err := marshalSanitized(data)
-	if err != nil {
-		return mcp.NewToolResultError(err.Error())
-	}
-	return mcp.NewToolResultText(string(b))
-}
-
-// TextResult 直接返回文本
-func TextResult(text string) *mcp.CallToolResult {
-	return mcp.NewToolResultText(text)
-}
-
-// ParamError 参数缺失错误
-func ParamError(name string) *mcp.CallToolResult {
-	return mcp.NewToolResultError(fmt.Sprintf("参数 %s 必填", name))
-}
-
-// ParamInvalidError 参数格式错误
-func ParamInvalidError(name string, detail string) *mcp.CallToolResult {
-	return mcp.NewToolResultError(fmt.Sprintf("参数 %s 格式错误: %s", name, detail))
-}
-
-// ServiceError 业务调用错误
-func ServiceError(action string, err error) *mcp.CallToolResult {
-	return mcp.NewToolResultError(fmt.Sprintf("%s失败: %v", action, err))
-}
-
-// BatchValidationError 批量校验错误:返回结构化的 valid/invalid 分离结果,
-// 便于 Agent 精确定位哪些输入非法并只修正这些项,而无需重猜整批。
-// reason 为对非法项的统一说明(如 "SN 格式非法")。
-func BatchValidationError(action string, valid []string, invalid []string, reason string) *mcp.CallToolResult {
-	return JSONResult(map[string]any{
-		"error":   true,
-		"action":  action,
-		"reason":  reason,
-		"valid":   valid,
-		"invalid": invalid,
-		"note":    "invalid 中的项未通过校验,请修正后重试;valid 为已通过校验的项。",
-	})
-}
-
-// EmptyResult 空结果
-func EmptyResult(msg string) *mcp.CallToolResult {
-	return mcp.NewToolResultText(msg)
-}
-
-// PageResult 将分页查询结果包装为 AI 友好的结构:分页元信息
-// total(符合条件的总数)、pageSize、pageNum、returned(本页返回条数)与
-// hasMore(是否还有下一页)直接返回,便于 Agent 判断是否翻完、如何继续翻页;
-// data 正文(带数据)经 PII 脱敏后无条件落盘,只在返回里给出 localPath 与 summary
-// (不含数据正文),避免分页数据进入模型上下文。
-//
-// hasMore = pageNum*pageSize < total(pageNum 从 1 开始)。
-// hint 用于落盘文件名前缀。
-func PageResult(total, pageSize, pageNum uint, data any, hint string) *mcp.CallToolResult {
-	returned := 0
-	// 尽力统计本页条数:data 通常是切片
-	switch d := data.(type) {
-	case nil:
-		returned = 0
-	default:
-		if arr, ok := toSliceLen(d); ok {
-			returned = arr
-		}
-	}
-
-	hasMore := uint(pageNum)*uint(pageSize) < uint(total)
-
-	meta := map[string]any{
-		"total":    total,
-		"pageSize": pageSize,
-		"pageNum":  pageNum,
-		"returned": returned,
-		"hasMore":  hasMore,
-	}
-
-	// 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)。
-func toSliceLen(v any) (int, bool) {
-	rv := reflect.ValueOf(v)
-	switch rv.Kind() {
-	case reflect.Slice, reflect.Array:
-		return rv.Len(), true
-	default:
-		return 0, false
-	}
-}

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

@@ -1,123 +0,0 @@
-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
-}

+ 0 - 141
service/cmd/mcp/tools/helper/pii_rules.go

@@ -1,141 +0,0 @@
-package helper
-
-import (
-	"strings"
-)
-
-// MaskChar 是脱敏时使用的掩码字符,默认为 '*'。
-// 抽为包级变量以便在需要时全局自定义(例如改为 '•' 或 '#')。
-var MaskChar = "*"
-
-// MaskKind 表示某个字段应采用的脱敏方式。
-type MaskKind int
-
-const (
-	// MaskFull 完全脱敏:整个值替换为固定长度的掩码串(不泄露长度)。
-	// 适用于手机号、完整地址串、经纬度、IMEI/IMSI/MAC/IP 等。
-	MaskFull MaskKind = iota
-	// MaskName 姓名脱敏:保留首字符,其余以掩码填充。
-	MaskName
-	// MaskEmail 邮箱脱敏:保留本地部分首字符与完整域名。
-	MaskEmail
-	// MaskAccount 账号脱敏:保留前 2 后 2,中间掩码。
-	MaskAccount
-)
-
-// piiFieldRules 是敏感字段名到脱敏方式的映射。
-// 匹配时统一将 JSON key 转为小写后查表(大小写不敏感精确匹配)。
-//
-// 明确不脱敏(保留原值)的字段(不出现在此表中即视为保留):
-//   - iccid / giccid:SIM 卡业务主键,作为查询标识符保留
-//   - city / province / country:行政区字段,地址保留到城市级
-//   - sn / giccid 等设备/业务标识
-var piiFieldRules = map[string]MaskKind{
-	// 手机号 —— 完全脱敏
-	"contactnumber":           MaskFull,
-	"financiercontractnumber": MaskFull,
-	"msisdn":                  MaskFull,
-	"phone":                   MaskFull,
-	"mobile":                  MaskFull,
-	"tel":                     MaskFull,
-
-	// 姓名 —— 保留首字符
-	"username":      MaskName,
-	"firstname":     MaskName,
-	"lastname":      MaskName,
-	"accountname":   MaskName,
-	"financiername": MaskName,
-	"display_name":  MaskName,
-	"displayname":   MaskName,
-
-	// 邮箱
-	"email": MaskEmail,
-
-	// 完整地址串 —— 完全脱敏(行政区字段 city/province/country 不在表中,保留)
-	"address":           MaskFull,
-	"deviceaddress":     MaskFull,
-	"addresssecondlast": MaskFull,
-	"gpsaddress":        MaskFull,
-
-	// 经纬度 —— 完全脱敏
-	"longitude": MaskFull,
-	"latitude":  MaskFull,
-	"lng":       MaskFull,
-	"lat":       MaskFull,
-
-	// 账号 —— 前2后2
-	"account":          MaskAccount,
-	"installeraccount": MaskAccount,
-
-	// 设备/网络标识 —— 完全脱敏(iccid/giccid 保留,不在表中)
-	"imei":      MaskFull,
-	"imsi":      MaskFull,
-	"mac":       MaskFull,
-	"deviceip":  MaskFull,
-	"gatewayip": MaskFull,
-}
-
-// fullMask 固定长度掩码串,用于 MaskFull,避免通过长度泄露原值信息。
-const fullMaskLen = 6
-
-// maskKindFor 返回字段名对应的脱敏方式;ok 为 false 表示该字段无需脱敏。
-func maskKindFor(key string) (MaskKind, bool) {
-	k, ok := piiFieldRules[strings.ToLower(key)]
-	return k, ok
-}
-
-// applyMaskString 对字符串值按指定方式脱敏。
-func applyMaskString(kind MaskKind, s string) string {
-	switch kind {
-	case MaskName:
-		return maskName(s)
-	case MaskEmail:
-		return maskEmail(s)
-	case MaskAccount:
-		return maskAccount(s)
-	case MaskFull:
-		fallthrough
-	default:
-		return maskFull()
-	}
-}
-
-// maskFull 返回固定长度的掩码串。
-func maskFull() string {
-	return strings.Repeat(MaskChar, fullMaskLen)
-}
-
-// maskName 保留首字符(按 rune),其余固定 3 个掩码字符。
-// 空字符串返回空。
-func maskName(s string) string {
-	if s == "" {
-		return s
-	}
-	r := []rune(s)
-	return string(r[0]) + strings.Repeat(MaskChar, 3)
-}
-
-// maskEmail 保留本地部分首字符 + 掩码 + 完整域名。
-// 非法邮箱(无 @)退化为完全脱敏。
-func maskEmail(s string) string {
-	if s == "" {
-		return s
-	}
-	at := strings.LastIndex(s, "@")
-	if at <= 0 || at == len(s)-1 {
-		return maskFull()
-	}
-	local := s[:at]
-	domain := s[at:] // 含 @
-	lr := []rune(local)
-	return string(lr[0]) + strings.Repeat(MaskChar, 3) + domain
-}
-
-// maskAccount 保留前 2 后 2,中间固定掩码;长度不足 5 时完全脱敏。
-func maskAccount(s string) string {
-	r := []rune(s)
-	if len(r) < 5 {
-		return maskFull()
-	}
-	return string(r[:2]) + strings.Repeat(MaskChar, 3) + string(r[len(r)-2:])
-}

+ 0 - 241
service/cmd/mcp/tools/helper/pii_stream.go

@@ -1,241 +0,0 @@
-package helper
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"strconv"
-)
-
-// MaskJSONBytes 对已序列化的 JSON 字节流做流式脱敏,返回脱敏后的 JSON 字节。
-//
-// 实现要点(优化 B):
-//   - 使用 json.Decoder 的 token 流式解析,不构建完整的 interface{} 对象树,
-//     内存占用为常数级(栈深度 = JSON 嵌套深度)。
-//   - 单次遍历:读到 object 的 key 命中敏感规则时,对其对应的 value 做脱敏;
-//     其余 token 原样透传。
-//   - 若敏感 key 的 value 是对象或数组,则整个子树被完全脱敏(fail-safe)。
-//   - 解析失败时返回 error(fail-closed,由上层拒绝输出,不泄露原始数据)。
-func MaskJSONBytes(in []byte) ([]byte, error) {
-	dec := json.NewDecoder(bytes.NewReader(in))
-	dec.UseNumber()
-
-	var out bytes.Buffer
-	w := &jsonWriter{buf: &out}
-
-	if err := maskValue(dec, w, nil); err != nil {
-		return nil, fmt.Errorf("PII 脱敏失败: %w", err)
-	}
-
-	// 确认没有多余内容(例如尾部垃圾),保证输入是单个合法 JSON 值。
-	if dec.More() {
-		return nil, fmt.Errorf("PII 脱敏失败: JSON 存在多余的尾部内容")
-	}
-
-	return out.Bytes(), nil
-}
-
-// maskValue 读取下一个 JSON 值并写入 w。
-// forceMask 非 nil 时,表示当前值处于某个敏感 key 之下,整个值(含子树)需完全脱敏。
-func maskValue(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
-	tok, err := dec.Token()
-	if err != nil {
-		return err
-	}
-
-	switch t := tok.(type) {
-	case json.Delim:
-		switch t {
-		case '{':
-			return maskObject(dec, w, forceMask)
-		case '[':
-			return maskArray(dec, w, forceMask)
-		default:
-			return fmt.Errorf("非预期的分隔符: %v", t)
-		}
-	default:
-		// 标量值(string / json.Number / bool / nil)
-		return writeScalar(w, tok, forceMask)
-	}
-}
-
-// maskObject 处理一个 JSON 对象。forceMask 非 nil 时整个对象子树被脱敏。
-func maskObject(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
-	w.openObject()
-	for dec.More() {
-		// 读 key
-		keyTok, err := dec.Token()
-		if err != nil {
-			return err
-		}
-		key, ok := keyTok.(string)
-		if !ok {
-			return fmt.Errorf("对象 key 非字符串: %T", keyTok)
-		}
-		w.writeKey(key)
-
-		// 决定该字段 value 的脱敏方式:
-		//   - 若已处于强制脱敏子树中,继续沿用父级 forceMask。
-		//   - 否则查规则表。
-		var childForce *MaskKind
-		if forceMask != nil {
-			childForce = forceMask
-		} else if kind, hit := maskKindFor(key); hit {
-			k := kind
-			childForce = &k
-		}
-
-		if err := maskValue(dec, w, childForce); err != nil {
-			return err
-		}
-	}
-	// 消费结束的 '}'
-	if _, err := dec.Token(); err != nil {
-		return err
-	}
-	w.closeObject()
-	return nil
-}
-
-// maskArray 处理一个 JSON 数组。forceMask 非 nil 时数组内每个元素都被脱敏。
-func maskArray(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
-	w.openArray()
-	for dec.More() {
-		if err := maskValue(dec, w, forceMask); err != nil {
-			return err
-		}
-	}
-	// 消费结束的 ']'
-	if _, err := dec.Token(); err != nil {
-		return err
-	}
-	w.closeArray()
-	return nil
-}
-
-// writeScalar 写入一个标量值,必要时脱敏。
-func writeScalar(w *jsonWriter, tok json.Token, forceMask *MaskKind) error {
-	if forceMask != nil {
-		// 处于敏感字段之下:
-		//   - 字符串按具体 MaskKind 处理
-		//   - 其余标量(数字/bool,例如经纬度以 number 存储)统一替换为掩码字符串
-		if s, ok := tok.(string); ok {
-			return w.writeValueRaw(quote(applyMaskString(*forceMask, s)))
-		}
-		if tok == nil {
-			// null 保持 null(无信息泄露)
-			return w.writeValueRaw("null")
-		}
-		return w.writeValueRaw(quote(maskFull()))
-	}
-
-	// 非敏感字段:原样写出
-	switch v := tok.(type) {
-	case string:
-		return w.writeValueRaw(quote(v))
-	case json.Number:
-		return w.writeValueRaw(v.String())
-	case bool:
-		return w.writeValueRaw(strconv.FormatBool(v))
-	case nil:
-		return w.writeValueRaw("null")
-	default:
-		return fmt.Errorf("非预期的标量类型: %T", tok)
-	}
-}
-
-// quote 将字符串按 JSON 规则转义并加引号。
-func quote(s string) string {
-	b, _ := json.Marshal(s)
-	return string(b)
-}
-
-// indentJSON 将紧凑 JSON 重新缩进为两空格格式,便于阅读并与优化 A 的输出保持一致。
-// 缩进失败(理论上不会发生,因输入已是合法 JSON)时返回原始字节。
-func indentJSON(b []byte) []byte {
-	var out bytes.Buffer
-	if err := json.Indent(&out, b, "", "  "); err != nil {
-		return b
-	}
-	return out.Bytes()
-}
-
-// jsonWriter 负责按结构写出 JSON,管理逗号与 key/value 状态。
-type jsonWriter struct {
-	buf   *bytes.Buffer
-	stack []*frame
-}
-
-type frame struct {
-	isObject   bool
-	needComma  bool // 是否需要在下一个元素/键前加逗号
-	expectKey  bool // 对象中:下一个 writeKey 之后期待 value
-	pendingKey bool // 已写入 key,等待 value(value 前不加逗号)
-}
-
-func (w *jsonWriter) top() *frame {
-	if len(w.stack) == 0 {
-		return nil
-	}
-	return w.stack[len(w.stack)-1]
-}
-
-// beforeValue 在写入一个值(标量或容器起始)之前处理逗号。
-func (w *jsonWriter) beforeValue() {
-	f := w.top()
-	if f == nil {
-		return
-	}
-	if f.isObject {
-		// 对象中值紧跟 key,key 已处理逗号,这里不加
-		if f.pendingKey {
-			f.pendingKey = false
-			return
-		}
-	}
-	if f.needComma {
-		w.buf.WriteByte(',')
-	}
-	f.needComma = true
-}
-
-func (w *jsonWriter) openObject() {
-	w.beforeValue()
-	w.buf.WriteByte('{')
-	w.stack = append(w.stack, &frame{isObject: true})
-}
-
-func (w *jsonWriter) closeObject() {
-	w.buf.WriteByte('}')
-	w.stack = w.stack[:len(w.stack)-1]
-}
-
-func (w *jsonWriter) openArray() {
-	w.beforeValue()
-	w.buf.WriteByte('[')
-	w.stack = append(w.stack, &frame{isObject: false})
-}
-
-func (w *jsonWriter) closeArray() {
-	w.buf.WriteByte(']')
-	w.stack = w.stack[:len(w.stack)-1]
-}
-
-func (w *jsonWriter) writeKey(key string) {
-	f := w.top()
-	if f != nil && f.needComma {
-		w.buf.WriteByte(',')
-	}
-	w.buf.WriteString(quote(key))
-	w.buf.WriteByte(':')
-	if f != nil {
-		f.needComma = true
-		f.pendingKey = true
-	}
-}
-
-func (w *jsonWriter) writeValueRaw(raw string) error {
-	w.beforeValue()
-	_, err := w.buf.WriteString(raw)
-	return err
-}

+ 0 - 165
service/cmd/mcp/tools/helper/pii_struct.go

@@ -1,165 +0,0 @@
-package helper
-
-import (
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/dto"
-)
-
-// sanitizeStrong 尝试对已知的强类型做源头脱敏(优化 A)。
-//
-// 返回:
-//   - 脱敏后的数据副本 + true:命中强类型,已完成脱敏。
-//   - nil + false:未命中强类型,调用方应改走流式脱敏(优化 B)。
-//
-// 设计约束:所有脱敏均作用于输入数据的副本,绝不修改上游对象(这些对象
-// 可能来自 Mongo 缓存或被其他调用方共享)。规则与掩码函数复用 pii_rules.go,
-// 确保 A 与 B 的行为一致。
-func sanitizeStrong(data any) (any, bool) {
-	switch v := data.(type) {
-	case *dto.SiteInfoDTO:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		sanitizeSiteInfoDTO(&cp)
-		return &cp, true
-
-	case dto.SiteInfoDTO:
-		sanitizeSiteInfoDTO(&v)
-		return v, true
-
-	case *dto.ResPage[dto.SiteInfoDTO]:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		cp.Data = make([]dto.SiteInfoDTO, len(v.Data))
-		copy(cp.Data, v.Data)
-		for i := range cp.Data {
-			sanitizeSiteInfoDTO(&cp.Data[i])
-		}
-		return &cp, true
-
-	case *domain.SimCardInfo:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		sanitizeDomainSimCardInfo(&cp)
-		return &cp, true
-
-	case *dto.SimCardInfo:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		sanitizeDTOSimCardInfo(&cp)
-		return &cp, true
-
-	case *dto.ResPage[dto.SimCardInfo]:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		cp.Data = make([]dto.SimCardInfo, len(v.Data))
-		copy(cp.Data, v.Data)
-		for i := range cp.Data {
-			sanitizeDTOSimCardInfo(&cp.Data[i])
-		}
-		return &cp, true
-
-	case *dto.ResPage[domain.SimCardInfo]:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		cp.Data = make([]domain.SimCardInfo, len(v.Data))
-		copy(cp.Data, v.Data)
-		for i := range cp.Data {
-			sanitizeDomainSimCardInfo(&cp.Data[i])
-		}
-		return &cp, true
-
-	case *domain.SimCardMonthlyData:
-		if v == nil {
-			return v, true
-		}
-		cp := *v
-		sanitizeSimCardMonthlyData(&cp)
-		return &cp, true
-
-	default:
-		return nil, false
-	}
-}
-
-// maskStrPtr 对 *string 字段就地脱敏(作用于副本的指针,需重新分配以免污染原值)。
-func maskStrPtr(p **string, kind MaskKind) {
-	if p == nil || *p == nil {
-		return
-	}
-	masked := applyMaskString(kind, **p)
-	*p = &masked
-}
-
-func maskStr(s *string, kind MaskKind) {
-	if s == nil {
-		return
-	}
-	*s = applyMaskString(kind, *s)
-}
-
-// sanitizeSiteInfoDTO 对站点信息做字段级脱敏。
-// 保留:city / province / country(行政区)、iccId(业务标识)、sn、各类版本与状态字段。
-func sanitizeSiteInfoDTO(s *dto.SiteInfoDTO) {
-	// 姓名
-	maskStrPtr(&s.UserName, MaskName)
-	maskStrPtr(&s.FirstName, MaskName)
-	maskStrPtr(&s.LastName, MaskName)
-	maskStrPtr(&s.AccountName, MaskName)
-	maskStrPtr(&s.FinancierName, MaskName)
-
-	// 手机号 —— 完全脱敏
-	maskStrPtr(&s.ContactNumber, MaskFull)
-	maskStrPtr(&s.FinancierContractNumber, MaskFull)
-
-	// 完整地址串 —— 完全脱敏
-	maskStrPtr(&s.Address, MaskFull)
-	maskStrPtr(&s.DeviceAddress, MaskFull)
-	maskStrPtr(&s.AddressSecondLast, MaskFull)
-
-	// 经纬度 —— 完全脱敏(string 类型)
-	s.Longitude = maskFull()
-	s.Latitude = maskFull()
-
-	// 账号
-	maskStrPtr(&s.Account, MaskAccount)
-	maskStrPtr(&s.InstallerAccount, MaskAccount)
-
-	// 设备/网络标识 —— 完全脱敏(保留 ICCID)
-	maskStrPtr(&s.Mac, MaskFull)
-	maskStrPtr(&s.DeviceIp, MaskFull)
-	maskStrPtr(&s.GatewayIp, MaskFull)
-	s.IMEI = maskFull()
-	// s.ICCID 保留
-}
-
-// sanitizeDomainSimCardInfo 对 domain.SimCardInfo 脱敏(保留 GICCID)。
-func sanitizeDomainSimCardInfo(s *domain.SimCardInfo) {
-	s.IMEI = maskFull()
-	s.IMSI = maskFull()
-	s.MSISDN = maskFull()
-	// s.GICCID 保留
-}
-
-// sanitizeDTOSimCardInfo 对 dto.SimCardInfo 脱敏(保留 GICCID、SN 列表)。
-func sanitizeDTOSimCardInfo(s *dto.SimCardInfo) {
-	s.IMEI = maskFull()
-	s.IMSI = maskFull()
-	s.MSISDN = maskFull()
-	// s.GICCID / s.SN 保留
-}
-
-// sanitizeSimCardMonthlyData 月用量数据本身不含 IMEI/IMSI/MSISDN,仅含 GICCID(simId) 与用量,
-// 无需脱敏;保留以显式声明该类型已被审阅。
-func sanitizeSimCardMonthlyData(_ *domain.SimCardMonthlyData) {}

+ 0 - 334
service/cmd/mcp/tools/helper/pii_test.go

@@ -1,334 +0,0 @@
-package helper
-
-import (
-	"encoding/json"
-	"strings"
-	"testing"
-
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/dto"
-)
-
-// ---------- 掩码函数单元测试 ----------
-
-func TestMaskName(t *testing.T) {
-	cases := map[string]string{
-		"":      "",
-		"张三":    "张***",
-		"John":  "J***",
-		"李":     "李***",
-	}
-	for in, want := range cases {
-		if got := maskName(in); got != want {
-			t.Errorf("maskName(%q)=%q, want %q", in, got, want)
-		}
-	}
-}
-
-func TestMaskEmail(t *testing.T) {
-	if got := maskEmail("john@corp.com"); got != "j***@corp.com" {
-		t.Errorf("maskEmail got %q", got)
-	}
-	// 非法邮箱退化为完全脱敏
-	if got := maskEmail("notanemail"); got != maskFull() {
-		t.Errorf("maskEmail(invalid) got %q", got)
-	}
-	if got := maskEmail(""); got != "" {
-		t.Errorf("maskEmail(empty) got %q", got)
-	}
-}
-
-func TestMaskAccount(t *testing.T) {
-	if got := maskAccount("abcdefgh"); got != "ab***gh" {
-		t.Errorf("maskAccount got %q", got)
-	}
-	// 过短完全脱敏
-	if got := maskAccount("abc"); got != maskFull() {
-		t.Errorf("maskAccount(short) got %q", got)
-	}
-}
-
-func TestMaskFullFixedLength(t *testing.T) {
-	if got := maskFull(); got != strings.Repeat(MaskChar, fullMaskLen) {
-		t.Errorf("maskFull got %q", got)
-	}
-}
-
-// ---------- 优化 B:流式 token 脱敏测试 ----------
-
-func TestMaskJSONBytes_FlatFields(t *testing.T) {
-	in := []byte(`{
-		"sn": "SN123",
-		"userName": "Alice",
-		"contactNumber": "13812345678",
-		"email": "a@b.com",
-		"longitude": 121.5,
-		"latitude": 31.2,
-		"address": "123 Main St",
-		"city": "Shanghai",
-		"province": "SH",
-		"country": "CN",
-		"iccid": "8986012345678901234",
-		"imei": "356938035643809",
-		"account": "installer01"
-	}`)
-	out, err := MaskJSONBytes(in)
-	if err != nil {
-		t.Fatalf("MaskJSONBytes err: %v", err)
-	}
-	var m map[string]any
-	if err := json.Unmarshal(out, &m); err != nil {
-		t.Fatalf("output not valid JSON: %v\n%s", err, out)
-	}
-
-	// 保留字段
-	if m["sn"] != "SN123" {
-		t.Errorf("sn should be preserved, got %v", m["sn"])
-	}
-	if m["iccid"] != "8986012345678901234" {
-		t.Errorf("iccid should be preserved, got %v", m["iccid"])
-	}
-	if m["city"] != "Shanghai" || m["province"] != "SH" || m["country"] != "CN" {
-		t.Errorf("admin region should be preserved: city=%v province=%v country=%v", m["city"], m["province"], m["country"])
-	}
-
-	// 完全脱敏字段
-	full := maskFull()
-	for _, k := range []string{"contactNumber", "longitude", "latitude", "address", "imei"} {
-		if m[k] != full {
-			t.Errorf("%s should be fully masked, got %v", k, m[k])
-		}
-	}
-	// 姓名
-	if m["userName"] != "A***" {
-		t.Errorf("userName masked wrong: %v", m["userName"])
-	}
-	// 邮箱
-	if m["email"] != "a***@b.com" {
-		t.Errorf("email masked wrong: %v", m["email"])
-	}
-	// 账号
-	if m["account"] != "in***01" {
-		t.Errorf("account masked wrong: %v", m["account"])
-	}
-}
-
-func TestMaskJSONBytes_Nested(t *testing.T) {
-	in := []byte(`{
-		"data": [
-			{"userName": "Bob", "sn": "S1", "nested": {"phone": "111", "keep": "ok"}},
-			{"userName": "Carol", "sn": "S2"}
-		],
-		"total": 2
-	}`)
-	out, err := MaskJSONBytes(in)
-	if err != nil {
-		t.Fatalf("err: %v", err)
-	}
-	var m map[string]any
-	if err := json.Unmarshal(out, &m); err != nil {
-		t.Fatalf("invalid JSON: %v\n%s", err, out)
-	}
-	arr := m["data"].([]any)
-	first := arr[0].(map[string]any)
-	if first["userName"] != "B***" {
-		t.Errorf("nested list userName wrong: %v", first["userName"])
-	}
-	if first["sn"] != "S1" {
-		t.Errorf("sn should be preserved in list: %v", first["sn"])
-	}
-	nested := first["nested"].(map[string]any)
-	if nested["phone"] != maskFull() {
-		t.Errorf("deeply nested phone should be masked: %v", nested["phone"])
-	}
-	if nested["keep"] != "ok" {
-		t.Errorf("non-sensitive nested field should be preserved: %v", nested["keep"])
-	}
-	if m["total"].(float64) != 2 {
-		t.Errorf("total wrong: %v", m["total"])
-	}
-}
-
-func TestMaskJSONBytes_SensitiveSubtreeRedacted(t *testing.T) {
-	// 敏感 key 对应对象/数组时,整个子树被脱敏
-	in := []byte(`{"address": {"street": "secret", "no": 42}, "list_phone": ["a","b"]}`)
-	// 注意 list_phone 不在规则表,仅测 address 子树
-	out, err := MaskJSONBytes(in)
-	if err != nil {
-		t.Fatalf("err: %v", err)
-	}
-	var m map[string]any
-	if err := json.Unmarshal(out, &m); err != nil {
-		t.Fatalf("invalid JSON: %v\n%s", err, out)
-	}
-	addr := m["address"].(map[string]any)
-	if addr["street"] != maskFull() {
-		t.Errorf("address.street should be masked: %v", addr["street"])
-	}
-	// number 子字段被替换为掩码字符串
-	if addr["no"] != maskFull() {
-		t.Errorf("address.no should be masked: %v", addr["no"])
-	}
-}
-
-func TestMaskJSONBytes_InvalidJSONFailsClosed(t *testing.T) {
-	_, err := MaskJSONBytes([]byte(`{not valid json`))
-	if err == nil {
-		t.Fatal("expected error for invalid JSON (fail-closed)")
-	}
-}
-
-func TestMaskJSONBytes_NullPreserved(t *testing.T) {
-	in := []byte(`{"userName": null, "phone": null}`)
-	out, err := MaskJSONBytes(in)
-	if err != nil {
-		t.Fatalf("err: %v", err)
-	}
-	if !strings.Contains(string(out), `"userName":null`) {
-		t.Errorf("null should be preserved: %s", out)
-	}
-}
-
-// ---------- 优化 A:强类型脱敏测试 ----------
-
-func TestSanitizeSiteInfoDTO_NoUpstreamPollution(t *testing.T) {
-	name := "Alice"
-	phone := "13812345678"
-	addr := "123 Main St"
-	iccid := "8986012345678901234"
-	orig := &dto.SiteInfoDTO{
-		Sn:            "SN1",
-		UserName:      &name,
-		ContactNumber: &phone,
-		Address:       &addr,
-		Longitude:     "121.5",
-		Latitude:      "31.2",
-		City:          "Shanghai",
-		ICCID:         iccid,
-		IMEI:          "356938035643809",
-	}
-
-	sanitized, ok := sanitizeStrong(orig)
-	if !ok {
-		t.Fatal("expected strong-type hit for *dto.SiteInfoDTO")
-	}
-	s := sanitized.(*dto.SiteInfoDTO)
-
-	// 脱敏结果正确
-	if *s.UserName != "A***" {
-		t.Errorf("userName: %v", *s.UserName)
-	}
-	if *s.ContactNumber != maskFull() {
-		t.Errorf("contactNumber: %v", *s.ContactNumber)
-	}
-	if *s.Address != maskFull() {
-		t.Errorf("address: %v", *s.Address)
-	}
-	if s.Longitude != maskFull() || s.Latitude != maskFull() {
-		t.Errorf("geo not masked: %v %v", s.Longitude, s.Latitude)
-	}
-	if s.IMEI != maskFull() {
-		t.Errorf("imei: %v", s.IMEI)
-	}
-	// 保留字段
-	if s.ICCID != iccid {
-		t.Errorf("iccid should be preserved: %v", s.ICCID)
-	}
-	if s.City != "Shanghai" {
-		t.Errorf("city should be preserved: %v", s.City)
-	}
-
-	// 上游未被污染
-	if *orig.UserName != "Alice" || *orig.ContactNumber != "13812345678" || *orig.Address != "123 Main St" {
-		t.Errorf("upstream polluted: name=%v phone=%v addr=%v", *orig.UserName, *orig.ContactNumber, *orig.Address)
-	}
-	if orig.Longitude != "121.5" || orig.IMEI != "356938035643809" {
-		t.Errorf("upstream scalar polluted: lng=%v imei=%v", orig.Longitude, orig.IMEI)
-	}
-}
-
-func TestSanitizeResPageSiteInfo(t *testing.T) {
-	name := "Bob"
-	orig := &dto.ResPage[dto.SiteInfoDTO]{
-		Data:  []dto.SiteInfoDTO{{Sn: "S1", UserName: &name, IMEI: "111"}},
-		Total: 1,
-	}
-	sanitized, ok := sanitizeStrong(orig)
-	if !ok {
-		t.Fatal("expected hit")
-	}
-	s := sanitized.(*dto.ResPage[dto.SiteInfoDTO])
-	if *s.Data[0].UserName != "B***" {
-		t.Errorf("page item userName: %v", *s.Data[0].UserName)
-	}
-	// 上游 slice 未被污染
-	if *orig.Data[0].UserName != "Bob" {
-		t.Errorf("upstream page polluted: %v", *orig.Data[0].UserName)
-	}
-}
-
-func TestSanitizeDomainSimCardInfo(t *testing.T) {
-	orig := &domain.SimCardInfo{GICCID: "GIC19", IMEI: "imei1", IMSI: "imsi1", MSISDN: "msisdn1"}
-	sanitized, ok := sanitizeStrong(orig)
-	if !ok {
-		t.Fatal("expected hit")
-	}
-	s := sanitized.(*domain.SimCardInfo)
-	if s.IMEI != maskFull() || s.IMSI != maskFull() || s.MSISDN != maskFull() {
-		t.Errorf("sim identifiers not masked: %+v", s)
-	}
-	if s.GICCID != "GIC19" {
-		t.Errorf("giccid should be preserved: %v", s.GICCID)
-	}
-	// 上游未污染
-	if orig.IMEI != "imei1" {
-		t.Errorf("upstream sim polluted: %v", orig.IMEI)
-	}
-}
-
-// ---------- A/B 一致性测试 ----------
-
-func TestAB_Consistency_SiteInfo(t *testing.T) {
-	name := "Alice"
-	phone := "13812345678"
-	addr := "123 Main St"
-	site := &dto.SiteInfoDTO{
-		Sn:            "SN1",
-		UserName:      &name,
-		ContactNumber: &phone,
-		Address:       &addr,
-		Longitude:     "121.5",
-		City:          "Shanghai",
-		ICCID:         "8986012345678901234",
-		IMEI:          "356938035643809",
-	}
-
-	// A 路径
-	sanitized, _ := sanitizeStrong(site)
-	aBytes, err := json.Marshal(sanitized)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// B 路径:直接对原始结构体序列化后走流式脱敏
-	rawBytes, _ := json.Marshal(site)
-	bBytes, err := MaskJSONBytes(rawBytes)
-	if err != nil {
-		t.Fatalf("B err: %v", err)
-	}
-
-	// 归一化比较(字段顺序可能不同,比较解析后的 map)
-	var ma, mb map[string]any
-	if err := json.Unmarshal(aBytes, &ma); err != nil {
-		t.Fatal(err)
-	}
-	if err := json.Unmarshal(bBytes, &mb); err != nil {
-		t.Fatal(err)
-	}
-
-	for _, k := range []string{"userName", "contactNumber", "address", "longitude", "imei", "iccId", "city", "sn"} {
-		if ma[k] != mb[k] {
-			t.Errorf("A/B mismatch on %q: A=%v B=%v", k, ma[k], mb[k])
-		}
-	}
-}

+ 0 - 384
service/cmd/mcp/tools/helper/spill.go

@@ -1,384 +0,0 @@
-package helper
-
-import (
-	"bufio"
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"regexp"
-	"sort"
-	"strings"
-	"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")
-
-// hintSanitizer 用于清洗文件名 hint,只保留安全字符
-var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
-
-// JSONResultOrFile 将数据序列化为 JSON,并在序列化前完成 PII 脱敏(A+B):
-//   - 命中已知强类型:走源头结构体脱敏(优化 A),零额外内存开销。
-//   - 其余类型:序列化后走流式 token 脱敏(优化 B)。
-//   - 脱敏失败:返回错误,拒绝输出(fail-closed,绝不泄露原始数据)。
-//
-// 脱敏完成后无条件落盘(数据类返回统一策略):无论数据大小都写入本地文件,
-// 返回体只含 localPath + bytes + summary(不含数据正文),避免数据进入模型上下文。
-//
-// hint 用于生成可读的文件名前缀(如 "plane_work_items")。
-func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
-	b, err := marshalSanitized(data)
-	if err != nil {
-		return mcp.NewToolResultError(err.Error())
-	}
-	return BytesResultOrFile(b, hint)
-}
-
-// marshalSanitized 执行 A+B 脱敏并返回脱敏后的 JSON 字节。
-//   - 优化 A:命中强类型时先对副本做字段级脱敏,再直接序列化(无需二次处理)。
-//   - 优化 B:未命中强类型时序列化后做流式 token 脱敏。
-func marshalSanitized(data any) ([]byte, error) {
-	if sanitized, ok := sanitizeStrong(data); ok {
-		b, err := json.MarshalIndent(sanitized, "", "  ")
-		if err != nil {
-			return nil, fmt.Errorf("序列化失败: %v", err)
-		}
-		return b, nil
-	}
-
-	b, err := json.MarshalIndent(data, "", "  ")
-	if err != nil {
-		return nil, fmt.Errorf("序列化失败: %v", err)
-	}
-	masked, err := MaskJSONBytes(b)
-	if err != nil {
-		return nil, err
-	}
-	return indentJSON(masked), nil
-}
-
-// JSONResultOrFileAuto 与 JSONResultOrFile 相同,但使用通用文件名前缀,
-// 便于对现有 helper.JSONResult 调用点做无侵入替换。
-func JSONResultOrFileAuto(data any) *mcp.CallToolResult {
-	return JSONResultOrFile(data, "result")
-}
-
-// TextResultOrFile 针对纯文本返回值无条件落盘(数据类返回统一策略):
-// 无论文本大小都写入本地文件,返回体只含 localPath + summary(不含正文)。
-func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
-	return BytesResultOrFile([]byte(text), hint)
-}
-
-// BytesResultOrFileMasked 针对上游返回的原始 JSON 字节(如 Plane API 响应)
-// 先做流式 PII 脱敏(优化 B),再无条件落盘(数据类返回统一策略)。
-//   - 脱敏失败(含无法解析的非法 JSON):返回错误,拒绝输出(fail-closed)。
-//
-// 相比"unmarshal 成 any 再 JSONResultOrFile",此路径省去一次反序列化+序列化往返。
-func BytesResultOrFileMasked(b []byte, hint string) *mcp.CallToolResult {
-	masked, err := MaskJSONBytes(b)
-	if err != nil {
-		return mcp.NewToolResultError(err.Error())
-	}
-	return BytesResultOrFile(indentJSON(masked), hint)
-}
-
-// BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做无条件落盘。
-//
-// 语义(数据类返回):无论数据大小都写入本地文件,返回结果只包含
-// localPath + bytes + summary(不含数据正文),供 Agent 按 localPath 读取完整内容。
-// 这样任意"带数据"的查询/导出结果都不会进入模型上下文,从根本上避免上下文膨胀。
-//
-// 落盘失败时返回错误(不再退化为内联,避免大结果灌爆上下文)。
-func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
-	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.NewToolResultError(fmt.Sprintf("结果落盘失败: %v", werr))
-	}
-
-	meta := map[string]any{
-		"localPath": path,
-		"bytes":     len(b),
-		"note":      "结果数据已写入本地文件,请使用文件读取工具按 localPath 读取完整内容。",
-	}
-	// 附带结构化摘要:帮助 Agent 在不读回完整文件的情况下了解数据规模与字段,节省 token。
-	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))
-}
-
-// 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())
-	}
-	return spillResult(b, hint)
-}
-
-// marshalSanitizedCompact 对单条数据做 A+B 脱敏并返回紧凑(无缩进/无换行)的 JSON 字节,
-// 适用于 NDJSON 逐行写入。
-//   - 强类型命中:sanitizeStrong 后 json.Marshal(紧凑)。
-//   - 否则:json.Marshal 后 MaskJSONBytes(其输出本身即紧凑)。
-func marshalSanitizedCompact(data any) ([]byte, error) {
-	if sanitized, ok := sanitizeStrong(data); ok {
-		b, err := json.Marshal(sanitized)
-		if err != nil {
-			return nil, fmt.Errorf("序列化失败: %v", err)
-		}
-		return b, nil
-	}
-	b, err := json.Marshal(data)
-	if err != nil {
-		return nil, fmt.Errorf("序列化失败: %v", err)
-	}
-	return MaskJSONBytes(b)
-}
-
-// ExportStreamNDJSON 流式导出:逐条消费 next(),边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),
-// 内存占用为常数级(任意时刻仅持有一条记录),适合超大数据量导出。
-//
-// next 语义:返回 (记录, more, err)。
-//   - more=false 且 err=nil:正常结束。
-//   - err!=nil:迭代出错,导出中止并清理半成品文件。
-//
-// 任一条脱敏/写入失败:删除半成品文件并返回错误(fail-closed,绝不留下未脱敏残片)。
-// 成功返回 {exported:true, format:"ndjson", localPath, count, summary}(不含数据正文)。
-func ExportStreamNDJSON(next func() (any, bool, error), hint string) *mcp.CallToolResult {
-	if err := os.MkdirAll(spillDir, 0755); err != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("创建导出目录失败: %v", err))
-	}
-	name := fmt.Sprintf("%s_%d.ndjson", sanitizeHint(hint), time.Now().UnixNano())
-	path := filepath.Join(spillDir, name)
-
-	f, err := os.Create(path)
-	if err != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("创建导出文件失败: %v", err))
-	}
-
-	// 失败时清理半成品文件的辅助闭包
-	failClose := func(msg string) *mcp.CallToolResult {
-		f.Close()
-		_ = os.Remove(path)
-		return mcp.NewToolResultError(msg)
-	}
-
-	w := bufio.NewWriter(f)
-	var count int64
-	var fields []string // 采样首条记录的字段名用于 summary
-
-	for {
-		rec, more, ierr := next()
-		if ierr != nil {
-			return failClose(fmt.Sprintf("导出迭代失败: %v", ierr))
-		}
-		if !more {
-			break
-		}
-
-		line, merr := marshalSanitizedCompact(rec)
-		if merr != nil {
-			return failClose(merr.Error())
-		}
-
-		// 采样首条字段名(尽力而为,失败不影响导出)
-		if count == 0 {
-			var m map[string]any
-			if json.Unmarshal(line, &m) == nil {
-				fields = sortedKeys(m)
-			}
-		}
-
-		if _, werr := w.Write(line); werr != nil {
-			return failClose(fmt.Sprintf("写入导出文件失败: %v", werr))
-		}
-		if werr := w.WriteByte('\n'); werr != nil {
-			return failClose(fmt.Sprintf("写入导出文件失败: %v", werr))
-		}
-		count++
-	}
-
-	if werr := w.Flush(); werr != nil {
-		return failClose(fmt.Sprintf("刷新导出文件失败: %v", werr))
-	}
-	if cerr := f.Close(); cerr != nil {
-		_ = os.Remove(path)
-		return mcp.NewToolResultError(fmt.Sprintf("关闭导出文件失败: %v", cerr))
-	}
-
-	if count == 0 {
-		_ = os.Remove(path)
-		return EmptyResult("符合条件的记录为 0,未生成导出文件")
-	}
-
-	meta := map[string]any{
-		"exported":  true,
-		"format":    "ndjson",
-		"localPath": path,
-		"count":     count,
-		"note":      "全量导出结果已写入 NDJSON 文件(每行一个 JSON 对象),请使用文件读取工具按 localPath 逐行读取解析。",
-	}
-	if len(fields) > 0 {
-		meta["summary"] = map[string]any{
-			"topType":  "ndjson",
-			"count":    count,
-			"itemType": "object",
-			"fields":   fields,
-		}
-	}
-	mb, err := json.MarshalIndent(meta, "", "  ")
-	if err != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("导出元信息序列化失败: %v", err))
-	}
-	return mcp.NewToolResultText(string(mb))
-}
-
-// summarizeJSON 尝试从已序列化的 JSON 字节中提取结构化摘要,供 Agent 快速了解
-// 落盘数据的规模与结构,从而尽量避免读回完整文件。解析失败时返回 nil(不影响主流程)。
-//
-// 摘要内容:
-//   - topType: 顶层类型(array / object / scalar)
-//   - 顶层为数组:count(元素个数)、itemType、fields(首个对象元素的字段名,采样)
-//   - 顶层为对象:fields(顶层字段名)
-func summarizeJSON(b []byte) map[string]any {
-	var v any
-	dec := json.NewDecoder(strings.NewReader(string(b)))
-	dec.UseNumber()
-	if err := dec.Decode(&v); err != nil {
-		return nil
-	}
-
-	switch t := v.(type) {
-	case []any:
-		s := map[string]any{
-			"topType": "array",
-			"count":   len(t),
-		}
-		if len(t) > 0 {
-			if obj, ok := t[0].(map[string]any); ok {
-				s["itemType"] = "object"
-				s["fields"] = sortedKeys(obj)
-			} else {
-				s["itemType"] = scalarKind(t[0])
-			}
-		}
-		return s
-	case map[string]any:
-		return map[string]any{
-			"topType": "object",
-			"fields":  sortedKeys(t),
-		}
-	default:
-		return map[string]any{"topType": "scalar"}
-	}
-}
-
-// sortedKeys 返回 map 的键列表(稳定排序,便于 Agent 阅读与 diff)。
-func sortedKeys(m map[string]any) []string {
-	keys := make([]string, 0, len(m))
-	for k := range m {
-		keys = append(keys, k)
-	}
-	sort.Strings(keys)
-	return keys
-}
-
-// scalarKind 返回标量的粗粒度类型名。
-func scalarKind(v any) string {
-	switch v.(type) {
-	case string:
-		return "string"
-	case bool:
-		return "bool"
-	case nil:
-		return "null"
-	default:
-		// json.Number 及其它
-		return "number"
-	}
-}
-
-// writeSpillFile 将 bytes 写入落盘目录,返回绝对路径。
-func writeSpillFile(hint string, b []byte) (string, error) {
-	if err := os.MkdirAll(spillDir, 0755); err != nil {
-		return "", err
-	}
-	name := fmt.Sprintf("%s_%d.json", sanitizeHint(hint), time.Now().UnixNano())
-	path := filepath.Join(spillDir, name)
-	if err := os.WriteFile(path, b, 0644); err != nil {
-		return "", err
-	}
-	return path, nil
-}
-
-// sanitizeHint 清洗文件名前缀,避免路径穿越和非法字符
-func sanitizeHint(hint string) string {
-	hint = hintSanitizer.ReplaceAllString(hint, "_")
-	hint = strings.Trim(hint, "_")
-	if hint == "" {
-		hint = "result"
-	}
-	if len(hint) > 64 {
-		hint = hint[:64]
-	}
-	return hint
-}
-
-// ValidateSourcePath 校验输入侧的本地文件路径:
-//   - 必须位于 AgentBaseDir(/tmp/fwh-toolbox-agent)目录或其子目录内
-//   - 清理 ".." 等路径穿越
-//
-// 返回清理后的绝对路径;非法路径返回 error。
-func ValidateSourcePath(path string) (string, error) {
-	if path == "" {
-		return "", fmt.Errorf("路径为空")
-	}
-
-	abs, err := filepath.Abs(filepath.Clean(path))
-	if err != nil {
-		return "", fmt.Errorf("路径解析失败: %v", err)
-	}
-
-	base := filepath.Clean(AgentBaseDir)
-	// 允许 base 本身或 base 的子路径;用分隔符前缀避免 /tmp/fwh-toolbox-agent-evil 误通过
-	if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
-		return "", fmt.Errorf("路径不在允许目录内,仅允许 %s 及其子目录", AgentBaseDir)
-	}
-
-	return abs, nil
-}

+ 0 - 165
service/cmd/mcp/tools/middleware/logging.go

@@ -1,165 +0,0 @@
-// Package middleware 提供 MCP 工具调用的横切关注点(日志等)。
-package middleware
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"runtime/debug"
-	"strings"
-	"time"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-	"go.uber.org/zap"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-)
-
-// maxParamsLogBytes 参数写入日志时的最大字节数,超过则截断,避免日志过大。
-const maxParamsLogBytes = 2 * 1024
-
-// maxErrLogBytes 错误结果内容写入日志时的最大字节数。
-const maxErrLogBytes = 1 * 1024
-
-// requestSeq 为每次调用生成一个进程内自增的请求序号,用于关联同一次调用的多条日志。
-var requestSeq int64
-
-// LoggingMiddleware 返回一个 MCP 工具处理中间件,统一记录工具调用日志:
-//
-// 第 1 类(调用入口/出口):
-//   - 入口:tool、request_id、脱敏后的 params(截断)
-//   - 出口:duration_ms、status、result_size / spilled
-//
-// 第 2 类(错误与异常):
-//   - handler 返回 Go error         -> level=error
-//   - 业务错误结果(result.IsError)-> level=warn,附错误内容摘要
-//   - panic                         -> level=error,附堆栈,并重新抛出(保持 WithRecovery 行为)
-func LoggingMiddleware() server.ToolHandlerMiddleware {
-	return func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
-		return func(ctx context.Context, req mcp.CallToolRequest) (result *mcp.CallToolResult, err error) {
-			tool := req.Params.Name
-			requestID := nextRequestID(tool)
-			start := time.Now()
-
-			// 入口日志
-			zap.L().Info("[tool] call start",
-				zap.String("request_id", requestID),
-				zap.String("tool", tool),
-				zap.String("params", maskedParams(req)),
-			)
-
-			// panic 兜底:记录后重新抛出,交由上层 WithRecovery 处理
-			defer func() {
-				if r := recover(); r != nil {
-					zap.L().Error("[tool] call panic",
-						zap.String("request_id", requestID),
-						zap.String("tool", tool),
-						zap.Int64("duration_ms", time.Since(start).Milliseconds()),
-						zap.Any("panic", r),
-						zap.String("stack", string(debug.Stack())),
-					)
-					panic(r)
-				}
-			}()
-
-			result, err = next(ctx, req)
-			durationMS := time.Since(start).Milliseconds()
-
-			switch {
-			case err != nil:
-				// handler 返回 Go error(框架级/传输级错误)
-				zap.L().Error("[tool] call error",
-					zap.String("request_id", requestID),
-					zap.String("tool", tool),
-					zap.Int64("duration_ms", durationMS),
-					zap.String("status", "error"),
-					zap.Error(err),
-				)
-			case result != nil && result.IsError:
-				// 业务错误结果(handler 返回 mcp.NewToolResultError)
-				zap.L().Warn("[tool] call business_error",
-					zap.String("request_id", requestID),
-					zap.String("tool", tool),
-					zap.Int64("duration_ms", durationMS),
-					zap.String("status", "business_error"),
-					zap.String("err", truncate(resultText(result), maxErrLogBytes)),
-				)
-			default:
-				// 成功
-				size, spilled := resultMeta(result)
-				zap.L().Info("[tool] call success",
-					zap.String("request_id", requestID),
-					zap.String("tool", tool),
-					zap.Int64("duration_ms", durationMS),
-					zap.String("status", "success"),
-					zap.Int("result_size", size),
-					zap.Bool("spilled", spilled),
-				)
-			}
-
-			return result, err
-		}
-	}
-}
-
-// nextRequestID 生成一个进程内唯一的请求 ID,格式:<tool>-<纳秒时间戳>-<序号>。
-func nextRequestID(tool string) string {
-	requestSeq++
-	return fmt.Sprintf("%s-%d-%d", tool, time.Now().UnixNano(), requestSeq)
-}
-
-// maskedParams 将入参序列化并做 PII 脱敏,超长则截断。脱敏失败时不泄露原始值。
-func maskedParams(req mcp.CallToolRequest) string {
-	args := req.GetArguments()
-	if len(args) == 0 {
-		return "{}"
-	}
-	b, err := json.Marshal(args)
-	if err != nil {
-		return "<marshal error>"
-	}
-	masked, err := helper.MaskJSONBytes(b)
-	if err != nil {
-		// fail-closed:脱敏失败则不输出原始参数
-		return "<masked: sanitize failed>"
-	}
-	return truncate(string(masked), maxParamsLogBytes)
-}
-
-// resultText 拼接结果中的文本内容。
-func resultText(result *mcp.CallToolResult) string {
-	if result == nil {
-		return ""
-	}
-	var sb strings.Builder
-	for _, c := range result.Content {
-		if tc, ok := c.(mcp.TextContent); ok {
-			sb.WriteString(tc.Text)
-		}
-	}
-	return sb.String()
-}
-
-// resultMeta 返回结果文本字节数,以及是否为落盘结果(spilled)。
-func resultMeta(result *mcp.CallToolResult) (size int, spilled bool) {
-	text := resultText(result)
-	size = len(text)
-	// 落盘结果的返回体是包含 "localPath" 的 JSON 摘要(数据类返回已统一为无条件落盘)。
-	if strings.Contains(text, `"localPath"`) {
-		spilled = true
-	}
-	return size, spilled
-}
-
-// truncate 按字节截断日志字段,避免单条日志过大。
-func truncate(s string, max int) string {
-	if len(s) <= max {
-		return s
-	}
-	end := max
-	for end > 0 && s[end]&0xC0 == 0x80 { // 回退到 UTF-8 rune 边界
-		end--
-	}
-	return s[:end] + "...(truncated)"
-}

+ 0 - 140
service/cmd/mcp/tools/middleware/logging_test.go

@@ -1,140 +0,0 @@
-package middleware
-
-import (
-	"context"
-	"strings"
-	"testing"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-	"go.uber.org/zap"
-	"go.uber.org/zap/zapcore"
-	"go.uber.org/zap/zaptest/observer"
-)
-
-// setupObservedLogger 替换全局 logger 为可观测 logger,返回观测器与还原函数。
-func setupObservedLogger(t *testing.T) (*observer.ObservedLogs, func()) {
-	t.Helper()
-	core, obs := observer.New(zapcore.InfoLevel)
-	orig := zap.L()
-	zap.ReplaceGlobals(zap.New(core))
-	return obs, func() { zap.ReplaceGlobals(orig) }
-}
-
-func newReq(name string, args map[string]any) mcp.CallToolRequest {
-	var req mcp.CallToolRequest
-	req.Params.Name = name
-	req.Params.Arguments = args
-	return req
-}
-
-func wrap(next server.ToolHandlerFunc) server.ToolHandlerFunc {
-	return LoggingMiddleware()(next)
-}
-
-func TestLogging_Success(t *testing.T) {
-	obs, restore := setupObservedLogger(t)
-	defer restore()
-
-	h := wrap(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return mcp.NewToolResultText(`{"ok":true}`), nil
-	})
-	_, err := h(context.Background(), newReq("service_site_query_info", map[string]any{"sn": "SN123"}))
-	if err != nil {
-		t.Fatalf("unexpected err: %v", err)
-	}
-
-	logs := obs.All()
-	if len(logs) != 2 {
-		t.Fatalf("want 2 log entries (start+success), got %d: %+v", len(logs), logs)
-	}
-	if logs[0].Message != "[tool] call start" {
-		t.Errorf("first log = %q, want start", logs[0].Message)
-	}
-	if logs[1].Message != "[tool] call success" || logs[1].Level != zapcore.InfoLevel {
-		t.Errorf("second log = %q level=%v, want success/info", logs[1].Message, logs[1].Level)
-	}
-	// status 字段
-	if got := logs[1].ContextMap()["status"]; got != "success" {
-		t.Errorf("status = %v, want success", got)
-	}
-}
-
-func TestLogging_BusinessError(t *testing.T) {
-	obs, restore := setupObservedLogger(t)
-	defer restore()
-
-	h := wrap(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return mcp.NewToolResultError("查询站点信息失败: not found"), nil
-	})
-	if _, err := h(context.Background(), newReq("service_site_query_info", nil)); err != nil {
-		t.Fatalf("unexpected err: %v", err)
-	}
-
-	last := obs.All()[len(obs.All())-1]
-	if last.Message != "[tool] call business_error" || last.Level != zapcore.WarnLevel {
-		t.Fatalf("last log = %q level=%v, want business_error/warn", last.Message, last.Level)
-	}
-	if got := last.ContextMap()["status"]; got != "business_error" {
-		t.Errorf("status = %v, want business_error", got)
-	}
-}
-
-func TestLogging_GoError(t *testing.T) {
-	obs, restore := setupObservedLogger(t)
-	defer restore()
-
-	h := wrap(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return nil, context.DeadlineExceeded
-	})
-	if _, err := h(context.Background(), newReq("data_mongo_query", nil)); err == nil {
-		t.Fatal("expected error to propagate")
-	}
-
-	last := obs.All()[len(obs.All())-1]
-	if last.Message != "[tool] call error" || last.Level != zapcore.ErrorLevel {
-		t.Fatalf("last log = %q level=%v, want error/error", last.Message, last.Level)
-	}
-}
-
-func TestLogging_Panic(t *testing.T) {
-	obs, restore := setupObservedLogger(t)
-	defer restore()
-
-	h := wrap(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		panic("boom")
-	})
-
-	defer func() {
-		if r := recover(); r == nil {
-			t.Fatal("expected panic to be re-thrown")
-		}
-		last := obs.All()[len(obs.All())-1]
-		if last.Message != "[tool] call panic" || last.Level != zapcore.ErrorLevel {
-			t.Fatalf("last log = %q level=%v, want panic/error", last.Message, last.Level)
-		}
-		if _, ok := last.ContextMap()["stack"]; !ok {
-			t.Error("panic log missing stack field")
-		}
-	}()
-
-	_, _ = h(context.Background(), newReq("service_site_query_info", nil))
-}
-
-// TestLogging_ParamMasking 验证参数中的敏感字段被脱敏后再写日志。
-func TestLogging_ParamMasking(t *testing.T) {
-	obs, restore := setupObservedLogger(t)
-	defer restore()
-
-	h := wrap(func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-		return mcp.NewToolResultText("ok"), nil
-	})
-	// phone 是常见 PII,MaskJSONBytes 应对其脱敏
-	_, _ = h(context.Background(), newReq("service_site_query_info", map[string]any{"phone": "13800138000"}))
-
-	start := obs.All()[0]
-	params, _ := start.ContextMap()["params"].(string)
-	if strings.Contains(params, "13800138000") {
-		t.Errorf("params log leaked raw phone: %q", params)
-	}
-}

+ 0 - 24
service/cmd/mcp/tools/register.go

@@ -1,24 +0,0 @@
-package tools
-
-import (
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/data"
-	"fwh-toolbox-server/cmd/mcp/tools/service"
-	"fwh-toolbox-server/cmd/mcp/tools/storage"
-	toolutil "fwh-toolbox-server/cmd/mcp/tools/util"
-)
-
-// RegisterAll 注册所有 MCP Tools(唯一对外导出入口)
-//
-// 按访问层级分类:
-//   - service: 业务逻辑层,调用 Service 层函数,有业务校验和数据聚合
-//   - data:    数据直查层,直接读取 DB/Cache,用于灵活查询
-//   - storage: 对象存储层,MinIO/文件读取操作
-//   - util:    辅助工具层,格式解析等纯函数
-func RegisterAll(s *server.MCPServer) {
-	service.RegisterServiceTools(s)
-	data.RegisterDataTools(s)
-	storage.RegisterStorageTools(s)
-	toolutil.RegisterUtilTools(s)
-}

+ 0 - 256
service/cmd/mcp/tools/service/auto_update.go

@@ -1,256 +0,0 @@
-package service
-
-import (
-	"context"
-	"encoding/json"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/services"
-)
-
-func registerAutoUpdateTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_auto_update_get_options",
-			mcp.WithDescription("查询自动升级配置选项(升级包列表和 aGate 版本列表)"),
-		),
-		handleAutoUpdateGetOptions,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_get_package",
-			mcp.WithDescription("查询指定升级包的详细信息"),
-			mcp.WithString("file_name", mcp.Required(), mcp.Description("升级包文件名")),
-		),
-		handleAutoUpdateGetPackage,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_create_package",
-			mcp.WithDescription("创建升级包信息 [写操作]"),
-			mcp.WithString("package_type", mcp.Required(), mcp.Description("包类型:system, aGate, subDevice, others")),
-			mcp.WithString("info", mcp.Required(), mcp.Description("升级包信息 JSON,包含 id(文件名)、title、invVersion[]")),
-		),
-		handleAutoUpdateCreatePackage,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_update_package",
-			mcp.WithDescription("更新升级包信息 [写操作]"),
-			mcp.WithString("file_name", mcp.Required(), mcp.Description("升级包文件名")),
-			mcp.WithString("info", mcp.Required(), mcp.Description("升级包信息 JSON,包含 id、title、invVersion[]")),
-		),
-		handleAutoUpdateUpdatePackage,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_delete_package",
-			mcp.WithDescription("删除升级包信息 [写操作]"),
-			mcp.WithString("package_type", mcp.Required(), mcp.Description("包类型:system, aGate, subDevice, others")),
-			mcp.WithString("file_name", mcp.Required(), mcp.Description("升级包文件名")),
-		),
-		handleAutoUpdateDeletePackage,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_get_apower_version",
-			mcp.WithDescription("查询 aPower 硬件版本列表"),
-		),
-		handleAutoUpdateGetAPowerVersion,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_get_config",
-			mcp.WithDescription("查询指定 aGate 版本的自动升级配置"),
-			mcp.WithString("agate_version", mcp.Required(), mcp.Description("aGate 硬件版本号")),
-		),
-		handleAutoUpdateGetConfig,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_update_config",
-			mcp.WithDescription("更新指定 aGate 版本的自动升级配置 [写操作]"),
-			mcp.WithString("agate_version", mcp.Required(), mcp.Description("aGate 硬件版本号")),
-			mcp.WithString("config", mcp.Required(), mcp.Description("配置 JSON,包含 config.defaultType 和 config.specialType")),
-		),
-		handleAutoUpdateUpdateConfig,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_delete_config",
-			mcp.WithDescription("删除指定 aGate 版本的自动升级配置 [写操作]"),
-			mcp.WithString("agate_version", mcp.Required(), mcp.Description("aGate 硬件版本号")),
-		),
-		handleAutoUpdateDeleteConfig,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_file_list",
-			mcp.WithDescription("查询自动升级配置文件列表(已生成的 Excel 文件)"),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-		),
-		handleAutoUpdateFileList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_auto_update_create_file",
-			mcp.WithDescription("生成新的自动升级配置 Excel 文件 [写操作]"),
-		),
-		handleAutoUpdateCreateFile,
-	)
-}
-
-func handleAutoUpdateGetOptions(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetAutoUpdateConfigOption()
-	if err != nil {
-		return helper.ServiceError("查询升级配置选项", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleAutoUpdateGetPackage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	fileName, err := req.RequireString("file_name")
-	if err != nil {
-		return helper.ParamError("file_name"), nil
-	}
-
-	data, err := services.GetAutoUpdatePackageInfo(fileName)
-	if err != nil {
-		return helper.ServiceError("查询升级包信息", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleAutoUpdateCreatePackage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	packageType, err := req.RequireString("package_type")
-	if err != nil {
-		return helper.ParamError("package_type"), nil
-	}
-	infoStr, err := req.RequireString("info")
-	if err != nil {
-		return helper.ParamError("info"), nil
-	}
-
-	var info domain.AutoUpdatePackages
-	if err := json.Unmarshal([]byte(infoStr), &info); err != nil {
-		return helper.ParamInvalidError("info", "需要有效的JSON格式"), nil
-	}
-
-	if err := services.CreateAutoUpdatePackageInfo(packageType, info); err != nil {
-		return helper.ServiceError("创建升级包", err), nil
-	}
-	return helper.TextResult("升级包创建成功"), nil
-}
-
-func handleAutoUpdateUpdatePackage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	fileName, err := req.RequireString("file_name")
-	if err != nil {
-		return helper.ParamError("file_name"), nil
-	}
-	infoStr, err := req.RequireString("info")
-	if err != nil {
-		return helper.ParamError("info"), nil
-	}
-
-	var info domain.AutoUpdatePackages
-	if err := json.Unmarshal([]byte(infoStr), &info); err != nil {
-		return helper.ParamInvalidError("info", "需要有效的JSON格式"), nil
-	}
-
-	if err := services.UpdateAutoUpdatePackageInfo(fileName, info); err != nil {
-		return helper.ServiceError("更新升级包", err), nil
-	}
-	return helper.TextResult("升级包更新成功"), nil
-}
-
-func handleAutoUpdateDeletePackage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	packageType, err := req.RequireString("package_type")
-	if err != nil {
-		return helper.ParamError("package_type"), nil
-	}
-	fileName, err := req.RequireString("file_name")
-	if err != nil {
-		return helper.ParamError("file_name"), nil
-	}
-
-	if err := services.DeleteAutoUpdatePackageInfo(packageType, fileName); err != nil {
-		return helper.ServiceError("删除升级包", err), nil
-	}
-	return helper.TextResult("升级包删除成功"), nil
-}
-
-func handleAutoUpdateGetAPowerVersion(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetAutoUpdateAPowerVersion()
-	if err != nil {
-		return helper.ServiceError("查询aPower版本列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleAutoUpdateGetConfig(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	agateVersion, err := req.RequireString("agate_version")
-	if err != nil {
-		return helper.ParamError("agate_version"), nil
-	}
-
-	data, err := services.GetAutoUpdateConfig(agateVersion)
-	if err != nil {
-		return helper.ServiceError("查询升级配置", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleAutoUpdateUpdateConfig(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	agateVersion, err := req.RequireString("agate_version")
-	if err != nil {
-		return helper.ParamError("agate_version"), nil
-	}
-	configStr, err := req.RequireString("config")
-	if err != nil {
-		return helper.ParamError("config"), nil
-	}
-
-	var config domain.AutoUpdateConfig
-	if err := json.Unmarshal([]byte(configStr), &config); err != nil {
-		return helper.ParamInvalidError("config", "需要有效的JSON格式"), nil
-	}
-
-	if err := services.UpdateAutoUpdateConfig(agateVersion, config); err != nil {
-		return helper.ServiceError("更新升级配置", err), nil
-	}
-	return helper.TextResult("升级配置更新成功"), nil
-}
-
-func handleAutoUpdateDeleteConfig(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	agateVersion, err := req.RequireString("agate_version")
-	if err != nil {
-		return helper.ParamError("agate_version"), nil
-	}
-
-	if err := services.DeleteAutoUpdateConfig(agateVersion); err != nil {
-		return helper.ServiceError("删除升级配置", err), nil
-	}
-	return helper.TextResult("升级配置删除成功"), nil
-}
-
-func handleAutoUpdateFileList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-
-	data, err := services.GetAutoUpdateConfigFileInfo(page, size)
-	if err != nil {
-		return helper.ServiceError("查询配置文件列表", err), nil
-	}
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "auto_update_file_list"), nil
-}
-
-func handleAutoUpdateCreateFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	if err := services.CreateAutoUpdateConfigFile(); err != nil {
-		return helper.ServiceError("生成配置文件", err), nil
-	}
-	return helper.TextResult("配置文件生成成功"), nil
-}

+ 0 - 33
service/cmd/mcp/tools/service/background_task.go

@@ -1,33 +0,0 @@
-package service
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/services"
-)
-
-func registerBackgroundTaskTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_background_task_list",
-			mcp.WithDescription("查询当前后台任务列表(包括任务状态、重试次数、参数信息)"),
-		),
-		handleBackgroundTaskList,
-	)
-}
-
-func handleBackgroundTaskList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetBackgroundTaskInfoList()
-	if err != nil {
-		return helper.ServiceError("查询后台任务列表", err), nil
-	}
-
-	if len(data) == 0 {
-		return helper.EmptyResult("当前无后台任务"), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 258
service/cmd/mcp/tools/service/batch_download.go

@@ -1,258 +0,0 @@
-package service
-
-import (
-	"context"
-	"encoding/json"
-	"strings"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-	"go.mongodb.org/mongo-driver/bson/primitive"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/services"
-)
-
-func registerBatchDownloadTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_batch_download_list",
-			mcp.WithDescription("查询批量下载任务列表,支持按 SN 和进度筛选"),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-			mcp.WithString("sn", mcp.Description("按 SN 过滤")),
-			mcp.WithString("progress", mcp.Description("按进度过滤:pending, running, success, failed")),
-		),
-		handleBatchDownloadList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_batch_download_create",
-			mcp.WithDescription("创建批量下载任务(自动入队执行)[写操作]"),
-			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("任务描述")),
-		),
-		handleBatchDownloadCreate,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_batch_download_update_desc",
-			mcp.WithDescription("更新批量下载任务的描述 [写操作]"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID(MongoDB ObjectID hex 字符串)")),
-			mcp.WithString("description", mcp.Required(), mcp.Description("新的描述")),
-		),
-		handleBatchDownloadUpdateDesc,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_batch_download_update_owner",
-			mcp.WithDescription("更新批量下载任务的 owner [写操作]"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID(MongoDB ObjectID hex 字符串)")),
-			mcp.WithString("owner", mcp.Required(), mcp.Description("owner 列表,JSON数组格式")),
-		),
-		handleBatchDownloadUpdateOwner,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_batch_download_delete",
-			mcp.WithDescription("删除批量下载任务 [写操作]"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID(MongoDB ObjectID hex 字符串)")),
-		),
-		handleBatchDownloadDelete,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_batch_download_path",
-			mcp.WithDescription("查询批量下载任务的文件目录结构"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID(MongoDB ObjectID hex 字符串)")),
-		),
-		handleBatchDownloadPath,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_batch_download_file_tree",
-			mcp.WithDescription("查询指定 tar.gz 文件的内部目录结构"),
-			mcp.WithString("file_path", mcp.Required(), mcp.Description("MinIO 中的文件路径")),
-		),
-		handleBatchDownloadFileTree,
-	)
-}
-
-func handleBatchDownloadList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-
-	var snPtr *string
-	if sn := req.GetString("sn", ""); sn != "" {
-		snPtr = &sn
-	}
-
-	var progressPtr *string
-	if progress := req.GetString("progress", ""); progress != "" {
-		progressPtr = &progress
-	}
-
-	data, err := services.GetBatchDownloadManageList(page, size, snPtr, progressPtr)
-	if err != nil {
-		return helper.ServiceError("查询批量下载列表", err), nil
-	}
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "batch_download_list"), nil
-}
-
-func handleBatchDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	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 {
-		return helper.ParamError("time_period"), nil
-	}
-	optionsStr, err := req.RequireString("options")
-	if err != nil {
-		return helper.ParamError("options"), nil
-	}
-
-	// 批量校验 SN:标准长度 20 位。非法项通过结构化错误返回,便于 Agent 精确修正。
-	valid := make([]string, 0, len(snList))
-	invalid := make([]string, 0)
-	for _, sn := range snList {
-		s := strings.TrimSpace(sn)
-		if len(s) < 20 {
-			invalid = append(invalid, sn)
-		} else {
-			valid = append(valid, s)
-		}
-	}
-	if len(invalid) > 0 {
-		return helper.BatchValidationError("创建批量下载任务", valid, invalid, "SN 长度不足,标准长度为 20 位"), nil
-	}
-
-	var timePeriod [2]string
-	if err := json.Unmarshal([]byte(timePeriodStr), &timePeriod); err != nil {
-		return helper.ParamInvalidError("time_period", "需要 [\"start\",\"end\"] 格式"), nil
-	}
-
-	var options domain.BatchDownloadOptions
-	if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
-		return helper.ParamInvalidError("options", "需要有效的JSON格式"), nil
-	}
-
-	description := req.GetString("description", "")
-
-	config := domain.BatchDownloadConfigWrite{
-		Sn:          valid,
-		Description: description,
-		TimePeriod:  timePeriod,
-		Options:     options,
-	}
-
-	if err := services.InsertBatchDownloadConfig(config); err != nil {
-		return helper.ServiceError("创建批量下载任务", err), nil
-	}
-	return helper.TextResult("批量下载任务创建成功,已加入队列"), nil
-}
-
-func handleBatchDownloadUpdateDesc(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-	desc, err := req.RequireString("description")
-	if err != nil {
-		return helper.ParamError("description"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	if err := services.UpdateBatchDownloadDescription(id, desc); err != nil {
-		return helper.ServiceError("更新描述", err), nil
-	}
-	return helper.TextResult("描述更新成功"), nil
-}
-
-func handleBatchDownloadUpdateOwner(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-	ownerStr, err := req.RequireString("owner")
-	if err != nil {
-		return helper.ParamError("owner"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	var owner []string
-	if err := json.Unmarshal([]byte(ownerStr), &owner); err != nil {
-		return helper.ParamInvalidError("owner", "需要JSON数组格式"), nil
-	}
-
-	if err := services.UpdateBatchDownloadOwner(id, owner); err != nil {
-		return helper.ServiceError("更新owner", err), nil
-	}
-	return helper.TextResult("owner更新成功"), nil
-}
-
-func handleBatchDownloadDelete(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	if err := services.DeleteBatchDownloadConfig(id); err != nil {
-		return helper.ServiceError("删除批量下载任务", err), nil
-	}
-	return helper.TextResult("删除成功"), nil
-}
-
-func handleBatchDownloadPath(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	data, err := services.GetBatchDownloadPath(id)
-	if err != nil {
-		return helper.ServiceError("查询文件目录", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleBatchDownloadFileTree(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	filePath, err := req.RequireString("file_path")
-	if err != nil {
-		return helper.ParamError("file_path"), nil
-	}
-
-	data, err := services.GetBatchDownloadFileTree(filePath)
-	if err != nil {
-		return helper.ServiceError("查询文件树", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 79
service/cmd/mcp/tools/service/dashboard.go

@@ -1,79 +0,0 @@
-package service
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/services"
-)
-
-func registerDashboardTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_dashboard_shutdown_date",
-			mcp.WithDescription("查询停机统计最新可用日期"),
-		),
-		handleDashboardShutdownDate,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_dashboard_shutdown_detail",
-			mcp.WithDescription("查询指定日期的停机详情数据(含各站点停机信息)"),
-			mcp.WithString("date", mcp.Description("统计日期,格式 yyyy-MM-dd(不传则查最新)")),
-		),
-		handleDashboardShutdownDetail,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_dashboard_alarm_detail",
-			mcp.WithDescription("查询指定日期的告警详情数据(含各站点告警码统计)"),
-			mcp.WithString("date", mcp.Description("统计日期,格式 yyyy-MM-dd(不传则查最新)")),
-		),
-		handleDashboardAlarmDetail,
-	)
-}
-
-func handleDashboardShutdownDate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	date, err := services.GetShutdownStatisticsDate()
-	if err != nil {
-		return helper.ServiceError("查询停机统计日期", err), nil
-	}
-	if date == nil {
-		return helper.EmptyResult("暂无停机统计数据"), nil
-	}
-	return helper.TextResult(*date), nil
-}
-
-func handleDashboardShutdownDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	date := req.GetString("date", "")
-
-	var datePtr *string
-	if date != "" {
-		datePtr = &date
-	}
-
-	data, err := services.GetShutdownInfoMap(datePtr)
-	if err != nil {
-		return helper.ServiceError("查询停机详情", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleDashboardAlarmDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	date := req.GetString("date", "")
-
-	var datePtr *string
-	if date != "" {
-		datePtr = &date
-	}
-
-	data, err := services.GetAlarmStatisticsInfoMap(datePtr)
-	if err != nil {
-		return helper.ServiceError("查询告警详情", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 95
service/cmd/mcp/tools/service/data_download.go

@@ -1,95 +0,0 @@
-package service
-
-import (
-	"context"
-	"encoding/json"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/domain"
-	mongoRepo "fwh-toolbox-server/repositories/mongo"
-	"fwh-toolbox-server/services"
-	"fwh-toolbox-server/task/queue/dataDownloadQueue"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-)
-
-const dataDownloadMCPOwner = "USER_AI"
-
-func registerDataDownloadTools(s *server.MCPServer) {
-	s.AddTool(mcp.NewTool("service_data_download_list",
-		mcp.WithDescription("查询新 DataDownload 任务(不访问旧 BatchDownload)"),
-		mcp.WithNumber("page", mcp.Description("页码,默认1")),
-		mcp.WithNumber("size", mcp.Description("每页数量,默认20")),
-		mcp.WithString("description", mcp.Description("描述,不区分大小写字面量包含")),
-		mcp.WithString("sn", mcp.Description("站点 SN")),
-		mcp.WithString("status", mcp.Description("任务状态"))), handleDataDownloadList)
-	s.AddTool(mcp.NewTool("service_data_download_create",
-		mcp.WithDescription("创建新 DataDownload 任务 [写操作]"),
-		mcp.WithArray("sn", mcp.WithStringItems(), mcp.Description("站点 SN 列表")),
-		mcp.WithString("sn_file", mcp.Description("位于 Agent 目录中的 SN 文件")),
-		mcp.WithString("start_date", mcp.Description("开始日期 yyyy-MM-dd;仅 UpdateLog 时可省略")),
-		mcp.WithString("end_date", mcp.Description("结束日期 yyyy-MM-dd;仅 UpdateLog 时可省略")),
-		mcp.WithString("options", mcp.Required(), mcp.Description("DataDownloadOptions JSON")),
-		mcp.WithString("description", mcp.Description("任务描述"))), handleDataDownloadCreate)
-	s.AddTool(mcp.NewTool("service_data_download_documents",
-		mcp.WithDescription("查询 DataDownload 逻辑文档目录"),
-		mcp.WithNumber("page", mcp.Description("页码,默认1")),
-		mcp.WithNumber("size", mcp.Description("每页数量,默认20"))), handleDataDownloadDocuments)
-}
-
-func handleDataDownloadList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page, size := dataDownloadMCPPage(req)
-	tasks, total, err := mongoRepo.ListDataDownloadTasks(ctx, dataDownloadMCPOwner, true, req.GetString("description", ""), req.GetString("sn", ""), req.GetString("status", ""), nil, nil, page, size)
-	if err != nil {
-		return helper.ServiceError("查询 DataDownload", err), nil
-	}
-	return helper.PageResult(uint(total), uint(size), uint(page), tasks, "data_download_list"), nil
-}
-
-func handleDataDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	resolved, errResult := helper.ResolveStringListOrFile(req, "sn", "sn_file")
-	if errResult != nil {
-		return errResult, nil
-	}
-	if len(resolved.Items) == 0 {
-		return helper.ParamInvalidError("sn", "至少提供一个 SN"), nil
-	}
-	optionsRaw, err := req.RequireString("options")
-	if err != nil {
-		return helper.ParamError("options"), nil
-	}
-	var options domain.DataDownloadOptions
-	if err := json.Unmarshal([]byte(optionsRaw), &options); err != nil {
-		return helper.ParamInvalidError("options", err.Error()), nil
-	}
-	task := &domain.DataDownloadTask{OwnerID: dataDownloadMCPOwner, OwnerName: "AI Assistant", SNs: resolved.Items, Description: req.GetString("description", ""), StartDate: req.GetString("start_date", ""), EndDate: req.GetString("end_date", ""), Options: options}
-	if err := services.CreateDataDownloadTask(ctx, task); err != nil {
-		return helper.ServiceError("创建 DataDownload", err), nil
-	}
-	if err := dataDownloadQueue.EnqueuePlan(task.ID); err != nil {
-		_ = mongoRepo.UpdateDataDownloadTaskStatus(context.Background(), task.ID, domain.DataDownloadError)
-		return helper.ServiceError("DataDownload 入队", err), nil
-	}
-	return helper.JSONResult(map[string]any{"created": true, "taskId": task.ID.Hex(), "owner": dataDownloadMCPOwner}), nil
-}
-
-func handleDataDownloadDocuments(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page, size := dataDownloadMCPPage(req)
-	documents, total, err := mongoRepo.ListDataDownloadDocuments(ctx, page, size)
-	if err != nil {
-		return helper.ServiceError("查询 DataDownload 文档", err), nil
-	}
-	return helper.PageResult(uint(total), uint(size), uint(page), documents, "data_download_documents"), nil
-}
-
-func dataDownloadMCPPage(req mcp.CallToolRequest) (int64, int64) {
-	page, size := int64(req.GetFloat("page", 1)), int64(req.GetFloat("size", 20))
-	if page < 1 {
-		page = 1
-	}
-	if size < 1 || size > 200 {
-		size = 20
-	}
-	return page, size
-}

+ 0 - 75
service/cmd/mcp/tools/service/feedback.go

@@ -1,75 +0,0 @@
-package service
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/services"
-)
-
-func registerFeedbackTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_feedback_create",
-			mcp.WithDescription("在 Plane 创建 Intake Issue(问题反馈或需求提交)[写操作]"),
-			mcp.WithString("title", mcp.Required(), mcp.Description("标题")),
-			mcp.WithString("type", mcp.Required(), mcp.Description("类型:bug 或 feature")),
-			mcp.WithString("priority", mcp.Description("优先级:urgent, high, medium, low, none(默认 medium)")),
-			mcp.WithString("description", mcp.Description("详细描述")),
-			mcp.WithString("category", mcp.Description("分类:bug类(bug,ui,performance,other) / feature类(new,improvement,experience)")),
-		),
-		handleFeedbackCreate,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_feedback_history",
-			mcp.WithDescription("查询 Plane Intake Issue 提交历史(带 Feedback 标签的记录)"),
-		),
-		handleFeedbackHistory,
-	)
-}
-
-func handleFeedbackCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	title, err := req.RequireString("title")
-	if err != nil {
-		return helper.ParamError("title"), nil
-	}
-	feedbackType, err := req.RequireString("type")
-	if err != nil {
-		return helper.ParamError("type"), nil
-	}
-	if feedbackType != "bug" && feedbackType != "feature" {
-		return helper.ParamInvalidError("type", "必须是 bug 或 feature"), nil
-	}
-
-	priority := req.GetString("priority", "medium")
-	description := req.GetString("description", "")
-	category := req.GetString("category", "")
-
-	body := dto.FeedbackSubmitDTO{
-		Title:       title,
-		Type:        feedbackType,
-		Priority:    priority,
-		Description: description,
-		Category:    category,
-	}
-
-	result, err := services.SubmitFeedback(body, "MCP-Agent")
-	if err != nil {
-		return helper.ServiceError("创建反馈", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(result), nil
-}
-
-func handleFeedbackHistory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetFeedbackHistory("")
-	if err != nil {
-		return helper.ServiceError("查询反馈历史", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 61
service/cmd/mcp/tools/service/monitor.go

@@ -1,61 +0,0 @@
-package service
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/services"
-)
-
-func registerMonitorTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_monitor_alarm_stats",
-			mcp.WithDescription("查询告警统计数据(按日期汇总各类告警数量)"),
-			mcp.WithString("date", mcp.Description("统计日期,格式 yyyy-MM-dd(不传则查最新)")),
-		),
-		handleAlarmStats,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_monitor_shutdown_stats",
-			mcp.WithDescription("查询停机统计数据(按日期汇总停机站点信息)"),
-			mcp.WithString("date", mcp.Description("统计日期,格式 yyyy-MM-dd(不传则查最新)")),
-		),
-		handleShutdownStats,
-	)
-}
-
-func handleAlarmStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	date := req.GetString("date", "")
-
-	var datePtr *string
-	if date != "" {
-		datePtr = &date
-	}
-
-	data, err := services.GetAlarmStatistics(datePtr)
-	if err != nil {
-		return helper.ServiceError("查询告警统计", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleShutdownStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	date := req.GetString("date", "")
-
-	var datePtr *string
-	if date != "" {
-		datePtr = &date
-	}
-
-	data, err := services.GetShutdownStatistics(datePtr)
-	if err != nil {
-		return helper.ServiceError("查询停机统计", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 20
service/cmd/mcp/tools/service/register.go

@@ -1,20 +0,0 @@
-package service
-
-import "github.com/mark3labs/mcp-go/server"
-
-// RegisterServiceTools 注册所有 Service 层 MCP Tools
-func RegisterServiceTools(s *server.MCPServer) {
-	registerSiteTools(s)
-	registerSimTools(s)
-	registerMonitorTools(s)
-	registerFeedbackTools(s)
-	registerDashboardTools(s)
-	registerBackgroundTaskTools(s)
-	registerTouTemplateTools(s)
-	registerSiteMonitorTools(s)
-	registerSiteInfoTools(s)
-	registerAutoUpdateTools(s)
-	registerBatchDownloadTools(s)
-	registerSiteAnalysisTools(s)
-	registerDataDownloadTools(s)
-}

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

@@ -1,160 +0,0 @@
-package service
-
-import (
-	"context"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/services"
-)
-
-func registerSimTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_sim_query",
-			mcp.WithDescription("查询单张 SIM 卡的详细信息,包括状态、平台、用量等"),
-			mcp.WithString("giccid", mcp.Required(), mcp.Description("SIM 卡 GICCID(19位)")),
-		),
-		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 卡列表,支持按状态和平台过滤"),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-			mcp.WithString("search", mcp.Description("搜索关键字(GICCID)")),
-			mcp.WithString("status", mcp.Description("状态过滤:UNKNOWN, NOT_ACTIVATED, ACTIVATED, DEACTIVATED, RETIRED(逗号分隔多选)")),
-			mcp.WithString("platform", mcp.Description("平台过滤:SmartHub, Acceleronix(逗号分隔多选)")),
-		),
-		handleSimSearch,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_sim_monthly_data",
-			mcp.WithDescription("查询单张 SIM 卡指定月份的每日用量数据"),
-			mcp.WithString("giccid", mcp.Required(), mcp.Description("SIM 卡 GICCID(19位)")),
-			mcp.WithString("month", mcp.Required(), mcp.Description("月份,格式 yyyy-MM")),
-		),
-		handleSimMonthlyData,
-	)
-}
-
-func handleSimQuery(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	giccid, err := req.RequireString("giccid")
-	if err != nil {
-		return helper.ParamError("giccid"), nil
-	}
-
-	data, err := services.GetSimCardInfo(giccid)
-	if err != nil {
-		return helper.ServiceError("查询SIM卡信息", err), nil
-	}
-
-	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 {
-		return helper.ParamError("giccid"), nil
-	}
-	month, err := req.RequireString("month")
-	if err != nil {
-		return helper.ParamError("month"), nil
-	}
-
-	data, err := services.GetSimCardMonthlyData(month, giccid)
-	if err != nil {
-		return helper.ServiceError("查询SIM卡月数据", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSimSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-	search := req.GetString("search", "")
-	status := req.GetString("status", "")
-	platform := req.GetString("platform", "")
-
-	var statusPtr *string
-	if status != "" {
-		statusPtr = &status
-	}
-	var platformPtr *string
-	if platform != "" {
-		platformPtr = &platform
-	}
-
-	data, err := services.GetSimCardBatchInfo(page, size, search, statusPtr, platformPtr)
-	if err != nil {
-		return helper.ServiceError("搜索SIM卡列表", err), nil
-	}
-
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "sim_search"), nil
-}

+ 0 - 99
service/cmd/mcp/tools/service/site.go

@@ -1,99 +0,0 @@
-package service
-
-import (
-	"context"
-	"fmt"
-	"time"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/services"
-)
-
-func registerSiteTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_site_query_info",
-			mcp.WithDescription("查询单个站点设备的详细信息,包括版本、网络状态、激活状态、固件、SN 等"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点 SN 序列号")),
-		),
-		handleSiteQueryInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_search",
-			mcp.WithDescription("搜索站点设备列表,支持分页和关键字搜索"),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-			mcp.WithString("keyword", mcp.Description("搜索关键字(匹配 SN、设备名)")),
-		),
-		handleSiteSearch,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_power_flow",
-			mcp.WithDescription("查询站点指定日期的功率流数据(PV、Grid、aPower、HomeLoad、Generator)"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点 SN 序列号")),
-			mcp.WithString("date", mcp.Required(), mcp.Description("日期,格式 yyyy-MM-dd")),
-		),
-		handleSitePowerFlow,
-	)
-}
-
-func handleSiteQueryInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := services.GetSiteInfoData(sn)
-	if err != nil {
-		return helper.ServiceError("查询站点信息", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-	keyword := req.GetString("keyword", "")
-
-	param := dto.SiteInfoRequest{
-		PageNum:  page,
-		PageSize: size,
-		Search:   keyword,
-	}
-
-	data, err := services.QuerySiteInfoDataList(param)
-	if err != nil {
-		return helper.ServiceError("搜索站点列表", err), nil
-	}
-
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "site_search"), nil
-}
-
-func handleSitePowerFlow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	dateStr, err := req.RequireString("date")
-	if err != nil {
-		return helper.ParamError("date"), nil
-	}
-
-	day, err := time.Parse("2006-01-02", dateStr)
-	if err != nil {
-		return helper.ParamInvalidError("date", fmt.Sprintf("需要 yyyy-MM-dd 格式: %v", err)), nil
-	}
-
-	data, err := services.QuerySitePowerFlow(sn, day)
-	if err != nil {
-		return helper.ServiceError("查询功率流", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 203
service/cmd/mcp/tools/service/site_analysis.go

@@ -1,203 +0,0 @@
-package service
-
-import (
-	"context"
-	"encoding/json"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-	"go.mongodb.org/mongo-driver/bson/primitive"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/services"
-)
-
-func registerSiteAnalysisTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_site_analysis_list",
-			mcp.WithDescription("查询站点分析任务列表,支持按 SN 和进度筛选"),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-			mcp.WithString("sn", mcp.Description("按 SN 过滤")),
-			mcp.WithString("progress", mcp.Description("按进度过滤:pending, running, success, failed")),
-		),
-		handleSiteAnalysisList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_analysis_get_config",
-			mcp.WithDescription("查询指定站点分析任务的配置详情"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID(MongoDB ObjectID hex 字符串)")),
-		),
-		handleSiteAnalysisGetConfig,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_analysis_create",
-			mcp.WithDescription("创建站点分析任务(自动入队执行)[写操作]"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点 SN")),
-			mcp.WithString("time_period", mcp.Required(), mcp.Description("时间范围 [\"start\",\"end\"],格式 yyyy-MM-dd")),
-			mcp.WithBoolean("enable_timeline", mcp.Description("是否分析时间线,默认true")),
-			mcp.WithBoolean("enable_runtime", mcp.Description("是否分析运行时数据,默认true")),
-			mcp.WithString("description", mcp.Description("任务描述")),
-		),
-		handleSiteAnalysisCreate,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_analysis_update_desc",
-			mcp.WithDescription("更新站点分析任务的描述 [写操作]"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
-			mcp.WithString("description", mcp.Required(), mcp.Description("新的描述")),
-		),
-		handleSiteAnalysisUpdateDesc,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_analysis_delete",
-			mcp.WithDescription("删除站点分析任务 [写操作]"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
-		),
-		handleSiteAnalysisDelete,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_analysis_data",
-			mcp.WithDescription("查询站点分析任务的结果数据(timeline + runtime)"),
-			mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")),
-		),
-		handleSiteAnalysisData,
-	)
-}
-
-func handleSiteAnalysisList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-
-	var snPtr *string
-	if sn := req.GetString("sn", ""); sn != "" {
-		snPtr = &sn
-	}
-
-	var progressPtr *string
-	if progress := req.GetString("progress", ""); progress != "" {
-		progressPtr = &progress
-	}
-
-	data, err := services.GetSiteAnalysisManageList(page, size, snPtr, nil, progressPtr, nil)
-	if err != nil {
-		return helper.ServiceError("查询站点分析列表", err), nil
-	}
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "site_analysis_list"), nil
-}
-
-func handleSiteAnalysisGetConfig(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	data, err := services.GetSiteAnalysisConfig(id)
-	if err != nil {
-		return helper.ServiceError("查询分析配置", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteAnalysisCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-	timePeriodStr, err := req.RequireString("time_period")
-	if err != nil {
-		return helper.ParamError("time_period"), nil
-	}
-
-	var timePeriod [2]string
-	if err := json.Unmarshal([]byte(timePeriodStr), &timePeriod); err != nil {
-		return helper.ParamInvalidError("time_period", "需要 [\"start\",\"end\"] 格式"), nil
-	}
-
-	enableTimeline := req.GetBool("enable_timeline", true)
-	enableRuntime := req.GetBool("enable_runtime", true)
-	description := req.GetString("description", "")
-
-	config := domain.SiteAnalysisConfigWrite{
-		Sn:          sn,
-		Description: description,
-		TimePeriod:  timePeriod,
-		Options: domain.SiteAnalysisOptions{
-			EnableTimeline: enableTimeline,
-			EnableRuntime:  enableRuntime,
-		},
-		User: "MCP-Agent",
-	}
-
-	if err := services.InsertSiteAnalysisConfig(config); err != nil {
-		return helper.ServiceError("创建站点分析任务", err), nil
-	}
-	return helper.TextResult("站点分析任务创建成功,已加入队列"), nil
-}
-
-func handleSiteAnalysisUpdateDesc(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-	desc, err := req.RequireString("description")
-	if err != nil {
-		return helper.ParamError("description"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	if err := services.UpdateSiteAnalysisDescription(id, desc); err != nil {
-		return helper.ServiceError("更新描述", err), nil
-	}
-	return helper.TextResult("描述更新成功"), nil
-}
-
-func handleSiteAnalysisDelete(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	if err := services.DeleteSiteAnalysisConfig(id); err != nil {
-		return helper.ServiceError("删除站点分析任务", err), nil
-	}
-	return helper.TextResult("删除成功"), nil
-}
-
-func handleSiteAnalysisData(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	idStr, err := req.RequireString("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	id, err := primitive.ObjectIDFromHex(idStr)
-	if err != nil {
-		return helper.ParamInvalidError("id", "无效的 ObjectID"), nil
-	}
-
-	data, err := services.GetSiteAnalysisData(id)
-	if err != nil {
-		return helper.ServiceError("查询分析数据", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 91
service/cmd/mcp/tools/service/site_info.go

@@ -1,91 +0,0 @@
-package service
-
-import (
-	"context"
-	"encoding/json"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/services"
-)
-
-func registerSiteInfoTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_site_info_update_status",
-			mcp.WithDescription("查询站点信息表的更新状态(最近一次更新时间和结果)"),
-		),
-		handleSiteInfoUpdateStatus,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_info_trigger_update",
-			mcp.WithDescription("触发站点信息表更新任务(从 FWH Pro 同步最新数据)[写操作]"),
-		),
-		handleSiteInfoTriggerUpdate,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_info_tags_list",
-			mcp.WithDescription("查询站点标签列表"),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-			mcp.WithString("search", mcp.Description("搜索关键字")),
-		),
-		handleSiteInfoTagsList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_info_tag_update",
-			mcp.WithDescription("创建或更新站点标签 [写操作]"),
-			mcp.WithString("tag", mcp.Required(), mcp.Description("标签数据,JSON格式,包含 id(可选)、name、color、description 字段")),
-		),
-		handleSiteInfoTagUpdate,
-	)
-}
-
-func handleSiteInfoUpdateStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetSiteInfoUpdateStatus()
-	if err != nil {
-		return helper.ServiceError("查询更新状态", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteInfoTriggerUpdate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	if err := services.UpdateSiteInfoDate(); err != nil {
-		return helper.ServiceError("触发站点信息更新", err), nil
-	}
-	return helper.TextResult("已触发站点信息更新任务"), nil
-}
-
-func handleSiteInfoTagsList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-	search := req.GetString("search", "")
-
-	data, err := services.GetSiteInfoTags(page, size, search)
-	if err != nil {
-		return helper.ServiceError("查询标签列表", err), nil
-	}
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "site_info_tags"), nil
-}
-
-func handleSiteInfoTagUpdate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	tagStr, err := req.RequireString("tag")
-	if err != nil {
-		return helper.ParamError("tag"), nil
-	}
-
-	var tag dto.SiteTagDTO
-	if err := json.Unmarshal([]byte(tagStr), &tag); err != nil {
-		return helper.ParamInvalidError("tag", "需要有效的JSON格式"), nil
-	}
-
-	if err := services.UpdateSiteInfoTag(tag); err != nil {
-		return helper.ServiceError("更新标签", err), nil
-	}
-	return helper.TextResult("标签更新成功"), nil
-}

+ 0 - 253
service/cmd/mcp/tools/service/site_monitor.go

@@ -1,253 +0,0 @@
-package service
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/domain"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/services"
-)
-
-func registerSiteMonitorTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_config_list",
-			mcp.WithDescription("查询所有站点监控配置列表"),
-		),
-		handleSiteMonitorConfigList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_name_list",
-			mcp.WithDescription("查询所有站点监控配置的名称列表"),
-		),
-		handleSiteMonitorNameList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_config_get",
-			mcp.WithDescription("查询指定名称的站点监控配置"),
-			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
-		),
-		handleSiteMonitorConfigGet,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_config_create",
-			mcp.WithDescription("创建站点监控配置(包含监控名、描述、站点SN列表)[写操作]"),
-			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
-			mcp.WithString("description", mcp.Description("配置描述")),
-			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,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_config_update",
-			mcp.WithDescription("更新指定名称的站点监控配置 [写操作]"),
-			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
-			mcp.WithString("description", mcp.Description("配置描述")),
-			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,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_config_delete",
-			mcp.WithDescription("删除指定名称的站点监控配置 [写操作]"),
-			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
-		),
-		handleSiteMonitorConfigDelete,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_base_info",
-			mcp.WithDescription("查询指定站点列表的基本信息(激活状态、国家、版本等)"),
-			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")),
-		),
-		handleSiteMonitorBaseInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_mode_info",
-			mcp.WithDescription("查询站点工作模式信息"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点 SN 序列号")),
-		),
-		handleSiteMonitorModeInfo,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_site_monitor_alarm_list",
-			mcp.WithDescription("查询站点告警列表"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点 SN 序列号")),
-		),
-		handleSiteMonitorAlarmList,
-	)
-}
-
-func handleSiteMonitorConfigList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetSiteMonitorConfig()
-	if err != nil {
-		return helper.ServiceError("查询监控配置列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteMonitorNameList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	data, err := services.GetSiteMonitorNameList()
-	if err != nil {
-		return helper.ServiceError("查询监控名称列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteMonitorConfigGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	name, err := req.RequireString("name")
-	if err != nil {
-		return helper.ParamError("name"), nil
-	}
-
-	data, err := services.GetOneSiteMonitorConfig(name)
-	if err != nil {
-		return helper.ServiceError("查询监控配置", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteMonitorConfigCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	name, err := req.RequireString("name")
-	if err != nil {
-		return helper.ParamError("name"), nil
-	}
-	description := req.GetString("description", "")
-
-	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:    src.Items,
-	}
-
-	if err := services.InsertSiteMonitorConfig(config); err != nil {
-		return helper.ServiceError("创建监控配置", err), nil
-	}
-	return helper.TextResult("创建成功"), nil
-}
-
-func handleSiteMonitorConfigUpdate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	name, err := req.RequireString("name")
-	if err != nil {
-		return helper.ParamError("name"), nil
-	}
-
-	// 先查现有配置
-	existing, err := services.GetOneSiteMonitorConfig(name)
-	if err != nil {
-		return helper.ServiceError("查询现有配置", err), nil
-	}
-	if len(existing) == 0 {
-		return helper.ServiceError("查询现有配置", fmt.Errorf("配置 %s 不存在", name)), nil
-	}
-
-	config := existing[0]
-
-	if desc := req.GetString("description", ""); desc != "" {
-		config.Description = desc
-	}
-	// 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 = src.Items
-	}
-
-	if err := services.UpdateSiteMonitorConfig(name, config); err != nil {
-		return helper.ServiceError("更新监控配置", err), nil
-	}
-	return helper.TextResult("更新成功"), nil
-}
-
-func handleSiteMonitorConfigDelete(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	name, err := req.RequireString("name")
-	if err != nil {
-		return helper.ParamError("name"), nil
-	}
-
-	if err := services.DeleteSiteMonitorConfig(name); err != nil {
-		return helper.ServiceError("删除监控配置", err), nil
-	}
-	return helper.TextResult("删除成功"), nil
-}
-
-func handleSiteMonitorBaseInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	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
-	}
-
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-
-	para := dto.SiteBaseInfoPara{
-		SiteList: src.Items,
-		PageSize: size,
-		PageNum:  page,
-	}
-
-	data, err := services.QuerySiteBaseInfoList(para)
-	if err != nil {
-		return helper.ServiceError("查询站点基本信息", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteMonitorModeInfo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := services.QuerySiteModeInfo(sn)
-	if err != nil {
-		return helper.ServiceError("查询站点工作模式", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleSiteMonitorAlarmList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	data, err := services.QuerySiteAlarmList(sn)
-	if err != nil {
-		return helper.ServiceError("查询站点告警列表", err), nil
-	}
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 169
service/cmd/mcp/tools/service/tou_template.go

@@ -1,169 +0,0 @@
-package service
-
-import (
-	"context"
-	"encoding/json"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/dto"
-	"fwh-toolbox-server/services"
-)
-
-func registerTouTemplateTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("service_tou_template_list",
-			mcp.WithDescription("查询 TOU 电价模版列表,支持按类型、状态筛选和关键字搜索"),
-			mcp.WithString("env", mcp.Description("目标服务器环境:pro-生产(默认) test-测试")),
-			mcp.WithNumber("page", mcp.Description("页码,从1开始,默认1")),
-			mcp.WithNumber("size", mcp.Description("每页数量,默认10")),
-			mcp.WithNumber("type", mcp.Description("模版类型:0-系统模板 1-API模板 2-用户推荐")),
-			mcp.WithNumber("status", mcp.Description("发布状态:0-未发布 2-已发布")),
-			mcp.WithString("search", mcp.Description("搜索关键字")),
-		),
-		handleTouTemplateList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_tou_template_detail",
-			mcp.WithDescription("查询指定 TOU 电价模版的详细信息"),
-			mcp.WithString("env", mcp.Description("目标服务器环境:pro-生产(默认) test-测试")),
-			mcp.WithNumber("id", mcp.Required(), mcp.Description("模版 ID")),
-		),
-		handleTouTemplateDetail,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_tou_template_save",
-			mcp.WithDescription("新增或更新 TOU 电价模版(updateOrSaveIotTouTemplate),返回模版 ID [写操作]。"+
-				"template.templateId 有值=更新,空=新增。当前仅支持测试环境(test)。"),
-			mcp.WithString("env", mcp.Description("目标服务器环境:test-测试(默认,当前仅支持)")),
-			mcp.WithString("payload", mcp.Required(),
-				mcp.Description("模版请求体 JSON,结构:{\"template\":{...},\"strategyList\":[{...}],\"lang\":\"ZH_CN\"}。"+
-					"template 含 countryId、provinceId、electricityType、name、mookRunCount、priority、env、templateId(更新时填)、sourceType 等(均为数值,字符串字段除外);"+
-					"strategyList[].detailVoList[] 为时段明细,含 startHourTime/endHourTime(HH:mm)、waveType、solarPriority/loadPriority(\"市电,负载,FHP\")、各功率(kW,数值)、SOC(%,数值) 等;数值字段传数字,可选数值字段未设置时省略该 key(不传空串或 null)。")),
-		),
-		handleTouTemplateSave,
-	)
-
-	s.AddTool(
-		mcp.NewTool("service_tou_template_cites",
-			mcp.WithDescription("查询国家/省/市列表(selectCitesList)。不传 country_id 和 type 时查询国家信息;"+
-				"查询某国家下的省/市(用于填写模版的 countryId/provinceId)时需传 country_id,且 TOU 模版场景 type 必须为 1。当前仅支持测试环境(test)。"),
-			mcp.WithString("env", mcp.Description("目标服务器环境:test-测试(默认,当前仅支持)")),
-			mcp.WithNumber("country_id", mcp.Description("国家 ID。不传则查询国家信息")),
-			mcp.WithNumber("type", mcp.Description("查询类型,与页面相关;TOU 模版查询省/市时必须为 1。不传则不带该参数(配合不传 country_id 查国家)")),
-		),
-		handleTouTemplateCites,
-	)
-}
-
-// isTestEnv 判断请求指定的目标环境是否为测试环境(test)。
-// 默认返回 defaultTest 指定的默认值。
-func isTestEnv(req mcp.CallToolRequest, defaultEnv string) bool {
-	return req.GetString("env", defaultEnv) == "test"
-}
-
-func handleTouTemplateList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	page := uint(req.GetFloat("page", 1))
-	size := uint(req.GetFloat("size", 10))
-	search := req.GetString("search", "")
-
-	var tempType *int
-	if v, err := req.RequireFloat("type"); err == nil {
-		t := int(v)
-		tempType = &t
-	}
-
-	var status *int
-	if v, err := req.RequireFloat("status"); err == nil {
-		s := int(v)
-		status = &s
-	}
-
-	var data *dto.ResPage[interface{}]
-	var err error
-	if isTestEnv(req, "pro") {
-		data, err = services.FwhTestGetTouTemplateProList(page, size, tempType, status, search)
-	} else {
-		data, err = services.GetTouTemplateProList(page, size, tempType, status, search)
-	}
-	if err != nil {
-		return helper.ServiceError("查询TOU模版列表", err), nil
-	}
-
-	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "tou_template_list"), nil
-}
-
-func handleTouTemplateDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	id, err := req.RequireFloat("id")
-	if err != nil {
-		return helper.ParamError("id"), nil
-	}
-
-	var data *interface{}
-	if isTestEnv(req, "pro") {
-		data, err = services.FwhTestGetTouTemplateProDetailInfo(uint(id))
-	} else {
-		data, err = services.GetTouTemplateProDetailInfo(uint(id))
-	}
-	if err != nil {
-		return helper.ServiceError("查询TOU模版详情", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}
-
-func handleTouTemplateSave(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	if !isTestEnv(req, "test") {
-		return helper.ParamInvalidError("env", "保存TOU模版当前仅支持测试环境(test)"), nil
-	}
-
-	payloadStr, err := req.RequireString("payload")
-	if err != nil {
-		return helper.ParamError("payload"), nil
-	}
-
-	var payload dto.FwhTestUpdateOrSaveTouTemplatePayload
-	if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil {
-		return helper.ParamInvalidError("payload", "需要有效的JSON格式"), nil
-	}
-
-	templateId, err := services.FwhTestSaveTouTemplate(payload)
-	if err != nil {
-		return helper.ServiceError("保存TOU模版", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]interface{}{
-		"templateId": templateId,
-		"message":    "TOU模版保存成功",
-	}), nil
-}
-
-func handleTouTemplateCites(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	if !isTestEnv(req, "test") {
-		return helper.ParamInvalidError("env", "查询省/市列表当前仅支持测试环境(test)"), nil
-	}
-
-	// country_id、type 均为可选:都不传时查询国家信息。
-	var countryId *int
-	if v, err := req.RequireFloat("country_id"); err == nil {
-		c := int(v)
-		countryId = &c
-	}
-
-	var cityType *int
-	if v, err := req.RequireFloat("type"); err == nil {
-		t := int(v)
-		cityType = &t
-	}
-
-	data, err := services.FwhTestGetTouTemplateCitesList(countryId, cityType)
-	if err != nil {
-		return helper.ServiceError("查询省/市列表", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(data), nil
-}

+ 0 - 231
service/cmd/mcp/tools/storage/file.go

@@ -1,231 +0,0 @@
-package storage
-
-import (
-	"context"
-	"fmt"
-	"os"
-	"path/filepath"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	"fwh-toolbox-server/utils/filestore"
-)
-
-// MinIO 文件前缀约定:
-//   - uploadPrefix:用户上传、供 Agent 读取的文件
-//   - downloadPrefix:Agent 生成、供用户下载的文件
-const (
-	uploadPrefix   = "upload"
-	downloadPrefix = "download"
-)
-
-// localTempDir 用于存放从 MinIO 下载的临时文件
-const localTempDir = "/tmp/fwh-toolbox-agent/downloads"
-
-func registerFileTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("storage_file_read",
-			mcp.WithDescription("读取文件文本内容(返回纯文本)。仅适用于文本类文件。key 为写入/上传/列表返回的对象 key。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("文件的对象 key(写入/上传成功或 storage_file_list 返回的 key)")),
-		),
-		handleFileRead,
-	)
-
-	s.AddTool(
-		mcp.NewTool("storage_file_read_to_local",
-			mcp.WithDescription("将文件下载到本地临时路径。支持任意文件类型(图片、PDF、压缩包等)。返回本地文件路径,可直接用于后续处理或分析。key 为写入/上传/列表返回的对象 key。"),
-			mcp.WithString("key", mcp.Required(), mcp.Description("文件的对象 key(写入/上传成功或 storage_file_list 返回的 key)")),
-		),
-		handleFileReadToLocal,
-	)
-
-	s.AddTool(
-		mcp.NewTool("storage_file_write",
-			mcp.WithDescription("将文本内容写入文件存储(MinIO)。可用于保存分析结果、生成报告等。最大 20MB。如需上传本地文件请使用 storage_file_upload。"),
-			mcp.WithString("fileName", mcp.Required(), mcp.Description("文件名(如 report.csv)")),
-			mcp.WithString("content", mcp.Required(), mcp.Description("要写入的文本内容(最大 20MB)")),
-			mcp.WithString("userId", mcp.Description("用户标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-agent)")),
-			mcp.WithString("sessionId", mcp.Description("会话标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-session)")),
-		),
-		handleFileWrite,
-	)
-
-	s.AddTool(
-		mcp.NewTool("storage_file_list",
-			mcp.WithDescription("列出当前会话中用户上传的所有文件,返回文件名、key、大小等信息。"),
-			mcp.WithString("userId", mcp.Description("用户标识,用于定位文件(尽量填写,不传则使用默认值 mcp-agent)")),
-			mcp.WithString("sessionId", mcp.Description("会话标识,用于定位文件(尽量填写,不传则使用默认值 mcp-session)")),
-		),
-		handleFileList,
-	)
-
-	s.AddTool(
-		mcp.NewTool("storage_file_upload",
-			mcp.WithDescription("将本地文件上传到文件存储(MinIO)。支持任意文件类型(图片、PDF、压缩包、Excel 等)。最大 100MB。直接读取本地文件路径,无需 base64 编码。"),
-			mcp.WithString("localPath", mcp.Required(), mcp.Description("本地文件的绝对路径(如 /tmp/data.zip、/home/user/report.pdf)")),
-			mcp.WithString("fileName", mcp.Description("上传后的文件名(不传则使用本地文件名)")),
-			mcp.WithString("userId", mcp.Description("用户标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-agent)")),
-			mcp.WithString("sessionId", mcp.Description("会话标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-session)")),
-		),
-		handleFileUpload,
-	)
-}
-
-func handleFileRead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-
-	if !filestore.HasFile(key) {
-		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", key)), nil
-	}
-
-	content, err := filestore.ReadFile(key)
-	if err != nil {
-		return helper.ServiceError("读取文件", err), nil
-	}
-
-	return helper.TextResultOrFile(content, "file_read"), nil
-}
-
-func handleFileReadToLocal(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-
-	if !filestore.HasFile(key) {
-		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", key)), nil
-	}
-
-	data, err := filestore.ReadFileBytes(key)
-	if err != nil {
-		return helper.ServiceError("读取文件", err), nil
-	}
-
-	// 确保临时目录存在
-	if err := os.MkdirAll(localTempDir, 0755); err != nil {
-		return helper.ServiceError("创建临时目录", err), nil
-	}
-
-	// 从 key 还原文件名:去掉 "{uuid}_" 前缀(8位uuid + 下划线 = 9字符)
-	fileName := filepath.Base(key)
-	if len(fileName) > 9 {
-		fileName = fileName[9:]
-	}
-	localPath := filepath.Join(localTempDir, fileName)
-	if err := os.WriteFile(localPath, data, 0644); err != nil {
-		return helper.ServiceError("写入本地文件", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"message":   "文件已下载到本地",
-		"localPath": localPath,
-		"fileName":  fileName,
-		"size":      len(data),
-	}), nil
-}
-
-func handleFileList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	userId := req.GetString("userId", "mcp-agent")
-	sessionId := req.GetString("sessionId", "mcp-session")
-
-	files, err := filestore.GetFiles(uploadPrefix, userId, sessionId)
-	if err != nil {
-		return helper.ServiceError("列举文件", err), nil
-	}
-	if len(files) == 0 {
-		return helper.EmptyResult("当前没有上传的文件"), nil
-	}
-
-	return helper.JSONResultOrFileAuto(files), nil
-}
-
-func handleFileWrite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	fileName, err := req.RequireString("fileName")
-	if err != nil {
-		return helper.ParamError("fileName"), nil
-	}
-	content, err := req.RequireString("content")
-	if err != nil {
-		return helper.ParamError("content"), nil
-	}
-
-	// 限制文本内容大小(最大 20MB)
-	const maxSize = 20 * 1024 * 1024
-	if len(content) > maxSize {
-		return helper.ParamInvalidError("content", fmt.Sprintf("内容大小 %d bytes 超过 20MB 限制", len(content))), nil
-	}
-
-	userId := req.GetString("userId", "mcp-agent")
-	sessionId := req.GetString("sessionId", "mcp-session")
-
-	// 仅取文件名部分,上传到 "download" 前缀(供用户下载)
-	record, err := filestore.DownloadFile(userId, sessionId, filepath.Base(fileName), content)
-	if err != nil {
-		return helper.ServiceError("写入文件", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"message":  "文件写入成功",
-		"fileName": record.FileName,
-		"size":     record.Size,
-		"key":      record.Key,
-	}), nil
-}
-
-func handleFileUpload(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	localPath, err := req.RequireString("localPath")
-	if err != nil {
-		return helper.ParamError("localPath"), nil
-	}
-
-	// 检查本地文件是否存在
-	info, err := os.Stat(localPath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return helper.ParamInvalidError("localPath", fmt.Sprintf("文件不存在: %s", localPath)), nil
-		}
-		return helper.ServiceError("检查文件", err), nil
-	}
-	if info.IsDir() {
-		return helper.ParamInvalidError("localPath", "路径是目录,不是文件"), nil
-	}
-
-	// 限制文件大小(最大 100MB)
-	const maxSize = 100 * 1024 * 1024
-	if info.Size() > maxSize {
-		return helper.ParamInvalidError("localPath", fmt.Sprintf("文件大小 %d bytes 超过 100MB 限制", info.Size())), nil
-	}
-
-	// 读取本地文件
-	data, err := os.ReadFile(localPath)
-	if err != nil {
-		return helper.ServiceError("读取本地文件", err), nil
-	}
-
-	// 确定文件名
-	fileName := req.GetString("fileName", "")
-	if fileName == "" {
-		fileName = filepath.Base(localPath)
-	}
-
-	userId := req.GetString("userId", "mcp-agent")
-	sessionId := req.GetString("sessionId", "mcp-session")
-
-	// 上传到 "download" 前缀(供用户下载),仅取文件名部分
-	record, err := filestore.DownloadBytes(userId, sessionId, filepath.Base(fileName), data)
-	if err != nil {
-		return helper.ServiceError("上传文件", err), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"message":  "文件上传成功",
-		"fileName": record.FileName,
-		"size":     record.Size,
-		"key":      record.Key,
-	}), nil
-}

+ 0 - 172
service/cmd/mcp/tools/storage/minio.go

@@ -1,172 +0,0 @@
-package storage
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"strings"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-	"github.com/minio/minio-go/v7"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-	minioUtil "fwh-toolbox-server/utils/minio"
-)
-
-// minioAllowedBuckets 允许访问的 bucket 白名单
-var minioAllowedBuckets = []string{
-	"fwh-toolbox",
-	"fwh-toolbox-ai-file",
-}
-
-func registerMinioTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("storage_minio_list_objects",
-			mcp.WithDescription("列出 MinIO bucket 中的对象(只读),用于查看已下载的文件"),
-			mcp.WithString("bucket", mcp.Required(), mcp.Description(
-				fmt.Sprintf("Bucket 名称,可选: %v", minioAllowedBuckets),
-			)),
-			mcp.WithString("prefix", mcp.Description("对象前缀过滤(如 SN 号或日期路径)")),
-			mcp.WithNumber("limit", mcp.Description("返回数量限制,默认20,最大100")),
-		),
-		handleMinioListObjects,
-	)
-
-	s.AddTool(
-		mcp.NewTool("storage_minio_read_object",
-			mcp.WithDescription("读取 MinIO 中的文本文件内容(只读,仅支持文本类型文件,最大 1MB)"),
-			mcp.WithString("bucket", mcp.Required(), mcp.Description(
-				fmt.Sprintf("Bucket 名称,可选: %v", minioAllowedBuckets),
-			)),
-			mcp.WithString("key", mcp.Required(), mcp.Description("对象 key(完整路径)")),
-		),
-		handleMinioReadObject,
-	)
-}
-
-func handleMinioListObjects(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	bucket, err := req.RequireString("bucket")
-	if err != nil {
-		return helper.ParamError("bucket"), nil
-	}
-
-	if !isAllowedBucket(bucket) {
-		return helper.ParamInvalidError("bucket", fmt.Sprintf("不允许访问此 bucket,可选: %v", minioAllowedBuckets)), nil
-	}
-
-	prefix := req.GetString("prefix", "")
-	limit := int(req.GetFloat("limit", 20))
-	if limit > 100 {
-		limit = 100
-	}
-
-	opts := minio.ListObjectsOptions{
-		Prefix:    prefix,
-		Recursive: true,
-	}
-
-	type ObjectInfo struct {
-		Key          string `json:"key"`
-		Size         int64  `json:"size"`
-		LastModified string `json:"lastModified"`
-		ContentType  string `json:"contentType,omitempty"`
-	}
-
-	var objects []ObjectInfo
-	for obj := range minioUtil.Client.ListObjects(ctx, bucket, opts) {
-		if obj.Err != nil {
-			return helper.ServiceError("列出MinIO对象", obj.Err), nil
-		}
-		objects = append(objects, ObjectInfo{
-			Key:          obj.Key,
-			Size:         obj.Size,
-			LastModified: obj.LastModified.Format("2006-01-02 15:04:05"),
-			ContentType:  obj.ContentType,
-		})
-		if len(objects) >= limit {
-			break
-		}
-	}
-
-	if len(objects) == 0 {
-		return helper.EmptyResult(fmt.Sprintf("bucket %s 中无匹配对象 (prefix: %s)", bucket, prefix)), nil
-	}
-
-	return helper.JSONResultOrFileAuto(map[string]any{
-		"bucket": bucket,
-		"prefix": prefix,
-		"count":  len(objects),
-		"objects": objects,
-	}), nil
-}
-
-func handleMinioReadObject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	bucket, err := req.RequireString("bucket")
-	if err != nil {
-		return helper.ParamError("bucket"), nil
-	}
-	key, err := req.RequireString("key")
-	if err != nil {
-		return helper.ParamError("key"), nil
-	}
-
-	if !isAllowedBucket(bucket) {
-		return helper.ParamInvalidError("bucket", fmt.Sprintf("不允许访问此 bucket,可选: %v", minioAllowedBuckets)), nil
-	}
-
-	// 获取对象信息
-	stat, err := minioUtil.Client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
-	if err != nil {
-		return helper.ServiceError("获取对象信息", err), nil
-	}
-
-	// 限制大小:仅允许读取 1MB 以内的文本文件
-	const maxSize = 1 * 1024 * 1024
-	if stat.Size > maxSize {
-		return helper.ParamInvalidError("key", fmt.Sprintf("文件过大 (%d bytes),仅支持读取 1MB 以内的文本文件", stat.Size)), nil
-	}
-
-	// 检查是否为文本类型
-	if !isTextContentType(stat.ContentType) {
-		return helper.ParamInvalidError("key", fmt.Sprintf("非文本文件 (content-type: %s),不支持读取", stat.ContentType)), nil
-	}
-
-	obj, err := minioUtil.Client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
-	if err != nil {
-		return helper.ServiceError("读取MinIO对象", err), nil
-	}
-	defer obj.Close()
-
-	data, err := io.ReadAll(obj)
-	if err != nil {
-		return helper.ServiceError("读取对象内容", err), nil
-	}
-
-	return helper.TextResultOrFile(string(data), "minio_object"), nil
-}
-
-func isAllowedBucket(name string) bool {
-	for _, b := range minioAllowedBuckets {
-		if b == name {
-			return true
-		}
-	}
-	return false
-}
-
-func isTextContentType(ct string) bool {
-	textTypes := []string{
-		"text/",
-		"application/json",
-		"application/xml",
-		"application/csv",
-		"application/javascript",
-	}
-	for _, t := range textTypes {
-		if strings.HasPrefix(ct, t) || strings.Contains(ct, t) {
-			return true
-		}
-	}
-	return false
-}

+ 0 - 9
service/cmd/mcp/tools/storage/register.go

@@ -1,9 +0,0 @@
-package storage
-
-import "github.com/mark3labs/mcp-go/server"
-
-// RegisterStorageTools 注册所有对象存储层 MCP Tools
-func RegisterStorageTools(s *server.MCPServer) {
-	registerFileTools(s)
-	registerMinioTools(s)
-}

+ 0 - 83
service/cmd/mcp/tools/util/format.go

@@ -1,83 +0,0 @@
-package util
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/mark3labs/mcp-go/mcp"
-	"github.com/mark3labs/mcp-go/server"
-
-	"fwh-toolbox-server/cmd/mcp/tools/helper"
-)
-
-func registerFormatTools(s *server.MCPServer) {
-	s.AddTool(
-		mcp.NewTool("util_parse_sn",
-			mcp.WithDescription(`解析 FranklinWH 设备 SN 序列号的含义。
-SN 格式: {产品代码}{版本}{工厂}{年}{周}{序号}
-示例: 10060006A03X25310091
-- 1006: 产品代码 (aGate=1006, aPower=1005, aHub/PowerLink=1002)
-- 0006: 硬件版本
-- A: 工厂代码
-- 03: 版本标识
-- X: 预留
-- 25: 年份(2025)
-- 31: 周数
-- 0091: 序号`),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("设备 SN 序列号")),
-		),
-		handleParseSN,
-	)
-}
-
-func handleParseSN(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	sn, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
-	}
-
-	sn = strings.TrimSpace(sn)
-	if len(sn) < 20 {
-		return helper.ParamInvalidError("sn", "SN 长度不足,标准长度为 20 位"), nil
-	}
-
-	// 解析产品类型
-	productCode := sn[0:4]
-	var productName string
-	switch productCode {
-	case "1006":
-		productName = "aGate (网关)"
-	case "1005":
-		productName = "aPower (储能电池)"
-	case "1002":
-		productName = "aHub / PowerLink"
-	case "1003":
-		productName = "MPPT"
-	default:
-		productName = "未知产品"
-	}
-
-	hwVersion := sn[4:8]
-	factory := string(sn[8])
-	verFlag := sn[9:11]
-	year := sn[12:14]
-	week := sn[14:16]
-	serial := sn[16:]
-
-	result := map[string]any{
-		"sn":           sn,
-		"productCode":  productCode,
-		"productName":  productName,
-		"hwVersion":    hwVersion,
-		"factory":      factory,
-		"versionFlag":  verFlag,
-		"year":         fmt.Sprintf("20%s", year),
-		"week":         week,
-		"serialNumber": serial,
-		"summary": fmt.Sprintf("%s | HW:%s | 工厂:%s | 20%s年第%s周 | 序号:%s",
-			productName, hwVersion, factory, year, week, serial),
-	}
-
-	return helper.JSONResultOrFileAuto(result), nil
-}

+ 0 - 8
service/cmd/mcp/tools/util/register.go

@@ -1,8 +0,0 @@
-package util
-
-import "github.com/mark3labs/mcp-go/server"
-
-// RegisterUtilTools 注册所有辅助工具层 MCP Tools
-func RegisterUtilTools(s *server.MCPServer) {
-	registerFormatTools(s)
-}

+ 0 - 1
service/cmd/only_build.sh

@@ -7,7 +7,6 @@ echo "===== 编译全部 ====="
 echo ""
 
 bash "$SCRIPT_DIR/only_build_toolbox.sh"
-bash "$SCRIPT_DIR/only_build_mcp.sh"
 
 echo ""
 echo "===== 全部编译完成 ====="

+ 0 - 8
service/cmd/only_build_mcp.sh

@@ -1,8 +0,0 @@
-#!/bin/bash
-set -e
-
-MCP_NAME="fwh-mcp-server"
-
-echo "🚀 Building $MCP_NAME..."
-GOOS=linux GOARCH=amd64 go build -o $MCP_NAME ./cmd/mcp/
-echo "✅ $MCP_NAME built successfully"

+ 0 - 68
service/cmd/test_acp/main.go

@@ -1,68 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"encoding/json"
-	"fmt"
-	"os/exec"
-	"time"
-)
-
-func main() {
-	cmd := exec.Command("kiro-cli", "acp")
-	cmd.Dir = "."
-
-	stdin, _ := cmd.StdinPipe()
-	stdout, _ := cmd.StdoutPipe()
-
-	if err := cmd.Start(); err != nil {
-		fmt.Printf("启动失败: %v\n", err)
-		return
-	}
-
-	scanner := bufio.NewScanner(stdout)
-	scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
-
-	// 等待进程就绪
-	time.Sleep(500 * time.Millisecond)
-
-	// 发送 initialize
-	req := map[string]any{
-		"jsonrpc": "2.0",
-		"id":      1,
-		"method":  "initialize",
-		"params": map[string]any{
-			"protocolVersion":    1,
-			"clientCapabilities": map[string]any{},
-			"clientInfo":         map[string]any{"name": "test", "version": "1.0.0"},
-		},
-	}
-	data, _ := json.Marshal(req)
-	data = append(data, '\n')
-	stdin.Write(data)
-
-	fmt.Println("已发送 initialize,等待响应...")
-
-	// 读取响应(10秒超时)
-	done := make(chan string, 1)
-	go func() {
-		if scanner.Scan() {
-			done <- scanner.Text()
-		} else {
-			done <- fmt.Sprintf("scan error: %v", scanner.Err())
-		}
-	}()
-
-	select {
-	case line := <-done:
-		if len(line) > 200 {
-			line = line[:200]
-		}
-		fmt.Printf("响应: %s\n", line)
-	case <-time.After(10 * time.Second):
-		fmt.Println("超时!")
-	}
-
-	stdin.Close()
-	cmd.Process.Kill()
-}

+ 1 - 22
service/config/config.go

@@ -77,21 +77,6 @@ type PlaneWebApiConfig struct {
 	LabelFeedbackId string
 }
 
-type KiroConfig struct {
-	// RunAsUser 指定拉起 kiro-cli 子进程时降权到的系统用户名。
-	// 为空时表示不降权,沿用后台服务自身的运行用户。
-	RunAsUser string
-	// Home 指定拉起 kiro-cli 子进程时使用的 HOME 目录(即 $HOME 环境变量)。
-	// kiro-cli 依赖 HOME 定位其配置与凭证。为空时回退到降权用户的家目录,
-	// 若也无法推导则使用默认值。
-	Home string
-	// DebugLog 为调试日志总开关(宏开关)。
-	// 开启后,拉起 kiro-cli 子进程时注入 KIRO_LOG_LEVEL=debug,
-	// 让 kiro-cli 输出 debug 级日志,便于问题排查。
-	// 由环境变量 KIRO_DEBUG_LOG 控制(true/1/on 开启,默认关闭)。
-	DebugLog bool
-}
-
 // AgentConfig 新 AI 助手(AgentServer)配置,见 docs/ai-assistant-agentserver/technical-design.md §3.2
 type AgentConfig struct {
 	// Enabled 新 AI 助手总开关(AGENT_ENABLED)
@@ -117,7 +102,6 @@ type Config struct {
 	FwhEs       FwhEsConfig
 	PlaneApi    PlaneApiConfig
 	PlaneWebApi PlaneWebApiConfig
-	Kiro        KiroConfig
 	Agent       AgentConfig
 }
 
@@ -131,7 +115,7 @@ type Config struct {
 // LoadConfig 读取配置文件和环境变量。
 // 可选参数 configPaths 指定候选配置文件路径(按顺序尝试,命中第一个存在的即停)。
 // 不传时使用默认路径 ["./.env", "./config.env"](后端主服务用)。
-// MCP 等独立进程可传入自己的配置文件,例如 LoadConfig("./config.mcp.env")。
+// MCP 等独立进程可传入自己的配置文件,例如 LoadConfig("./config.agent-mcp.env")。
 func LoadConfig(configPaths ...string) (*Config, error) {
 	v := viper.New()
 
@@ -270,11 +254,6 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 			LabelFeatureId:  v.GetString("PLANE_WEB_LABEL_FEATURE"),
 			LabelFeedbackId: v.GetString("PLANE_WEB_LABEL_FEEDBACK"),
 		},
-		Kiro: KiroConfig{
-			RunAsUser: v.GetString("KIRO_RUN_AS_USER"),
-			Home:      v.GetString("KIRO_HOME"),
-			DebugLog:  v.GetBool("KIRO_DEBUG_LOG"),
-		},
 		Agent: AgentConfig{
 			Enabled:        v.GetBool("AGENT_ENABLED"),
 			ServerURL:      v.GetString("AGENT_SERVER_URL"),

+ 0 - 483
service/controllers/aiChatControllers.go

@@ -1,483 +0,0 @@
-package controllers
-
-import (
-	"bufio"
-	"context"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/url"
-	"os/user"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/gofiber/fiber/v2"
-	"github.com/gofiber/fiber/v2/utils"
-	"github.com/minio/minio-go/v7"
-	"go.uber.org/zap"
-
-	"fwh-toolbox-server/middleware"
-	"fwh-toolbox-server/services"
-	"fwh-toolbox-server/utils/filestore"
-	minioUtil "fwh-toolbox-server/utils/minio"
-)
-
-var acpManager *services.ACPManager
-
-// InitAIChat 初始化 AI Chat 模块
-// runAsUser 指定拉起 kiro-cli 子进程时降权到的系统用户名,为空则不降权。
-// home 指定 kiro-cli 子进程的 HOME 目录;为空时回退到降权用户的家目录,
-// 若仍无法推导则使用默认值。
-// debugLog 为调试日志宏开关,开启时向 kiro-cli 子进程注入 KIRO_LOG_LEVEL=debug。
-func InitAIChat(serviceDir string, runAsUser string, home string, debugLog bool) {
-	home = strings.TrimSpace(home)
-
-	// 未显式配置 HOME 时的回退:优先推导降权用户的家目录,否则用默认值。
-	if home == "" {
-		home = "/home/jerry-remote"
-		if u := strings.TrimSpace(runAsUser); u != "" {
-			if usr, err := user.Lookup(u); err == nil {
-				home = usr.HomeDir
-			} else {
-				zap.L().Warn("[AIChat] 查找降权用户失败,沿用默认 HOME",
-					zap.String("user", u), zap.String("home", home), zap.Error(err))
-			}
-		}
-	}
-
-	// 组装传给 kiro-cli 子进程的环境变量。
-	childEnv := []string{"HOME=" + home}
-	if debugLog {
-		// 宏开关:开启 kiro-cli debug 级日志。
-		// 日志输出路径(Linux,kiro-cli acp 默认):/tmp/kiro-log/logs/kiro-chat.log
-		childEnv = append(childEnv, "KIRO_LOG_LEVEL=debug")
-	}
-
-	zap.L().Info("[AIChat] 初始化 ACP",
-		zap.String("runAsUser", runAsUser), zap.String("home", home),
-		zap.Bool("debugLog", debugLog))
-
-	acpManager = services.NewACPManager(serviceDir, childEnv, runAsUser)
-}
-
-// AiChat 发送消息(SSE 流式响应)
-// POST /ai/chat
-func AiChat(c *fiber.Ctx) error {
-	var req struct {
-		Message   string `json:"message"`
-		SessionId string `json:"sessionId"`
-	}
-	if err := c.BodyParser(&req); err != nil {
-		return Fail(c, "Invalid request body")
-	}
-	if req.Message == "" {
-		return Fail(c, "message 不能为空")
-	}
-
-	userId := middleware.AuthGetUserName(c)
-
-	sess, err := acpManager.GetOrCreateSession(userId)
-	if err != nil {
-		return Error(c, fmt.Sprintf("AI 会话创建失败: %v", err))
-	}
-
-	// SSE 流式响应
-	c.Set("Content-Type", "text/event-stream")
-	c.Set("Cache-Control", "no-cache")
-	c.Set("Connection", "keep-alive")
-	c.Set("X-Accel-Buffering", "no")
-
-	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
-		// 连接生命周期上下文:写入失败(客户端断开)时取消,
-		// 令 service/acp 层尽快停止本轮并解除阻塞。
-		ctx, cancel := context.WithCancel(context.Background())
-		defer cancel()
-
-		// flush 封装:返回 false 表示连接已断开
-		flush := func() bool {
-			if err := w.Flush(); err != nil {
-				cancel()
-				return false
-			}
-			return true
-		}
-
-		// 发送 session_info 事件,包含实际生效的 agent 和 model
-		infoContent, _ := json.Marshal(fiber.Map{"agentId": sess.AgentId, "modelId": sess.ModelId})
-		infoEvent, _ := json.Marshal(services.AIChatEvent{Type: "session_info", Content: string(infoContent)})
-		fmt.Fprintf(w, "event: session_info\ndata: %s\n\n", string(infoEvent))
-		if !flush() {
-			return
-		}
-
-		// 如果有欢迎消息且是新会话,先发送 welcome 事件
-		if sess.WelcomeMessage != "" {
-			welcomeEvent, _ := json.Marshal(services.AIChatEvent{Type: "welcome", Content: sess.WelcomeMessage})
-			fmt.Fprintf(w, "event: welcome\ndata: %s\n\n", string(welcomeEvent))
-			if !flush() {
-				return
-			}
-		}
-
-		eventCh := make(chan services.AIChatEvent, 50)
-
-		go acpManager.Prompt(ctx, sess, req.Message, eventCh)
-
-		for event := range eventCh {
-			data, _ := json.Marshal(event)
-			fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, string(data))
-			if !flush() {
-				// 客户端断开:取消上下文,让 service 层停止转发并排空。
-				// 继续 range 直到 eventCh 被关闭,避免 goroutine 泄漏。
-				cancel()
-				for range eventCh {
-				}
-				return
-			}
-		}
-	})
-	return nil
-}
-
-// GetAiSessionList 获取用户的会话历史列表
-// GET /ai/sessions/list
-func GetAiSessionList(c *fiber.Ctx) error {
-	userId := middleware.AuthGetUserName(c)
-	list := acpManager.GetSessionList(userId)
-	return Success(c, list)
-}
-
-// GetCurrentAiSession 获取当前活跃会话信息(含实际生效的 agent/model 与最近用量)
-// GET /ai/sessions/current
-func GetCurrentAiSession(c *fiber.Ctx) error {
-	userId := middleware.AuthGetUserName(c)
-	sess := acpManager.GetSessionInfo(userId)
-	if sess == nil {
-		// 无活跃会话,返回空对象
-		return Success(c, fiber.Map{})
-	}
-
-	var contextUsagePercent float64
-	var turnDurationMs int64
-	if sess.Client != nil {
-		contextUsagePercent, turnDurationMs = sess.Client.GetMeta()
-	}
-
-	return Success(c, fiber.Map{
-		"sessionId":           sess.SessionId,
-		"title":               sess.Title,
-		"agentId":             sess.AgentId,
-		"modelId":             sess.ModelId,
-		"contextUsagePercent": contextUsagePercent,
-		"turnDurationMs":      turnDurationMs,
-	})
-}
-
-// CreateAiSession 创建新的 AI 会话(保留旧会话到历史)
-// POST /ai/sessions/new
-func CreateAiSession(c *fiber.Ctx) error {
-	userId := middleware.AuthGetUserName(c)
-
-	// 创建新会话(旧会话自动归档到历史)
-	sess, err := acpManager.CreateNewSession(userId)
-	if err != nil {
-		return Error(c, fmt.Sprintf("创建会话失败: %v", err))
-	}
-
-	return Success(c, fiber.Map{
-		"sessionId":      sess.SessionId,
-		"welcomeMessage": sess.WelcomeMessage,
-		"agentId":        sess.AgentId,
-		"modelId":        sess.ModelId,
-	})
-}
-
-// SwitchAiSession 切换到历史会话(恢复 AI 上下文,轻量响应)
-// POST /ai/sessions/:id
-func SwitchAiSession(c *fiber.Ctx) error {
-	sessionId := utils.CopyString(c.Params("id"))
-	if sessionId == "" {
-		return Fail(c, "sessionId 不能为空")
-	}
-
-	userId := middleware.AuthGetUserName(c)
-	sess, err := acpManager.LoadSession(userId, sessionId)
-	if err != nil {
-		return Error(c, fmt.Sprintf("加载会话失败: %v", err))
-	}
-
-	return Success(c, fiber.Map{
-		"sessionId":      sess.SessionId,
-		"title":          sess.Title,
-		"welcomeMessage": sess.WelcomeMessage,
-		"agentId":        sess.AgentId,
-		"modelId":        sess.ModelId,
-	})
-}
-
-// StreamLoadAiSession SSE 流式推送回放消息
-// GET /ai/sessions/:id/load
-func StreamLoadAiSession(c *fiber.Ctx) error {
-	sessionId := utils.CopyString(c.Params("id"))
-	if sessionId == "" {
-		return Fail(c, "sessionId 不能为空")
-	}
-
-	userId := middleware.AuthGetUserName(c)
-
-	// 获取回放消息(会话必须已通过 SwitchAiSession 加载)
-	replayMessages := acpManager.GetReplayMessages(userId)
-	if replayMessages == nil {
-		return Fail(c, "会话未加载,请先调用切换会话接口")
-	}
-
-	// SSE 流式响应
-	c.Set("Content-Type", "text/event-stream")
-	c.Set("Cache-Control", "no-cache")
-	c.Set("Connection", "keep-alive")
-	c.Set("X-Accel-Buffering", "no")
-
-	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
-		var totalBytes int
-
-		for _, msg := range replayMessages {
-			data, _ := json.Marshal(msg)
-			totalBytes += len(msg.Content)
-			fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(data))
-			w.Flush()
-		}
-
-		// 发送 done 事件,包含统计信息和建议
-		suggestNewSession := totalBytes >= 100*1024 // 100KB 以上建议新建
-		donePayload, _ := json.Marshal(fiber.Map{
-			"sessionId":         sessionId,
-			"totalMessages":     len(replayMessages),
-			"totalBytes":        totalBytes,
-			"suggestNewSession": suggestNewSession,
-		})
-		fmt.Fprintf(w, "event: done\ndata: %s\n\n", string(donePayload))
-		w.Flush()
-	})
-	return nil
-}
-
-// UpdateAiSessionTitle 更新会话标题
-// PATCH /ai/sessions/:id
-func UpdateAiSessionTitle(c *fiber.Ctx) error {
-	sessionId := utils.CopyString(c.Params("id"))
-	if sessionId == "" {
-		return Fail(c, "sessionId 不能为空")
-	}
-
-	var req struct {
-		Title string `json:"title"`
-	}
-	if err := c.BodyParser(&req); err != nil {
-		return Fail(c, "Invalid request body")
-	}
-
-	userId := middleware.AuthGetUserName(c)
-	acpManager.UpdateSessionTitle(userId, sessionId, req.Title)
-	return Success[any](c, nil, "标题已更新")
-}
-
-// DeleteAiSession 删除 AI 会话
-// DELETE /ai/sessions/:id
-func DeleteAiSession(c *fiber.Ctx) error {
-	sessionId := utils.CopyString(c.Params("id"))
-	userId := middleware.AuthGetUserName(c)
-
-	if sessionId == "" {
-		// 无 id 参数,删除当前活跃会话
-		acpManager.DeleteSession(userId)
-	} else {
-		// 删除指定会话
-		acpManager.DeleteSessionFromHistory(userId, sessionId)
-	}
-
-	return Success[any](c, nil, "会话已删除")
-}
-
-// CancelAiChat 取消当前 AI 操作
-// POST /ai/cancel
-func CancelAiChat(c *fiber.Ctx) error {
-	userId := middleware.AuthGetUserName(c)
-	sess := acpManager.GetSessionInfo(userId)
-	if sess == nil {
-		return Fail(c, "没有活跃的会话")
-	}
-	acpManager.Cancel(sess)
-	return Success[any](c, nil, "已取消")
-}
-
-// ResolvePermission 前端回传写权限选择
-// POST /ai/permission
-func ResolvePermission(c *fiber.Ctx) error {
-	var req struct {
-		OptionId string `json:"optionId"`
-	}
-	if err := c.BodyParser(&req); err != nil {
-		return Fail(c, "Invalid request body")
-	}
-	if req.OptionId == "" {
-		return Fail(c, "optionId 不能为空")
-	}
-
-	userId := middleware.AuthGetUserName(c)
-	if err := acpManager.ResolvePermission(userId, req.OptionId); err != nil {
-		return Fail(c, err.Error())
-	}
-
-	return Success[any](c, nil, "已回复")
-}
-
-// DownloadAiFile 下载 AI 生成的文件
-// GET /ai/files/download?key=xxx
-func DownloadAiFile(c *fiber.Ctx) error {
-	key := utils.CopyString(c.Query("key"))
-	if key == "" {
-		return Fail(c, "key 不能为空")
-	}
-
-	// 直接从 MinIO 读取文件(key 即 objectKey)
-	obj, err := minioUtil.Client.GetObject(context.Background(), filestore.AIFileBucket, key, minio.GetObjectOptions{})
-	if err != nil {
-		return Fail(c, "文件不存在")
-	}
-	defer obj.Close()
-
-	// 获取文件信息
-	stat, err := obj.Stat()
-	if err != nil {
-		return Fail(c, fmt.Sprintf("文件不存在: %v", err))
-	}
-
-	// 读取全部内容
-	data, err := io.ReadAll(obj)
-	if err != nil {
-		return Error(c, fmt.Sprintf("读取文件失败: %v", err))
-	}
-
-	// 从 key 中提取文件名(key 格式: prefix/userId/sessionId/uuid_fileName)
-	fileName := filepath.Base(key)
-	// 去掉 uuid 前缀(前9个字符: 8位uuid + 下划线)
-	if len(fileName) > 9 {
-		fileName = fileName[9:]
-	}
-
-	// 文件名可能含中文/非 ASCII,必须做 RFC 5987 编码后再写入响应头,
-	// 否则 Content-Disposition 含非法非 ASCII 字节,nginx 会判定 upstream 响应头非法而返回 502。
-	encoded := url.PathEscape(fileName)
-	c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s; filename*=UTF-8''%s", encoded, encoded))
-	c.Set("Content-Type", stat.ContentType)
-	return c.Send(data)
-}
-
-// UploadAiFile 上传文件给 AI 分析
-// POST /ai/files/upload
-func UploadAiFile(c *fiber.Ctx) error {
-	userId := middleware.AuthGetUserName(c)
-
-	sess, err := acpManager.GetOrCreateSession(userId)
-	if err != nil {
-		return Error(c, fmt.Sprintf("AI 会话创建失败: %v", err))
-	}
-
-	// 读取上传文件
-	file, err := c.FormFile("file")
-	if err != nil {
-		return Fail(c, "请选择要上传的文件")
-	}
-
-	// 限制文件大小(最大 2MB)
-	if file.Size > 2*1024*1024 {
-		return Fail(c, "文件大小不能超过 2MB")
-	}
-
-	// 读取文件内容
-	f, err := file.Open()
-	if err != nil {
-		return Error(c, "打开文件失败")
-	}
-	defer f.Close()
-
-	data, err := io.ReadAll(f)
-	if err != nil {
-		return Error(c, "读取文件失败")
-	}
-
-	// 上传到 MinIO 的 "upload" 前缀(这样 Agent readTextFile 可以读到)
-	record, err := filestore.UploadFile(userId, sess.SessionId, file.Filename, string(data))
-	if err != nil {
-		return Error(c, fmt.Sprintf("保存文件失败: %v", err))
-	}
-
-	return Success(c, fiber.Map{
-		"key":      record.Key,
-		"fileName": record.FileName,
-		"size":     record.Size,
-	})
-}
-
-// ListAiFiles 获取用户的 AI 文件列表
-// GET /ai/files/list?type=upload|download
-func ListAiFiles(c *fiber.Ctx) error {
-	userId := middleware.AuthGetUserName(c)
-	fileType := utils.CopyString(c.Query("type"))
-
-	if fileType != "upload" && fileType != "download" {
-		return Fail(c, "type 参数必须为 upload 或 download")
-	}
-
-	// 按用户前缀列出 MinIO 文件
-	prefix := fmt.Sprintf("%s/%s/", fileType, userId)
-	objects, err := minioUtil.ListFilesObjectFromBucket(filestore.AIFileBucket, prefix, true)
-	if err != nil {
-		return Error(c, fmt.Sprintf("获取文件列表失败: %v", err))
-	}
-
-	type FileItem struct {
-		Key       string `json:"key"`
-		FileName  string `json:"fileName"`
-		Size      int64  `json:"size"`
-		CreatedAt int64  `json:"createdAt"` // Unix ms
-		ExpiresAt int64  `json:"expiresAt"` // Unix ms(0 表示不过期)
-	}
-
-	now := time.Now()
-	list := make([]FileItem, 0, len(objects))
-	for _, obj := range objects {
-		// 跳过已过期的文件(MinIO 可能延迟清理)
-		if !obj.Expires.IsZero() && obj.Expires.Before(now) {
-			continue
-		}
-
-		// 从 key 中提取文件名: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
-		fileName := filepath.Base(obj.Key)
-		if len(fileName) > 9 { // 去掉 "uuid_" 前缀(8位uuid + 下划线)
-			fileName = fileName[9:]
-		}
-
-		var expiresAt int64
-		if !obj.Expires.IsZero() {
-			expiresAt = obj.Expires.UnixMilli()
-		}
-
-		list = append(list, FileItem{
-			Key:       obj.Key,
-			FileName:  fileName,
-			Size:      obj.Size,
-			CreatedAt: obj.LastModified.UnixMilli(),
-			ExpiresAt: expiresAt,
-		})
-	}
-
-	// 按创建时间倒序
-	sort.Slice(list, func(i, j int) bool {
-		return list[i].CreatedAt > list[j].CreatedAt
-	})
-
-	return Success(c, list)
-}

+ 0 - 14
service/domain/aiChatDomain.go

@@ -1,14 +0,0 @@
-package domain
-
-import "time"
-
-// AiChatSession AI 助手会话
-type AiChatSession struct {
-	UserId     string    `bson:"userId"`
-	SessionId  string    `bson:"sessionId"`
-	Title      string    `bson:"title"`
-	IsActive   bool      `bson:"isActive"`
-	CreatedAt  time.Time `bson:"createdAt"`
-	LastActive time.Time `bson:"lastActive"`
-	UpdatedAt  time.Time `bson:"updatedAt"`
-}

+ 0 - 2
service/go.sum

@@ -192,8 +192,6 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
 github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
 github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.57.0 h1:jzWKyCzdWnwnZt05cvcQQ+ngiUl2RnixXJa7Kj4qP1E=
-github.com/mark3labs/mcp-go v0.57.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
 github.com/mark3labs/mcp-go v1.4.0 h1:gEWEv7YOTJjW4K9cxuwfr0FLRFgSWOE1Bju1SxPlctI=
 github.com/mark3labs/mcp-go v1.4.0/go.mod h1:6L8eBboqlAMnYrvl8rpbUeV2qE/SJh52aVGrXIomVb0=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=

+ 0 - 3
service/main.go

@@ -139,9 +139,6 @@ func main() {
 		}()
 	}
 
-	// 初始化 AI Chat (ACP Client)
-	controllers.InitAIChat(".", cfg.Kiro.RunAsUser, cfg.Kiro.Home, cfg.Kiro.DebugLog)
-
 	// 初始化新 AI 助手(AgentServer)
 	controllers.InitAgent(cfg.Agent)
 

+ 0 - 1
service/repositories/mongo/agentSessionRepo.go

@@ -13,7 +13,6 @@ import (
 )
 
 // 新 AI 助手(AgentServer)的会话归属与子资源索引,见 docs/ai-assistant-agentserver/technical-design.md §3.3。
-// 与旧助手的 AiChatSessions 相互独立。
 const (
 	CollectionName_AgentSessions  = "AgentSessions"
 	CollectionName_AgentResources = "AgentResources"

+ 0 - 138
service/repositories/mongo/aiChatRepo.go

@@ -1,138 +0,0 @@
-package mongo
-
-import (
-	"context"
-	"fwh-toolbox-server/database"
-	"fwh-toolbox-server/domain"
-	"time"
-
-	"go.mongodb.org/mongo-driver/bson"
-	"go.mongodb.org/mongo-driver/mongo/options"
-)
-
-const CollectionName_AiChatSession = "AiChatSessions"
-
-// UpsertAiChatSession 创建或更新会话记录
-func UpsertAiChatSession(sess domain.AiChatSession) error {
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	collection := database.GetCollection(CollectionName_AiChatSession)
-
-	filter := bson.M{
-		"userId":    sess.UserId,
-		"sessionId": sess.SessionId,
-	}
-	update := bson.M{
-		"$set": bson.M{
-			"title":      sess.Title,
-			"isActive":   sess.IsActive,
-			"lastActive": sess.LastActive,
-			"updatedAt":  time.Now(),
-		},
-		"$setOnInsert": bson.M{
-			"userId":    sess.UserId,
-			"sessionId": sess.SessionId,
-			"createdAt": sess.CreatedAt,
-		},
-	}
-
-	opts := options.Update().SetUpsert(true)
-	_, err := collection.UpdateOne(ctx, filter, update, opts)
-	return err
-}
-
-// GetAiChatSessionsByUser 获取用户的所有会话(按 lastActive 倒序)
-func GetAiChatSessionsByUser(userId string) ([]domain.AiChatSession, error) {
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	collection := database.GetCollection(CollectionName_AiChatSession)
-
-	filter := bson.M{"userId": userId}
-	findOpts := options.Find().
-		SetSort(bson.D{{Key: "lastActive", Value: -1}}).
-		SetLimit(50)
-
-	cursor, err := collection.Find(ctx, filter, findOpts)
-	if err != nil {
-		return nil, err
-	}
-	defer cursor.Close(ctx)
-
-	var sessions []domain.AiChatSession
-	if err := cursor.All(ctx, &sessions); err != nil {
-		return nil, err
-	}
-	return sessions, nil
-}
-
-// UpdateAiChatSessionTitle 更新会话标题
-func UpdateAiChatSessionTitle(userId string, sessionId string, title string) error {
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	collection := database.GetCollection(CollectionName_AiChatSession)
-
-	filter := bson.M{"userId": userId, "sessionId": sessionId}
-	update := bson.M{
-		"$set": bson.M{
-			"title":     title,
-			"updatedAt": time.Now(),
-		},
-	}
-
-	_, err := collection.UpdateOne(ctx, filter, update)
-	return err
-}
-
-// UpdateAiChatSessionActive 更新会话的活跃状态
-func UpdateAiChatSessionActive(userId string, sessionId string, isActive bool) error {
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	collection := database.GetCollection(CollectionName_AiChatSession)
-
-	filter := bson.M{"userId": userId, "sessionId": sessionId}
-	update := bson.M{
-		"$set": bson.M{
-			"isActive":   isActive,
-			"lastActive": time.Now(),
-			"updatedAt":  time.Now(),
-		},
-	}
-
-	_, err := collection.UpdateOne(ctx, filter, update)
-	return err
-}
-
-// DeactivateAllAiChatSessions 将用户所有会话设为非活跃
-func DeactivateAllAiChatSessions(userId string) error {
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	collection := database.GetCollection(CollectionName_AiChatSession)
-
-	filter := bson.M{"userId": userId, "isActive": true}
-	update := bson.M{
-		"$set": bson.M{
-			"isActive":  false,
-			"updatedAt": time.Now(),
-		},
-	}
-
-	_, err := collection.UpdateMany(ctx, filter, update)
-	return err
-}
-
-// DeleteAiChatSession 删除会话记录
-func DeleteAiChatSession(userId string, sessionId string) error {
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-
-	collection := database.GetCollection(CollectionName_AiChatSession)
-
-	filter := bson.M{"userId": userId, "sessionId": sessionId}
-	_, err := collection.DeleteOne(ctx, filter)
-	return err
-}

+ 0 - 114
service/repositories/redis/aiChatRepo.go

@@ -1,114 +0,0 @@
-package redis
-
-import (
-	"context"
-	"encoding/json"
-	"fwh-toolbox-server/cache"
-	"time"
-
-	"go.uber.org/zap"
-)
-
-var (
-	aiChatCtx            = context.Background()
-	aiChatSessionKey     = "fwhtoolbox:aiChat:session:"     // {prefix}{userId} → sessionId
-	aiChatSessionListKey = "fwhtoolbox:aiChat:sessionList:" // {prefix}{userId} → JSON array
-	aiChatTTL            = 7 * 24 * time.Hour
-)
-
-// AiSessionItem 会话列表项(Redis 存储结构)
-type AiSessionItem struct {
-	SessionId  string `json:"sessionId"`
-	Title      string `json:"title"`
-	CreatedAt  int64  `json:"createdAt"`
-	LastActive int64  `json:"lastActive"`
-}
-
-// ============ 当前活跃会话 ============
-
-// AiActiveSession 当前活跃会话信息(Redis 存储结构)
-type AiActiveSession struct {
-	SessionId  string `json:"sessionId"`
-	Title      string `json:"title"`
-	CreatedAt  int64  `json:"createdAt"`
-	LastActive int64  `json:"lastActive"`
-}
-
-// SaveAiSession 保存用户当前活跃会话完整信息
-func SaveAiSession(userId string, sess AiActiveSession) error {
-	key := aiChatSessionKey + userId
-	data, err := json.Marshal(sess)
-	if err != nil {
-		return err
-	}
-	return cache.Set(aiChatCtx, key, string(data), aiChatTTL)
-}
-
-// GetAiSession 获取用户保存的活跃会话信息
-func GetAiSession(userId string) (*AiActiveSession, error) {
-	key := aiChatSessionKey + userId
-	raw, err := cache.Get(aiChatCtx, key)
-	if err != nil {
-		return nil, err
-	}
-	var sess AiActiveSession
-	if err := json.Unmarshal([]byte(raw), &sess); err != nil {
-		return nil, err
-	}
-	return &sess, nil
-}
-
-// DeleteAiSession 删除用户保存的活跃会话信息
-func DeleteAiSession(userId string) {
-	key := aiChatSessionKey + userId
-	cache.Delete(aiChatCtx, key)
-}
-
-// ============ 会话历史列表 ============
-
-// SaveAiSessionList 保存用户完整会话历史列表
-func SaveAiSessionList(userId string, list []AiSessionItem) error {
-	key := aiChatSessionListKey + userId
-	data, err := json.Marshal(list)
-	if err != nil {
-		return err
-	}
-	return cache.Set(aiChatCtx, key, string(data), aiChatTTL)
-}
-
-// GetAiSessionList 获取用户完整会话历史列表
-func GetAiSessionList(userId string) ([]AiSessionItem, error) {
-	key := aiChatSessionListKey + userId
-	raw, err := cache.Get(aiChatCtx, key)
-	if err != nil {
-		return nil, err
-	}
-	var list []AiSessionItem
-	if err := json.Unmarshal([]byte(raw), &list); err != nil {
-		return nil, err
-	}
-	return list, nil
-}
-
-// DeleteAiSessionList 删除用户会话历史列表
-func DeleteAiSessionList(userId string) {
-	key := aiChatSessionListKey + userId
-	cache.Delete(aiChatCtx, key)
-}
-
-// UpdateAiSessionTitle 更新列表中指定会话的标题
-func UpdateAiSessionTitle(userId string, sessionId string, title string) {
-	list, err := GetAiSessionList(userId)
-	if err != nil {
-		return
-	}
-	for i := range list {
-		if list[i].SessionId == sessionId {
-			list[i].Title = title
-			break
-		}
-	}
-	if err := SaveAiSessionList(userId, list); err != nil {
-		zap.L().Warn("[AIChat] 更新会话标题到Redis失败", zap.Error(err))
-	}
-}

+ 0 - 23
service/repositories/repositories.go

@@ -2,7 +2,6 @@ package repositories
 
 import (
 	"fwh-toolbox-server/config"
-	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/repositories/api/acceleronix"
 	"fwh-toolbox-server/repositories/api/fwh_es_mqtt"
 	"fwh-toolbox-server/repositories/api/fwh_pro"
@@ -485,28 +484,6 @@ var RefreshAutoUpdateConfigFileInfo = redis.RefreshAutoUpdateConfigFileInfo
 var GetAutoUpdateConfigFileInfoTotal = redis.GetAutoUpdateConfigFileInfoTotal
 var GetAutoUpdateConfigFileInfoByPage = redis.GetAutoUpdateConfigFileInfoByPage
 
-// AIChat - Redis
-type AiSessionItem = redis.AiSessionItem
-type AiActiveSession = redis.AiActiveSession
-
-var SaveAiSession = redis.SaveAiSession
-var GetAiSession = redis.GetAiSession
-var DeleteAiSession = redis.DeleteAiSession
-var SaveAiSessionList = redis.SaveAiSessionList
-var GetAiSessionList = redis.GetAiSessionList
-var DeleteAiSessionList = redis.DeleteAiSessionList
-var UpdateAiSessionTitle = redis.UpdateAiSessionTitle
-
-// AIChat - MongoDB
-type AiChatSession = domain.AiChatSession
-
-var UpsertAiChatSession = mongo.UpsertAiChatSession
-var GetAiChatSessionsByUser = mongo.GetAiChatSessionsByUser
-var UpdateAiChatSessionTitle = mongo.UpdateAiChatSessionTitle
-var UpdateAiChatSessionActive = mongo.UpdateAiChatSessionActive
-var DeactivateAllAiChatSessions = mongo.DeactivateAllAiChatSessions
-var DeleteAiChatSession = mongo.DeleteAiChatSession
-
 /*************** MQTT ***************/
 // FwhMqtt
 type FwhMqttEnv = fwh_mqtt.Env

+ 1 - 1
service/routes/agentRoutes.go

@@ -6,7 +6,7 @@ import (
 	"github.com/gofiber/fiber/v2"
 )
 
-// AgentSetup 新 AI 助手(AgentServer)路由,与旧助手的 /ai/* 相互独立
+// AgentSetup 新 AI 助手(AgentServer)路由
 func AgentSetup(api fiber.Router) {
 	// 新助手对当前用户是否可用(不受开关限制,前端据此决定是否显示菜单)
 	api.Get("/agent/config", controllers.GetAgentConfig)

+ 0 - 48
service/routes/aiChatRoutes.go

@@ -1,48 +0,0 @@
-package routes
-
-import (
-	"fwh-toolbox-server/controllers"
-
-	"github.com/gofiber/fiber/v2"
-)
-
-func AiChatSetup(api fiber.Router) {
-	// 发送消息(SSE 流式响应)
-	api.Post("/ai/chat", controllers.AiChat)
-
-	// 获取会话历史列表
-	api.Get("/ai/sessions/list", controllers.GetAiSessionList)
-
-	// 获取当前活跃会话信息(agent/model/用量)
-	api.Get("/ai/sessions/current", controllers.GetCurrentAiSession)
-
-	// 创建新会话
-	api.Post("/ai/sessions/new", controllers.CreateAiSession)
-
-	// 切换到历史会话(恢复 AI 上下文,轻量响应)
-	api.Post("/ai/sessions/:id", controllers.SwitchAiSession)
-
-	// 流式加载会话回放消息(SSE)
-	api.Get("/ai/sessions/:id/load", controllers.StreamLoadAiSession)
-
-	// 更新会话标题
-	api.Patch("/ai/sessions/:id", controllers.UpdateAiSessionTitle)
-
-	// 删除会话
-	api.Delete("/ai/sessions/:id", controllers.DeleteAiSession)
-
-	// 取消当前操作
-	api.Post("/ai/cancel", controllers.CancelAiChat)
-
-	// 回复写权限请求
-	api.Post("/ai/permission", controllers.ResolvePermission)
-
-	// 下载 AI 生成的文件
-	api.Get("/ai/files/download", controllers.DownloadAiFile)
-
-	// 获取用户的 AI 文件列表
-	api.Get("/ai/files/list", controllers.ListAiFiles)
-
-	// 上传文件给 AI 分析
-	api.Post("/ai/files/upload", controllers.UploadAiFile)
-}

+ 0 - 1
service/routes/routes.go

@@ -36,7 +36,6 @@ func Setup(app *fiber.App) {
 	SimCardManageSetup(api)
 	MqttDataManageSetup(api)
 	FeedbackSetup(api)
-	AiChatSetup(api)
 	AgentSetup(api)
 
 	app.Get("/swagger/*", swagger.HandlerDefault)

+ 0 - 14
service/scheduler/scheduler.go

@@ -9,7 +9,6 @@ import (
 	"go.uber.org/zap"
 
 	"fwh-toolbox-server/services"
-	"fwh-toolbox-server/utils/agentfiles"
 )
 
 func Init() *cron.Cron {
@@ -69,19 +68,6 @@ 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.AddFunc("@every 1h", func() {
 		if err := services.CleanupDataDownloadPackages(context.Background()); err != nil {
 			zap.L().Error("DataDownload package cleanup failed", zap.String("err", err.Error()))

+ 1 - 1
service/services/agentServices.go

@@ -14,7 +14,7 @@ import (
 )
 
 // 新 AI 助手(AgentServer):会话归属、按角色选择 Profile、代理组装。
-// 设计见 docs/ai-assistant-agentserver/technical-design.md §3,与旧助手(aiChatServices.go)相互独立。
+// 设计见 docs/ai-assistant-agentserver/technical-design.md §3。
 
 // ErrAgentSessionNotFound 会话不存在、已删除或不属于当前用户
 var ErrAgentSessionNotFound = errors.New("会话不存在或无权访问")

+ 0 - 650
service/services/aiChatServices.go

@@ -1,650 +0,0 @@
-package services
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"fwh-toolbox-server/repositories"
-	"fwh-toolbox-server/utils/acp"
-	"os"
-	"os/exec"
-	"sync"
-	"time"
-
-	"go.uber.org/zap"
-)
-
-// AIChatEvent SSE 事件(输出给前端)
-type AIChatEvent struct {
-	Type        string `json:"type"`                  // text, tool_call, tool_update, done, error
-	Content     string `json:"content,omitempty"`     // 文本内容
-	ToolCallId  string `json:"toolCallId,omitempty"`  // 工具调用唯一ID(前端按 id 关联状态)
-	ToolName    string `json:"name,omitempty"`        // 工具原始 title(前端作 hover tooltip)
-	Description string `json:"description,omitempty"` // 工具简要描述(前端展示)
-	Status      string `json:"status,omitempty"`      // 工具状态(已归一化:running/completed/error)
-	SessionId   string `json:"sessionId,omitempty"`   // 会话ID
-	StopReason  string `json:"stopReason,omitempty"`
-}
-
-// ACPSession 单个用户的 ACP 会话
-type ACPSession struct {
-	UserId         string
-	SessionId      string
-	Title          string
-	WelcomeMessage string
-	AgentId        string              // 实际生效的 agent(currentModeId)
-	ModelId        string              // 实际生效的 model(currentModelId)
-	ReplayMessages []acp.ReplayMessage // session/load 回放的历史消息
-	Client         *acp.Client
-	Mutex          sync.Mutex
-	LastActive     time.Time
-	CreatedAt      time.Time
-}
-
-// SessionListItem 会话列表项(返回给前端)
-type SessionListItem struct {
-	SessionId  string `json:"sessionId"`
-	Title      string `json:"title"`
-	CreatedAt  int64  `json:"createdAt"`
-	LastActive int64  `json:"lastActive"`
-	IsActive   bool   `json:"isActive"`
-}
-
-// ACPManager 管理所有用户的 ACP 会话
-type ACPManager struct {
-	sessions   map[string]*ACPSession // key: userId -> 当前活跃会话(持有进程引用)
-	mu         sync.Mutex
-	serviceDir string
-	acpConfig  acp.ClientConfig
-}
-
-// NewACPManager 创建 ACP 管理器
-// runAsUser 指定拉起 kiro-cli 子进程时降权到的系统用户名,为空则不降权。
-func NewACPManager(serviceDir string, env []string, runAsUser string) *ACPManager {
-	return &ACPManager{
-		sessions:   make(map[string]*ACPSession),
-		serviceDir: serviceDir,
-		acpConfig: acp.ClientConfig{
-			Command:    "kiro-cli",
-			Args:       []string{"acp", "--agent", "fwh-toolbox-agent"},
-			WorkingDir: serviceDir,
-			Env:        env,
-			RunAsUser:  runAsUser,
-		},
-	}
-}
-
-// ============ 会话生命周期 ============
-
-// GetOrCreateSession 获取或创建用户的 ACP 会话
-// 优先级:内存活跃会话 → Redis 持久化恢复 → 创建新会话
-func (m *ACPManager) GetOrCreateSession(userId string) (*ACPSession, error) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	// 1. 检查内存中是否有活跃会话
-	if sess, ok := m.sessions[userId]; ok {
-		if sess.Client.IsAlive() {
-			sess.LastActive = time.Now()
-			return sess, nil
-		}
-		sess.Client.Close()
-		delete(m.sessions, userId)
-	}
-
-	// 2. 尝试从 Redis 恢复会话
-	savedSession, err := repositories.GetAiSession(userId)
-	if err == nil && savedSession != nil {
-		sess, err := m.startAndLoadSession(userId, savedSession)
-		if err == nil {
-			zap.L().Info("[AIChat] 从 Redis 恢复会话成功", zap.String("userId", userId), zap.String("sessionId", savedSession.SessionId))
-			return sess, nil
-		}
-		zap.L().Warn("[AIChat] 从 Redis 恢复会话失败,将创建新会话", zap.String("userId", userId), zap.Error(err))
-		repositories.DeleteAiSession(userId)
-	}
-
-	// 3. 创建新会话
-	return m.createSession(userId)
-}
-
-// CreateNewSession 强制创建一个新会话(归档旧会话到 Redis 历史)
-func (m *ACPManager) CreateNewSession(userId string) (*ACPSession, error) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	// 归档当前活跃会话
-	if sess, ok := m.sessions[userId]; ok {
-		m.archiveSession(userId, sess)
-		sess.Client.Close()
-		delete(m.sessions, userId)
-	}
-
-	return m.createSession(userId)
-}
-
-// LoadSession 加载历史会话(恢复 AI 上下文,切换会话)
-// 仅切换会话并恢复上下文,不返回回放消息(回放消息通过 SSE 接口单独获取)
-func (m *ACPManager) LoadSession(userId string, sessionId string) (*ACPSession, error) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	// 如果已经是当前会话,直接返回
-	if sess, ok := m.sessions[userId]; ok {
-		if sess.SessionId == sessionId {
-			sess.LastActive = time.Now()
-			return sess, nil
-		}
-		// 归档当前活跃会话
-		m.archiveSession(userId, sess)
-		sess.Client.Close()
-		delete(m.sessions, userId)
-	}
-
-	// 从 Redis 历史列表中获取标题
-	item := m.getSessionListFromRedisHistory(userId, sessionId)
-	if item == nil {
-		return nil, fmt.Errorf("redis session/load 失败")
-	}
-
-	// 从 Redis 历史列表中移除该会话(因为它即将变为活跃)
-	m.removeFromRedisHistory(userId, sessionId)
-
-	// 启动 ACP 进程并 load 会话
-	client, err := acp.NewClient(m.acpConfig)
-	if err != nil {
-		return nil, err
-	}
-	if err := client.Initialize(); err != nil {
-		client.Close()
-		return nil, err
-	}
-
-	loadedId, welcomeMsg, agentId, modelId, replayMessages, err := client.LoadSession(sessionId, m.serviceDir)
-	if err != nil {
-		client.Close()
-		return nil, fmt.Errorf("session/load 失败: %w", err)
-	}
-
-	zap.L().Info("[AIChat] session/load 回放完成",
-		zap.String("sessionId", loadedId),
-		zap.String("welcomeMsg", welcomeMsg),
-		zap.Int("replayCount", len(replayMessages)),
-	)
-
-	sess := &ACPSession{
-		UserId:         userId,
-		SessionId:      sessionId,
-		Title:          item.Title,
-		WelcomeMessage: welcomeMsg,
-		AgentId:        agentId,
-		ModelId:        modelId,
-		ReplayMessages: replayMessages,
-		Client:         client,
-		LastActive:     time.Now(),
-		CreatedAt:      time.UnixMilli(item.CreatedAt),
-	}
-	client.UserId = userId
-	m.sessions[userId] = sess
-
-	// 持久化
-	m.activateSession(userId, sess)
-
-	zap.L().Info("[AIChat] 会话加载成功", zap.String("userId", userId), zap.String("sessionId", sessionId))
-	return sess, nil
-}
-
-// GetReplayMessages 获取指定用户当前活跃会话的回放消息
-// 用于 SSE 流式推送
-func (m *ACPManager) GetReplayMessages(userId string) []acp.ReplayMessage {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	if sess, ok := m.sessions[userId]; ok {
-		return sess.ReplayMessages
-	}
-	return nil
-}
-
-// ============ 会话列表查询 ============
-
-// GetSessionList 获取用户的会话列表(当前活跃 + Redis 历史)
-func (m *ACPManager) GetSessionList(userId string) []SessionListItem {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	list := make([]SessionListItem, 0)
-
-	// 当前活跃会话排在最前
-	if sess, ok := m.sessions[userId]; ok {
-		list = append(list, SessionListItem{
-			SessionId:  sess.SessionId,
-			Title:      sess.Title,
-			CreatedAt:  sess.CreatedAt.UnixMilli(),
-			LastActive: sess.LastActive.UnixMilli(),
-			IsActive:   true,
-		})
-	} else {
-		// 内存无活跃会话(后端重启),从 Redis 恢复完整会话到内存
-		activeSess, err := repositories.GetAiSession(userId)
-		if err == nil && activeSess != nil {
-			sess, err := m.startAndLoadSession(userId, activeSess)
-			if err == nil {
-				list = append(list, SessionListItem{
-					SessionId:  sess.SessionId,
-					Title:      sess.Title,
-					CreatedAt:  sess.CreatedAt.UnixMilli(),
-					LastActive: sess.LastActive.UnixMilli(),
-					IsActive:   true,
-				})
-				zap.L().Info("[AIChat] GetSessionList 恢复会话到内存", zap.String("userId", userId), zap.String("sessionId", sess.SessionId))
-			}
-		}
-	}
-
-	// 从 Redis 获取历史列表
-	redisItems, err := repositories.GetAiSessionList(userId)
-	if err == nil && len(redisItems) > 0 {
-		for _, item := range redisItems {
-			list = append(list, SessionListItem{
-				SessionId:  item.SessionId,
-				Title:      item.Title,
-				CreatedAt:  item.CreatedAt,
-				LastActive: item.LastActive,
-				IsActive:   false,
-			})
-		}
-	}
-
-	return list
-}
-
-// ============ 会话操作 ============
-
-// UpdateSessionTitle 更新会话标题
-func (m *ACPManager) UpdateSessionTitle(userId string, sessionId string, title string) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	// 检查当前活跃会话
-	if sess, ok := m.sessions[userId]; ok && sess.SessionId == sessionId {
-		sess.Title = title
-		m.saveSessionTitle(userId, sess)
-		return
-	}
-
-	// 历史会话
-	m.saveHistorySessionTitle(userId, sessionId, title)
-}
-
-// DeleteSessionFromHistory 删除某会话
-func (m *ACPManager) DeleteSessionFromHistory(userId string, sessionId string) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	// 如果是当前活跃会话,关闭它
-	if sess, ok := m.sessions[userId]; ok && sess.SessionId == sessionId {
-		sess.Client.Close()
-		delete(m.sessions, userId)
-		m.removeSession(userId, sessionId, true)
-		return
-	}
-
-	// 历史会话
-	m.removeSession(userId, sessionId, false)
-}
-
-// DeleteSession 删除当前活跃会话
-func (m *ACPManager) DeleteSession(userId string) {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	if sess, ok := m.sessions[userId]; ok {
-		sess.Client.Close()
-		delete(m.sessions, userId)
-		m.removeSession(userId, sess.SessionId, true)
-		zap.L().Info("[AIChat] 会话已删除", zap.String("userId", userId))
-	}
-}
-
-// GetSessionInfo 获取用户当前活跃会话信息
-func (m *ACPManager) GetSessionInfo(userId string) *ACPSession {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-	return m.sessions[userId]
-}
-
-// Cancel 取消当前操作
-func (m *ACPManager) Cancel(sess *ACPSession) {
-	sess.Client.Cancel(sess.SessionId)
-}
-
-// ResolvePermission 前端回传权限选择
-func (m *ACPManager) ResolvePermission(userId string, optionId string) error {
-	sess := m.GetSessionInfo(userId)
-	if sess == nil {
-		return fmt.Errorf("没有活跃的会话")
-	}
-	sess.Client.ResolvePermission(optionId)
-	return nil
-}
-
-// ============ 消息发送 ============
-
-// Prompt 发送消息并流式读取响应。
-// ctx 由上层(HTTP 处理器)在连接断开时取消:一旦取消,立即通知 Agent 取消本轮
-// (Client.Cancel)、唤醒 acp 层阻塞的事件发送(Client.AbortEvents),并停止向
-// eventCh 转发,避免 goroutine 因客户端断开而永久阻塞。
-func (m *ACPManager) Prompt(ctx context.Context, sess *ACPSession, message string, eventCh chan<- AIChatEvent) {
-	sess.Mutex.Lock()
-	defer sess.Mutex.Unlock()
-
-	sess.LastActive = time.Now()
-
-	// 首次发消息时自动设置标题(取消息前 30 字)
-	if sess.Title == "" && message != "" {
-		title := message
-		if len([]rune(title)) > 30 {
-			title = string([]rune(title)[:30]) + "..."
-		}
-		sess.Title = title
-	}
-
-	// 注入会话上下文(让 AI 在调用 MCP Tool 时能传入正确的 userId 和 sessionId)
-	contextMessage := fmt.Sprintf("[context] userId=%s, sessionId=%s\n\n%s", sess.UserId, sess.SessionId, message)
-
-	// 用 acp.Client.Prompt 流式读取
-	acpEvents := make(chan acp.Event, 50)
-	go sess.Client.Prompt(sess.SessionId, contextMessage, acpEvents)
-
-	// 监听上层取消:断开时通知 Agent 取消并解除 acp 层发送阻塞
-	go func() {
-		<-ctx.Done()
-		sess.Client.Cancel(sess.SessionId)
-		sess.Client.AbortEvents()
-	}()
-
-	// 转发单条事件,支持在上层取消时中断,避免阻塞在满通道上
-	send := func(ev AIChatEvent) bool {
-		select {
-		case eventCh <- ev:
-			return true
-		case <-ctx.Done():
-			return false
-		}
-	}
-
-	// 转换 acp.Event → AIChatEvent
-	for event := range acpEvents {
-		var ok bool
-		switch event.Type {
-		case "text":
-			ok = send(AIChatEvent{Type: "text", Content: event.Content})
-		case "tool_call":
-			ok = send(AIChatEvent{
-				Type:        "tool_call",
-				ToolCallId:  event.ToolCallId,
-				ToolName:    event.ToolName,
-				Description: acp.DescribeTool(event.ToolName),
-				Status:      acp.NormalizeStatus(event.ToolStatus),
-			})
-		case "tool_update":
-			// update 一般只变更状态;若本次携带了新 title 才重新映射简述
-			ev := AIChatEvent{
-				Type:       "tool_update",
-				ToolCallId: event.ToolCallId,
-				Status:     acp.NormalizeStatus(event.ToolStatus),
-			}
-			if event.ToolName != "" {
-				ev.ToolName = event.ToolName
-				ev.Description = acp.DescribeTool(event.ToolName)
-			}
-			ok = send(ev)
-		case "permission":
-			ok = send(AIChatEvent{Type: "permission", Content: toJSON(event.Permission)})
-		case "metadata":
-			ok = send(AIChatEvent{Type: "metadata", Content: event.Content})
-		case "done":
-			ok = send(AIChatEvent{Type: "done", SessionId: event.SessionId, StopReason: event.StopReason})
-		case "error":
-			ok = send(AIChatEvent{Type: "error", Content: event.Content})
-		default:
-			ok = true
-		}
-		if !ok {
-			// 上层已取消:继续排空 acpEvents 直至 acp 层因 abort/取消关闭它,
-			// 但不再向 eventCh 转发,防止阻塞。
-			for range acpEvents {
-			}
-			break
-		}
-	}
-
-	close(eventCh)
-}
-
-// ============ 内部方法 ============
-
-// createSession 创建新 ACP 会话
-func (m *ACPManager) createSession(userId string) (*ACPSession, error) {
-	client, err := acp.NewClient(m.acpConfig)
-	if err != nil {
-		return nil, err
-	}
-	if err := client.Initialize(); err != nil {
-		client.Close()
-		return nil, err
-	}
-
-	sessionId, welcomeMsg, agentId, modelId, err := client.NewSession(m.serviceDir)
-	if err != nil {
-		client.Close()
-		return nil, err
-	}
-
-	now := time.Now()
-	sess := &ACPSession{
-		UserId:         userId,
-		SessionId:      sessionId,
-		Title:          "",
-		WelcomeMessage: welcomeMsg,
-		AgentId:        agentId,
-		ModelId:        modelId,
-		Client:         client,
-		LastActive:     now,
-		CreatedAt:      now,
-	}
-	client.UserId = userId
-	m.sessions[userId] = sess
-
-	// 持久化
-	m.saveNewSession(userId, sess)
-
-	zap.L().Info("[AIChat] 会话创建成功", zap.String("userId", userId), zap.String("sessionId", sessionId), zap.String("agent", agentId), zap.String("model", modelId))
-	return sess, nil
-}
-
-// startAndLoadSession 启动 ACP 进程并 load 指定会话(用于 Redis 恢复)
-func (m *ACPManager) startAndLoadSession(userId string, active *repositories.AiActiveSession) (*ACPSession, error) {
-	client, err := acp.NewClient(m.acpConfig)
-	if err != nil {
-		return nil, err
-	}
-	if err := client.Initialize(); err != nil {
-		client.Close()
-		return nil, err
-	}
-
-	_, welcomeMsg, agentId, modelId, _, err := client.LoadSession(active.SessionId, m.serviceDir)
-	if err != nil {
-		client.Close()
-		return nil, fmt.Errorf("session/load 失败: %w", err)
-	}
-
-	now := time.Now()
-	sess := &ACPSession{
-		UserId:         userId,
-		SessionId:      active.SessionId,
-		Title:          active.Title,
-		WelcomeMessage: welcomeMsg,
-		AgentId:        agentId,
-		ModelId:        modelId,
-		Client:         client,
-		LastActive:     now,
-		CreatedAt:      time.UnixMilli(active.CreatedAt),
-	}
-	client.UserId = userId
-	m.sessions[userId] = sess
-	return sess, nil
-}
-
-// ============ 内部方法:会话存储 ============
-
-// persistNewSession 持久化新创建的活跃会话(Redis + MongoDB)
-func (m *ACPManager) saveNewSession(userId string, sess *ACPSession) {
-	repositories.SaveAiSession(userId, repositories.AiActiveSession{
-		SessionId:  sess.SessionId,
-		Title:      sess.Title,
-		CreatedAt:  sess.CreatedAt.UnixMilli(),
-		LastActive: sess.LastActive.UnixMilli(),
-	})
-	repositories.DeactivateAllAiChatSessions(userId)
-	repositories.UpsertAiChatSession(repositories.AiChatSession{
-		UserId:     userId,
-		SessionId:  sess.SessionId,
-		Title:      sess.Title,
-		IsActive:   true,
-		CreatedAt:  sess.CreatedAt,
-		LastActive: sess.LastActive,
-	})
-}
-
-// persistActivateSession 持久化会话激活(Redis + MongoDB)
-func (m *ACPManager) activateSession(userId string, sess *ACPSession) {
-	repositories.SaveAiSession(userId, repositories.AiActiveSession{
-		SessionId:  sess.SessionId,
-		Title:      sess.Title,
-		CreatedAt:  sess.CreatedAt.UnixMilli(),
-		LastActive: sess.LastActive.UnixMilli(),
-	})
-	repositories.DeactivateAllAiChatSessions(userId)
-	repositories.UpdateAiChatSessionActive(userId, sess.SessionId, true)
-}
-
-// persistArchiveSession 持久化会话归档(Redis + MongoDB)
-func (m *ACPManager) archiveSession(userId string, sess *ACPSession) {
-	item := repositories.AiSessionItem{
-		SessionId:  sess.SessionId,
-		Title:      sess.Title,
-		CreatedAt:  sess.CreatedAt.UnixMilli(),
-		LastActive: sess.LastActive.UnixMilli(),
-	}
-
-	list, _ := repositories.GetAiSessionList(userId)
-	list = append([]repositories.AiSessionItem{item}, list...)
-
-	if len(list) > 50 {
-		overflow := list[50:]
-		list = list[:50]
-		go func() {
-			for _, old := range overflow {
-				m.deleteKiroSession(old.SessionId)
-			}
-		}()
-	}
-
-	if err := repositories.SaveAiSessionList(userId, list); err != nil {
-		zap.L().Warn("[AIChat] 归档会话到Redis失败", zap.String("userId", userId), zap.Error(err))
-	}
-	repositories.UpdateAiChatSessionActive(userId, sess.SessionId, false)
-}
-
-// persistUpdateTitle 持久化标题更新(Redis + MongoDB)
-func (m *ACPManager) saveSessionTitle(userId string, sess *ACPSession) {
-	repositories.SaveAiSession(userId, repositories.AiActiveSession{
-		SessionId:  sess.SessionId,
-		Title:      sess.Title,
-		CreatedAt:  sess.CreatedAt.UnixMilli(),
-		LastActive: sess.LastActive.UnixMilli(),
-	})
-	repositories.UpdateAiChatSessionTitle(userId, sess.SessionId, sess.Title)
-}
-
-// persistUpdateHistoryTitle 持久化历史会话标题更新(Redis + MongoDB)
-func (m *ACPManager) saveHistorySessionTitle(userId string, sessionId string, title string) {
-	repositories.UpdateAiSessionTitle(userId, sessionId, title)
-	repositories.UpdateAiChatSessionTitle(userId, sessionId, title)
-}
-
-// persistDeleteSession 持久化删除会话(Redis + MongoDB)
-func (m *ACPManager) removeSession(userId string, sessionId string, isActive bool) {
-	if isActive {
-		repositories.DeleteAiSession(userId)
-	} else {
-		m.removeFromRedisHistory(userId, sessionId)
-	}
-	repositories.DeleteAiChatSession(userId, sessionId)
-}
-
-// ============ 内部方法:Redis 操作 ============
-func (m *ACPManager) removeFromRedisHistory(userId string, sessionId string) {
-	list, err := repositories.GetAiSessionList(userId)
-	if err != nil || len(list) == 0 {
-		return
-	}
-
-	for i := range list {
-		if list[i].SessionId == sessionId {
-			list = append(list[:i], list[i+1:]...)
-			repositories.SaveAiSessionList(userId, list)
-			return
-		}
-	}
-}
-
-// getSessionListFromRedisHistory 从 Redis 历史列表中获取会话
-func (m *ACPManager) getSessionListFromRedisHistory(userId string, sessionId string) *repositories.AiSessionItem {
-	list, err := repositories.GetAiSessionList(userId)
-	if err != nil {
-		zap.L().Warn("[AIChat] 从redis读取AiSessionList失败", zap.String("err", err.Error()))
-		return nil
-	}
-	for _, item := range list {
-		if item.SessionId == sessionId {
-			return &item
-		}
-	}
-	return nil
-}
-
-// deleteKiroSession 通过 kiro-cli 命令删除会话
-func (m *ACPManager) deleteKiroSession(sessionId string) {
-	cmd := exec.Command("kiro-cli", "chat", "--delete-session", sessionId)
-	cmd.Dir = m.serviceDir
-	// 复用与 ACP 拉起一致的环境变量(如 HOME),保证操作的是同一份会话数据
-	cmd.Env = append(os.Environ(), m.acpConfig.Env...)
-	// 与拉起子进程一致地降权,避免以 root 身份写入目标用户的会话文件
-	if err := acp.ApplyRunAsUser(cmd, m.acpConfig.RunAsUser); err != nil {
-		zap.L().Warn("[AIChat] 删除Kiro会话降权失败,跳过删除",
-			zap.String("sessionId", sessionId),
-			zap.Error(err),
-		)
-		return
-	}
-	if output, err := cmd.CombinedOutput(); err != nil {
-		zap.L().Warn("[AIChat] 删除Kiro会话失败",
-			zap.String("sessionId", sessionId),
-			zap.Error(err),
-			zap.String("output", string(output)),
-		)
-	} else {
-		zap.L().Info("[AIChat] 删除Kiro会话成功", zap.String("sessionId", sessionId))
-	}
-}
-
-// ============ 工具函数 ============
-
-func toJSON(v any) string {
-	data, _ := json.Marshal(v)
-	return string(data)
-}

+ 0 - 1569
service/utils/acp/acp.go

@@ -1,1569 +0,0 @@
-// Package acp 实现 ACP (Agent Client Protocol) 客户端
-// 基于 ACP v1 规范,通过 stdin/stdout ND-JSON 与 Agent 子进程通讯
-package acp
-
-import (
-	"bufio"
-	"encoding/json"
-	"fmt"
-	"io"
-	"os"
-	"os/exec"
-	"os/user"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"sync"
-	"sync/atomic"
-	"syscall"
-	"time"
-
-	"fwh-toolbox-server/utils/filestore"
-
-	"go.uber.org/zap"
-)
-
-// ============ JSON-RPC 2.0 基础类型 ============
-
-// Request JSON-RPC 请求
-type Request struct {
-	JSONRPC string `json:"jsonrpc"`
-	ID      int64  `json:"id"`
-	Method  string `json:"method"`
-	Params  any    `json:"params,omitempty"`
-}
-
-// Response JSON-RPC 响应
-type Response struct {
-	JSONRPC string          `json:"jsonrpc"`
-	ID      *int64          `json:"id,omitempty"`
-	Method  string          `json:"method,omitempty"`
-	Result  json.RawMessage `json:"result,omitempty"`
-	Error   *RPCError       `json:"error,omitempty"`
-	Params  json.RawMessage `json:"params,omitempty"`
-}
-
-// RPCError JSON-RPC 错误
-type RPCError struct {
-	Code    int    `json:"code"`
-	Message string `json:"message"`
-	Data    string `json:"data,omitempty"`
-}
-
-// FullMessage 返回包含 data 的完整错误信息
-func (e *RPCError) FullMessage() string {
-	if e.Data != "" {
-		return fmt.Sprintf("%s: %s", e.Message, e.Data)
-	}
-	return e.Message
-}
-
-// ============ ACP 协议类型 ============
-
-// InitializeParams 初始化请求参数
-type InitializeParams struct {
-	ProtocolVersion    int                `json:"protocolVersion"`
-	ClientInfo         ClientInfo         `json:"clientInfo"`
-	ClientCapabilities ClientCapabilities `json:"clientCapabilities"`
-}
-
-// ClientInfo 客户端信息
-type ClientInfo struct {
-	Name    string `json:"name"`
-	Version string `json:"version"`
-}
-
-// ClientCapabilities 客户端能力声明
-type ClientCapabilities struct {
-	FS       *FSCapabilities `json:"fs,omitempty"`
-	Terminal bool            `json:"terminal,omitempty"`
-}
-
-// FSCapabilities 文件系统能力
-type FSCapabilities struct {
-	ReadTextFile  bool `json:"readTextFile"`
-	WriteTextFile bool `json:"writeTextFile"`
-}
-
-// NewSessionParams 创建会话参数
-type NewSessionParams struct {
-	CWD        string `json:"cwd"`
-	MCPServers []any  `json:"mcpServers"`
-}
-
-// NewSessionResult 创建会话响应
-type NewSessionResult struct {
-	SessionId string         `json:"sessionId"`
-	Modes     *SessionModes  `json:"modes,omitempty"`
-	Models    *SessionModels `json:"models,omitempty"`
-}
-
-// SessionModes 会话模式
-type SessionModes struct {
-	CurrentModeId  string        `json:"currentModeId"`
-	AvailableModes []SessionMode `json:"availableModes"`
-}
-
-// SessionMode 单个模式
-type SessionMode struct {
-	Id          string       `json:"id"`
-	Name        string       `json:"name"`
-	Description string       `json:"description"`
-	Meta        *SessionMeta `json:"_meta,omitempty"`
-}
-
-// SessionMeta 模式元数据
-type SessionMeta struct {
-	WelcomeMessage string `json:"welcomeMessage"`
-}
-
-// SessionModels 会话可用模型
-type SessionModels struct {
-	CurrentModelId  string         `json:"currentModelId"`
-	AvailableModels []SessionModel `json:"availableModels"`
-}
-
-// SessionModel 单个模型
-type SessionModel struct {
-	ModelId     string `json:"modelId"`
-	Name        string `json:"name"`
-	Description string `json:"description"`
-}
-
-// PromptParams 发送消息参数
-type PromptParams struct {
-	SessionId string         `json:"sessionId"`
-	Prompt    []ContentBlock `json:"prompt"`
-}
-
-// ContentBlock 内容块
-type ContentBlock struct {
-	Type string `json:"type"`
-	Text string `json:"text"`
-}
-
-// PromptResult prompt 响应
-type PromptResult struct {
-	StopReason string `json:"stopReason"`
-}
-
-// CancelParams 取消参数
-type CancelParams struct {
-	SessionId string `json:"sessionId"`
-}
-
-// ============ Session Update 事件类型 ============
-
-// SessionUpdate 会话更新通知
-type SessionUpdate struct {
-	SessionId string          `json:"sessionId"`
-	Update    json.RawMessage `json:"update"`
-}
-
-// UpdateBase 更新基础类型
-type UpdateBase struct {
-	SessionUpdate string `json:"sessionUpdate"`
-}
-
-// AgentMessageChunk Agent 文本流式输出
-type AgentMessageChunk struct {
-	SessionUpdate string       `json:"sessionUpdate"` // "agent_message_chunk"
-	MessageId     string       `json:"messageId,omitempty"`
-	Content       ContentBlock `json:"content"`
-}
-
-// ToolCall 工具调用通知
-type ToolCall struct {
-	SessionUpdate string `json:"sessionUpdate"` // "tool_call"
-	ToolCallId    string `json:"toolCallId"`
-	Title         string `json:"title"`
-	Status        string `json:"status"` // pending, running, completed, failed
-}
-
-// ToolCallUpdate 工具调用状态更新
-type ToolCallUpdate struct {
-	SessionUpdate string `json:"sessionUpdate"` // "tool_call_update"
-	ToolCallId    string `json:"toolCallId"`
-	Title         string `json:"title,omitempty"`
-	Status        string `json:"status"`
-}
-
-// ============ 客户端事件(对外输出) ============
-
-// Event 输出给上层的事件
-type Event struct {
-	Type       string // "text", "tool_call", "tool_update", "done", "error", "permission"
-	Content    string
-	ToolCallId string // 工具调用唯一ID(用于前端按 id 关联状态更新)
-	ToolName   string
-	ToolStatus string
-	SessionId  string
-	StopReason string
-	// 权限请求相关
-	Permission *PermissionEvent `json:"permission,omitempty"`
-}
-
-// PermissionEvent 权限请求事件(推给前端)
-type PermissionEvent struct {
-	RequestId string             `json:"requestId"` // 用于前端回传
-	Title     string             `json:"title"`     // 权限标题
-	Options   []PermissionOption `json:"options"`   // 可选选项
-}
-
-// PermissionOption 权限选项
-type PermissionOption struct {
-	OptionId string `json:"optionId"`
-	Name     string `json:"name"`
-	Kind     string `json:"kind"` // allow_once, allow_always, deny
-}
-
-// ============ ACP Client 实现 ============
-
-// PermissionWaitTimeout 前端授权回传的最长等待时长。
-// 超时后默认按 deny 处理,避免读循环因前端断开而永久阻塞。
-const PermissionWaitTimeout = 5 * time.Minute
-
-// pendingResponse 等待中的请求:读循环收到匹配 id 的响应后通过 ch 投递
-type pendingResponse struct {
-	ch chan Response
-}
-
-// notifySink 通知回调:读循环把 notification 派发给当前阶段的接收者。
-// 返回值无意义,仅用于让不同阶段(Prompt / LoadSession / 默认)自定义处理。
-type notifySink func(method string, params json.RawMessage)
-
-// Client ACP 客户端(管理单个 Agent 进程)
-//
-// 并发模型:构造时启动唯一的 readLoop goroutine,负责读取 stdout 的每一行并派发:
-//   - 响应(有 id、无 method)→ 按 id 投递到 pending map 中等待的 channel;
-//   - Agent→Client 反向请求(有 id、有 method)→ 交给 handleAgentRequest(在独立
-//     goroutine 中处理,避免权限等待阻塞读循环);
-//   - 通知(无 id、有 method)→ 交给当前 notifySink。
-//
-// 所有对 stdin 的写入通过 writeMu 串行化;Cancel 与权限响应因此不会互相死锁。
-type Client struct {
-	cmd     *exec.Cmd
-	stdin   io.WriteCloser
-	scanner *bufio.Scanner
-
-	sessionId string
-	requestId int64
-
-	writeMu sync.Mutex // 保护 stdin 写入
-
-	// 请求分发:id -> 等待响应的 channel
-	pendingMu sync.Mutex
-	pending   map[int64]*pendingResponse
-
-	// 通知分发:读循环把 notification 交给当前 sink
-	sinkMu  sync.Mutex
-	sink    notifySink
-	eventCh chan<- Event  // 当前 prompt 的事件通道(用于推送 permission 事件),受 sinkMu 保护
-	abortCh chan struct{} // 当前 prompt 的中止信号(上层停止消费时用于解除发送阻塞),受 sinkMu 保护
-	emitWg  sync.WaitGroup // 统计在途 emit,endTurn 借此在 close(eventCh) 前等待发送退出
-
-	// 权限请求等待机制
-	permissionCh chan string // 接收前端选择的 optionId
-
-	// 进程存活标记:由 readLoop / waitProcess 单点置位,IsAlive 读取
-	dead   atomic.Bool
-	doneCh chan struct{} // 进程退出/读循环结束时关闭
-
-	// 文件存储:会话身份(用于定位 MinIO 中的文件)
-	UserId string
-
-	// 文本累积(仅用于在 turn 结束时打一条完整回复日志)。
-	// 由 sink(readLoop goroutine)写入、Prompt goroutine 读取,故用 textMu 保护。
-	textMu     sync.Mutex
-	textBuffer string
-
-	// 最近一次上下文用量与耗时(供首屏展示,读取需加 metaMu)
-	metaMu             sync.Mutex
-	lastContextUsage   float64
-	lastTurnDurationMs int64
-}
-
-// ClientConfig 客户端配置
-type ClientConfig struct {
-	Command    string   // Agent 命令(如 "kiro-cli")
-	Args       []string // 命令参数(如 ["acp"])
-	WorkingDir string   // 工作目录
-	Env        []string // 额外环境变量(如 "HOME=/home/user")
-	RunAsUser  string   // 降权运行的目标系统用户名,为空则不降权
-}
-
-// ApplyRunAsUser 让 cmd 以指定系统用户身份运行(降权)。
-// runAsUser 为空时不做任何处理(沿用当前进程用户)。
-// 该函数会:
-//  1. 解析目标用户的 uid/gid,并设置 cmd.SysProcAttr.Credential;
-//  2. 附带解析该用户的补充组(supplementary groups);
-//  3. 修正 HOME/USER/LOGNAME 环境变量为目标用户的值,避免 kiro-cli
-//     仍读取 root 的家目录导致配置/凭证路径错误。
-//
-// 注意:设置 Credential 需要当前进程具备相应权限(通常为 root)。
-func ApplyRunAsUser(cmd *exec.Cmd, runAsUser string) error {
-	runAsUser = strings.TrimSpace(runAsUser)
-	if runAsUser == "" {
-		return nil
-	}
-
-	u, err := user.Lookup(runAsUser)
-	if err != nil {
-		return fmt.Errorf("查找降权用户 %q 失败: %w", runAsUser, err)
-	}
-
-	uid, err := strconv.ParseUint(u.Uid, 10, 32)
-	if err != nil {
-		return fmt.Errorf("解析用户 %q 的 uid(%s) 失败: %w", runAsUser, u.Uid, err)
-	}
-	gid, err := strconv.ParseUint(u.Gid, 10, 32)
-	if err != nil {
-		return fmt.Errorf("解析用户 %q 的 gid(%s) 失败: %w", runAsUser, u.Gid, err)
-	}
-
-	// 解析补充组(失败不致命,仅记录告警)
-	var groups []uint32
-	if gidStrs, gErr := u.GroupIds(); gErr == nil {
-		for _, gs := range gidStrs {
-			if g, pErr := strconv.ParseUint(gs, 10, 32); pErr == nil {
-				groups = append(groups, uint32(g))
-			}
-		}
-	} else {
-		zap.L().Warn("[ACP] 读取降权用户补充组失败", zap.String("user", runAsUser), zap.Error(gErr))
-	}
-
-	if cmd.SysProcAttr == nil {
-		cmd.SysProcAttr = &syscall.SysProcAttr{}
-	}
-	cmd.SysProcAttr.Credential = &syscall.Credential{
-		Uid:    uint32(uid),
-		Gid:    uint32(gid),
-		Groups: groups,
-	}
-
-	// 修正环境变量,确保子进程使用目标用户的身份。
-	// USER/LOGNAME 强制设为目标用户;HOME 尊重上层(配置)已传入的值,
-	// 仅在未提供时才回退到用户家目录。
-	if cmd.Env == nil {
-		cmd.Env = os.Environ()
-	}
-	cmd.Env = overrideEnv(cmd.Env, map[string]string{
-		"USER":    u.Username,
-		"LOGNAME": u.Username,
-	})
-	cmd.Env = fillEnvIfAbsent(cmd.Env, "HOME", u.HomeDir)
-
-	effectiveHome := u.HomeDir
-	for _, e := range cmd.Env {
-		if strings.HasPrefix(e, "HOME=") {
-			effectiveHome = strings.TrimPrefix(e, "HOME=")
-			break
-		}
-	}
-
-	zap.L().Info("[ACP] 子进程降权运行",
-		zap.String("user", u.Username),
-		zap.String("uid", u.Uid),
-		zap.String("gid", u.Gid),
-		zap.String("home", effectiveHome),
-	)
-	return nil
-}
-
-// fillEnvIfAbsent 仅当 env 中不存在 key 时才追加 key=value,否则原样返回。
-func fillEnvIfAbsent(env []string, key, value string) []string {
-	prefix := key + "="
-	for _, e := range env {
-		if strings.HasPrefix(e, prefix) {
-			return env
-		}
-	}
-	return append(env, prefix+value)
-}
-
-// envHasKey 判断 env("KEY=VALUE" 形式的切片)中是否已存在指定 key。
-func envHasKey(env []string, key string) bool {
-	prefix := key + "="
-	for _, e := range env {
-		if strings.HasPrefix(e, prefix) {
-			return true
-		}
-	}
-	return false
-}
-
-// ensureDirWritableByRunAsUser 确保日志目录可被降权用户写入。
-// 主服务常以 root 运行,创建的目录属主为 root;子进程降权到 runAsUser 后会写不进去,
-// 导致 kiro-cli 日志被静默丢弃。这里将目录(含其直接父目录)chown 到 runAsUser 的 uid/gid。
-// runAsUser 为空表示不降权(与主进程同用户),无需调整。所有失败仅记录告警,不致命。
-func ensureDirWritableByRunAsUser(dir string, runAsUser string) {
-	runAsUser = strings.TrimSpace(runAsUser)
-	if runAsUser == "" {
-		return
-	}
-	u, err := user.Lookup(runAsUser)
-	if err != nil {
-		zap.L().Warn("[ACP] 查找降权用户失败,日志目录属主未调整", zap.String("user", runAsUser), zap.Error(err))
-		return
-	}
-	uid, err1 := strconv.Atoi(u.Uid)
-	gid, err2 := strconv.Atoi(u.Gid)
-	if err1 != nil || err2 != nil {
-		zap.L().Warn("[ACP] 解析降权用户 uid/gid 失败,日志目录属主未调整",
-			zap.String("user", runAsUser), zap.String("uid", u.Uid), zap.String("gid", u.Gid))
-		return
-	}
-	// 同时调整父目录(如 /tmp/kiro-log)与目标目录(如 /tmp/kiro-log/logs)。
-	for _, d := range []string{filepath.Dir(dir), dir} {
-		if err := os.Chown(d, uid, gid); err != nil {
-			zap.L().Warn("[ACP] 调整日志目录属主失败(降权用户可能无法写入日志)",
-				zap.String("dir", d), zap.String("user", runAsUser), zap.Error(err))
-		}
-	}
-}
-
-// overrideEnv 用 kv 覆盖 env 中已存在的同名变量,不存在则追加。
-func overrideEnv(env []string, kv map[string]string) []string {
-	out := make([]string, 0, len(env)+len(kv))
-	seen := make(map[string]bool, len(kv))
-	for _, e := range env {
-		key := e
-		if idx := strings.IndexByte(e, '='); idx >= 0 {
-			key = e[:idx]
-		}
-		if v, ok := kv[key]; ok {
-			out = append(out, key+"="+v)
-			seen[key] = true
-		} else {
-			out = append(out, e)
-		}
-	}
-	for k, v := range kv {
-		if !seen[k] {
-			out = append(out, k+"="+v)
-		}
-	}
-	return out
-}
-
-// NewClient 创建并启动 ACP Client
-func NewClient(cfg ClientConfig) (*Client, error) {
-	cmd := exec.Command(cfg.Command, cfg.Args...)
-
-	// 确保工作目录是绝对路径
-	absDir, err := filepath.Abs(cfg.WorkingDir)
-	if err != nil {
-		absDir = cfg.WorkingDir
-	}
-	cmd.Dir = absDir
-
-	// 设置环境变量
-	cmd.Env = append(os.Environ(), cfg.Env...)
-
-	// A 方案:为每次启动的子进程分配带启动时间戳的独立日志文件,避免覆盖历史日志。
-	// 仅在开启了 kiro-cli debug 日志(env 含 KIRO_LOG_LEVEL)、且调用方未显式指定
-	// KIRO_CHAT_LOG_FILE 时生效。KIRO_CHAT_LOG_FILE 是 kiro-cli acp 官方支持的日志路径变量。
-	if envHasKey(cmd.Env, "KIRO_LOG_LEVEL") && !envHasKey(cmd.Env, "KIRO_CHAT_LOG_FILE") {
-		logDir := "/tmp/kiro-log/logs"
-		if err := os.MkdirAll(logDir, 0o777); err != nil {
-			zap.L().Warn("[ACP] 创建日志目录失败,沿用 kiro-cli 默认日志路径",
-				zap.String("dir", logDir), zap.Error(err))
-		} else {
-			// 主服务常以 root 运行,创建出的目录默认属主为 root;而子进程会被
-			// ApplyRunAsUser 降权到 cfg.RunAsUser,若目录不可写日志将被静默丢弃。
-			// 因此这里把目录属主/权限调整到降权用户可写。
-			ensureDirWritableByRunAsUser(logDir, cfg.RunAsUser)
-
-			logPath := filepath.Join(logDir, fmt.Sprintf("kiro-chat-%s.log", time.Now().Format("20060102T150405.000")))
-			cmd.Env = append(cmd.Env, "KIRO_CHAT_LOG_FILE="+logPath)
-			// 顺带关闭 ANSI 颜色码,便于后续直接阅读/grep 日志。
-			if !envHasKey(cmd.Env, "KIRO_LOG_NO_COLOR") {
-				cmd.Env = append(cmd.Env, "KIRO_LOG_NO_COLOR=1")
-			}
-			zap.L().Info("[ACP] 本次子进程日志文件", zap.String("logFile", logPath))
-		}
-	}
-
-	// 若配置了降权用户,则以该用户身份拉起子进程
-	if err := ApplyRunAsUser(cmd, cfg.RunAsUser); err != nil {
-		return nil, err
-	}
-
-	stdin, err := cmd.StdinPipe()
-	if err != nil {
-		return nil, fmt.Errorf("创建 stdin pipe 失败: %w", err)
-	}
-
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		return nil, fmt.Errorf("创建 stdout pipe 失败: %w", err)
-	}
-
-	// 捕获 stderr
-	stderrPipe, _ := cmd.StderrPipe()
-	go func() {
-		if stderrPipe != nil {
-			s := bufio.NewScanner(stderrPipe)
-			for s.Scan() {
-				zap.L().Warn("[ACP] agent stderr", zap.String("line", s.Text()))
-			}
-		}
-	}()
-
-	if err := cmd.Start(); err != nil {
-		return nil, fmt.Errorf("启动 Agent 进程失败: %w", err)
-	}
-
-	scanner := bufio.NewScanner(stdout)
-	scanner.Buffer(make([]byte, 0, 2*1024*1024), 2*1024*1024) // 2MB buffer
-
-	c := &Client{
-		cmd:          cmd,
-		stdin:        stdin,
-		scanner:      scanner,
-		pending:      make(map[int64]*pendingResponse),
-		permissionCh: make(chan string, 1),
-		doneCh:       make(chan struct{}),
-	}
-
-	// 监听子进程退出,单点置位 dead 标记(供 IsAlive 可靠判活)
-	go c.waitProcess()
-	// 启动唯一的读循环,统一分发响应/请求/通知
-	go c.readLoop()
-
-	return c, nil
-}
-
-// waitProcess 阻塞等待子进程退出,退出后标记 dead。
-// 这是回收子进程、置位存活标记的唯一入口,因此 IsAlive 不再依赖
-// cmd.ProcessState(只有调用 Wait 后才会被填充)的偶然状态。
-func (c *Client) waitProcess() {
-	_ = c.cmd.Wait()
-	c.markDead()
-}
-
-// markDead 标记进程已退出,并关闭 doneCh(幂等)。
-func (c *Client) markDead() {
-	if c.dead.CompareAndSwap(false, true) {
-		close(c.doneCh)
-	}
-}
-
-// ============ 读循环与分发 ============
-
-// readLoop 是唯一读取 stdout 的 goroutine。它把每行消息按类型派发:
-//   - 响应(有 id、无 method)→ 投递到 pending map 中等待的 channel;
-//   - 反向请求(有 id、有 method)→ 在独立 goroutine 中处理(权限等待不阻塞读循环);
-//   - 通知(无 id、有 method)→ 交给当前 notifySink。
-//
-// scanner 结束(EOF / 进程退出)时标记 dead,并唤醒所有等待中的请求。
-func (c *Client) readLoop() {
-	defer func() {
-		c.markDead()
-		c.failAllPending()
-	}()
-
-	for c.scanner.Scan() {
-		line := c.scanner.Text()
-		if line == "" {
-			continue
-		}
-
-		zap.L().Debug("[ACP] <<< 收到", zap.String("raw", line[:min(len(line), 500)]))
-
-		var msg struct {
-			JSONRPC string          `json:"jsonrpc"`
-			ID      json.RawMessage `json:"id,omitempty"`
-			Method  string          `json:"method,omitempty"`
-			Result  json.RawMessage `json:"result,omitempty"`
-			Error   *RPCError       `json:"error,omitempty"`
-			Params  json.RawMessage `json:"params,omitempty"`
-		}
-		if err := json.Unmarshal([]byte(line), &msg); err != nil {
-			zap.L().Warn("[ACP] JSON解析失败,跳过", zap.String("line", line[:min(len(line), 100)]))
-			continue
-		}
-
-		hasId := len(msg.ID) > 0 && string(msg.ID) != "null"
-
-		switch {
-		case hasId && msg.Method == "":
-			// 响应:按 id 派发给等待者
-			var id int64
-			if err := json.Unmarshal(msg.ID, &id); err != nil {
-				zap.L().Warn("[ACP] 响应 id 解析失败", zap.String("id", string(msg.ID)))
-				continue
-			}
-			c.deliverResponse(id, Response{
-				JSONRPC: msg.JSONRPC,
-				ID:      &id,
-				Result:  msg.Result,
-				Error:   msg.Error,
-			})
-
-		case hasId && msg.Method != "":
-			// Agent→Client 反向请求:独立 goroutine 处理,避免权限等待阻塞读循环
-			id := append(json.RawMessage(nil), msg.ID...)
-			params := append(json.RawMessage(nil), msg.Params...)
-			method := msg.Method
-			go c.handleAgentRequest(id, method, params)
-
-		case !hasId && msg.Method != "":
-			// 通知:交给当前 sink
-			c.dispatchNotification(msg.Method, msg.Params)
-
-		default:
-			zap.L().Debug("[ACP] 未处理的消息", zap.String("line", line[:min(len(line), 200)]))
-		}
-	}
-}
-
-// registerPending 注册一个等待响应的请求,返回接收 channel。
-func (c *Client) registerPending(id int64) chan Response {
-	ch := make(chan Response, 1)
-	c.pendingMu.Lock()
-	c.pending[id] = &pendingResponse{ch: ch}
-	c.pendingMu.Unlock()
-	return ch
-}
-
-// unregisterPending 移除等待项(请求完成或超时后调用)。
-func (c *Client) unregisterPending(id int64) {
-	c.pendingMu.Lock()
-	delete(c.pending, id)
-	c.pendingMu.Unlock()
-}
-
-// deliverResponse 把响应投递给等待者;无人等待则忽略。
-func (c *Client) deliverResponse(id int64, resp Response) {
-	c.pendingMu.Lock()
-	p, ok := c.pending[id]
-	if ok {
-		delete(c.pending, id)
-	}
-	c.pendingMu.Unlock()
-	if ok {
-		p.ch <- resp
-	} else {
-		zap.L().Debug("[ACP] 收到无等待者的响应", zap.Int64("id", id))
-	}
-}
-
-// failAllPending 进程退出时唤醒所有等待中的请求,返回错误响应。
-func (c *Client) failAllPending() {
-	c.pendingMu.Lock()
-	pend := c.pending
-	c.pending = make(map[int64]*pendingResponse)
-	c.pendingMu.Unlock()
-	for id, p := range pend {
-		p.ch <- Response{ID: &id, Error: &RPCError{Code: -1, Message: "Agent 进程已断开"}}
-	}
-}
-
-// setSink 设置当前通知接收者,返回旧 sink(便于恢复)。
-func (c *Client) setSink(s notifySink) notifySink {
-	c.sinkMu.Lock()
-	old := c.sink
-	c.sink = s
-	c.sinkMu.Unlock()
-	return old
-}
-
-// beginTurn 在一次 prompt 开始时设置事件通道与中止信号(受 sinkMu 保护),
-// 返回本轮的 abort channel 供上层(如断连时)主动中止。
-func (c *Client) beginTurn(ch chan<- Event) chan struct{} {
-	abort := make(chan struct{})
-	c.sinkMu.Lock()
-	c.eventCh = ch
-	c.abortCh = abort
-	c.sinkMu.Unlock()
-	return abort
-}
-
-// endTurn 在一次 prompt 结束时清理,并保证调用方随后可安全 close(eventCh):
-//  1. 关闭 abort,唤醒可能阻塞在发送上的 emit;
-//  2. 等待所有在途 emit(emitWg)退出;
-//  3. 在锁内清空 eventCh/abortCh。
-//
-// 注意:emit 不持 sinkMu 阻塞发送,故这里关闭 abort 不会与 emit 形成锁环。
-func (c *Client) endTurn() {
-	c.sinkMu.Lock()
-	abort := c.abortCh
-	c.sinkMu.Unlock()
-
-	if abort != nil {
-		safeClose(abort)
-	}
-	c.emitWg.Wait()
-
-	c.sinkMu.Lock()
-	c.eventCh = nil
-	c.abortCh = nil
-	c.sinkMu.Unlock()
-}
-
-// abortTurn 主动中止当前 turn 的事件发送(上层连接断开时调用)。
-// 仅关闭 abort 信号,唤醒阻塞的 emit;turn 的最终结束仍由 Prompt 收到响应/取消驱动。
-func (c *Client) abortTurn() {
-	c.sinkMu.Lock()
-	abort := c.abortCh
-	c.sinkMu.Unlock()
-	if abort != nil {
-		safeClose(abort)
-	}
-}
-
-// AbortEvents 供上层在连接断开时调用:唤醒所有阻塞在事件发送上的 goroutine,
-// 使当前 turn 的事件流尽快收敛(配合 Cancel 通知 Agent 停止本轮)。
-func (c *Client) AbortEvents() {
-	c.abortTurn()
-}
-
-// safeClose 幂等关闭一个 struct{} channel。
-func safeClose(ch chan struct{}) {
-	defer func() { _ = recover() }()
-	close(ch)
-}
-
-// emit 向当前 turn 的事件通道发送一个事件,返回是否成功送达。
-// 发送不持有 sinkMu(避免与 endTurn 关闭 abort 形成锁环),改用 emitWg 标记在途状态,
-// 使 endTurn 能在 close(eventCh) 前等待所有发送退出,杜绝向已关闭通道发送。
-// 发送同时监听 abort(上层停止消费)与 doneCh(进程退出),任一触发即放弃发送。
-func (c *Client) emit(ev Event) bool {
-	c.emitWg.Add(1)
-	defer c.emitWg.Done()
-
-	c.sinkMu.Lock()
-	ch := c.eventCh
-	abort := c.abortCh
-	c.sinkMu.Unlock()
-
-	if ch == nil {
-		return false
-	}
-	select {
-	case ch <- ev:
-		return true
-	case <-abort:
-		return false
-	case <-c.doneCh:
-		return false
-	}
-}
-
-// pushPermissionEvent 把权限事件推给当前 eventCh;返回是否成功(无活跃通道时返回 false)。
-func (c *Client) pushPermissionEvent(ev Event) bool {
-	return c.emit(ev)
-}
-
-// dispatchNotification 把通知交给当前 sink;无 sink 时走默认处理(仅缓存 metadata)。
-func (c *Client) dispatchNotification(method string, params json.RawMessage) {
-	c.sinkMu.Lock()
-	s := c.sink
-	c.sinkMu.Unlock()
-	if s != nil {
-		s(method, params)
-		return
-	}
-	c.defaultNotify(method, params)
-}
-
-// defaultNotify 无活跃 sink 时的兜底:仅捕获 metadata 用量,其余忽略。
-func (c *Client) defaultNotify(method string, params json.RawMessage) {
-	if method == "_kiro.dev/metadata" {
-		var data struct {
-			ContextUsagePercentage float64 `json:"contextUsagePercentage"`
-			TurnDurationMs         int64   `json:"turnDurationMs,omitempty"`
-		}
-		if err := json.Unmarshal(params, &data); err == nil {
-			c.setMeta(data.ContextUsagePercentage, data.TurnDurationMs)
-		}
-	}
-}
-
-// ResolvePermission 前端回传权限选择结果
-func (c *Client) ResolvePermission(optionId string) {
-	select {
-	case c.permissionCh <- optionId:
-	default:
-		// 没有等待中的权限请求,忽略
-	}
-}
-
-// appendText 累积本轮 AI 文本(sink/readLoop goroutine 调用)。
-func (c *Client) appendText(s string) {
-	c.textMu.Lock()
-	c.textBuffer += s
-	c.textMu.Unlock()
-}
-
-// takeText 取出并清空累积文本(Prompt goroutine 在 turn 结束时调用)。
-func (c *Client) takeText() string {
-	c.textMu.Lock()
-	s := c.textBuffer
-	c.textBuffer = ""
-	c.textMu.Unlock()
-	return s
-}
-
-// resetText 清空累积文本(turn 开始前调用,避免跨 turn 串味)。
-func (c *Client) resetText() {
-	c.textMu.Lock()
-	c.textBuffer = ""
-	c.textMu.Unlock()
-}
-
-// setMeta 更新最近的上下文用量与耗时(值 >0 时才覆盖)
-func (c *Client) setMeta(usage float64, turnMs int64) {
-	c.metaMu.Lock()
-	defer c.metaMu.Unlock()
-	if usage > 0 {
-		c.lastContextUsage = usage
-	}
-	if turnMs > 0 {
-		c.lastTurnDurationMs = turnMs
-	}
-}
-
-// GetMeta 返回最近的上下文用量(百分比)与上一轮耗时(毫秒)
-func (c *Client) GetMeta() (float64, int64) {
-	c.metaMu.Lock()
-	defer c.metaMu.Unlock()
-	return c.lastContextUsage, c.lastTurnDurationMs
-}
-
-// Initialize 执行初始化握手
-func (c *Client) Initialize() error {
-	params := InitializeParams{
-		ProtocolVersion: 1,
-		ClientInfo: ClientInfo{
-			Name:    "fwh-toolbox",
-			Version: "1.0.0",
-		},
-		ClientCapabilities: ClientCapabilities{
-			FS: &FSCapabilities{
-				ReadTextFile:  true,
-				WriteTextFile: true,
-			},
-			Terminal: false, // 不声明终端能力,强制 Agent 使用 writeTextFile
-		},
-	}
-
-	_, err := c.sendAndWait("initialize", params)
-	return err
-}
-
-// NewSession 创建新会话,返回 sessionId、welcomeMessage、实际生效的 agentId 和 modelId
-func (c *Client) NewSession(cwd string) (sessionId string, welcomeMsg string, agentId string, modelId string, err error) {
-	params := NewSessionParams{
-		CWD:        cwd,
-		MCPServers: []any{},
-	}
-
-	result, err := c.sendAndWait("session/new", params)
-	if err != nil {
-		return "", "", "", "", err
-	}
-
-	var res NewSessionResult
-	if err := json.Unmarshal(result, &res); err != nil {
-		return "", "", "", "", fmt.Errorf("解析 session/new 响应失败: %w", err)
-	}
-
-	zap.L().Info("session/new", zap.String("result", string(result)))
-
-	c.sessionId = res.SessionId
-
-	// 提取当前 mode 的 welcomeMessage 和实际生效的 agentId
-	if res.Modes != nil {
-		agentId = res.Modes.CurrentModeId
-		for _, mode := range res.Modes.AvailableModes {
-			if mode.Id == res.Modes.CurrentModeId && mode.Meta != nil {
-				welcomeMsg = mode.Meta.WelcomeMessage
-				break
-			}
-		}
-	}
-
-	// 提取实际生效的 modelId
-	if res.Models != nil {
-		modelId = res.Models.CurrentModelId
-	}
-
-	// session/new 之后 Kiro 紧跟发送的 commands/available、metadata 等通知,
-	// 由读循环交给默认 sink 处理:其中 metadata 的初始上下文用量会被 setMeta 缓存。
-	// 不再需要单独的 drainInitialMeta 阻塞读取。
-
-	return res.SessionId, welcomeMsg, agentId, modelId, nil
-}
-
-// ReplayMessage 回放的单条消息
-type ReplayMessage struct {
-	Role      string `json:"role"` // "user" 或 "assistant"
-	Content   string `json:"content"`
-	MessageId string `json:"messageId,omitempty"`
-}
-
-// LoadSession 加载已有会话(恢复历史对话上下文),返回回放的消息列表
-func (c *Client) LoadSession(sessionId string, cwd string) (string, string, string, string, []ReplayMessage, error) {
-	params := map[string]any{
-		"sessionId":  sessionId,
-		"cwd":        cwd,
-		"mcpServers": []any{},
-	}
-
-	// 回放消息在 session/load 响应之前以通知形式到达。用一个临时 sink 收集它们,
-	// 由读循环在等待响应期间调用(sendAndWaitWithSink 会在返回前恢复原 sink)。
-	var replayMessages []ReplayMessage
-	currentMsg := &ReplayMessage{}
-	sink := func(method string, p json.RawMessage) {
-		if method != "session/update" && method != "_kiro.dev/session/update" {
-			// 非回放通知(metadata 等)走默认处理,避免丢失用量
-			c.defaultNotify(method, p)
-			return
-		}
-		var notif SessionUpdate
-		if err := json.Unmarshal(p, &notif); err != nil {
-			return
-		}
-		var base UpdateBase
-		if err := json.Unmarshal(notif.Update, &base); err != nil {
-			return
-		}
-
-		switch base.SessionUpdate {
-		case "user_message_chunk":
-			if currentMsg.Content != "" {
-				replayMessages = append(replayMessages, *currentMsg)
-			}
-			var chunk AgentMessageChunk
-			json.Unmarshal(notif.Update, &chunk)
-			currentMsg = &ReplayMessage{Role: "user", Content: chunk.Content.Text, MessageId: chunk.MessageId}
-
-		case "agent_message_chunk":
-			var chunk AgentMessageChunk
-			json.Unmarshal(notif.Update, &chunk)
-			if currentMsg.Role != "assistant" || (chunk.MessageId != "" && chunk.MessageId != currentMsg.MessageId) {
-				if currentMsg.Content != "" {
-					replayMessages = append(replayMessages, *currentMsg)
-				}
-				currentMsg = &ReplayMessage{Role: "assistant", Content: chunk.Content.Text, MessageId: chunk.MessageId}
-			} else {
-				currentMsg.Content += chunk.Content.Text
-			}
-
-		case "agent_thought_chunk", "tool_call", "tool_call_update", "tool_call_chunk":
-			// 思考内容与工具调用回放忽略
-
-		default:
-			// 其他类型忽略
-		}
-	}
-
-	result, err := c.sendAndWaitWithSink("session/load", params, sink)
-	if err != nil {
-		return "", "", "", "", nil, fmt.Errorf("session/load 错误: %w", err)
-	}
-
-	c.sessionId = sessionId
-	// 追加最后一条未关闭的消息
-	if currentMsg.Content != "" {
-		replayMessages = append(replayMessages, *currentMsg)
-	}
-
-	// 解析响应中的 welcomeMessage、实际生效的 agentId 和 modelId
-	var welcomeMsg string
-	var agentId string
-	var modelId string
-	if len(result) > 0 {
-		var res NewSessionResult
-		if err := json.Unmarshal(result, &res); err == nil {
-			if res.Modes != nil {
-				agentId = res.Modes.CurrentModeId
-				for _, mode := range res.Modes.AvailableModes {
-					if mode.Id == res.Modes.CurrentModeId && mode.Meta != nil {
-						welcomeMsg = mode.Meta.WelcomeMessage
-						break
-					}
-				}
-			}
-			if res.Models != nil {
-				modelId = res.Models.CurrentModelId
-			}
-		}
-	}
-
-	// session/load 之后的初始用量 metadata 通知由默认 sink 捕获,无需单独处理。
-	return sessionId, welcomeMsg, agentId, modelId, replayMessages, nil
-}
-
-// Prompt 发送消息并流式读取事件
-// eventCh 会在 turn 结束或出错时关闭
-func (c *Client) Prompt(sessionId string, message string, eventCh chan<- Event) {
-	// 开始一个 turn:登记事件通道与 abort 信号。退出时 endTurn 会关闭 abort、
-	// 等待所有在途 emit 结束,再清空通道引用,之后才 close(eventCh)——
-	// 保证权限/流式 goroutine 不会向已关闭的通道发送。
-	c.beginTurn(eventCh)
-	defer func() {
-		c.endTurn()
-		close(eventCh)
-	}()
-
-	params := PromptParams{
-		SessionId: sessionId,
-		Prompt: []ContentBlock{
-			{Type: "text", Text: message},
-		},
-	}
-
-	// 注册等待 prompt 响应
-	promptId := atomic.AddInt64(&c.requestId, 1)
-	respCh := c.registerPending(promptId)
-
-	// 清空上一轮可能残留的累积文本,避免跨 turn 串味
-	c.resetText()
-
-	// 安装 sink:turn 期间的 session/update 与扩展通知都转成 Event 推给 eventCh。
-	// 反向请求(requestPermission)由读循环单独派发,不经过 sink。
-	old := c.setSink(func(method string, p json.RawMessage) {
-		switch method {
-		case "session/update", "_kiro.dev/session/update":
-			c.handleSessionUpdate(p)
-		default:
-			c.handleExtensionNotification(method, p)
-		}
-	})
-	defer c.setSink(old)
-
-	if err := c.writeRequest(promptId, "session/prompt", params); err != nil {
-		c.unregisterPending(promptId)
-		c.emit(Event{Type: "error", Content: fmt.Sprintf("发送 prompt 失败: %v", err)})
-		return
-	}
-	zap.L().Debug("[ACP] prompt 已发送", zap.Int64("promptId", promptId), zap.String("sessionId", sessionId))
-
-	// 等待 prompt 响应(turn 结束)或进程退出
-	select {
-	case resp := <-respCh:
-		if full := c.takeText(); full != "" {
-			zap.L().Info("[ACP] AI 回复完整内容", zap.String("text", full))
-		}
-		if resp.Error != nil {
-			c.emit(Event{Type: "error", Content: resp.Error.FullMessage()})
-			return
-		}
-		var result PromptResult
-		if err := json.Unmarshal(resp.Result, &result); err == nil {
-			c.emit(Event{Type: "done", SessionId: sessionId, StopReason: result.StopReason})
-		}
-	case <-c.doneCh:
-		c.unregisterPending(promptId)
-		c.emit(Event{Type: "error", Content: "Agent 进程已断开"})
-	}
-}
-
-// Cancel 取消当前 prompt turn(ACP 协议:Notification, method="session/cancel", params={sessionId})
-func (c *Client) Cancel(sessionId string) {
-	c.sendNotification("session/cancel", CancelParams{SessionId: sessionId})
-}
-
-// GetSessionId 获取当前会话 ID
-func (c *Client) GetSessionId() string {
-	return c.sessionId
-}
-
-// Close 关闭 ACP Client,终止 Agent 进程
-func (c *Client) Close() {
-	// 关闭 stdin 通知 Agent 退出
-	c.stdin.Close()
-
-	// 终止进程(回收由 waitProcess 中的 cmd.Wait 负责,此处不再重复 Wait)
-	if c.cmd.Process != nil {
-		c.cmd.Process.Kill()
-	}
-
-	// 解除可能阻塞在 permissionCh 上的 goroutine
-	select {
-	case c.permissionCh <- "deny":
-	default:
-	}
-}
-
-// IsAlive 检查 Agent 进程是否存活。
-// 依赖 waitProcess/readLoop 单点设置的 dead 标记,而非 cmd.ProcessState
-// (后者只有在 Wait 之后才被填充,子进程自行崩溃时会误判为存活)。
-func (c *Client) IsAlive() bool {
-	return !c.dead.Load()
-}
-
-// ============ 内部方法 ============
-
-// sendRequest 发送请求(不等待响应),返回 request ID。
-// 注意:调用方若需要等待响应,应使用 sendAndWait;直接使用本方法的场景
-// (如 Prompt)需自行通过 registerPending/读循环获取响应。
-func (c *Client) sendRequest(method string, params any) int64 {
-	id := atomic.AddInt64(&c.requestId, 1)
-	if err := c.writeRequest(id, method, params); err != nil {
-		zap.L().Error("[ACP] 发送请求失败", zap.String("method", method), zap.Error(err))
-	}
-	return id
-}
-
-// writeRequest 序列化并写入一个带 id 的请求,写入通过 writeMu 串行化。
-func (c *Client) writeRequest(id int64, method string, params any) error {
-	req := Request{
-		JSONRPC: "2.0",
-		ID:      id,
-		Method:  method,
-		Params:  params,
-	}
-
-	data, err := json.Marshal(req)
-	if err != nil {
-		return fmt.Errorf("序列化请求失败: %w", err)
-	}
-
-	zap.L().Debug("[ACP] >>> 发送", zap.String("raw", string(data)))
-	return c.writeLine(data)
-}
-
-// writeLine 追加换行并串行写入 stdin。
-func (c *Client) writeLine(data []byte) error {
-	c.writeMu.Lock()
-	defer c.writeMu.Unlock()
-	data = append(data, '\n')
-	_, err := c.stdin.Write(data)
-	return err
-}
-
-// sendNotification 发送 JSON-RPC Notification(不带 id,不期望响应)
-func (c *Client) sendNotification(method string, params any) {
-	notif := struct {
-		JSONRPC string `json:"jsonrpc"`
-		Method  string `json:"method"`
-		Params  any    `json:"params,omitempty"`
-	}{
-		JSONRPC: "2.0",
-		Method:  method,
-		Params:  params,
-	}
-
-	data, err := json.Marshal(notif)
-	if err != nil {
-		zap.L().Error("[ACP] 序列化通知失败", zap.Error(err))
-		return
-	}
-
-	zap.L().Debug("[ACP] >>> 发送(notification)", zap.String("raw", string(data)))
-	if err := c.writeLine(data); err != nil {
-		zap.L().Error("[ACP] 写入 stdin 失败(通知)", zap.String("method", method), zap.Error(err))
-	}
-}
-
-// sendAndWait 发送请求并等待对应 ID 的响应(通过读循环分发)。
-// 若传入非 nil sink,在等待期间临时接管通知(用于捕获 init/load 阶段的通知),
-// 返回前恢复原 sink。
-func (c *Client) sendAndWait(method string, params any) (json.RawMessage, error) {
-	return c.sendAndWaitWithSink(method, params, nil)
-}
-
-func (c *Client) sendAndWaitWithSink(method string, params any, sink notifySink) (json.RawMessage, error) {
-	id := atomic.AddInt64(&c.requestId, 1)
-	ch := c.registerPending(id)
-
-	if sink != nil {
-		old := c.setSink(sink)
-		defer c.setSink(old)
-	}
-
-	if err := c.writeRequest(id, method, params); err != nil {
-		c.unregisterPending(id)
-		return nil, err
-	}
-
-	select {
-	case resp := <-ch:
-		if resp.Error != nil {
-			return nil, fmt.Errorf("ACP 错误 [%d]: %s", resp.Error.Code, resp.Error.FullMessage())
-		}
-		return resp.Result, nil
-	case <-c.doneCh:
-		c.unregisterPending(id)
-		return nil, fmt.Errorf("未收到 %s 的响应(进程已退出)", method)
-	}
-}
-
-// handleSessionUpdate 解析并分发 session/update 通知
-func (c *Client) handleSessionUpdate(params json.RawMessage) {
-	var notif SessionUpdate
-	if err := json.Unmarshal(params, &notif); err != nil {
-		return
-	}
-
-	// 解析 update 的类型
-	var base UpdateBase
-	if err := json.Unmarshal(notif.Update, &base); err != nil {
-		return
-	}
-
-	// zap.L().Debug("[ACP] session/update", zap.String("type", base.SessionUpdate))
-
-	switch base.SessionUpdate {
-	case "agent_message_chunk":
-		var chunk AgentMessageChunk
-		if err := json.Unmarshal(notif.Update, &chunk); err == nil {
-			c.appendText(chunk.Content.Text)
-			c.emit(Event{Type: "text", Content: chunk.Content.Text})
-		}
-
-	case "agent_thought_chunk":
-		var chunk AgentMessageChunk
-		if err := json.Unmarshal(notif.Update, &chunk); err == nil {
-			c.appendText(chunk.Content.Text)
-			c.emit(Event{Type: "text", Content: chunk.Content.Text})
-		}
-
-	case "tool_call":
-		var tc ToolCall
-		if err := json.Unmarshal(notif.Update, &tc); err == nil {
-			zap.L().Debug("[ACP] → tool_call", zap.String("title", tc.Title), zap.String("status", tc.Status), zap.String("id", tc.ToolCallId))
-			c.emit(Event{Type: "tool_call", ToolCallId: tc.ToolCallId, ToolName: tc.Title, ToolStatus: tc.Status})
-		}
-
-	case "tool_call_update":
-		var tcu ToolCallUpdate
-		if err := json.Unmarshal(notif.Update, &tcu); err == nil {
-			zap.L().Debug("[ACP] → tool_call_update", zap.String("title", tcu.Title), zap.String("status", tcu.Status), zap.String("id", tcu.ToolCallId))
-			c.emit(Event{Type: "tool_update", ToolCallId: tcu.ToolCallId, ToolName: tcu.Title, ToolStatus: tcu.Status})
-		}
-
-	case "tool_call_chunk":
-		// 工具调用的流式参数输出(Kiro 扩展)
-		zap.L().Debug("[ACP] → tool_call_chunk (ignored)")
-
-	case "plan":
-		zap.L().Debug("[ACP] → plan update")
-
-	default:
-		zap.L().Debug("[ACP] → 未知 session/update 类型", zap.String("type", base.SessionUpdate))
-	}
-}
-
-// handleExtensionNotification 处理 Kiro 扩展通知
-func (c *Client) handleExtensionNotification(method string, params json.RawMessage) {
-	switch method {
-	case "_kiro.dev/commands/available":
-		// 可用命令和工具列表
-		var data struct {
-			SessionId string `json:"sessionId"`
-			Commands  []struct {
-				Name        string `json:"name"`
-				Description string `json:"description"`
-			} `json:"commands"`
-			Tools []struct {
-				Name        string `json:"name"`
-				Description string `json:"description"`
-				Source      string `json:"source"`
-			} `json:"tools"`
-		}
-		if err := json.Unmarshal(params, &data); err == nil {
-			zap.L().Debug("[ACP] 可用命令/工具更新",
-				zap.Int("commands", len(data.Commands)),
-				zap.Int("tools", len(data.Tools)),
-			)
-			// 推送工具列表给前端(可选)
-			if len(data.Tools) > 0 {
-				toolNames := make([]string, 0, len(data.Tools))
-				for _, t := range data.Tools {
-					toolNames = append(toolNames, t.Name)
-				}
-				c.emit(Event{Type: "tools_available", Content: toJSONStr(toolNames)})
-			}
-		}
-
-	case "_kiro.dev/metadata":
-		// 上下文使用量和耗时
-		var data struct {
-			SessionId              string  `json:"sessionId"`
-			ContextUsagePercentage float64 `json:"contextUsagePercentage"`
-			TurnDurationMs         int64   `json:"turnDurationMs,omitempty"`
-		}
-		if err := json.Unmarshal(params, &data); err == nil {
-			zap.L().Debug("[ACP] metadata",
-				zap.Float64("contextUsage%", data.ContextUsagePercentage),
-				zap.Int64("turnMs", data.TurnDurationMs),
-			)
-			// 缓存最近用量/耗时,供首屏读取
-			c.setMeta(data.ContextUsagePercentage, data.TurnDurationMs)
-			// 上下文用量每条通知都会带(turnDurationMs 仅 turn 结束那条才有),
-			// 因此总是转发用量,耗时有值时才附带
-			content := map[string]any{
-				"contextUsagePercent": data.ContextUsagePercentage,
-			}
-			if data.TurnDurationMs > 0 {
-				content["turnDurationMs"] = data.TurnDurationMs
-			}
-			c.emit(Event{
-				Type:    "metadata",
-				Content: toJSONStr(content),
-			})
-		}
-
-	case "_kiro.dev/subagent/list_update":
-		// 子代理列表更新
-		zap.L().Debug("[ACP] 子代理列表更新")
-
-	case "_kiro.dev/mcp/server_initialized":
-		// MCP Server 初始化完成
-		var data struct {
-			ServerName string `json:"serverName"`
-		}
-		json.Unmarshal(params, &data)
-		zap.L().Info("[ACP] MCP Server 初始化完成", zap.String("server", data.ServerName))
-
-	case "_kiro.dev/compaction/status":
-		// 上下文压缩状态
-		zap.L().Debug("[ACP] 上下文压缩中")
-
-	case "_kiro.dev/clear/status":
-		// 会话清除状态
-		zap.L().Debug("[ACP] 会话清除中")
-
-	default:
-		zap.L().Debug("[ACP] 未处理的扩展通知", zap.String("method", method))
-	}
-}
-
-// toJSONStr 序列化为 JSON 字符串
-func toJSONStr(v any) string {
-	data, _ := json.Marshal(v)
-	return string(data)
-}
-
-// applyLineWindow 按 ACP readTextFile 语义对文本做行窗口截取。
-//   - line 为 1 基起始行号(nil 或 <=0 视为从第 1 行开始);
-//   - limit 为返回的最大行数(nil 或 <=0 视为不限制,返回到文件末尾)。
-//
-// 保留行间换行;起始行越界时返回空串。用于避免把整份大文件回传给 Agent。
-func applyLineWindow(content string, line, limit *int) string {
-	start := 1
-	if line != nil && *line > 0 {
-		start = *line
-	}
-	// 无窗口需求:整文件返回
-	if start <= 1 && (limit == nil || *limit <= 0) {
-		return content
-	}
-
-	lines := strings.Split(content, "\n")
-	if start > len(lines) {
-		return ""
-	}
-	from := start - 1 // 转 0 基
-	to := len(lines)
-	if limit != nil && *limit > 0 {
-		if end := from + *limit; end < to {
-			to = end
-		}
-	}
-	return strings.Join(lines[from:to], "\n")
-}
-
-// handleAgentRequest 处理 Agent→Client 的反向请求。
-// 该方法由读循环在独立 goroutine 中调用,因此权限等待不会阻塞读循环;
-// stdin 写入通过 writeLine(writeMu)串行化,不与 Cancel 争锁。
-func (c *Client) handleAgentRequest(rawId json.RawMessage, method string, rawParams json.RawMessage) {
-	zap.L().Debug("[ACP] 处理反向请求", zap.String("method", method), zap.String("id", string(rawId)), zap.String("params", string(rawParams[:min(len(rawParams), 200)])))
-
-	var respData []byte
-
-	switch method {
-	case "readTextFile":
-		// 读取文件:先从 FileStore 查找(MinIO),再从本地文件系统
-		var params struct {
-			Path  string `json:"path"`
-			Line  *int   `json:"line,omitempty"`
-			Limit *int   `json:"limit,omitempty"`
-		}
-		if err := json.Unmarshal(rawParams, &params); err == nil && params.Path != "" {
-			var content string
-			var readErr error
-
-			// 优先从 FileStore(MinIO)读取:先按路径解析出 object key
-			key, keyErr := filestore.FindKeyByPath("upload", c.UserId, c.sessionId, params.Path)
-			if keyErr == nil && key != "" {
-				content, readErr = filestore.ReadFile(key)
-				zap.L().Debug("[ACP] readTextFile 从 MinIO 读取", zap.String("path", params.Path), zap.String("key", key))
-			} else {
-				// 回退到本地文件系统
-				data, err := os.ReadFile(params.Path)
-				if err == nil {
-					content = string(data)
-					zap.L().Debug("[ACP] readTextFile 从本地读取", zap.String("path", params.Path))
-				} else {
-					readErr = err
-				}
-			}
-
-			if readErr == nil {
-				windowed := applyLineWindow(content, params.Line, params.Limit)
-				result, _ := json.Marshal(map[string]string{"content": windowed})
-				respData, _ = json.Marshal(map[string]any{
-					"jsonrpc": "2.0",
-					"id":      rawId,
-					"result":  json.RawMessage(result),
-				})
-			}
-		}
-		if respData == nil {
-			respData, _ = json.Marshal(map[string]any{
-				"jsonrpc": "2.0",
-				"id":      rawId,
-				"error":   map[string]any{"code": -1, "message": "file not found"},
-			})
-		}
-
-	case "writeTextFile":
-		// 写入文件到 MinIO(不写本地)
-		var params struct {
-			Path    string `json:"path"`
-			Content string `json:"content"`
-		}
-		if err := json.Unmarshal(rawParams, &params); err == nil && params.Path != "" {
-			record, err := filestore.UploadFile(c.UserId, c.sessionId, filepath.Base(params.Path), params.Content)
-			if err == nil {
-				respData, _ = json.Marshal(map[string]any{
-					"jsonrpc": "2.0",
-					"id":      rawId,
-					"result":  map[string]any{},
-				})
-				_ = record // 文件下载通过前端标记检测实现,不再推送事件
-			} else {
-				zap.L().Error("[ACP] writeTextFile MinIO上传失败", zap.Error(err))
-			}
-		}
-		if respData == nil {
-			respData, _ = json.Marshal(map[string]any{
-				"jsonrpc": "2.0",
-				"id":      rawId,
-				"error":   map[string]any{"code": -1, "message": "write failed"},
-			})
-		}
-
-	case "requestPermission", "session/request_permission":
-		// 解析权限请求参数
-		var params struct {
-			ToolCall *struct {
-				Title string `json:"title"`
-			} `json:"toolCall"`
-			Options []struct {
-				OptionId string `json:"optionId"`
-				Name     string `json:"name"`
-				Kind     string `json:"kind"`
-			} `json:"options"`
-		}
-		json.Unmarshal(rawParams, &params)
-
-		title := ""
-		if params.ToolCall != nil {
-			title = params.ToolCall.Title
-		}
-
-		// 当前策略:所有权限请求都推给前端授权。
-		options := make([]PermissionOption, 0, len(params.Options))
-		for _, opt := range params.Options {
-			options = append(options, PermissionOption{
-				OptionId: opt.OptionId,
-				Name:     opt.Name,
-				Kind:     opt.Kind,
-			})
-		}
-
-		requestId := fmt.Sprintf("perm_%s", string(rawId))
-		pushed := c.pushPermissionEvent(Event{
-			Type: "permission",
-			Permission: &PermissionEvent{
-				RequestId: requestId,
-				Title:     title,
-				Options:   options,
-			},
-		})
-
-		if pushed {
-			// 丢弃可能残留的旧授权结果,避免消费到上一次请求/Close 写入的值
-			select {
-			case <-c.permissionCh:
-			default:
-			}
-
-			// 有界等待前端回复:超时 / 进程退出 时按 deny 处理,避免永久阻塞。
-			// 本 goroutine 阻塞不影响读循环(读循环在另一 goroutine)。
-			selectedOptionId := c.selectDenyOption(params.Options)
-			timer := time.NewTimer(PermissionWaitTimeout)
-			select {
-			case selectedOptionId = <-c.permissionCh:
-			case <-timer.C:
-				zap.L().Warn("[ACP] 权限等待超时,默认拒绝", zap.String("title", title))
-			case <-c.doneCh:
-				zap.L().Warn("[ACP] 权限等待期间进程已退出", zap.String("title", title))
-			}
-			timer.Stop()
-
-			respData, _ = json.Marshal(map[string]any{
-				"jsonrpc": "2.0",
-				"id":      rawId,
-				"result": map[string]any{
-					"outcome": map[string]any{
-						"outcome":  "selected",
-						"optionId": selectedOptionId,
-					},
-				},
-			})
-		} else {
-			// 无事件通道(非 prompt 阶段):自动允许
-			respData, _ = json.Marshal(map[string]any{
-				"jsonrpc": "2.0",
-				"id":      rawId,
-				"result": map[string]any{
-					"outcome": map[string]any{
-						"outcome":  "selected",
-						"optionId": "allow_once",
-					},
-				},
-			})
-		}
-
-	default:
-		// 未知请求返回错误
-		respData, _ = json.Marshal(map[string]any{
-			"jsonrpc": "2.0",
-			"id":      rawId,
-			"error":   map[string]any{"code": -32601, "message": "method not supported"},
-		})
-	}
-
-	if respData != nil {
-		zap.L().Debug("[ACP] 发送反向响应", zap.String("method", method), zap.String("resp", string(respData[:min(len(respData), 200)])))
-		if err := c.writeLine(respData); err != nil {
-			zap.L().Error("[ACP] 写入反向响应失败", zap.String("method", method), zap.Error(err))
-		}
-	} else {
-		zap.L().Warn("[ACP] 反向请求无响应数据", zap.String("method", method))
-	}
-}
-
-// selectDenyOption 从可选项中挑选一个 deny 语义的 optionId 作为超时/退出时的默认值。
-// 找不到显式 deny 选项时回退到字面量 "deny"。
-func (c *Client) selectDenyOption(options []struct {
-	OptionId string `json:"optionId"`
-	Name     string `json:"name"`
-	Kind     string `json:"kind"`
-}) string {
-	for _, opt := range options {
-		if strings.Contains(strings.ToLower(opt.Kind), "deny") || strings.Contains(strings.ToLower(opt.OptionId), "deny") {
-			return opt.OptionId
-		}
-	}
-	return "deny"
-}

+ 0 - 192
service/utils/acp/toolLabel.go

@@ -1,192 +0,0 @@
-package acp
-
-import "regexp"
-
-// DefaultToolLabel 未命中任何规则时的兜底简述
-const DefaultToolLabel = "调用工具"
-
-// mcpIdentRe 判断 title 是否为 MCP 工具标识名(service_/data_/storage_/plane_/util_ 前缀)。
-// 仅在标识名形态下才走「动作 + 对象」组合,避免误伤内置工具的自然语言 title。
-var mcpIdentRe = regexp.MustCompile(`(?i)(^|[^a-z])(service|data|storage|plane|util)_[a-z]`)
-
-// domainNoiseRe 域名中会误触发动作识别的噪声片段(其中的 update 属于对象名而非动作)。
-// 在动作识别前先剔除,避免 auto_update / update_log 等被误判为“更新”动作。
-var domainNoiseRe = regexp.MustCompile(`(?i)auto_update|system_update|ahub_update|update_log`)
-
-// objectRule 对象词识别规则(title 片段 → 对象中文名)
-type objectRule struct {
-	re   *regexp.Regexp
-	noun string
-}
-
-// objectRules 顺序即优先级,更具体的对象必须排在更宽泛的之前。
-var objectRules = []objectRule{
-	// —— Plane 项目管理相关对象 ——
-	{regexp.MustCompile(`(?i)fault_record`), "故障工单"},
-	{regexp.MustCompile(`(?i)intake_issue`), "收件箱工作项"},
-	{regexp.MustCompile(`(?i)sub_issue`), "子工作项"},
-	{regexp.MustCompile(`(?i)issue_relation|_relation`), "工作项关联"},
-	{regexp.MustCompile(`(?i)issue_link`), "工作项链接"},
-	{regexp.MustCompile(`(?i)work_item|_issues?(_|$)|_issue(_|$)|issue`), "工作项"},
-	{regexp.MustCompile(`(?i)cycle`), "周期"},
-	{regexp.MustCompile(`(?i)module`), "模块"},
-	{regexp.MustCompile(`(?i)page`), "页面"},
-	{regexp.MustCompile(`(?i)workspace_member`), "工作空间成员"},
-	{regexp.MustCompile(`(?i)project_member`), "项目成员"},
-	{regexp.MustCompile(`(?i)project_asset`), "项目资源"},
-	{regexp.MustCompile(`(?i)workspace`), "工作空间"},
-	{regexp.MustCompile(`(?i)project`), "项目"},
-	{regexp.MustCompile(`(?i)label`), "标签"},
-	{regexp.MustCompile(`(?i)state`), "状态"},
-	{regexp.MustCompile(`(?i)comment`), "评论"},
-	{regexp.MustCompile(`(?i)sticky|stickies`), "便签"},
-	{regexp.MustCompile(`(?i)assets?|entity`), "资源"},
-
-	// —— 站点 / 监控 / 数据相关对象 ——
-	{regexp.MustCompile(`(?i)alarm_snapshot`), "告警快照"},
-	{regexp.MustCompile(`(?i)alarm_statistic`), "告警统计"},
-	{regexp.MustCompile(`(?i)event_alarm`), "事件告警"},
-	{regexp.MustCompile(`(?i)alarm`), "告警"},
-	{regexp.MustCompile(`(?i)shutdown`), "停机"},
-	{regexp.MustCompile(`(?i)power_flow`), "功率流"},
-	{regexp.MustCompile(`(?i)mode_info`), "运行模式"},
-	{regexp.MustCompile(`(?i)base_info`), "基础信息"},
-	{regexp.MustCompile(`(?i)tou_template`), "电价模板"},
-	{regexp.MustCompile(`(?i)apower_version`), "APower版本"},
-	{regexp.MustCompile(`(?i)auto_update|_package|升级包`), "升级包"},
-	{regexp.MustCompile(`(?i)batch_download`), "批量下载"},
-	{regexp.MustCompile(`(?i)background_task`), "后台任务"},
-	{regexp.MustCompile(`(?i)site_analysis`), "站点分析"},
-	{regexp.MustCompile(`(?i)site_monitor`), "站点监控"},
-	{regexp.MustCompile(`(?i)_tag|tags_`), "站点标签"},
-	{regexp.MustCompile(`(?i)site`), "站点"},
-	{regexp.MustCompile(`(?i)data_pool`), "数据池"},
-	{regexp.MustCompile(`(?i)_card|_sim|sim_`), "SIM卡"},
-	{regexp.MustCompile(`(?i)dashboard`), "看板"},
-	{regexp.MustCompile(`(?i)feedback`), "反馈"},
-	{regexp.MustCompile(`(?i)monitor`), "监控"},
-	{regexp.MustCompile(`(?i)snapshot`), "快照数据"},
-	{regexp.MustCompile(`(?i)system_update_log|ahub_update_log|update_log`), "升级日志"},
-	{regexp.MustCompile(`(?i)ems_log|mqtt_log|_log(_|$)`), "日志"},
-	{regexp.MustCompile(`(?i)pid_info|vid_info|emspara`), "EMS参数"},
-
-	// —— 数据库 / 存储 ——
-	{regexp.MustCompile(`(?i)mongo`), "MongoDB"},
-	{regexp.MustCompile(`(?i)redis`), "Redis缓存"},
-	{regexp.MustCompile(`(?i)pgsql`), "PostgreSQL"},
-	{regexp.MustCompile(`(?i)file|minio|object`), "文件"},
-}
-
-// actionRule 动作词识别规则(title 片段 → 动词 + 后缀)
-type actionRule struct {
-	re     *regexp.Regexp
-	verb   string
-	suffix string
-}
-
-// actionRules 顺序即优先级。变更类动作先于查询类;unarchive 先于 archive;list 先于 get。
-var actionRules = []actionRule{
-	{regexp.MustCompile(`(?i)unarchive`), "取消归档", ""},
-	{regexp.MustCompile(`(?i)archive`), "归档", ""},
-	{regexp.MustCompile(`(?i)create`), "创建", ""},
-	{regexp.MustCompile(`(?i)update|mark`), "更新", ""},
-	{regexp.MustCompile(`(?i)delete`), "删除", ""},
-	{regexp.MustCompile(`(?i)remove`), "移除", ""},
-	{regexp.MustCompile(`(?i)accept`), "受理", ""},
-	{regexp.MustCompile(`(?i)reject`), "驳回", ""},
-	{regexp.MustCompile(`(?i)add`), "添加", ""},
-	{regexp.MustCompile(`(?i)bind`), "绑定", ""},
-	{regexp.MustCompile(`(?i)trigger`), "触发", ""},
-	{regexp.MustCompile(`(?i)upload`), "上传", ""},
-	{regexp.MustCompile(`(?i)write`), "写入", ""},
-	{regexp.MustCompile(`(?i)read`), "读取", ""},
-	{regexp.MustCompile(`(?i)search`), "搜索", ""},
-	{regexp.MustCompile(`(?i)_list(_|$)|list_|_tree|name_list`), "查询", "列表"},
-	// 查询类兜底动词(get/detail/info/find/query/count/exists 等)
-	{regexp.MustCompile(`(?i)get|detail|info|find|query|count|exists|options|config|summary|activit`), "查询", ""},
-}
-
-// fallbackRules 非 MCP 标识名(内置工具 / 中文描述 / 通用关键词)的兜底映射表。
-type toolLabelRule struct {
-	re    *regexp.Regexp
-	label string
-}
-
-var fallbackRules = []toolLabelRule{
-	{regexp.MustCompile(`(?i)web_search|联网搜索`), "联网搜索"},
-	{regexp.MustCompile(`(?i)web_fetch|fetch|抓取|网页`), "抓取网页"},
-	{regexp.MustCompile(`(?i)knowledge|知识库|检索`), "知识库检索"},
-	{regexp.MustCompile(`(?i)subagent|子任务`), "子任务处理"},
-	{regexp.MustCompile(`(?i)todo|任务清单`), "任务清单"},
-	{regexp.MustCompile(`(?i)introspect`), "功能查询"},
-	{regexp.MustCompile(`(?i)\bcode\b|代码`), "代码分析"},
-	{regexp.MustCompile(`(?i)grep|搜索|查找`), "搜索内容"},
-	{regexp.MustCompile(`(?i)glob|查找文件`), "查找文件"},
-	{regexp.MustCompile(`(?i)read|cat|查看|读取`), "读取文件"},
-	{regexp.MustCompile(`(?i)write|create|edit|写入|修改`), "写入文件"},
-	{regexp.MustCompile(`(?i)util_|parse_sn`), "解析序列号"},
-	{regexp.MustCompile(`(?i)execut|bash|shell|command|执行|运行`), "执行命令"},
-}
-
-// DescribeTool 根据工具调用 title 返回详细描述(动作 + 对象)。
-//
-//	MCP 标识名形态:组合「动作 + 对象」,如 create_issue -> “创建工作项”,
-//	                list_work_items -> “查询工作项列表”。
-//	其他形态:回退到关键词兜底表;无命中返回 DefaultToolLabel。
-func DescribeTool(title string) string {
-	if title == "" {
-		return DefaultToolLabel
-	}
-
-	// MCP 标识名 → 动作 + 对象 组合
-	if mcpIdentRe.MatchString(title) {
-		obj := ""
-		for _, r := range objectRules {
-			if r.re.MatchString(title) {
-				obj = r.noun
-				break
-			}
-		}
-		if obj != "" {
-			// 默认动作为“查询”(大多数无动词的工具是读取类)
-			verb, suffix := "查询", ""
-			// 剔除域名噪声后再识别动作,避免 auto_update / update_log 误判为“更新”
-			actionSrc := domainNoiseRe.ReplaceAllString(title, "_")
-			for _, a := range actionRules {
-				if a.re.MatchString(actionSrc) {
-					verb, suffix = a.verb, a.suffix
-					break
-				}
-			}
-			return verb + obj + suffix
-		}
-	}
-
-	// 回退:内置工具 / 中文描述 / 通用关键词
-	for _, r := range fallbackRules {
-		if r.re.MatchString(title) {
-			return r.label
-		}
-	}
-	return DefaultToolLabel
-}
-
-// NormalizeStatus 将 ACP / 各来源的工具状态归一化为前端使用的三态:
-// running / completed / error。
-//
-//	pending, in_progress, running -> running
-//	completed                     -> completed
-//	failed, error                 -> error
-//	其他(未知)                    -> running(先按运行中处理,避免误判为错误)
-func NormalizeStatus(raw string) string {
-	switch raw {
-	case "pending", "in_progress", "running":
-		return "running"
-	case "completed":
-		return "completed"
-	case "failed", "error":
-		return "error"
-	default:
-		return "running"
-	}
-}

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

@@ -1,102 +0,0 @@
-// 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
-}

+ 0 - 270
service/utils/filestore/filestore.go

@@ -1,270 +0,0 @@
-package filestore
-
-import (
-	"bytes"
-	"context"
-	"fmt"
-	"io"
-	"path/filepath"
-	"strings"
-	"time"
-
-	minioUtil "fwh-toolbox-server/utils/minio"
-
-	"github.com/google/uuid"
-	"github.com/minio/minio-go/v7"
-	"go.uber.org/zap"
-)
-
-// AIFileBucket AI 相关文件统一存放的 MinIO bucket。
-const AIFileBucket = "fwh-toolbox-ai-file"
-
-// AIFileExpireDays 上传文件的过期天数,超过后由 MinIO 生命周期策略自动清理。
-const AIFileExpireDays = 7
-
-// FileRecord 文件记录,描述一个已存入 MinIO 的对象。
-// Key 是唯一寻址标识(完整 object key),读取时直接使用它。
-type FileRecord struct {
-	Key       string `json:"key"`       // MinIO object key,读取文件时的唯一寻址标识
-	FileName  string `json:"fileName"`  // 原始文件名
-	Size      int64  `json:"size"`      // 文件大小(字节)
-	CreatedAt int64  `json:"createdAt"` // 创建时间戳(Unix 秒)
-}
-
-// writeFile 将文本内容上传到 MinIO,返回文件记录。
-// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
-// 其中 uuid 前缀用于避免同名文件相互覆盖。
-// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
-func writeFile(prefix, userId, sessionId, fileName, content string) (*FileRecord, error) {
-	uniqueId := uuid.New().String()[:8]
-	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
-
-	// 上传到 MinIO(带7天过期)
-	reader := strings.NewReader(content)
-	ctx := context.Background()
-
-	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(content)), minio.PutObjectOptions{
-		ContentType: DetectContentType(fileName),
-		Expires:     time.Now().Add(AIFileExpireDays * 24 * time.Hour),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
-	}
-
-	record := &FileRecord{
-		Key:       objectKey,
-		FileName:  fileName,
-		Size:      int64(len(content)),
-		CreatedAt: time.Now().Unix(),
-	}
-
-	zap.L().Info("[FileStore] 文件已保存到MinIO",
-		zap.String("prefix", prefix),
-		zap.String("key", objectKey),
-		zap.String("fileName", fileName),
-	)
-
-	return record, nil
-}
-
-// writeBytes 将字节内容上传到 MinIO(支持任意文件类型)
-// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
-func writeBytes(prefix, userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
-	uniqueId := uuid.New().String()[:8]
-	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", prefix, userId, sessionId, uniqueId, fileName)
-
-	reader := bytes.NewReader(data)
-	ctx := context.Background()
-
-	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
-		ContentType: DetectContentType(fileName),
-		Expires:     time.Now().Add(AIFileExpireDays * 24 * time.Hour),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
-	}
-
-	record := &FileRecord{
-		Key:       objectKey,
-		FileName:  fileName,
-		Size:      int64(len(data)),
-		CreatedAt: time.Now().Unix(),
-	}
-
-	zap.L().Info("[FileStore] 文件已保存到MinIO",
-		zap.String("prefix", prefix),
-		zap.String("key", objectKey),
-		zap.String("fileName", fileName),
-	)
-
-	return record, nil
-}
-
-// UploadFile 上传文本内容到 MinIO 的 "upload" 前缀下(用户上传/供 Agent 读取的文件)。
-func UploadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
-	return writeFile("upload", userId, sessionId, fileName, content)
-}
-
-// DownloadFile 上传文本内容到 MinIO 的 "download" 前缀下(Agent 生成/供用户下载的文件)。
-func DownloadFile(userId, sessionId, fileName, content string) (*FileRecord, error) {
-	return writeFile("download", userId, sessionId, fileName, content)
-}
-
-// UploadBytes 上传字节内容到 MinIO 的 "upload" 前缀下(支持任意文件类型)。
-func UploadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
-	return writeBytes("upload", userId, sessionId, fileName, data)
-}
-
-// DownloadBytes 上传字节内容到 MinIO 的 "download" 前缀下(支持任意文件类型)。
-func DownloadBytes(userId, sessionId, fileName string, data []byte) (*FileRecord, error) {
-	return writeBytes("download", userId, sessionId, fileName, data)
-}
-
-// ReadFile 按 key 从 MinIO 读取文件文本内容。
-func ReadFile(key string) (string, error) {
-	data, err := ReadFileBytes(key)
-	if err != nil {
-		return "", err
-	}
-	return string(data), nil
-}
-
-// ReadFileBytes 按 key 从 MinIO 读取文件原始字节内容。
-func ReadFileBytes(key string) ([]byte, error) {
-	ctx := context.Background()
-	obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, key, minio.GetObjectOptions{})
-	if err != nil {
-		return nil, fmt.Errorf("从MinIO读取文件失败: %w", err)
-	}
-	defer obj.Close()
-
-	data, err := io.ReadAll(obj)
-	if err != nil {
-		return nil, fmt.Errorf("读取文件内容失败: %w", err)
-	}
-
-	return data, nil
-}
-
-// HasFile 检查指定 key 的文件是否存在于 MinIO。
-// 通过 StatObject 验证对象存在性,出错(含不存在)时返回 false。
-func HasFile(key string) bool {
-	if key == "" {
-		return false
-	}
-	_, err := minioUtil.StatObjectFromBucket(AIFileBucket, key)
-	return err == nil
-}
-
-// GetFiles 列出指定范围下的所有文件记录。
-// prefix、userId 必填;sessionId 可为空。
-//   - sessionId 非空:列举 {prefix}/{userId}/{sessionId}/ 下的对象
-//   - sessionId 为空:列举 {prefix}/{userId}/ 下的对象(跨会话)
-//
-// 直接以 MinIO 为数据源递归列举对象。
-func GetFiles(prefix, userId, sessionId string) ([]FileRecord, error) {
-	if prefix == "" {
-		return nil, fmt.Errorf("prefix 不能为空")
-	}
-	if userId == "" {
-		return nil, fmt.Errorf("userId 不能为空")
-	}
-
-	var listPrefix string
-	if sessionId == "" {
-		listPrefix = fmt.Sprintf("%s/%s/", prefix, userId)
-	} else {
-		listPrefix = fmt.Sprintf("%s/%s/%s/", prefix, userId, sessionId)
-	}
-
-	objects, err := minioUtil.ListFilesObjectFromBucket(AIFileBucket, listPrefix, true)
-	if err != nil {
-		return nil, fmt.Errorf("列举MinIO文件失败: %w", err)
-	}
-
-	result := make([]FileRecord, 0, len(objects))
-	for _, obj := range objects {
-		// 从 object key 中还原原始文件名:去掉 "{uuid}_" 前缀(8位uuid + 下划线 = 9字符)
-		fileName := filepath.Base(obj.Key)
-		if len(fileName) > 9 {
-			fileName = fileName[9:]
-		}
-		result = append(result, FileRecord{
-			Key:       obj.Key,
-			FileName:  fileName,
-			Size:      obj.Size,
-			CreatedAt: obj.LastModified.Unix(),
-		})
-	}
-	return result, nil
-}
-
-// FindKeyByFileName 在 {prefix}/{userId}/{sessionId}/ 下按原始文件名查找最新的 object key。
-//
-// 由于 object key 形如 {prefix}/{userId}/{sessionId}/{uuid}_{fileName},包含随机 uuid,
-// 无法由 fileName 直接推导。此处通过列举该会话前缀下的对象、匹配还原后的文件名,
-// 并在多个同名文件时返回创建时间最新的一个(CreatedAt 最大)。
-// 找不到时返回空字符串。
-func FindKeyByFileName(prefix, userId, sessionId, fileName string) (string, error) {
-	files, err := GetFiles(prefix, userId, sessionId)
-	if err != nil {
-		return "", err
-	}
-
-	var newestKey string
-	var newestAt int64 = -1
-	for _, f := range files {
-		if f.FileName == fileName && f.CreatedAt > newestAt {
-			newestKey = f.Key
-			newestAt = f.CreatedAt
-		}
-	}
-	return newestKey, nil
-}
-
-// FindKeyByPath 与 FindKeyByFileName 类似,但接受 Agent 侧使用的路径(如 /uploads/report.csv),
-// 自动取 basename 作为文件名进行查找。
-func FindKeyByPath(prefix, userId, sessionId, path string) (string, error) {
-	return FindKeyByFileName(prefix, userId, sessionId, filepath.Base(path))
-}
-
-// DetectContentType 根据文件扩展名推断 Content-Type
-func DetectContentType(fileName string) string {
-	ext := strings.ToLower(filepath.Ext(fileName))
-	switch ext {
-	case ".txt":
-		return "text/plain; charset=utf-8"
-	case ".json":
-		return "application/json"
-	case ".html", ".htm":
-		return "text/html"
-	case ".css":
-		return "text/css"
-	case ".js":
-		return "application/javascript"
-	case ".ts":
-		return "application/typescript"
-	case ".go":
-		return "text/x-go"
-	case ".py":
-		return "text/x-python"
-	case ".md":
-		return "text/markdown"
-	case ".csv":
-		return "text/csv"
-	case ".xml":
-		return "application/xml"
-	case ".yaml", ".yml":
-		return "text/yaml"
-	case ".sh":
-		return "text/x-shellscript"
-	case ".vue":
-		return "text/x-vue"
-	case ".sql":
-		return "text/x-sql"
-	case ".log":
-		return "text/plain; charset=utf-8"
-	default:
-		return "application/octet-stream"
-	}
-}

+ 1 - 1
service/utils/pii/stream.go

@@ -1,4 +1,4 @@
-// Package pii 提供字段级 PII 脱敏(规则拷贝自旧 MCP 的 helper/pii_*.go,两者独立维护)。
+// Package pii 提供字段级 PII 脱敏。
 package pii
 
 import (

+ 0 - 538
src/layout/header/Actions/AiChat/ChatPanel.vue

@@ -1,538 +0,0 @@
-<script setup lang="ts">
-import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin, NTag, NModal, NCard, NDrawer, NDrawerContent } from 'naive-ui'
-import { ref, nextTick, watch, onMounted, computed } from 'vue'
-import { useClipboard } from '@vueuse/core'
-import { useAiChat } from './useAiChat'
-import { listAiFiles, downloadAiFile } from '@/service/aiChat'
-import type { AiFileItem } from './interface'
-import MessageItem from './MessageItem.vue'
-
-const {
-  messages,
-  isStreaming,
-  welcomeMessage,
-  sessionList,
-  currentSessionId,
-  isLoadingSession,
-  pendingFiles,
-  isUploading,
-  sessionLoadDoneInfo,
-  currentAgent,
-  currentModel,
-  contextUsage,
-  turnDuration,
-  sendMessage,
-  cancelChat,
-  clearMessages,
-  resolvePermission,
-  fetchSession,
-  createNewSession,
-  loadSession,
-  deleteSession,
-  handleUploadFile,
-  removePendingFile,
-  renameSession,
-  dismissSessionLoadInfo,
-} = useAiChat()
-
-const inputText = ref('')
-const scrollRef = ref<InstanceType<typeof NScrollbar>>()
-const showSidebar = ref(false)
-const fileInputRef = ref<HTMLInputElement>()
-const editingSessionId = ref('')
-const editingTitle = ref('')
-
-const { copy } = useClipboard()
-
-function copySessionId(id?: string) {
-  const target = id || currentSessionId.value
-  if (target) {
-    copy(target)
-  }
-}
-
-// 上下文用量百分比(取整,0~100)
-const contextUsagePct = computed(() => {
-  const v = Math.round(contextUsage.value || 0)
-  return Math.min(100, Math.max(0, v))
-})
-
-// 进度条颜色:<60% 绿 / 60~80% 黄 / >80% 红
-const contextUsageColor = computed(() => {
-  const v = contextUsagePct.value
-  if (v > 80) return '#ef4444'
-  if (v >= 60) return '#eab308'
-  return '#22c55e'
-})
-
-// 上一轮耗时格式化(毫秒 → s / ms)
-const turnDurationText = computed(() => {
-  const ms = turnDuration.value || 0
-  if (ms <= 0) return ''
-  if (ms < 1000) return `${ms}ms`
-  return `${(ms / 1000).toFixed(1)}s`
-})
-
-onMounted(() => {
-  fetchSession()
-})
-
-// 自动滚动到底部(文本增量或工具调用片段变化时)
-watch(
-  () => {
-    const last = messages.value[messages.value.length - 1]
-    return `${last?.content ?? ''}|${last?.segments?.length ?? 0}`
-  },
-  () => {
-    nextTick(() => {
-      scrollRef.value?.scrollTo({ top: 99999, behavior: 'smooth' })
-    })
-  },
-)
-
-function handleSend() {
-  if (!inputText.value.trim() || isStreaming.value) return
-  const msg = inputText.value
-  inputText.value = ''
-  sendMessage(msg)
-}
-
-function handleKeydown(e: KeyboardEvent) {
-  if (e.key === 'Enter' && !e.shiftKey) {
-    e.preventDefault()
-    handleSend()
-  }
-}
-
-function triggerFileInput() {
-  fileInputRef.value?.click()
-}
-
-function onFileSelected(e: Event) {
-  const input = e.target as HTMLInputElement
-  const file = input.files?.[0]
-  if (file) {
-    handleUploadFile(file)
-  }
-  // 重置 input,允许再次选择同名文件
-  input.value = ''
-}
-
-function startRename(sessionId: string, currentTitle: string) {
-  editingSessionId.value = sessionId
-  editingTitle.value = currentTitle || ''
-}
-
-function confirmRename(sessionId: string) {
-  const title = editingTitle.value.trim()
-  if (title) {
-    renameSession(sessionId, title)
-  }
-  editingSessionId.value = ''
-  editingTitle.value = ''
-}
-
-function cancelRename() {
-  editingSessionId.value = ''
-  editingTitle.value = ''
-}
-
-function handleNewSession() {
-  createNewSession()
-  showSidebar.value = false
-}
-
-function handleLoadSession(sessionId: string) {
-  loadSession(sessionId)
-  showSidebar.value = false
-}
-
-function formatTime(ts: number): string {
-  if (!ts) return ''
-  const d = new Date(ts)
-  const now = new Date()
-  const diff = now.getTime() - d.getTime()
-  if (diff < 60000) return '刚刚'
-  if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
-  if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
-  if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
-  return d.toLocaleDateString()
-}
-
-// 会话加载弹窗相关
-const showSessionLoadDialog = computed(() => {
-  return sessionLoadDoneInfo.value !== null && sessionLoadDoneInfo.value.suggestNewSession
-})
-
-const sessionLoadDialogText = computed(() => {
-  if (!sessionLoadDoneInfo.value) return ''
-  const { totalMessages, totalBytes } = sessionLoadDoneInfo.value
-  const sizeKB = Math.round(totalBytes / 1024)
-  if (totalBytes > 500 * 1024) {
-    return `当前会话内容较多(${totalMessages} 条消息,约 ${sizeKB}KB),强烈建议创建新会话以获得更好的体验。`
-  }
-  return `当前会话内容较多(${totalMessages} 条消息,约 ${sizeKB}KB),建议创建新会话。`
-})
-
-function handleContinueSession() {
-  dismissSessionLoadInfo()
-}
-
-function handleCreateNewSessionFromDialog() {
-  dismissSessionLoadInfo()
-  createNewSession()
-}
-
-// ============ 文件历史 Drawer ============
-const fileDrawerVisible = ref(false)
-const fileDrawerType = ref<'upload' | 'download'>('upload')
-const fileList = ref<AiFileItem[]>([])
-const isLoadingFiles = ref(false)
-
-async function showFileDrawer(type: 'upload' | 'download') {
-  fileDrawerType.value = type
-  fileDrawerVisible.value = true
-  isLoadingFiles.value = true
-  try {
-    const data = await listAiFiles(type)
-    fileList.value = data || []
-  }
-  catch {
-    fileList.value = []
-  }
-  finally {
-    isLoadingFiles.value = false
-  }
-}
-
-function handleDownloadFile(key: string, fileName: string) {
-  downloadAiFile(key, fileName)
-}
-
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
-}
-
-function formatExpiry(expiresAt: number): string {
-  if (!expiresAt) return '不过期'
-  const now = Date.now()
-  const diff = expiresAt - now
-  if (diff <= 0) return '已过期'
-  const hours = Math.floor(diff / 3600000)
-  if (hours < 1) return '即将过期'
-  if (hours < 24) return `${hours} 小时后过期`
-  const days = Math.floor(hours / 24)
-  return `${days} 天后过期`
-}
-</script>
-
-<template>
-  <div class="relative flex h-full flex-col">
-    <!-- 顶部操作栏 -->
-    <div class="flex items-center justify-between border-b px-3 py-2 dark:border-neutral-700">
-      <div class="flex items-center gap-2">
-        <NButton text size="small" @click="showSidebar = !showSidebar">
-          <span class="i-ph:list text-sm" />
-        </NButton>
-        <span class="text-xs text-neutral-400">AI 助手</span>
-        <span
-          v-if="currentSessionId"
-          class="cursor-pointer rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:hover:text-neutral-300"
-          :title="currentSessionId"
-          @click.stop="copySessionId()"
-        >
-          {{ currentSessionId.slice(0, 8) }}
-        </span>
-      </div>
-      <div class="flex items-center gap-1">
-        <NButton text size="small" title="上传文件历史" @click="showFileDrawer('upload')">
-          <span class="i-ph:upload-simple text-sm" />
-        </NButton>
-        <NButton text size="small" title="AI生成文件" @click="showFileDrawer('download')">
-          <span class="i-ph:download-simple text-sm" />
-        </NButton>
-        <NButton text size="small" title="新建对话" @click="handleNewSession">
-          <span class="i-ph:plus text-sm" />
-        </NButton>
-        <NButton text size="small" title="清空当前消息" @click="clearMessages">
-          <span class="i-ph:trash text-sm" />
-        </NButton>
-      </div>
-    </div>
-
-    <!-- 第2行状态条:实际生效的 Agent / Model 与上下文用量 -->
-    <div class="flex items-center justify-between gap-2 border-b px-3 py-1 text-[10px] text-neutral-400 dark:border-neutral-700">
-      <div class="flex items-center gap-3 truncate">
-        <span v-if="currentAgent" class="flex items-center gap-0.5 truncate" :title="`Agent: ${currentAgent}`">
-          <span>🤖</span>
-          <span class="truncate">{{ currentAgent }}</span>
-        </span>
-        <span v-if="currentModel" class="flex items-center gap-0.5 truncate" :title="`Model: ${currentModel}`">
-          <span>⚡</span>
-          <span class="truncate">{{ currentModel }}</span>
-        </span>
-      </div>
-      <div class="flex shrink-0 items-center gap-2">
-        <span v-if="turnDurationText" class="flex items-center gap-0.5" title="上一轮耗时">
-          <span>⏱</span>
-          <span>{{ turnDurationText }}</span>
-        </span>
-        <span class="flex items-center gap-1" title="上下文用量">
-          <span>上下文</span>
-          <span class="inline-block h-1.5 w-16 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-600">
-            <span
-              class="block h-full rounded-full transition-all"
-              :style="{ width: `${contextUsagePct}%`, backgroundColor: contextUsageColor }"
-            />
-          </span>
-          <span :style="{ color: contextUsageColor }">{{ contextUsagePct }}%</span>
-        </span>
-      </div>
-    </div>
-
-    <!-- 会话列表侧边栏 -->
-    <Transition name="slide">
-      <div
-        v-if="showSidebar"
-        class="absolute inset-y-0 left-0 z-10 w-56 border-r bg-white flex flex-col dark:border-neutral-700 dark:bg-neutral-800"
-      >
-        <div class="flex items-center justify-between border-b px-3 py-2 dark:border-neutral-700">
-          <span class="text-xs font-medium text-neutral-500">对话历史</span>
-          <NButton text size="tiny" @click="showSidebar = false">
-            <span class="i-ph:x text-xs" />
-          </NButton>
-        </div>
-        <NScrollbar class="flex-1">
-          <div class="p-2 space-y-1">
-            <div
-              v-for="sess in sessionList"
-              :key="sess.sessionId"
-              class="group cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
-              :class="{ 'bg-blue-50 dark:bg-blue-900/20': sess.isActive }"
-              @click="handleLoadSession(sess.sessionId)"
-            >
-              <div class="flex items-center justify-between">
-                <!-- 编辑模式 -->
-                <input
-                  v-if="editingSessionId === sess.sessionId"
-                  v-model="editingTitle"
-                  class="flex-1 rounded border border-blue-300 bg-white px-1 text-xs outline-none dark:border-blue-600 dark:bg-neutral-700"
-                  @click.stop
-                  @keydown.enter.stop="confirmRename(sess.sessionId)"
-                  @keydown.escape.stop="cancelRename"
-                  @blur="confirmRename(sess.sessionId)"
-                >
-                <!-- 显示模式 -->
-                <span
-                  v-else
-                  class="flex-1 truncate text-neutral-700 dark:text-neutral-300"
-                  @dblclick.stop="startRename(sess.sessionId, sess.title)"
-                >
-                  {{ sess.title || '新对话' }}
-                </span>
-                <div class="hidden items-center gap-0.5 group-hover:flex">
-                  <NButton text size="tiny" title="重命名" @click.stop="startRename(sess.sessionId, sess.title)">
-                    <span class="i-ph:pencil-simple text-xs text-neutral-400" />
-                  </NButton>
-                  <NPopconfirm @positive-click.stop="deleteSession(sess.sessionId)">
-                    <template #trigger>
-                      <NButton text size="tiny" @click.stop>
-                        <span class="i-ph:trash text-xs text-neutral-400" />
-                      </NButton>
-                    </template>
-                    删除此对话?
-                  </NPopconfirm>
-                </div>
-              </div>
-              <div class="mt-0.5 flex items-center gap-1 text-neutral-400 text-[10px]">
-                <span
-                  class="cursor-pointer rounded bg-neutral-100 px-1 font-mono transition-colors hover:bg-neutral-200 hover:text-neutral-600 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:hover:text-neutral-300"
-                  :title="sess.sessionId"
-                  @click.stop="copySessionId(sess.sessionId)"
-                >
-                  {{ sess.sessionId.slice(0, 8) }}
-                </span>
-                <span>·</span>
-                <span>{{ formatTime(sess.lastActive) }}</span>
-              </div>
-            </div>
-            <NEmpty v-if="sessionList.length === 0" description="暂无历史对话" size="small" />
-          </div>
-        </NScrollbar>
-      </div>
-    </Transition>
-
-    <!-- 加载中遮罩 -->
-    <div
-      v-if="isLoadingSession"
-      class="absolute inset-0 z-20 flex items-center justify-center bg-white/60 dark:bg-neutral-900/60"
-    >
-      <NSpin size="medium" />
-    </div>
-
-    <!-- 消息列表 -->
-    <NScrollbar ref="scrollRef" class="flex-1">
-      <div class="space-y-3 p-4">
-        <NEmpty v-if="messages.length === 0 && !welcomeMessage" description="输入消息开始对话" />
-        <div
-          v-else-if="messages.length === 0 && welcomeMessage"
-          class="text-sm text-neutral-500 whitespace-pre-wrap"
-        >
-          {{ welcomeMessage }}
-        </div>
-        <MessageItem
-          v-for="msg in messages"
-          :key="msg.id"
-          :message="msg"
-          @resolve-permission="resolvePermission"
-        />
-      </div>
-    </NScrollbar>
-
-    <!-- 流式输出中提示:固定在输入区上方,不随消息列表滚动 -->
-    <div
-      v-if="isStreaming"
-      class="flex items-center gap-1.5 border-t px-3 py-1.5 text-xs text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"
-    >
-      <NSpin :size="12" />
-      <span class="animate-pulse">思考中...</span>
-    </div>
-
-    <!-- 输入区域 -->
-    <div class="border-t p-3 dark:border-neutral-700">
-      <!-- 已上传文件标签 -->
-      <div v-if="pendingFiles.length > 0" class="mb-2 flex flex-wrap gap-1">
-        <NTag
-          v-for="(file, idx) in pendingFiles"
-          :key="file.key"
-          size="small"
-          closable
-          @close="removePendingFile(idx)"
-        >
-          <template #icon>
-            <span class="i-ph:file-text text-xs" />
-          </template>
-          {{ file.fileName }}
-        </NTag>
-      </div>
-
-      <!-- 上传中提示 -->
-      <div v-if="isUploading" class="mb-2 flex items-center gap-1 text-xs text-neutral-400">
-        <NSpin :size="12" />
-        <span>上传中...</span>
-      </div>
-
-      <div class="flex items-center gap-2">
-        <!-- 上传按钮 -->
-        <NButton
-          text
-          size="small"
-          :disabled="isStreaming || isUploading || pendingFiles.length >= 5"
-          title="上传文件"
-          @click="triggerFileInput"
-        >
-          <span class="i-ph:paperclip text-lg" />
-        </NButton>
-        <input
-          ref="fileInputRef"
-          type="file"
-          class="hidden"
-          accept=".txt,.csv,.json,.md,.xml,.yaml,.yml,.js,.ts,.vue,.go,.py,.sh,.sql,.log,.html,.css"
-          @change="onFileSelected"
-        >
-
-        <NInput
-          v-model:value="inputText"
-          type="textarea"
-          :autosize="{ minRows: 1, maxRows: 4 }"
-          placeholder="输入消息...(Shift+Enter 换行)"
-          :disabled="isStreaming"
-          @keydown="handleKeydown"
-        />
-        <NButton
-          v-if="!isStreaming"
-          type="primary"
-          size="small"
-          :disabled="!inputText.trim()"
-          @click="handleSend"
-        >
-          <span class="i-ph:paper-plane-tilt" />
-        </NButton>
-        <NButton v-else type="error" size="small" @click="cancelChat">
-          <span class="i-ph:stop-circle" />
-        </NButton>
-      </div>
-    </div>
-
-    <!-- 会话加载建议弹窗 -->
-    <NModal :show="showSessionLoadDialog" :mask-closable="false">
-      <NCard
-        style="width: 400px"
-        title="会话提示"
-        :bordered="false"
-        size="small"
-        role="dialog"
-        aria-modal="true"
-      >
-        <p class="text-sm text-neutral-600 dark:text-neutral-300">
-          {{ sessionLoadDialogText }}
-        </p>
-        <template #footer>
-          <div class="flex justify-end gap-2">
-            <NButton size="small" @click="handleContinueSession">
-              继续使用
-            </NButton>
-            <NButton type="primary" size="small" @click="handleCreateNewSessionFromDialog">
-              创建新会话
-            </NButton>
-          </div>
-        </template>
-      </NCard>
-    </NModal>
-
-    <!-- 文件历史 Drawer -->
-    <NDrawer v-model:show="fileDrawerVisible" :width="320" placement="right">
-      <NDrawerContent :title="fileDrawerType === 'upload' ? '上传文件历史' : 'AI 生成文件'">
-        <div v-if="isLoadingFiles" class="flex justify-center py-8">
-          <NSpin size="medium" />
-        </div>
-        <div v-else-if="fileList.length === 0" class="py-8">
-          <NEmpty :description="fileDrawerType === 'upload' ? '暂无上传文件' : '暂无生成文件'" size="small" />
-        </div>
-        <div v-else class="space-y-2">
-          <div
-            v-for="file in fileList"
-            :key="file.key"
-            class="flex items-center gap-2 rounded-md border p-2 dark:border-neutral-600"
-          >
-            <div class="flex-1 min-w-0">
-              <div class="truncate text-xs font-medium text-neutral-700 dark:text-neutral-200">
-                {{ file.fileName }}
-              </div>
-              <div class="mt-0.5 text-[10px] text-neutral-400">
-                {{ formatFileSize(file.size) }} · {{ formatExpiry(file.expiresAt) }}
-              </div>
-            </div>
-            <NButton text size="tiny" title="下载" @click="handleDownloadFile(file.key, file.fileName)">
-              <span class="i-ph:download text-sm text-blue-500" />
-            </NButton>
-          </div>
-        </div>
-      </NDrawerContent>
-    </NDrawer>
-  </div>
-</template>
-
-<style scoped>
-.slide-enter-active,
-.slide-leave-active {
-  transition: transform 0.2s ease;
-}
-
-.slide-enter-from,
-.slide-leave-to {
-  transform: translateX(-100%);
-}
-</style>

+ 0 - 391
src/layout/header/Actions/AiChat/MessageItem.vue

@@ -1,391 +0,0 @@
-<script setup lang="ts">
-import { NTag, NSpin, NButton, NCard } from 'naive-ui'
-import { computed, ref } from 'vue'
-import { marked, Renderer } from 'marked'
-import DOMPurify from 'dompurify'
-import { useClipboard } from '@vueuse/core'
-import { downloadAiFile } from '@/service/aiChat'
-import type { ChatMessage, ToolCallInfo } from './interface'
-
-const props = defineProps<{
-  message: ChatMessage
-}>()
-
-const emit = defineEmits<{
-  resolvePermission: [optionId: string]
-}>()
-
-const isUser = computed(() => props.message.role === 'user')
-
-// 是否使用有序 segments 渲染(新消息带 segments;旧消息回退到旧逻辑)
-const useSegments = computed(
-  () => !isUser.value && Array.isArray(props.message.segments) && props.message.segments.length > 0,
-)
-
-// 自定义 marked renderer,为代码块添加复制按钮
-const renderer = new Renderer()
-renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
-  const escaped = text
-    .replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;')
-  const langLabel = lang ? `<span class="code-lang-label">${lang}</span>` : ''
-  return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" data-code="${escaped}">复制</button><pre><code>${text}</code></pre></div>`
-}
-
-// 将一段 AI 文本渲染为安全 HTML
-function renderMarkdown(text: string): string {
-  const raw = marked.parse(text, { async: false, renderer }) as string
-  return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
-}
-
-const formattedContent = computed(() => {
-  if (isUser.value) {
-    // 用户消息保持纯文本显示
-    return props.message.content.replace(/\n/g, '<br/>')
-  }
-  // AI 回复使用 Markdown 渲染
-  return renderMarkdown(props.message.content)
-})
-
-// 工具标签展示映射:区分执行中 / 已完成 / 失败
-function toolTagType(status: ToolCallInfo['status']) {
-  if (status === 'running') return 'warning'
-  if (status === 'completed') return 'success'
-  return 'error'
-}
-
-function toolStatusLabel(status: ToolCallInfo['status']) {
-  if (status === 'running') return '执行中'
-  if (status === 'completed') return '已完成'
-  return '失败'
-}
-
-// 复制到剪贴板
-const { copy } = useClipboard()
-const copiedIndex = ref<string | null>(null)
-
-function handleContentClick(e: MouseEvent) {
-  const target = e.target as HTMLElement
-  if (target.classList.contains('copy-btn')) {
-    const code = target.getAttribute('data-code')
-      ?.replace(/&amp;/g, '&')
-      .replace(/&lt;/g, '<')
-      .replace(/&gt;/g, '>')
-      .replace(/&quot;/g, '"')
-    if (code) {
-      copy(code)
-      // 显示已复制反馈
-      const id = Math.random().toString(36).slice(2)
-      copiedIndex.value = id
-      target.textContent = '已复制 ✓'
-      target.classList.add('copied')
-      setTimeout(() => {
-        if (copiedIndex.value === id) {
-          target.textContent = '复制'
-          target.classList.remove('copied')
-          copiedIndex.value = null
-        }
-      }, 2000)
-    }
-  }
-}
-
-function downloadFile(key: string, fileName: string) {
-  downloadAiFile(key, fileName)
-}
-
-function formatSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes}B`
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
-  return `${(bytes / 1024 / 1024).toFixed(1)}MB`
-}
-</script>
-
-<template>
-  <div class="flex" :class="isUser ? 'justify-end' : 'justify-start'">
-    <div
-      class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
-      :class="isUser
-        ? 'bg-blue-500 text-white'
-        : 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'"
-    >
-      <!-- 工具调用标签(旧消息回退:无 segments 时集中展示在顶部) -->
-      <div v-if="!useSegments && message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
-        <NTag
-          v-for="tc in message.toolCalls"
-          :key="tc.id"
-          size="small"
-          :title="tc.title"
-          :type="toolTagType(tc.status)"
-        >
-          <template #icon>
-            <NSpin v-if="tc.status === 'running'" :size="10" />
-          </template>
-          {{ tc.description }}
-          <span class="ml-1 opacity-70">· {{ toolStatusLabel(tc.status) }}</span>
-        </NTag>
-      </div>
-
-      <!-- 权限请求 UI -->
-      <div v-if="message.permission && !message.permission.resolved" class="my-2">
-        <NCard size="small" :bordered="true" class="!bg-yellow-50 dark:!bg-yellow-900/20">
-          <div class="mb-2 text-xs font-medium text-yellow-700 dark:text-yellow-300">
-            🔒 写操作授权请求
-          </div>
-          <div class="mb-2 text-xs text-neutral-600 dark:text-neutral-300">
-            {{ message.permission.title }}
-          </div>
-          <div class="flex flex-wrap gap-2">
-            <NButton
-              v-for="opt in message.permission.options"
-              :key="opt.optionId"
-              size="tiny"
-              :type="opt.kind === 'deny' ? 'error' : opt.kind === 'allow_always' ? 'warning' : 'success'"
-              @click="emit('resolvePermission', opt.optionId)"
-            >
-              {{ opt.name }}
-            </NButton>
-          </div>
-        </NCard>
-      </div>
-
-      <!-- 权限已授权提示 -->
-      <div v-if="message.permission?.resolved" class="my-1">
-        <NTag size="small" type="success" :bordered="false">
-          ✅ 已授权
-        </NTag>
-      </div>
-
-      <!-- 文件下载按钮(旧消息回退:无 segments 时集中展示在顶部;有 segments 时按标记位置内联渲染) -->
-      <div v-if="!useSegments && message.files?.length" class="my-2 space-y-1">
-        <div v-for="file in message.files" :key="file.key" class="flex items-center gap-2">
-          <NButton
-            size="tiny"
-            type="primary"
-            @click="downloadFile(file.key, file.fileName)"
-          >
-            <template #icon>
-              <span class="i-ph:download-simple" />
-            </template>
-            {{ file.fileName }}
-            <span class="ml-1 text-xs opacity-70">({{ formatSize(file.size) }})</span>
-          </NButton>
-        </div>
-      </div>
-
-      <!-- 消息内容 -->
-      <!-- 有序 segments:文本与工具调用按到达顺序交错渲染 -->
-      <div v-if="useSegments" class="segments" @click="handleContentClick">
-        <template v-for="(seg, idx) in message.segments" :key="idx">
-          <div
-            v-if="seg.kind === 'text' && seg.content.trim()"
-            class="markdown-body"
-            v-html="renderMarkdown(seg.content)"
-          />
-          <div v-else-if="seg.kind === 'tool'" class="my-1 flex flex-wrap gap-1">
-            <NTag
-              size="small"
-              :title="seg.tool.title"
-              :type="toolTagType(seg.tool.status)"
-              :bordered="false"
-            >
-              <template #icon>
-                <NSpin v-if="seg.tool.status === 'running'" :size="10" />
-              </template>
-              {{ seg.tool.description }}
-              <span class="ml-1 opacity-70">· {{ toolStatusLabel(seg.tool.status) }}</span>
-            </NTag>
-          </div>
-          <div v-else-if="seg.kind === 'file'" class="my-2">
-            <NButton
-              size="tiny"
-              type="primary"
-              @click="downloadFile(seg.file.key, seg.file.fileName)"
-            >
-              <template #icon>
-                <span class="i-ph:download-simple" />
-              </template>
-              {{ seg.file.fileName }}
-              <span class="ml-1 text-xs opacity-70">({{ formatSize(seg.file.size) }})</span>
-            </NButton>
-          </div>
-        </template>
-      </div>
-      <!-- 旧消息回退:整体文本渲染 -->
-      <div
-        v-else-if="message.content"
-        :class="{ 'markdown-body': !isUser }"
-        v-html="formattedContent"
-        @click="handleContentClick"
-      />
-    </div>
-  </div>
-</template>
-
-<style scoped lang="scss">
-.markdown-body {
-  line-height: 1.6;
-
-  :deep(h1),
-  :deep(h2),
-  :deep(h3),
-  :deep(h4),
-  :deep(h5),
-  :deep(h6) {
-    margin-top: 0.5em;
-    margin-bottom: 0.25em;
-    font-weight: 600;
-    line-height: 1.4;
-  }
-
-  :deep(h1) { font-size: 1.25em; }
-  :deep(h2) { font-size: 1.15em; }
-  :deep(h3) { font-size: 1.05em; }
-
-  :deep(p) {
-    margin: 0.4em 0;
-  }
-
-  :deep(ul),
-  :deep(ol) {
-    padding-left: 1.5em;
-    margin: 0.4em 0;
-  }
-
-  :deep(li) {
-    margin: 0.2em 0;
-  }
-
-  :deep(code) {
-    background: rgba(127, 127, 127, 0.15);
-    border-radius: 3px;
-    padding: 0.15em 0.4em;
-    font-size: 0.9em;
-    font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
-  }
-
-  :deep(pre) {
-    background: rgba(0, 0, 0, 0.06);
-    border-radius: 6px;
-    padding: 0.75em 1em;
-    margin: 0.5em 0;
-    overflow-x: auto;
-
-    code {
-      background: none;
-      padding: 0;
-      border-radius: 0;
-      font-size: 0.85em;
-    }
-  }
-
-  :deep(.code-block-wrapper) {
-    position: relative;
-    margin: 0.5em 0;
-
-    pre {
-      margin: 0;
-      padding-top: 2em;
-    }
-
-    .code-lang-label {
-      position: absolute;
-      top: 6px;
-      left: 12px;
-      font-size: 0.7em;
-      color: rgba(127, 127, 127, 0.7);
-      user-select: none;
-      pointer-events: none;
-    }
-
-    .copy-btn {
-      position: absolute;
-      top: 6px;
-      right: 8px;
-      padding: 2px 8px;
-      font-size: 0.7em;
-      color: rgba(127, 127, 127, 0.8);
-      background: rgba(127, 127, 127, 0.1);
-      border: 1px solid rgba(127, 127, 127, 0.2);
-      border-radius: 4px;
-      cursor: pointer;
-      transition: all 0.2s;
-      user-select: none;
-      z-index: 1;
-
-      &:hover {
-        color: rgba(127, 127, 127, 1);
-        background: rgba(127, 127, 127, 0.2);
-      }
-
-      &.copied {
-        color: #10b981;
-        border-color: #10b981;
-        background: rgba(16, 185, 129, 0.1);
-      }
-    }
-  }
-
-  :deep(blockquote) {
-    border-left: 3px solid rgba(127, 127, 127, 0.4);
-    padding-left: 0.75em;
-    margin: 0.5em 0;
-    color: rgba(127, 127, 127, 0.8);
-  }
-
-  :deep(table) {
-    border-collapse: collapse;
-    margin: 0.5em 0;
-    width: 100%;
-    font-size: 0.9em;
-  }
-
-  :deep(th),
-  :deep(td) {
-    border: 1px solid rgba(127, 127, 127, 0.3);
-    padding: 0.3em 0.6em;
-    text-align: left;
-  }
-
-  :deep(th) {
-    background: rgba(127, 127, 127, 0.1);
-    font-weight: 600;
-  }
-
-  :deep(hr) {
-    border: none;
-    border-top: 1px solid rgba(127, 127, 127, 0.3);
-    margin: 0.75em 0;
-  }
-
-  :deep(a) {
-    color: #3b82f6;
-    text-decoration: underline;
-  }
-
-  :deep(img) {
-    max-width: 100%;
-    border-radius: 4px;
-  }
-
-  :deep(> :first-child) {
-    margin-top: 0;
-  }
-
-  :deep(> :last-child) {
-    margin-bottom: 0;
-  }
-}
-
-:global(.dark) .markdown-body {
-  :deep(pre) {
-    background: rgba(255, 255, 255, 0.06);
-  }
-
-  :deep(a) {
-    color: #60a5fa;
-  }
-}
-</style>

+ 0 - 64
src/layout/header/Actions/AiChat/chatDB.ts

@@ -1,64 +0,0 @@
-import { openDB } from 'idb'
-import type { ChatMessage, LocalChatSession } from './interface'
-
-const DB_NAME = 'ai-chat-history'
-const DB_VERSION = 1
-const STORE_NAME = 'sessions'
-
-function getDB() {
-  return openDB(DB_NAME, DB_VERSION, {
-    upgrade(db) {
-      if (!db.objectStoreNames.contains(STORE_NAME)) {
-        const store = db.createObjectStore(STORE_NAME, { keyPath: 'sessionId' })
-        store.createIndex('updatedAt', 'updatedAt')
-      }
-    },
-  })
-}
-
-export const chatDB = {
-  /** 保存会话消息到 IndexedDB */
-  async saveSession(sessionId: string, title: string, messages: ChatMessage[]) {
-    const db = await getDB()
-    const existing = await db.get(STORE_NAME, sessionId)
-    const session: LocalChatSession = {
-      sessionId,
-      title,
-      messages: [...messages],
-      createdAt: existing?.createdAt || Date.now(),
-      updatedAt: Date.now(),
-    }
-    await db.put(STORE_NAME, session)
-  },
-
-  /** 获取会话消息 */
-  async getSession(sessionId: string): Promise<LocalChatSession | undefined> {
-    const db = await getDB()
-    return db.get(STORE_NAME, sessionId)
-  },
-
-  /** 删除会话 */
-  async deleteSession(sessionId: string) {
-    const db = await getDB()
-    await db.delete(STORE_NAME, sessionId)
-  },
-
-  /** 获取所有本地会话列表(按更新时间倒序) */
-  async getAllSessions(): Promise<LocalChatSession[]> {
-    const db = await getDB()
-    const all = await db.getAll(STORE_NAME)
-    return all.sort((a, b) => b.updatedAt - a.updatedAt)
-  },
-
-  /** 获取所有本地缓存的 sessionId 列表 */
-  async getAllSessionIds(): Promise<string[]> {
-    const db = await getDB()
-    return db.getAllKeys(STORE_NAME) as Promise<string[]>
-  },
-
-  /** 清除所有本地会话数据 */
-  async clearAll() {
-    const db = await getDB()
-    await db.clear(STORE_NAME)
-  },
-}

+ 0 - 38
src/layout/header/Actions/AiChat/index.vue

@@ -1,38 +0,0 @@
-<script setup lang="ts">
-import { NButton, NDrawer, NDrawerContent } from 'naive-ui'
-import { ref } from 'vue'
-import ChatPanel from './ChatPanel.vue'
-
-defineOptions({
-  name: 'AiChatAction',
-})
-
-const drawerShow = ref(false)
-const drawerWidth = ref(480)
-</script>
-
-<template>
-  <!-- 顶部栏按钮 -->
-  <NButton text :focusable="false" @click="drawerShow = true">
-    <template #icon>
-      <span class="i-ph:chat-circle-dots text-lg" />
-    </template>
-  </NButton>
-
-  <!-- 侧边抽屉(可拖拽宽度) -->
-  <NDrawer
-    v-model:show="drawerShow"
-    v-model:width="drawerWidth"
-    :min-width="320"
-    :max-width="800"
-    resizable
-    placement="right"
-    :show-mask="false"
-    :mask-closable="false"
-    display-directive="show"
-  >
-    <NDrawerContent closable content-class="!p-0 h-full">
-      <ChatPanel />
-    </NDrawerContent>
-  </NDrawer>
-</template>

+ 0 - 102
src/layout/header/Actions/AiChat/interface.ts

@@ -1,102 +0,0 @@
-export interface ChatMessage {
-  id: string
-  role: 'user' | 'assistant'
-  content: string
-  toolCalls?: ToolCallInfo[]
-  // 按到达顺序交错排列的文本 / 工具调用片段,用于让工具标签散落在文本流中
-  segments?: MessageSegment[]
-  permission?: PermissionRequest
-  files?: FileInfo[]
-  timestamp: number
-}
-
-export interface ToolCallInfo {
-  id: string // toolCallId,用于按 id 关联状态更新与作为渲染 key
-  description: string // 展示用简要描述
-  title?: string // 工具原始 title,作 hover tooltip
-  status: 'running' | 'completed' | 'error'
-}
-
-// 文本片段:一段连续的 AI 文本输出
-export interface TextSegment {
-  kind: 'text'
-  content: string
-}
-
-// 工具调用片段:内联展示的工具调用标签
-export interface ToolSegment {
-  kind: 'tool'
-  tool: ToolCallInfo
-}
-
-// 文件下载片段:内联展示的文件下载按钮(出现在 $$FILE_DOWNLOAD$$ 标记的位置)
-export interface FileSegment {
-  kind: 'file'
-  file: FileInfo
-}
-
-// 消息片段:按到达顺序交错的文本 / 工具调用 / 文件下载
-export type MessageSegment = TextSegment | ToolSegment | FileSegment
-
-export interface PermissionRequest {
-  requestId: string
-  title: string
-  options: PermissionOption[]
-  resolved?: boolean
-}
-
-export interface PermissionOption {
-  optionId: string
-  name: string
-  kind: string
-}
-
-export interface FileInfo {
-  key: string
-  fileName: string
-  size: number
-}
-
-export interface AIChatSession {
-  active: boolean
-  sessionId: string
-  title?: string
-  welcomeMessage?: string
-  lastActive?: string
-}
-
-// ============ 会话历史管理 ============
-
-export interface SessionListItem {
-  sessionId: string
-  title: string
-  createdAt: number
-  lastActive: number
-  isActive: boolean
-}
-
-// IndexedDB 本地存储的会话消息
-export interface LocalChatSession {
-  sessionId: string
-  title: string
-  messages: ChatMessage[]
-  createdAt: number
-  updatedAt: number
-}
-
-// 已上传待发送的文件
-export interface UploadedFile {
-  key: string
-  fileName: string
-  path: string
-  size: number
-}
-
-// AI 文件列表项(MinIO 文件历史)
-export interface AiFileItem {
-  key: string
-  fileName: string
-  size: number
-  createdAt: number // Unix ms
-  expiresAt: number // Unix ms(0 表示不过期)
-}

+ 0 - 916
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -1,916 +0,0 @@
-import { ref, toRaw, isRef, unref } from 'vue'
-import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile, MessageSegment } from './interface'
-import { useUserStore } from '@/stores/user'
-import { useDiscreteApi } from '@/composable/useDiscreteApi'
-import { chatDB } from './chatDB'
-import {
-  getAiSessionList,
-  getCurrentAiSession,
-  createAiSession,
-  switchAiSession,
-  deleteAiSession,
-  cancelAiChat,
-  resolveAiPermission,
-  uploadAiFile,
-  updateAiSessionTitle,
-} from '@/service/aiChat'
-
-// SSE done 事件数据
-export interface SessionLoadDoneInfo {
-  totalMessages: number
-  totalBytes: number
-  suggestNewSession: boolean
-}
-
-// 文件下载标记:$$FILE_DOWNLOAD:{...}$$(模块作用域,避免每次调用重新编译)
-const FILE_MARKER_RE = /\$\$FILE_DOWNLOAD:([\s\S]*?)\$\$/g
-
-// 用户消息中的上下文前缀:[context] userId=xxx, sessionId=xxx\n\n(模块作用域,避免每次调用重新编译)
-// 用 [^\n]* 精确限定 userId/sessionId 值(不含换行),避免贪婪 .* 越过分隔的 \n\n。
-const CONTEXT_PREFIX_RE = /^\[context\] userId=[^\n]*, sessionId=[^\n]*\n\n/
-
-// 全局消息提示(模块作用域,避免每次调用重建 discrete api 实例)
-const { message } = useDiscreteApi()
-
-// 解析出的行内文件片段(用于内联下载按钮)
-interface ParsedFileChunk {
-  kind: 'file'
-  file: { key: string, fileName: string, size: number }
-}
-// 解析出的文本片段
-interface ParsedTextChunk {
-  kind: 'text'
-  content: string
-}
-type ParsedChunk = ParsedTextChunk | ParsedFileChunk
-
-// 深度普通化:把 Vue 响应式对象(含所有嵌套层级)递归还原为纯 JS 对象,
-// 使其可被 structured clone(IndexedDB.put)。相比 toRaw 只解一层、
-// 或 JSON 往返会丢失共享引用,这里用 WeakMap 记录已拷贝对象,
-// 保证如 tool 在 toolCalls 与 segments 中的“共享同一引用”在拷贝后依然成立,
-// 从而读取会话、Vue 重新响应式化后,tool 状态同步语义得以保留。
-// 同时兼容循环引用。
-function deepPlain<T>(input: T, seen: WeakMap<object, any> = new WeakMap()): T {
-  const raw: any = toRaw(isRef(input) ? unref(input) : input)
-  if (raw === null || typeof raw !== 'object') return raw as T
-  if (seen.has(raw)) return seen.get(raw)
-
-  const out: any = Array.isArray(raw) ? [] : {}
-  seen.set(raw, out) // 先登记再递归,命中共享/循环引用时复用同一份拷贝
-  for (const key of Object.keys(raw)) {
-    out[key] = deepPlain(raw[key], seen)
-  }
-  return out as T
-}
-
-// 按 $$FILE_DOWNLOAD$$ 标记把文本切分为「文本 / 文件」有序片段。
-// 非法/残缺标记会被丢弃(既不产生文件片段,也不保留标记文本)。
-function splitByFileMarker(text: string): ParsedChunk[] {
-  const chunks: ParsedChunk[] = []
-  let lastIndex = 0
-  for (const m of text.matchAll(FILE_MARKER_RE)) {
-    const idx = m.index ?? 0
-    const before = text.slice(lastIndex, idx)
-    if (before) chunks.push({ kind: 'text', content: before })
-    try {
-      const fileData = JSON.parse(m[1])
-      chunks.push({ kind: 'file', file: { key: fileData.key, fileName: fileData.fileName, size: fileData.size } })
-    }
-    catch { /* ignore malformed / partial marker */ }
-    lastIndex = idx + m[0].length
-  }
-  const rest = text.slice(lastIndex)
-  if (rest) chunks.push({ kind: 'text', content: rest })
-  return chunks
-}
-
-export function useAiChat() {
-  const messages = ref<ChatMessage[]>([])
-  const isStreaming = ref(false)
-  const currentSessionId = ref('')
-  const welcomeMessage = ref('')
-  const sessionList = ref<SessionListItem[]>([])
-  const pendingFiles = ref<UploadedFile[]>([])
-  const isUploading = ref(false)
-  const isLoadingSession = ref(false)
-  const sessionLoadDoneInfo = ref<SessionLoadDoneInfo | null>(null)
-
-  // 实际生效的 agent / model 与上下文用量(百分比 0~100)
-  const currentAgent = ref('')
-  const currentModel = ref('')
-  const contextUsage = ref(0)
-  const turnDuration = ref(0) // 上一轮耗时(毫秒)
-
-  const userStore = useUserStore()
-
-  function getBaseUrl(): string {
-    const base = import.meta.env.BASE_URL || '/'
-    return `${base}api`
-  }
-
-  // ============ 会话列表管理 ============
-
-  // 获取会话列表
-  async function fetchSessionList() {
-    try {
-      const data = await getAiSessionList()
-      if (data) {
-        sessionList.value = data
-
-        // 清理本地缓存中不存在于后端列表的会话
-        const serverIds = new Set(data.map((s: any) => s.sessionId))
-        const localIds = await chatDB.getAllSessionIds()
-        for (const localId of localIds) {
-          if (!serverIds.has(localId)) {
-            await chatDB.deleteSession(localId)
-          }
-        }
-      }
-    }
-    catch {
-      // 忽略
-    }
-  }
-
-  // 获取当前会话信息(首次打开时调用)
-  async function fetchSession() {
-    isLoadingSession.value = true
-    try {
-      // 先查询会话列表
-      const list = await getAiSessionList()
-      if (list) {
-        sessionList.value = list
-      }
-
-      // 如果存在已激活会话,直接使用
-      const activeSession = list?.find(s => s.isActive)
-      if (activeSession) {
-        currentSessionId.value = activeSession.sessionId
-        // 从 IndexedDB 恢复本地消息
-        await loadLocalMessages(activeSession.sessionId)
-        // 获取当前会话的 agent/model/用量(首屏展示)
-        try {
-          const info = await getCurrentAiSession()
-          currentAgent.value = info?.agentId || ''
-          currentModel.value = info?.modelId || ''
-          contextUsage.value = info?.contextUsagePercent || 0
-          turnDuration.value = info?.turnDurationMs || 0
-        }
-        catch { /* 忽略 */ }
-        return
-      }
-
-      // 没有激活会话但列表中有历史会话:加载最近的一个
-      if (list && list.length > 0) {
-        const latest = list[0]
-        currentSessionId.value = latest.sessionId
-        await loadLocalMessages(latest.sessionId)
-        // 后台恢复 Kiro 上下文
-        const data = await switchAiSession(latest.sessionId)
-        currentAgent.value = data?.agentId || ''
-        currentModel.value = data?.modelId || ''
-        await fetchSessionList()
-        return
-      }
-
-      // 列表完全为空:创建新会话
-      await createNewSession()
-    }
-    catch {
-      // 忽略
-    }
-    finally {
-      isLoadingSession.value = false
-    }
-  }
-
-  // 创建新会话
-  async function createNewSession() {
-    isLoadingSession.value = true
-    try {
-      const data = await createAiSession()
-
-      // 后端未返回有效 sessionId:视为创建失败,明确提示,不静默停留在旧会话
-      if (!data?.sessionId) {
-        message.error('创建新会话失败,请稍后重试')
-        return
-      }
-
-      // 保存当前会话消息到 IndexedDB
-      if (currentSessionId.value && messages.value.length > 0) {
-        await saveLocalMessages()
-      }
-
-      // 切换到新会话(先落地本地状态,保证 UI 一定切过去)
-      currentSessionId.value = data.sessionId
-      messages.value = []
-      welcomeMessage.value = data.welcomeMessage || '欢迎使用AI助手'
-      currentAgent.value = data.agentId || ''
-      currentModel.value = data.modelId || ''
-      contextUsage.value = 0
-      turnDuration.value = 0
-
-      // 刷新列表:新会话作为活跃会话由后端返回(IsActive=true),需要重新拉取才能在侧边栏正确展示/高亮。
-      // 列表刷新失败不影响已完成的切换,仅退化为侧边栏暂不显示,下次进入会自动纠正。
-      try {
-        await fetchSessionList()
-        // 校验新会话确实出现在列表中,未出现给出提示(便于定位后端归档/列表问题)
-        if (!sessionList.value.some(s => s.sessionId === data.sessionId)) {
-          message.warning('新会话已创建,但暂未出现在历史列表中')
-        }
-      }
-      catch {
-        message.warning('新会话已创建,历史列表刷新失败')
-      }
-    }
-    catch (err: any) {
-      message.error(`创建新会话失败:${err?.message || '未知错误'}`)
-    }
-    finally {
-      isLoadingSession.value = false
-    }
-  }
-
-  // 加载历史会话
-  async function loadSession(sessionId: string) {
-    if (sessionId === currentSessionId.value) return
-    isLoadingSession.value = true
-    sessionLoadDoneInfo.value = null
-
-    try {
-      // 保存当前会话消息到 IndexedDB
-      if (currentSessionId.value && messages.value.length > 0) {
-        await saveLocalMessages()
-      }
-
-      // 切换会话前重置上下文用量
-      contextUsage.value = 0
-      turnDuration.value = 0
-
-      // 先尝试从 IndexedDB 缓存加载
-      const cached = await chatDB.getSession(sessionId)
-      if (cached && cached.messages.length > 0) {
-        currentSessionId.value = sessionId
-        messages.value = cached.messages
-        welcomeMessage.value = ''
-
-        // 调用 switchAiSession 恢复 Kiro 上下文
-        const data = await switchAiSession(sessionId)
-        currentAgent.value = data?.agentId || ''
-        currentModel.value = data?.modelId || ''
-        await fetchSessionList()
-      }
-      else {
-        // 无缓存:先切换会话恢复上下文
-        const data = await switchAiSession(sessionId)
-        if (data?.sessionId) {
-          currentSessionId.value = data.sessionId
-          welcomeMessage.value = ''
-          currentAgent.value = data.agentId || ''
-          currentModel.value = data.modelId || ''
-          messages.value = []
-
-          // 通过 SSE 流式加载回放消息
-          await streamLoadReplayMessages(sessionId)
-
-          await fetchSessionList()
-        }
-      }
-    }
-    catch {
-      // 忽略
-    }
-    finally {
-      isLoadingSession.value = false
-    }
-  }
-
-  // SSE 流式加载回放消息
-  async function streamLoadReplayMessages(sessionId: string): Promise<void> {
-    const url = `${getBaseUrl()}/ai/sessions/${sessionId}/load`
-
-    const response = await fetch(url, {
-      method: 'GET',
-      headers: {
-        Authorization: `Bearer ${userStore.token}`,
-      },
-    })
-
-    if (!response.ok) {
-      throw new Error(`SSE 加载失败: ${response.status}`)
-    }
-
-    const reader = response.body?.getReader()
-    if (!reader) {
-      throw new Error('无法读取响应流')
-    }
-
-    const decoder = new TextDecoder()
-    let buffer = ''
-    let messageIndex = 0
-
-    while (true) {
-      const { done, value } = await reader.read()
-      if (done) break
-
-      buffer += decoder.decode(value, { stream: true })
-
-      // 按 \n\n 分割 SSE 事件
-      const parts = buffer.split('\n\n')
-      buffer = parts.pop() || ''
-
-      for (const part of parts) {
-        if (!part.trim()) continue
-
-        const lines = part.split('\n')
-        let eventType = ''
-        let data = ''
-
-        for (const line of lines) {
-          if (line.startsWith('event: ')) {
-            eventType = line.slice(7)
-          } else if (line.startsWith('data: ')) {
-            data = line.slice(6)
-          }
-        }
-
-        if (!data) continue
-
-        try {
-          const payload = JSON.parse(data)
-
-          if (eventType === 'message') {
-            // 逐条添加回放消息
-            const chatMsg: ChatMessage = {
-              id: payload.messageId || `replay_${messageIndex}`,
-              role: payload.role,
-              content: stripContextPrefix(payload.content, payload.role),
-              timestamp: Date.now() - 1000 + messageIndex,
-            }
-            // 解析历史消息中的下载标记,生成内联下载按钮
-            hydrateReplayMessage(chatMsg)
-            messages.value.push(chatMsg)
-            messageIndex++
-          }
-          else if (eventType === 'done') {
-            // done 事件:保存统计信息,用于弹窗判断
-            sessionLoadDoneInfo.value = {
-              totalMessages: payload.totalMessages,
-              totalBytes: payload.totalBytes,
-              suggestNewSession: payload.suggestNewSession,
-            }
-            // 保存到 IndexedDB 缓存
-            if (messages.value.length > 0) {
-              await saveLocalMessages()
-            }
-          }
-        }
-        catch {
-          // 非 JSON 数据忽略
-        }
-      }
-    }
-
-    // 处理剩余 buffer
-    if (buffer.trim()) {
-      const lines = buffer.split('\n')
-      let eventType = ''
-      let data = ''
-
-      for (const line of lines) {
-        if (line.startsWith('event: ')) {
-          eventType = line.slice(7)
-        } else if (line.startsWith('data: ')) {
-          data = line.slice(6)
-        }
-      }
-
-      if (data) {
-        try {
-          const payload = JSON.parse(data)
-          if (eventType === 'message') {
-            const chatMsg: ChatMessage = {
-              id: payload.messageId || `replay_${messageIndex}`,
-              role: payload.role,
-              content: stripContextPrefix(payload.content, payload.role),
-              timestamp: Date.now() - 1000 + messageIndex,
-            }
-            hydrateReplayMessage(chatMsg)
-            messages.value.push(chatMsg)
-          }
-          else if (eventType === 'done') {
-            sessionLoadDoneInfo.value = {
-              totalMessages: payload.totalMessages,
-              totalBytes: payload.totalBytes,
-              suggestNewSession: payload.suggestNewSession,
-            }
-            if (messages.value.length > 0) {
-              await saveLocalMessages()
-            }
-          }
-        }
-        catch {
-          // ignore
-        }
-      }
-    }
-  }
-
-  // 删除会话
-  async function deleteSession(sessionId: string) {
-    try {
-      await deleteAiSession(sessionId)
-      // 清除 IndexedDB 中的消息
-      await chatDB.deleteSession(sessionId)
-      // 如果删的是当前会话,清空消息
-      if (sessionId === currentSessionId.value) {
-        messages.value = []
-        currentSessionId.value = ''
-      }
-      // 刷新列表
-      await fetchSessionList()
-
-      // 如果没有任何会话了,自动创建新会话
-      if (sessionList.value.length === 0) {
-        await createNewSession()
-      }
-    }
-    catch {
-      // 忽略
-    }
-  }
-
-  // 重命名会话
-  async function renameSession(sessionId: string, title: string) {
-    try {
-      await updateAiSessionTitle(sessionId, title)
-      // 更新本地列表中的标题
-      const item = sessionList.value.find(s => s.sessionId === sessionId)
-      if (item) {
-        item.title = title
-      }
-    }
-    catch {
-      // 忽略
-    }
-  }
-
-  // ============ IndexedDB 本地消息管理 ============
-
-  async function saveLocalMessages() {
-    if (!currentSessionId.value || messages.value.length === 0) return
-    const title = getSessionTitle()
-    // 深度普通化:递归解响应式并保留共享引用,避免 IndexedDB 结构化克隆失败
-    await chatDB.saveSession(currentSessionId.value, title, deepPlain(messages.value))
-  }
-
-  async function loadLocalMessages(sessionId: string) {
-    const session = await chatDB.getSession(sessionId)
-    if (session) {
-      messages.value = session.messages
-    } else {
-      messages.value = []
-    }
-  }
-
-  function getSessionTitle(): string {
-    const firstUserMsg = messages.value.find((m) => m.role === 'user')
-    if (firstUserMsg) {
-      const text = firstUserMsg.content
-      return text.length > 30 ? `${text.slice(0, 30)}...` : text
-    }
-    return '新对话'
-  }
-
-  // ============ 文件上传 ============
-
-  async function handleUploadFile(file: File) {
-    // 限制数量
-    if (pendingFiles.value.length >= 5) {
-      return
-    }
-    // 限制大小
-    if (file.size > 2 * 1024 * 1024) {
-      return
-    }
-
-    isUploading.value = true
-    try {
-      const result = await uploadAiFile(file)
-      if (result) {
-        pendingFiles.value.push({
-          key: result.key,
-          fileName: result.fileName,
-          path: result.path,
-          size: result.size,
-        })
-      }
-    }
-    catch {
-      // 忽略
-    }
-    finally {
-      isUploading.value = false
-    }
-  }
-
-  function removePendingFile(index: number) {
-    pendingFiles.value.splice(index, 1)
-  }
-
-  // ============ 消息发送 ============
-
-  function generateId(): string {
-    return `${Date.now()}-${Math.random().toString(36).slice(2)}`
-  }
-
-  async function sendMessage(content: string) {
-    if (!content.trim() || isStreaming.value) return
-
-    // 构建实际发送的消息(包含文件上下文)
-    let actualMessage = content.trim()
-    // 记录本次随消息发送的附件,用于在用户气泡中展示
-    const attachedFiles = pendingFiles.value.map(f => ({
-      key: f.key,
-      fileName: f.fileName,
-      size: f.size,
-    }))
-    if (pendingFiles.value.length > 0) {
-      const filePaths = pendingFiles.value.map(f => f.path).join(', ')
-      actualMessage = `[用户上传了文件: ${filePaths},请使用 readTextFile 工具读取文件内容后进行分析]\n\n${actualMessage}`
-      // 清空待发送文件
-      pendingFiles.value = []
-    }
-
-    // 添加用户消息(显示原始内容,不含注入的文件提示;附带展示上传的附件)
-    messages.value.push({
-      id: generateId(),
-      role: 'user',
-      content: content.trim(),
-      files: attachedFiles.length > 0 ? attachedFiles : undefined,
-      timestamp: Date.now(),
-    })
-
-    // 添加 AI 消息占位
-    messages.value.push({
-      id: generateId(),
-      role: 'assistant',
-      content: '',
-      toolCalls: [],
-      segments: [],
-      timestamp: Date.now(),
-    })
-    const aiMessage: ChatMessage = messages.value.at(-1)!
-
-    isStreaming.value = true
-
-    try {
-      const response = await fetch(`${getBaseUrl()}/ai/chat`, {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-          Authorization: `Bearer ${userStore.token}`,
-        },
-        body: JSON.stringify({
-          message: actualMessage,
-          sessionId: currentSessionId.value,
-        }),
-      })
-
-      if (!response.ok) {
-        aiMessage.content = `错误: ${response.status} ${response.statusText}`
-        isStreaming.value = false
-        return
-      }
-
-      const reader = response.body?.getReader()
-      if (!reader) {
-        aiMessage.content = '错误: 无法读取响应流'
-        isStreaming.value = false
-        return
-      }
-
-      const decoder = new TextDecoder()
-      let buffer = ''
-
-      while (true) {
-        const { done, value } = await reader.read()
-        if (done) break
-
-        buffer += decoder.decode(value, { stream: true })
-
-        // 按 \n\n 分割 SSE 事件
-        const parts = buffer.split('\n\n')
-        buffer = parts.pop() || ''
-
-        for (const part of parts) {
-          if (!part.trim()) continue
-          parseSSEEvent(part, aiMessage)
-        }
-      }
-
-      // 处理剩余 buffer
-      if (buffer.trim()) {
-        parseSSEEvent(buffer, aiMessage)
-      }
-    } catch (err: any) {
-      const errText = `\n\n[连接错误: ${err.message || '未知错误'}]`
-      aiMessage.content += errText
-      // 同步写入 segments,确保 segments 渲染分支下也能显示错误
-      if (!aiMessage.segments) aiMessage.segments = []
-      const last = aiMessage.segments.at(-1)
-      if (last && last.kind === 'text') {
-        last.content += errText
-      }
-      else {
-        aiMessage.segments.push({ kind: 'text', content: errText })
-      }
-    } finally {
-      isStreaming.value = false
-      // 每次消息完成后保存到 IndexedDB
-      await saveLocalMessages()
-    }
-  }
-
-  // 将一段文本追加到 segments:末尾是文本片段则合并,否则新建文本片段。
-  // file/tool 片段会“打断”文本合并,从而让后续文本另起片段,保持先后顺序。
-  function appendTextSegment(aiMessage: ChatMessage, text: string) {
-    if (!text) return
-    if (!aiMessage.segments) aiMessage.segments = []
-    const last = aiMessage.segments.at(-1)
-    if (last && last.kind === 'text') {
-      last.content += text
-    }
-    else {
-      aiMessage.segments.push({ kind: 'text', content: text })
-    }
-  }
-
-  function parseSSEEvent(raw: string, aiMessage: ChatMessage) {
-    const lines = raw.split('\n')
-    let eventType = ''
-    let data = ''
-
-    for (const line of lines) {
-      if (line.startsWith('event: ')) {
-        eventType = line.slice(7)
-      } else if (line.startsWith('data: ')) {
-        data = line.slice(6)
-      }
-    }
-
-    if (!data) return
-
-    try {
-      const payload = JSON.parse(data)
-
-      switch (eventType || payload.type) {
-        case 'text': {
-          const text = payload.content || ''
-          // 按 $$FILE_DOWNLOAD:{...}$$ 标记位置切分文本,保持「文本 / 文件按钮」的先后顺序
-          if (!aiMessage.segments) aiMessage.segments = []
-          let cleanText = ''
-          for (const chunk of splitByFileMarker(text)) {
-            if (chunk.kind === 'text') {
-              cleanText += chunk.content
-              appendTextSegment(aiMessage, chunk.content)
-            }
-            else {
-              // 内联插入 file segment,同时写入 files 供旧渲染回退(按 key 去重)
-              if (!aiMessage.files) aiMessage.files = []
-              if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
-              aiMessage.segments.push({ kind: 'file', file: chunk.file })
-            }
-          }
-          aiMessage.content += cleanText
-          break
-        }
-        case 'tool_call':
-          if (!aiMessage.toolCalls) aiMessage.toolCalls = []
-          {
-            // 工具对象在 toolCalls 与 segments 中共享同一引用,
-            // 以便 tool_update / done 更新状态时两处同步生效
-            const tool: ToolCallInfo = {
-              id: payload.toolCallId || '',
-              description: payload.description || '调用工具',
-              title: payload.name || '',
-              status: 'running',
-            }
-            aiMessage.toolCalls.push(tool)
-            if (!aiMessage.segments) aiMessage.segments = []
-            aiMessage.segments.push({ kind: 'tool', tool })
-          }
-          break
-        case 'tool_update': {
-          // 按 toolCallId 关联对应的工具调用
-          const tc = aiMessage.toolCalls?.find((t: ToolCallInfo) => t.id === payload.toolCallId)
-          if (tc) {
-            if (payload.status) tc.status = payload.status
-            // update 携带新 title/description 时一并更新
-            if (payload.description) tc.description = payload.description
-            if (payload.name) tc.title = payload.name
-          }
-          break
-        }
-        case 'permission': {
-          const permData =
-            typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
-          aiMessage.permission = {
-            requestId: permData.requestId,
-            title: permData.title,
-            options: permData.options,
-            resolved: false,
-          }
-          break
-        }
-        case 'done':
-          if (payload.sessionId) {
-            currentSessionId.value = payload.sessionId
-          }
-          // turn 结束:将所有仍在 running 的 tool call 标记为完成
-          if (aiMessage.toolCalls) {
-            for (const tc of aiMessage.toolCalls) {
-              if (tc.status === 'running') {
-                tc.status = 'completed'
-              }
-            }
-          }
-          // 最终扫描:处理跨 chunk 的标记
-          extractFileDownloads(aiMessage)
-          break
-        case 'welcome':
-          welcomeMessage.value = payload.content || ''
-          break
-        case 'session_info': {
-          // 实际生效的 agent / model
-          try {
-            const info =
-              typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
-            if (info?.agentId) currentAgent.value = info.agentId
-            if (info?.modelId) currentModel.value = info.modelId
-          }
-          catch { /* ignore */ }
-          break
-        }
-        case 'metadata': {
-          // 上下文用量(content 是嵌套 JSON 字符串)
-          try {
-            const meta =
-              typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
-            if (typeof meta?.contextUsagePercent === 'number') {
-              contextUsage.value = meta.contextUsagePercent
-            }
-            if (typeof meta?.turnDurationMs === 'number' && meta.turnDurationMs > 0) {
-              turnDuration.value = meta.turnDurationMs
-            }
-          }
-          catch { /* ignore */ }
-          break
-        }
-        case 'error': {
-          const errText = `\n\n[错误: ${payload.content || '未知'}]`
-          aiMessage.content += errText
-          // 同步写入 segments:当本轮已产生 tool/text/file 片段时,渲染走 segments 分支,
-          // 若不同步写入,错误信息将不会显示(只存在于 content 中)。
-          appendTextSegment(aiMessage, errText)
-          break
-        }
-      }
-    } catch {
-      // 非 JSON 数据忽略
-    }
-  }
-
-  // 去除回放消息中注入的 [context] 前缀
-  function stripContextPrefix(content: string, role: string): string {
-    if (role !== 'user') return content
-    // 匹配 "[context] userId=xxx, sessionId=xxx\n\n" 前缀
-    return content.replace(CONTEXT_PREFIX_RE, '')
-  }
-
-  // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记。
-  // 1) 从完整 content 提取所有文件,去重补进 files(保证按钮至少能显示);
-  // 2) 归一化 segments:合并相邻文本片段后按标记切分,重建内联的 file 片段,
-  //    同时保留 tool / 已有 file 片段的原始顺序作为锚点。
-  function extractFileDownloads(aiMessage: ChatMessage) {
-    // —— 1) content 层:提取 + 去重补进 files,并清除标记文本 ——
-    for (const chunk of splitByFileMarker(aiMessage.content)) {
-      if (chunk.kind !== 'file') continue
-      if (!aiMessage.files) aiMessage.files = []
-      if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
-    }
-    aiMessage.content = aiMessage.content.replace(FILE_MARKER_RE, '').trim()
-
-    // —— 2) segments 层:归一化重建 ——
-    if (!aiMessage.segments || aiMessage.segments.length === 0) return
-
-    const rebuilt: MessageSegment[] = []
-    let textBuf = ''
-
-    // 把累积的文本按标记切分后刷入 rebuilt(文本、内联 file 交错)
-    const flushText = () => {
-      if (!textBuf) return
-      for (const chunk of splitByFileMarker(textBuf)) {
-        rebuilt.push(chunk)
-      }
-      textBuf = ''
-    }
-
-    for (const seg of aiMessage.segments) {
-      if (seg.kind === 'text') {
-        // 累积相邻文本,使跨片段的标记残片能拼接成完整标记
-        textBuf += seg.content
-      }
-      else {
-        // tool / 已有 file 片段:作为锚点,刷出前面的文本后原样保留
-        flushText()
-        rebuilt.push(seg)
-      }
-    }
-    flushText()
-
-    // 去除重复的 file 片段(同一 key 只保留首次出现)
-    const seenKeys = new Set<string>()
-    aiMessage.segments = rebuilt.filter((seg) => {
-      if (seg.kind !== 'file') return true
-      if (seenKeys.has(seg.file.key)) return false
-      seenKeys.add(seg.file.key)
-      return true
-    })
-  }
-
-  // 会话回放:为一条 assistant 历史消息解析 $$FILE_DOWNLOAD$$ 标记,
-  // 生成内联 file 片段(复用 extractFileDownloads 的归一化逻辑)。
-  // 历史消息文本一次到位,先整体作为一个文本片段,再交给归一化切分。
-  function hydrateReplayMessage(chatMsg: ChatMessage) {
-    if (chatMsg.role !== 'assistant' || !chatMsg.content) return
-    chatMsg.segments = [{ kind: 'text', content: chatMsg.content }]
-    extractFileDownloads(chatMsg)
-  }
-
-  async function resolvePermission(optionId: string) {
-    try {
-      // 记住当前要 resolve 的 requestId,防止 await 期间被新 permission 覆盖后误标记
-      const lastMsg = messages.value.at(-1)
-      const targetRequestId = lastMsg?.permission?.requestId
-      await resolveAiPermission(optionId)
-      // await 回来后检查 permission 是否仍是同一个(未被新事件覆盖)
-      if (lastMsg?.permission && lastMsg.permission.requestId === targetRequestId) {
-        lastMsg.permission.resolved = true
-      }
-    }
-    catch {
-      // 忽略
-    }
-  }
-
-  async function cancelChat() {
-    try {
-      await cancelAiChat()
-    }
-    catch {
-      // 忽略
-    }
-  }
-
-  function clearMessages() {
-    messages.value = []
-    if (currentSessionId.value) {
-      chatDB.deleteSession(currentSessionId.value)
-    }
-  }
-
-  // 清除弹窗信息(用户做出选择后)
-  function dismissSessionLoadInfo() {
-    sessionLoadDoneInfo.value = null
-  }
-
-  return {
-    messages,
-    isStreaming,
-    currentSessionId,
-    welcomeMessage,
-    sessionList,
-    isLoadingSession,
-    pendingFiles,
-    isUploading,
-    sessionLoadDoneInfo,
-    currentAgent,
-    currentModel,
-    contextUsage,
-    turnDuration,
-    sendMessage,
-    cancelChat,
-    clearMessages,
-    resolvePermission,
-    fetchSession,
-    fetchSessionList,
-    createNewSession,
-    loadSession,
-    deleteSession,
-    handleUploadFile,
-    removePendingFile,
-    renameSession,
-    dismissSessionLoadInfo,
-  }
-}

+ 0 - 2
src/layout/header/Actions/index.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { ButtonAnimation } from '@/components'
 
-import AiChatAction from './AiChat/index.vue'
 import ConfigureDrawer from './component/ConfigureDrawer.vue'
 import FullScreen from './component/FullScreen.vue'
 import Signout from './component/SignOut.vue'
@@ -13,7 +12,6 @@ defineOptions({
 </script>
 <template>
   <div class="flex items-center gap-x-3">
-    <AiChatAction />
     <!-- <ButtonAnimation :animation="false" tag="a" href="https://github.com/tenianon/lithe-admin" target="_blank">
       <span class="i-mdi:github size-5" />
     </ButtonAnimation> -->

+ 0 - 102
src/service/aiChat/index.ts

@@ -1,102 +0,0 @@
-// API List
-// /ai/chat              - POST  发送消息(SSE 流式响应)
-// /ai/sessions          - GET   获取当前会话信息
-// /ai/sessions/list     - GET   获取会话历史列表
-// /ai/sessions/new      - POST  创建新会话
-// /ai/sessions/:id      - POST  切换会话(恢复 AI 上下文,轻量响应)
-// /ai/sessions/:id/load - GET   流式加载回放消息(SSE)
-// /ai/sessions/:id      - PATCH 更新会话标题
-// /ai/sessions/:id      - DELETE 删除会话
-// /ai/cancel            - POST  取消当前操作
-// /ai/permission        - POST  回复权限请求
-// /ai/files/upload      - POST  上传文件
-// /ai/files/download    - GET   下载文件
-// /ai/files/list        - GET   获取文件列表
-
-import http from '@/service/serverReq'
-import type { SessionListItem, AiFileItem } from '@/layout/header/Actions/AiChat/interface'
-
-const API = {
-  chat: '/ai/chat',
-  sessionList: '/ai/sessions/list',
-  sessionCurrent: '/ai/sessions/current',
-  sessionNew: '/ai/sessions/new',
-  sessionSwitch: (id: string) => `/ai/sessions/${id}`,
-  sessionLoad: (id: string) => `/ai/sessions/${id}/load`,
-  sessionUpdate: (id: string) => `/ai/sessions/${id}`,
-  sessionDelete: (id: string) => `/ai/sessions/${id}`,
-  cancel: '/ai/cancel',
-  permission: '/ai/permission',
-  fileUpload: '/ai/files/upload',
-  fileDownload: '/ai/files/download',
-  fileList: '/ai/files/list',
-}
-
-// GET 获取会话历史列表
-export const getAiSessionList = async (): Promise<SessionListItem[]> => {
-  return await http.get<SessionListItem[]>(API.sessionList)
-}
-
-// GET 获取当前活跃会话信息(agent/model/用量)
-export const getCurrentAiSession = async (): Promise<{
-  sessionId?: string
-  title?: string
-  agentId?: string
-  modelId?: string
-  contextUsagePercent?: number
-  turnDurationMs?: number
-}> => {
-  return await http.get(API.sessionCurrent)
-}
-
-// POST 创建新会话
-export const createAiSession = async (): Promise<{ sessionId: string, welcomeMessage?: string, agentId?: string, modelId?: string }> => {
-  return await http.post(API.sessionNew)
-}
-
-// POST 切换会话(恢复 AI 上下文,轻量响应)
-export const switchAiSession = async (sessionId: string): Promise<{ sessionId: string, title?: string, welcomeMessage?: string, agentId?: string, modelId?: string }> => {
-  return await http.post(API.sessionSwitch(sessionId))
-}
-
-// PATCH 更新会话标题
-export const updateAiSessionTitle = async (sessionId: string, title: string) => {
-  return await http.request(API.sessionUpdate(sessionId), 'PATCH', { data: { title } })
-}
-
-// DELETE 删除会话
-export const deleteAiSession = async (sessionId: string) => {
-  return await http.delete(API.sessionDelete(sessionId))
-}
-
-// POST 取消当前操作
-export const cancelAiChat = async () => {
-  return await http.post(API.cancel)
-}
-
-// POST 回复权限请求
-export const resolveAiPermission = async (optionId: string) => {
-  return await http.post(API.permission, { optionId })
-}
-
-// GET 下载文件(流式下载)
-export const downloadAiFile = async (key: string, fileName: string) => {
-  await http.download(API.fileDownload, fileName, {
-    params: { key },
-  })
-}
-
-// POST 上传文件给 AI 分析
-export const uploadAiFile = async (file: File): Promise<{ key: string, fileName: string, path: string, size: number }> => {
-  const formData = new FormData()
-  formData.append('file', file)
-  return await http.post(API.fileUpload, formData)
-}
-
-// GET 获取文件列表(上传/下载)
-export const listAiFiles = async (type: 'upload' | 'download'): Promise<AiFileItem[]> => {
-  return await http.get<AiFileItem[]>(API.fileList, { params: { type } })
-}
-
-// 导出 chat SSE 请求路径(流式请求需在 useAiChat 中直接 fetch)
-export { API as AI_CHAT_API }