Browse Source

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

Sisyphus Agent 2 days ago
parent
commit
8c69dbcd2a

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11165,6 +11165,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         addPolicyTermsColumn();
         createHomeBannersTable();
 
+        // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
+        try {
+            jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'",
+                    "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
+            log.info("迁移341: 已为 BASIC 体验会员补充 referral_commission 功能");
+        } catch (Exception e) {
+            log.warn("迁移341: 更新 BASIC features 失败: {}", e.getMessage());
+        }
+
         // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_feedback (" +

+ 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;
+    }
+}

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

@@ -0,0 +1,529 @@
+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("name", name);
+        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("name", name);
+        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();
+        }
+    }
+}

+ 8 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ContentRecommendService.java

@@ -6,6 +6,8 @@ import com.etotem.cfc.dto.RecommendItemVO;
 import com.etotem.cfc.dto.RecommendRequestDTO;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
+
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -34,6 +36,9 @@ public class ContentRecommendService {
     @Resource
     private ArticleReadingRecordMapper articleReadingRecordMapper;
 
+    @Resource
+    private ArticleQuizRecordMapper articleQuizRecordMapper;
+
     @Resource
     private ProductOrderMapper productOrderMapper;
 
@@ -134,12 +139,9 @@ public class ContentRecommendService {
         // 获取孩子已读的文章ID
         Set<Long> readArticleIds = new HashSet<>();
         if (memberId != null) {
-            List<ArticleReadingRecord> records = articleReadingRecordMapper.selectList(
-                    new QueryWrapper<ArticleReadingRecord>()
-                            .eq("child_id", memberId)
-                            .select("DISTINCT article_id"));
-            for (ArticleReadingRecord r : records) {
-                if (r.getArticleId() != null) readArticleIds.add(r.getArticleId());
+            List<Long> completedIds = articleReadingRecordMapper.selectCompletedArticleIdsByChild(memberId);
+            if (completedIds != null) {
+                readArticleIds.addAll(completedIds);
             }
         }
 

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -254,7 +254,8 @@ public class MembershipService implements MembershipServiceInterface {
                     || "assessment_quota_lite".equals(feature)
                     || "health_report_upload_lite".equals(feature)
                     || "growth_plan_lite".equals(feature)
-                    || "task_template_lite".equals(feature);
+                    || "task_template_lite".equals(feature)
+                    || "referral_commission".equals(feature);
         }
 
         // FAMILY用户可用功能

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/mcp/McpAuthService.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.service.mcp;
+
+import com.etotem.cfc.config.McpConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+@Slf4j
+@Service
+public class McpAuthService {
+
+    @Resource
+    private McpConfig mcpConfig;
+
+    public boolean validate(String apiKeyHeader) {
+        if (apiKeyHeader == null || apiKeyHeader.isEmpty()) {
+            log.warn("[MCP] 缺少 Mcp-Api-Key 请求头");
+            return false;
+        }
+        String expected = mcpConfig.getApiKey();
+        if (expected == null || expected.isEmpty()) {
+            log.warn("[MCP] 未配置 mcp.api-key,所有请求被拒绝");
+            return false;
+        }
+        boolean ok = expected.equals(apiKeyHeader);
+        if (!ok) {
+            log.warn("[MCP] Mcp-Api-Key 不匹配");
+        }
+        return ok;
+    }
+}

+ 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

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -5671,7 +5671,7 @@ VALUES (
     '体验会员', 
     '9.9元/月体验版,解锁核心功能,限次使用AI与报告解读',
     990,
-    '["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","assessment_quota_lite","health_report_upload_lite","growth_plan_lite","task_template_lite"]',
+    '["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","assessment_quota_lite","health_report_upload_lite","growth_plan_lite","task_template_lite","referral_commission"]',
     3,
     3
 );

+ 16 - 10
cfc-frontend/pages/membership/index.vue

@@ -80,7 +80,7 @@
 </template>
 
 <script>
-import { getMyMembership, getMyButler, reportAppEnter
+import { getMyMembership, getMyButler, reportAppEnter, getMembershipBenefits
 } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
@@ -125,22 +125,28 @@ export default {
       getMyMembership().then(function(res) {
         var m = res.data || {}
         that.membership = m
-        if (m.levelCode && m.levelCode !== 'FREE') {
+        var levelCode = m.memberLevel || m.levelCode || 'FREE'
+        if (levelCode && levelCode !== 'FREE') {
           that.isFree = false
-          that.currentLevelName = m.levelName || '家庭会员'
-          if (m.levelCode === 'BASIC') {
-            that.currentLevelName = '体验会员'
-          } else if (m.levelCode === 'FAMILY') {
-            that.currentLevelName = '家庭会员'
-          } else if (m.levelCode === 'PREMIUM') {
-            that.currentLevelName = '高级会员'
-          }
+          that.currentLevelName = that.getLevelName(levelCode)
         } else {
           that.isFree = true
           that.currentLevelName = '免费用户'
+          levelCode = 'BASIC'
         }
+        // 动态加载权益功能列表(免费用户展示 BASIC 体验会员功能)
+        getMembershipBenefits(levelCode).then(function(benefitsRes) {
+          var benefits = (benefitsRes && benefitsRes.data) || []
+          if (benefits.length > 0) {
+            that.features = benefits.map(function(b) { return b.benefitName || '' }).filter(function(n) { return n })
+          }
+        }).catch(function() {})
       }).catch(function() {})
     },
+    getLevelName(code) {
+      var map = { FREE: '免费用户', BASIC: '体验会员', FAMILY: '家庭会员', PREMIUM: '高级会员', LIFETIME: '终身会员' }
+      return map[code] || code || ''
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },

+ 1 - 0
cfc-frontend/utils/api.js

@@ -592,6 +592,7 @@ export const getMembershipPlans = () => {
 export const getMyMembership = () => {
 	return request('/api/membership/my', 'POST')
 }
+export const getMembershipBenefits = (levelCode) => request('/api/membership/benefits', 'POST', { levelCode })
 
 export const getGiftMembershipProgress = () => {
 	return request('/api/membership/gift-membership-progress', 'POST')

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d865f5ee5f16286a3d58556f9a5991d55e7d53d0
+5e340d1dd652a30f93ce0c9490da7f14d27d7d25

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1487",
+  "version": "1.0.1488",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 65 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,71 @@
 
 ---
 
+## v1.0.1488 (2026-10-03)
+
+### Bug 修复
+- 补全提交遗漏的 McpAuthService(MCP 认证服务)[skip ci]
+- 家庭挑战功能修复与待同意状态流转实现
+- 阅读计时和知识测评适配所有登录用户
+- HomeBannerManage 上传成功/失败提示 this.. 语法错误修复为 this.$message [skip ci]
+
+### 新功能
+- 增加 MCP (Model Context Protocol) 服务端实现
+- 体验会员补充 referral_commission 权益 + 前端动态加载权益列表
+- 完成阅读且获得CF值后从文章列表中隐藏
+
+### 其他
+- - 新增 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/** 至公开路径)
+- 
+- - MembershipService: BASIC 可用功能加入 referral_commission
+- - schema.sql: membership_levels 种子数据同步
+- - membership/index.vue: 动态加载权益功能列表(免费用户展示 BASIC 体验会员功能)
+- - utils/api.js: 新增 getMembershipBenefits 接口封装
+- - ContentRecommendService: 改用 selectCompletedArticleIdsByChild 查询已读文章(与文章列表过滤逻辑一致)
+- 
+- - 挑战页点击已存在挑战跳转至活动首页(错误URL) → 改为打开详情弹窗
+- - 首页家庭挑战待同意状态卡片无法点击 → 添加整卡@cardClick事件并传递challengeId
+- - 编辑挑战任务无法修改参与成员 → 移除v-if=editMode限制,openEditModal预填充memberIds,submitForm编辑时也传participantMemberIds
+- 功能新增:
+- - 邀请参与人时任务状态为pending,设置回复时效(24/48/72h可选)
+- - 超时未回复的pending成员自动置为rejected
+- - 除发起人外全部拒绝 → 挑战状态变为not_launched(未成功发起)
+- - pending挑战详情页展示截止时间/超时标签
+- 后端:
+- - schema.sql + DatabaseInitializer: 新增respond_deadline字段
+- - FamilyChallenge实体: 新增respondDeadline属性
+- - createChallenge: 支持respondDeadlineHours参数(默认48h)
+- - updateChallenge: 支持pending状态编辑 + participantMemberIds更新(方案2A:active不可改)
+- - respondChallenge: 惰性检查deadline超时
+- - 新增expirePendingChallenge方法处理超时
+- - getChallengeHistory纳入not_launched状态
+- 前端:
+- - challenge-manage.vue: 添加详情弹窗(含同意/拒绝/编辑/删除按钮),添加回复时效选择器(24/48/72h),auto-open通过URL参数?id=xxx
+- - FamilyChallengeCard.vue: 整卡@click+('cardClick'),pending状态展示剩余天数/超时标签
+- - index-home.vue + parent-index.vue: 绑定@cardClick → goChallengeManage(ch.id)
+- 
+- - ArticleService.getPublicList: 新增 memberId 参数,权限过滤后移除已读完+得CF的文章
+- - ArticleController.list: 读取 body.memberId(回退 currentMemberId)并传入 service
+- - article-center/index.vue: 传 memberId 给列表接口,知识分享页隐藏已完成文章
+- - mind-detail/articles.vue: 同上,心智知识页同步过滤
+- - ArticleControllerTest: 适配 getPublicList 新增 memberId 参数
+- 
+- - 匿名用户可浏览文章,读满时长后提示登录后完成知识测评并跳转登录
+- - 答题接口 getActiveMemberId() 优先 currentChildId,否则用 userId
+- - 移除 FREE 会员限制,所有登录用户均可参加文章知识测评
+- 
+
+
 ## v1.0.1487 (2026-10-02)
 
 ### Bug 修复

+ 66 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1487
+> 当前版本: v1.0.1488
 
 ## 历史版本
 
@@ -8,6 +8,71 @@
 
 ---
 
+## v1.0.1488 (2026-10-03)
+
+### Bug 修复
+- 补全提交遗漏的 McpAuthService(MCP 认证服务)[skip ci]
+- 家庭挑战功能修复与待同意状态流转实现
+- 阅读计时和知识测评适配所有登录用户
+- HomeBannerManage 上传成功/失败提示 this.. 语法错误修复为 this.$message [skip ci]
+
+### 新功能
+- 增加 MCP (Model Context Protocol) 服务端实现
+- 体验会员补充 referral_commission 权益 + 前端动态加载权益列表
+- 完成阅读且获得CF值后从文章列表中隐藏
+
+### 其他
+- - 新增 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/** 至公开路径)
+- 
+- - MembershipService: BASIC 可用功能加入 referral_commission
+- - schema.sql: membership_levels 种子数据同步
+- - membership/index.vue: 动态加载权益功能列表(免费用户展示 BASIC 体验会员功能)
+- - utils/api.js: 新增 getMembershipBenefits 接口封装
+- - ContentRecommendService: 改用 selectCompletedArticleIdsByChild 查询已读文章(与文章列表过滤逻辑一致)
+- 
+- - 挑战页点击已存在挑战跳转至活动首页(错误URL) → 改为打开详情弹窗
+- - 首页家庭挑战待同意状态卡片无法点击 → 添加整卡@cardClick事件并传递challengeId
+- - 编辑挑战任务无法修改参与成员 → 移除v-if=editMode限制,openEditModal预填充memberIds,submitForm编辑时也传participantMemberIds
+- 功能新增:
+- - 邀请参与人时任务状态为pending,设置回复时效(24/48/72h可选)
+- - 超时未回复的pending成员自动置为rejected
+- - 除发起人外全部拒绝 → 挑战状态变为not_launched(未成功发起)
+- - pending挑战详情页展示截止时间/超时标签
+- 后端:
+- - schema.sql + DatabaseInitializer: 新增respond_deadline字段
+- - FamilyChallenge实体: 新增respondDeadline属性
+- - createChallenge: 支持respondDeadlineHours参数(默认48h)
+- - updateChallenge: 支持pending状态编辑 + participantMemberIds更新(方案2A:active不可改)
+- - respondChallenge: 惰性检查deadline超时
+- - 新增expirePendingChallenge方法处理超时
+- - getChallengeHistory纳入not_launched状态
+- 前端:
+- - challenge-manage.vue: 添加详情弹窗(含同意/拒绝/编辑/删除按钮),添加回复时效选择器(24/48/72h),auto-open通过URL参数?id=xxx
+- - FamilyChallengeCard.vue: 整卡@click+('cardClick'),pending状态展示剩余天数/超时标签
+- - index-home.vue + parent-index.vue: 绑定@cardClick → goChallengeManage(ch.id)
+- 
+- - ArticleService.getPublicList: 新增 memberId 参数,权限过滤后移除已读完+得CF的文章
+- - ArticleController.list: 读取 body.memberId(回退 currentMemberId)并传入 service
+- - article-center/index.vue: 传 memberId 给列表接口,知识分享页隐藏已完成文章
+- - mind-detail/articles.vue: 同上,心智知识页同步过滤
+- - ArticleControllerTest: 适配 getPublicList 新增 memberId 参数
+- 
+- - 匿名用户可浏览文章,读满时长后提示登录后完成知识测评并跳转登录
+- - 答题接口 getActiveMemberId() 优先 currentChildId,否则用 userId
+- - 移除 FREE 会员限制,所有登录用户均可参加文章知识测评
+- 
+
+
 ## v1.0.1487 (2026-10-02)
 
 ### Bug 修复