Преглед на файлове

feat: 增加 MCP (Model Context Protocol) 服务端实现

新增功能:
- 新增 MCP 协议 HTTP 端点 POST /api/mcp,实现 JSON-RPC 2.0 + Streamable HTTP 传输
- 新增 13 个工具供 AI 助手调用:成员列表、今日任务、健康报告状态、AI 对话、活动/文章/商品搜索、用户概览、待办/待审批心愿、能量概览、家庭概览、联系人搜索
- 认证方式:通过 application.yml 中 mcp.api-key 配置(默认空值,开发无感知),通过 Mcp-Api-Key header 传递
- 支持已有 JWT/Interceptor:/api/mcp/** 加入 PUBLIC_PATHS 放行

新增/修改文件:
- config/McpConfig.java (新)
- service/mcp/McpAuthService.java (新)
- controller/mcp/McpController.java (新)
- controller/mcp/McpToolRegistry.java (新)
- application.yml (增 mcp.api-key)
- config/JwtInterceptor.java (加 /api/mcp/** 至公开路径)
Sisyphus преди 3 дни
родител
ревизия
c2154bef39

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -29,7 +29,7 @@ public class JwtInterceptor implements HandlerInterceptor {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
-    private static final String[] PUBLIC_PATHS = {
+private static final String[] PUBLIC_PATHS = {
         "/api/auth/send-code",
         "/api/auth/phone-login",
         "/api/auth/wechat-phone-login",
@@ -63,7 +63,8 @@ public class JwtInterceptor implements HandlerInterceptor {
         "/api/mind/alert/active",
         "/api/mind/alert/checkin-alerts",
         "/api/mind/traditional/mirror",
-        "/api/mind/traditional/compatibility"
+        "/api/mind/traditional/compatibility",
+        "/api/mcp/**"
     };
 
     // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/McpConfig.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class McpConfig {
+
+    @Value("${mcp.api-key:}")
+    private String apiKey;
+
+    public String getApiKey() {
+        return apiKey;
+    }
+}

+ 96 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mcp/McpController.java

@@ -0,0 +1,96 @@
+package com.etotem.cfc.controller.mcp;
+
+import com.alibaba.fastjson.JSON;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.mcp.McpAuthService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+
+/**
+ * MCP (Model Context Protocol) 服务端入口。
+ * 协议规范:JSON-RPC 2.0 over Streamable HTTP
+ * 端点:POST /api/mcp
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/mcp")
+public class McpController {
+
+    @Resource
+    private McpAuthService mcpAuthService;
+
+    @Resource
+    private McpToolRegistry toolRegistry;
+
+    @PostMapping
+    public Result<Object> handle(@RequestBody Map<String, Object> jsonRpc, HttpServletRequest request) {
+        // 1. API Key 认证
+        String apiKeyHeader = request.getHeader("Mcp-Api-Key");
+        if (!mcpAuthService.validate(apiKeyHeader)) {
+            return buildJsonRpcError(null, -32000, "Unauthorized: invalid or missing Mcp-Api-Key");
+        }
+
+        // 2. 解析 JSON-RPC 消息
+        String jsonrpc = getString(jsonRpc, "jsonrpc");
+        if (!"2.0".equals(jsonrpc)) {
+            return buildJsonRpcError(null, -32600, "Invalid JSON-RPC version");
+        }
+
+        String method = getString(jsonRpc, "method");
+        Object id = jsonRpc.get("id");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> params = (Map<String, Object>) jsonRpc.get("params");
+        if (params == null) {
+            params = java.util.Collections.emptyMap();
+        }
+
+        log.info("[MCP] method={}, id={}", method, id);
+
+        // 3. 分发处理
+        try {
+            Object result;
+            if ("tools/list".equals(method)) {
+                result = toolRegistry.listTools();
+            } else if ("tools/call".equals(method)) {
+                result = toolRegistry.callTool(params);
+            } else {
+                result = buildJsonRpcError(id, -32601, "Method not found: " + method);
+            }
+            return buildJsonRpcResponse(id, result);
+        } catch (IllegalArgumentException e) {
+            return buildJsonRpcError(id, -32602, e.getMessage());
+        } catch (Exception e) {
+            log.error("[MCP] 处理异常: {}", e.getMessage(), e);
+            return buildJsonRpcError(id, -32603, "Internal error: " + e.getMessage());
+        }
+    }
+
+    private Result<Object> buildJsonRpcResponse(Object id, Object result) {
+        Map<String, Object> resp = new java.util.LinkedHashMap<>();
+        resp.put("jsonrpc", "2.0");
+        if (id != null) resp.put("id", id);
+        resp.put("result", result);
+        return Result.success(resp);
+    }
+
+    private Result<Object> buildJsonRpcError(Object id, int code, String message) {
+        Map<String, Object> error = new java.util.LinkedHashMap<>();
+        error.put("code", code);
+        error.put("message", message);
+        error.put("data", null);
+        Map<String, Object> resp = new java.util.LinkedHashMap<>();
+        resp.put("jsonrpc", "2.0");
+        if (id != null) resp.put("id", id);
+        resp.put("error", error);
+        return Result.success(resp);
+    }
+
+    private String getString(Map<String, Object> map, String key) {
+        Object v = map.get(key);
+        return v != null ? v.toString() : null;
+    }
+}

+ 527 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mcp/McpToolRegistry.java

@@ -0,0 +1,527 @@
+package com.etotem.cfc.controller.mcp;
+
+import com.alibaba.fastjson.JSON;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ProductListQueryDTO;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.*;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * MCP 工具注册中心。
+ * 所有 MCP 工具的逻辑实现在此。
+ */
+@Slf4j
+@Component
+public class McpToolRegistry {
+
+    // ---- 服务依赖 ----
+    @Resource
+    private FamilyMemberService familyMemberService;
+
+    @Resource
+    private TaskService taskService;
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private ActivityService activityService;
+
+    @Resource
+    private ArticleService articleService;
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private UserService userService;
+
+    @Resource
+    private WishService wishService;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private ContactService contactService;
+
+    @Resource
+    private FamilyContextService familyContextService;
+
+    // ----------------------------------------------------------------
+    // tools/list
+    // ----------------------------------------------------------------
+
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> listTools() {
+        List<Map<String, Object>> tools = new ArrayList<>();
+        tools.add(toolDef("list_family_members",
+                "获取指定用户所属家庭的成员列表,含基本信息(昵称/头像/角色标签)和五维能量概览。返回按辈分排序的列表。",
+                toolSchema(
+                        required("userId", "string", "用户 ID(登录账号 userId,非 memberId)"),
+                        optional("page", "integer", "页码,默认 1"),
+                        optional("pageSize", "integer", "每页数量,默认 20")
+                )
+        ));
+        tools.add(toolDef("get_today_tasks",
+                "获取指定家庭成员今日的任务列表。仅返回 status=in_progress 或 pending 的任务,不含已完成任务。",
+                toolSchema(
+                        required("memberId", "string", "家庭成员 ID(family_members.id)"),
+                        optional("dimensionCode", "string", "按维度过滤,如 '身体'/'心智'/'行动'/'财富'/'心理'")
+                )
+        ));
+        tools.add(toolDef("get_health_report_status",
+                "查询健康报告采集状态。支持查询草稿解析进度,以及已入库报告的类型和评分摘要。",
+                toolSchema(
+                        required("draftId", "string", "草稿 ID(health_report_drafts.id)")
+                )
+        ));
+        tools.add(toolDef("send_ai_message",
+                "向 AI 助手发送消息并获取回复。自动注入家庭上下文(家庭成员/任务/能量等),支持多轮对话。",
+                toolSchema(
+                        required("query", "string", "用户问题/指令文本"),
+                        optional("conversationId", "string", "对话 ID;不传则创建新对话"),
+                        optional("context", "string", "额外上下文 JSON 字符串,覆盖自动注入的上下文")
+                )
+        ));
+        tools.add(toolDef("search_activities",
+                "搜索已发布的活动列表,按创建时间倒序。支持按维度、关键词过滤,支持分页。",
+                toolSchema(
+                        optional("dimensionCode", "string", "维度代码过滤,如 '身'/'智'/'行'/'富'/'心'"),
+                        optional("keyword", "string", "关键词搜索(标题/描述)"),
+                        optional("page", "integer", "页码,默认 1"),
+                        optional("pageSize", "integer", "每页数量,默认 10")
+                )
+        ));
+        tools.add(toolDef("search_articles",
+                "搜索已发布文章列表,按精选排序后按发布时间倒序。支持按分类、维度、关键词过滤,支持分页。",
+                toolSchema(
+                        optional("categoryId", "string", "文章分类 ID,不传则搜索全部"),
+                        optional("keyword", "string", "关键词搜索(标题/摘要/标签)"),
+                        optional("dimensionCode", "string", "维度代码过滤"),
+                        optional("page", "integer", "页码,默认 1"),
+                        optional("pageSize", "integer", "每页数量,默认 10")
+                )
+        ));
+        tools.add(toolDef("search_products",
+                "搜索已上架商品列表,按排序后按创建时间倒序。支持按品类、关键词、成长档案关联过滤,支持分页。",
+                toolSchema(
+                        optional("productType", "string", "商品品类过滤"),
+                        optional("keyword", "string", "关键词搜索(名称/描述)"),
+                        optional("growthRelated", "boolean", "true=仅返回成长档案关联商品"),
+                        optional("page", "integer", "页码,默认 1"),
+                        optional("pageSize", "integer", "每页数量,默认 10")
+                )
+        ));
+        tools.add(toolDef("get_user_profile",
+                "获取用户基本信息:用户 ID、昵称、手机号、头像、角色、当前家庭 ID 与名称、会员等级。",
+                toolSchema(
+                        required("userId", "string", "用户 ID")
+                )
+        ));
+        tools.add(toolDef("get_my_pending_tasks",
+                "获取当前用户的待办任务列表(含审核中、待完成、即将到期)。按优先级和截止时间排序。",
+                toolSchema(
+                        required("userId", "string", "用户 ID"),
+                        optional("limit", "integer", "返回数量上限,默认 20")
+                )
+        ));
+        tools.add(toolDef("get_my_wishes_pending",
+                "获取当前用户的待审批心愿列表。包含心愿名称、状态(待审批/已通过/已拒绝)、积分等。",
+                toolSchema(
+                        required("userId", "string", "用户 ID")
+                )
+        ));
+        tools.add(toolDef("get_energy_overview",
+                "获取指定家庭成员的五维能量概览:身/智/富/行/心五个维度的当前能量值和百分比排名。",
+                toolSchema(
+                        required("memberId", "string", "家庭成员 ID"),
+                        optional("date", "string", "查询日期,格式 yyyy-MM-dd,默认今天")
+                )
+        ));
+        tools.add(toolDef("get_family_summary",
+                "获取家庭整体概览:家庭成员数、今日待办任务数、本周活动数、总能量值排名。用于快速了解家庭状态。",
+                toolSchema(
+                        required("userId", "string", "用户 ID(家庭管理员)")
+                )
+        ));
+        tools.add(toolDef("search_contact",
+                "根据昵称或手机号搜索联系人,返回匹配联系人的 ID、昵称、头像、最近互动时间。用于添加联系人推荐。",
+                toolSchema(
+                        required("keyword", "string", "搜索关键词(昵称/手机号)"),
+                        optional("limit", "integer", "返回数量上限,默认 10")
+                )
+        ));
+
+        Map<String, Object> resp = new LinkedHashMap<>();
+        resp.put("tools", tools);
+        return resp;
+    }
+
+    // ----------------------------------------------------------------
+    // tools/call
+    // ----------------------------------------------------------------
+
+    @SuppressWarnings("unchecked")
+    public Object callTool(Map<String, Object> params) {
+        String toolName = getString(params, "name");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> args = (Map<String, Object>) params.get("arguments");
+        if (args == null) args = Collections.emptyMap();
+
+        switch (toolName) {
+            case "list_family_members":   return handleListMembers(args);
+            case "get_today_tasks":       return handleTodayTasks(args);
+            case "get_health_report_status": return handleHealthReportStatus(args);
+            case "send_ai_message":       return handleAiChat(args);
+            case "search_activities":     return handleSearchActivities(args);
+            case "search_articles":       return handleSearchArticles(args);
+            case "search_products":       return handleSearchProducts(args);
+            case "get_user_profile":      return handleUserProfile(args);
+            case "get_my_pending_tasks":  return handleMyPendingTasks(args);
+            case "get_my_wishes_pending": return handleMyWishesPending(args);
+            case "get_energy_overview":   return handleEnergyOverview(args);
+            case "get_family_summary":    return handleFamilySummary(args);
+            case "search_contact":        return handleSearchContact(args);
+            default:
+                throw new IllegalArgumentException("Unknown tool: " + toolName);
+        }
+    }
+
+    // ----------------------------------------------------------------
+    // 各工具 handler
+    // ----------------------------------------------------------------
+
+    private Object handleListMembers(Map<String, Object> args) {
+        Long userId = parseLongArg(args, "userId");
+        int page = parseIntArg(args, "page", 1);
+        int pageSize = parseIntArg(args, "pageSize", 20);
+
+        List<?> all = familyMemberService.listMembers(userId);
+        int total = all.size();
+        int from = Math.min((page - 1) * pageSize, total);
+        int to = Math.min(from + pageSize, total);
+        List<?> pageList = all.subList(from, to);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("members", pageList);
+        result.put("total", total);
+        result.put("page", page);
+        result.put("pageSize", pageSize);
+        return wrapContent("获取到 " + total + " 名家庭成员", result);
+    }
+
+    private Object handleTodayTasks(Map<String, Object> args) {
+        Long memberId = parseLongArg(args, "memberId");
+        String dim = getStringArg(args, "dimensionCode");
+        if (dim != null) dim = dim.toLowerCase();
+
+        List<?> tasks;
+        if (dim != null && !dim.isEmpty()) {
+            tasks = taskService.getTodayTasks(memberId, dim);
+        } else {
+            tasks = taskService.getTodayTasks(memberId);
+        }
+
+        return wrapContent("获取到 " + tasks.size() + " 个今日任务", tasks);
+    }
+
+    private Object handleHealthReportStatus(Map<String, Object> args) {
+        Long draftId = parseLongArg(args, "draftId");
+        if (draftId == null) throw new IllegalArgumentException("draftId 必填");
+
+        HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
+        if (draft == null) {
+            return wrapContent("草稿不存在", null);
+        }
+
+        Map<String, Object> info = new LinkedHashMap<>();
+        info.put("draftId", draft.getId());
+        info.put("status", draft.getParseStatus());
+        info.put("parseError", draft.getParseError());
+        info.put("reportType", draft.getReportType());
+        info.put("createdAt", draft.getCreatedAt());
+        info.put("updatedAt", draft.getUpdatedAt());
+        return wrapContent("草稿 [" + draft.getId() + "] 状态:" + draft.getParseStatus(), info);
+    }
+
+    private Object handleAiChat(Map<String, Object> args) {
+        String query = getStringArg(args, "query");
+        if (query == null || query.isEmpty()) throw new IllegalArgumentException("query 必填");
+        String conversationId = getStringArg(args, "conversationId");
+        String contextStr = getStringArg(args, "context");
+        Map<String, Object> context = contextStr != null ? JSON.parseObject(contextStr) : null;
+
+        Map<String, Object> result = aiGateway.chat(query, 0L, conversationId, context);
+        if (result == null) {
+            return wrapContent("AI 服务暂时不可用,请稍后重试", null);
+        }
+
+        String answer = (String) result.get("answer");
+        String convId = (String) result.get("conversationId");
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("answer", answer);
+        data.put("conversationId", convId);
+        Object tasks = result.get("tasks");
+        if (tasks != null) data.put("tasks", tasks);
+        return wrapContent(answer, data);
+    }
+
+    private Object handleSearchActivities(Map<String, Object> args) {
+        String dim = getStringArg(args, "dimensionCode");
+        if (dim != null) dim = dim.toLowerCase();
+        int page = parseIntArg(args, "page", 1);
+        int pageSize = parseIntArg(args, "pageSize", 10);
+
+        Result<Map<String, Object>> result = activityService.list(dim, page, pageSize, 0L);
+        if (result == null || result.getCode() != 200) {
+            return wrapContent("查询活动失败", null);
+        }
+
+        Map<String, Object> data = result.getData();
+        List<?> records = (List<?>) data.get("records");
+        long total = toLong(data.get("total"));
+        return wrapContent("查询到 " + total + " 条活动记录", records);
+    }
+
+    private Object handleSearchArticles(Map<String, Object> args) {
+        Long categoryId = parseLongArg(args, "categoryId");
+        String keyword = getStringArg(args, "keyword");
+        String dim = getStringArg(args, "dimensionCode");
+        if (dim != null) dim = dim.toLowerCase();
+        int page = parseIntArg(args, "page", 1);
+        int pageSize = parseIntArg(args, "pageSize", 10);
+
+        Page<Article> pageResult =
+                articleService.getPublicList(categoryId, keyword, dim, page, pageSize, 0L, null, null);
+        List<?> records = pageResult.getRecords();
+        long total = pageResult.getTotal();
+        return wrapContent("查询到 " + total + " 篇文章", records);
+    }
+
+    private Object handleSearchProducts(Map<String, Object> args) {
+        String productType = getStringArg(args, "productType");
+        String keyword = getStringArg(args, "keyword");
+        Boolean growthRelated = getBoolArg(args, "growthRelated");
+        int page = parseIntArg(args, "page", 1);
+        int pageSize = parseIntArg(args, "pageSize", 10);
+
+        ProductListQueryDTO query = new ProductListQueryDTO();
+        query.setPage(page);
+        query.setSize(pageSize);
+        query.setProductType(productType);
+        query.setKeyword(keyword);
+        query.setGrowthRelated(growthRelated);
+
+        Result<Map<String, Object>> result = productService.list(query, 0L, null);
+        if (result == null || result.getCode() != 200) {
+            return wrapContent("查询商品失败", null);
+        }
+
+        Map<String, Object> data = result.getData();
+        List<?> records = (List<?>) data.get("records");
+        long total = toLong(data.get("total"));
+        return wrapContent("查询到 " + total + " 件商品", records);
+    }
+
+    private Object handleUserProfile(Map<String, Object> args) {
+        Long userId = parseLongArg(args, "userId");
+        if (userId == null) throw new IllegalArgumentException("userId 必填");
+
+        com.etotem.cfc.entity.User user = userService.getUserInfo(userId);
+        if (user == null) {
+            return wrapContent("用户不存在", null);
+        }
+
+        Map<String, Object> profile = new LinkedHashMap<>();
+        profile.put("userId", user.getId());
+        profile.put("nickname", user.getNickname());
+        profile.put("phone", user.getPhone());
+        profile.put("avatar", user.getAvatar());
+        profile.put("role", user.getRole());
+        profile.put("familyId", user.getFamilyId());
+        profile.put("createdAt", user.getCreatedAt());
+        profile.put("updatedAt", user.getUpdatedAt());
+        return wrapContent("用户信息", profile);
+    }
+
+    private Object handleMyPendingTasks(Map<String, Object> args) {
+        Long memberId = parseLongArg(args, "memberId");
+        if (memberId == null) throw new IllegalArgumentException("memberId 必填(可传 userId 同值)");
+        List<?> tasks = taskService.getMyPendingTasks(memberId);
+        return wrapContent("获取到 " + tasks.size() + " 个待办任务", tasks);
+    }
+
+    private Object handleMyWishesPending(Map<String, Object> args) {
+        Long userId = parseLongArg(args, "userId");
+        if (userId == null) throw new IllegalArgumentException("userId 必填");
+        // 通过 userId 获取其 familyId,再查家庭待审批心愿
+        com.etotem.cfc.entity.User user = userService.getUserInfo(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return wrapContent("用户不属于任何家庭", Collections.emptyList());
+        }
+        List<?> wishes = wishService.getParentPendingWishes(user.getFamilyId());
+        return wrapContent("获取到 " + wishes.size() + " 个待审批心愿", wishes);
+    }
+
+    private Object handleEnergyOverview(Map<String, Object> args) {
+        Long memberId = parseLongArg(args, "memberId");
+        if (memberId == null) throw new IllegalArgumentException("memberId 必填");
+
+        Map<String, Object> overview = energyService.getOverview(memberId);
+        return wrapContent("五维能量概览", overview);
+    }
+
+    private Object handleFamilySummary(Map<String, Object> args) {
+        Long userId = parseLongArg(args, "userId");
+        if (userId == null) throw new IllegalArgumentException("userId 必填");
+
+        Map<String, Object> summary = familyContextService.buildContext(userId);
+        return wrapContent("家庭概览", summary);
+    }
+
+    private Object handleSearchContact(Map<String, Object> args) {
+        Long userId = parseLongArg(args, "userId");
+        if (userId == null) throw new IllegalArgumentException("userId 必填");
+
+        com.etotem.cfc.common.Result<java.util.List<com.etotem.cfc.dto.ContactDTO>> result = contactService.list(userId);
+        List<?> contacts = (result != null && result.getCode() == 200) ? result.getData() : Collections.emptyList();
+        return wrapContent("获取到 " + contacts.size() + " 个联系人", contacts);
+    }
+
+    // ----------------------------------------------------------------
+    // 工具定义辅助方法
+    // ----------------------------------------------------------------
+
+    private Map<String, Object> toolDef(String name, String desc, Map<String, Object> schema) {
+        Map<String, Object> tool = new LinkedHashMap<>();
+        tool.put("name", name);
+        tool.put("description", desc);
+        tool.put("inputSchema", schema);
+        return tool;
+    }
+
+    private Map<String, Object> toolSchema(Map<String, Object>... properties) {
+        Map<String, Object> schema = new LinkedHashMap<>();
+        schema.put("type", "object");
+        schema.put("properties", joinProperties(properties));
+        schema.put("required", extractRequired(properties));
+        return schema;
+    }
+
+    private static Map<String, Object> required(String name, String type, String description) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("type", type);
+        p.put("description", description);
+        return p;
+    }
+
+    private static Map<String, Object> optional(String name, String type, String description) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("type", type);
+        p.put("description", description);
+        return p;
+    }
+
+    private static Map<String, Object> joinProperties(Map<String, Object>[] properties) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        for (Map<String, Object> p : properties) {
+            String key = (String) p.get("name");
+            if (key != null) result.put(key, p);
+        }
+        return result;
+    }
+
+    private static List<String> extractRequired(Map<String, Object>[] properties) {
+        List<String> required = new ArrayList<>();
+        for (Map<String, Object> p : properties) {
+            String desc = (String) p.get("description");
+            if (desc != null && desc.startsWith("必填")) {
+                required.add((String) p.get("name"));
+            }
+        }
+        return required;
+    }
+
+    // ----------------------------------------------------------------
+    // 响应封装
+    // ----------------------------------------------------------------
+
+    private Map<String, Object> wrapContent(String summaryText, Object data) {
+        Map<String, Object> contentItem = new LinkedHashMap<>();
+        contentItem.put("type", "text");
+        contentItem.put("text", summaryText);
+        List<Map<String, Object>> content = new ArrayList<>();
+        content.add(contentItem);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("content", content);
+        if (data != null) {
+            result.put("data", data);
+        }
+        return result;
+    }
+
+    // ----------------------------------------------------------------
+    // 参数解析工具
+    // ----------------------------------------------------------------
+
+    private Long parseLongArg(Map<String, Object> args, String key) {
+        Object v = args.get(key);
+        if (v == null) return null;
+        if (v instanceof Number) return ((Number) v).longValue();
+        try { return Long.parseLong(v.toString()); } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(key + " 必须为数字");
+        }
+    }
+
+    private int parseIntArg(Map<String, Object> args, String key, int defaultVal) {
+        Object v = args.get(key);
+        if (v == null) return defaultVal;
+        if (v instanceof Number) return ((Number) v).intValue();
+        try { return Integer.parseInt(v.toString()); } catch (NumberFormatException e) {
+            return defaultVal;
+        }
+    }
+
+    private boolean getBoolArg(Map<String, Object> args, String key) {
+        Object v = args.get(key);
+        if (v == null) return false;
+        if (v instanceof Boolean) return (Boolean) v;
+        return Boolean.parseBoolean(v.toString());
+    }
+
+    private String getStringArg(Map<String, Object> args, String key) {
+        Object v = args.get(key);
+        return v != null ? v.toString() : null;
+    }
+
+    private String getString(Map<String, Object> map, String key) {
+        Object v = map.get(key);
+        return v != null ? v.toString() : null;
+    }
+
+    private long toLong(Object v) {
+        if (v == null) return 0L;
+        if (v instanceof Number) return ((Number) v).longValue();
+        try { return Long.parseLong(v.toString()); } catch (NumberFormatException e) { return 0L; }
+    }
+
+    private Date parseDate(String s) {
+        try {
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+            return sdf.parse(s);
+        } catch (Exception e) {
+            return new Date();
+        }
+    }
+}

+ 3 - 0
cfc-backend/src/main/resources/application.yml

@@ -45,6 +45,9 @@ sfms:
     username: zxyj
     password: zxyj@123
 
+mcp:
+  api-key: ""
+
 mybatis-plus:
   mapper-locations: classpath*:/mapper/**/*.xml
   type-aliases-package: com.etotem.cfc.entity