Selaa lähdekoodia

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

E2E Test Bot 2 päivää sitten
vanhempi
sitoutus
bd5f59e0dd
29 muutettua tiedostoa jossa 1495 lisäystä ja 133 poistoa
  1. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/config/McpConfig.java
  4. 4 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
  5. 96 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mcp/McpController.java
  6. 529 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mcp/McpToolRegistry.java
  7. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java
  8. 6 3
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java
  9. 15 2
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  10. 8 6
      cfc-backend/src/main/java/com/etotem/cfc/service/ContentRecommendService.java
  11. 141 7
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
  12. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  13. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/service/mcp/McpAuthService.java
  14. 3 0
      cfc-backend/src/main/resources/application.yml
  15. 3 2
      cfc-backend/src/main/resources/schema.sql
  16. 6 6
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ArticleControllerTest.java
  17. 17 5
      cfc-frontend/components/FamilyChallengeCard.vue
  18. 6 2
      cfc-frontend/pages/article-center/index.vue
  19. 422 9
      cfc-frontend/pages/health/challenge-manage.vue
  20. 5 2
      cfc-frontend/pages/home-pages/parent-index.vue
  21. 4 3
      cfc-frontend/pages/index-home/index.vue
  22. 16 10
      cfc-frontend/pages/membership/index.vue
  23. 6 2
      cfc-frontend/pages/mind-detail/articles.vue
  24. 8 66
      cfc-frontend/pages/profile-main/profile.vue
  25. 1 0
      cfc-frontend/utils/api.js
  26. 1 1
      cfc-web/.last_build_commit
  27. 1 1
      cfc-web/package.json
  28. 65 0
      cfc-web/public/CHANGELOG-v1.0.md
  29. 66 1
      cfc-web/public/CHANGELOG.md

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

@@ -2948,6 +2948,8 @@ public class DatabaseInitializer implements CommandLineRunner {
         // 家庭挑战:目标模式与目标值(aggregate=集体总合目标 / all_members=全员个人目标)
         ensureColumn("family_challenge", "target_mode", "VARCHAR(20) DEFAULT 'aggregate' COMMENT '目标模式: aggregate=集体总合/all_members=全员个人目标'");
         ensureColumn("family_challenge", "target_value", "INT DEFAULT 0 COMMENT '目标值(aggregate=家庭合计值, all_members=每人目标值)'");
+        // 迁移: family_challenge 表添加回复时效字段(待同意成员超时未回复自动拒绝)
+        ensureColumn("family_challenge", "respond_deadline", "DATETIME NULL COMMENT '回复时效截止时间(超过后未回复的 pending 成员自动置为 rejected)'");
 
         // 迁移248: 创建 health_report_edit_logs 表(健康报告修改留痕,原迁移222误置于initializeDefaultData中仅测试环境执行)
         try {
@@ -11163,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;
+    }
+}

+ 4 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -36,10 +36,12 @@ public class ArticleController {
 
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
-                                       @RequestAttribute(value = "userId", required = false) Long userId) {
+                                       @RequestAttribute(value = "userId", required = false) Long userId,
+                                       @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long categoryId = ParamUtils.getLong(body.get("categoryId"));
         String keyword = (String) body.get("keyword");
         String dimensionCode = body.get("dimensionCode") != null ? body.get("dimensionCode").toString() : null;
+        Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
         int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
         int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 10;
 
@@ -54,7 +56,7 @@ public class ArticleController {
         }
 
         Page<Article> result = articleService.getPublicList(categoryId, keyword, dimensionCode, page, size,
-                userId, familyId, vendorType);
+                userId, familyId, vendorType, memberId);
         return Result.success(result);
     }
 

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

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java

@@ -46,6 +46,9 @@ public class FamilyChallenge implements Serializable {
 
     private Date createdAt;
 
+    /** 回复时效截止时间:超过后未回复的 pending 成员自动置为 rejected */
+    private Date respondDeadline;
+
     /** 非表字段:家庭总进度(aggregate 模式使用) */
     @TableField(exist = false)
     private Integer totalProgress;

+ 6 - 3
cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java

@@ -49,9 +49,12 @@ public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRec
     long countDistinctByArticle(@Param("articleId") Long articleId);
 
     /**
-     * 查询某用户已达标阅读的文章 ID 列表
+     * 查询某成员已完成阅读且获得CF值的文章 ID 列表
+     * (阅读达标 reading_completed=1 + 答题获得CF值 cf_earned>0 才从列表中隐藏)
      */
-    @Select("SELECT article_id FROM article_reading_records " +
-            "WHERE child_id = #{childId} AND reading_completed = 1")
+    @Select("SELECT DISTINCT r.article_id FROM article_reading_records r " +
+            "WHERE r.child_id = #{childId} AND r.reading_completed = 1 " +
+            "AND EXISTS (SELECT 1 FROM article_quiz_records q " +
+            "            WHERE q.article_id = r.article_id AND q.member_id = r.child_id AND q.cf_earned > 0)")
     List<Long> selectCompletedArticleIdsByChild(@Param("childId") Long childId);
 }

+ 15 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -86,6 +86,11 @@ public class ArticleService {
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
                                         Long userId, Long familyId, String vendorType) {
+        return getPublicList(categoryId, keyword, dimensionCode, page, size, userId, familyId, vendorType, null);
+    }
+
+    public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
+                                        Long userId, Long familyId, String vendorType, Long memberId) {
         LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
                 .eq(Article::getStatus, "published")
                 .orderByDesc(Article::getIsFeatured)
@@ -103,8 +108,8 @@ public class ArticleService {
             String dim = dimensionCode.trim();
             // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 relatedDimensions
             wrapper.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
-                .or().like(Article::getRelatedDimensions, dim)
+                    .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                    .or().like(Article::getRelatedDimensions, dim)
             );
         }
 
@@ -116,6 +121,14 @@ public class ArticleService {
 
         List<Article> visible = articlePermissionService.filterVisible(all, userId, familyId, vendorType);
 
+        // 过滤已完成阅读且获得CF值的文章(达到阈值且答题得CF后不再出现在文章列表中)
+        if (memberId != null) {
+            List<Long> completedIds = articleReadingRecordMapper.selectCompletedArticleIdsByChild(memberId);
+            if (!completedIds.isEmpty()) {
+                visible.removeIf(a -> completedIds.contains(a.getId()));
+            }
+        }
+
         enrichTotalDurationSeconds(visible);
 
         // 手动分页(与 getFeatured 相同模式),total 为过滤后的真实总数

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

+ 141 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -61,6 +61,11 @@ public class FamilyChallengeService {
                     && c.getEndDate() != null && new Date().after(c.getEndDate())) {
                 checkAndSettle(c);
             }
+            // pending 且已过回复时效 → 自动拒绝未回复成员(惰性检查)
+            else if ("pending".equals(c.getStatus())
+                    && c.getRespondDeadline() != null && new Date().after(c.getRespondDeadline())) {
+                expirePendingChallenge(c);
+            }
         }
 
         SortUtil.applySort(wrapper);
@@ -144,7 +149,8 @@ public class FamilyChallengeService {
     /**
      * 同意/拒绝挑战(参与成员动作,即时结算方向评分)。
      * agree:status→agreed → 结算同意评分 → 除 rejected 外全部 agreed → 挑战转 active
-     * reject:status→rejected → 结算拒绝评分 → 除发起人外无剩余参与成员 → 挑战自动 cancelled(Q6)
+     * reject:status→rejected → 结算拒绝评分 → 除发起人外无剩余参与成员 → 挑战自动 not_launched(未成功发起)
+     * 回复时效:pending 且已过 respondDeadline 时,未回复成员自动置为 rejected。
      */
     @Transactional
     public Result<Void> respondChallenge(Long challengeId, Long memberId, String action) {
@@ -159,7 +165,17 @@ public class FamilyChallengeService {
         if (challenge == null) {
             return Result.error("挑战不存在");
         }
+        // 惰性超时:pending 且已过回复时效 → 未回复成员自动拒绝
+        if ("pending".equals(challenge.getStatus())
+                && challenge.getRespondDeadline() != null
+                && new Date().after(challenge.getRespondDeadline())) {
+            expirePendingChallenge(challenge);
+            challenge = familyChallengeMapper.selectById(challengeId);
+        }
         if (!"pending".equals(challenge.getStatus())) {
+            if ("not_launched".equals(challenge.getStatus())) {
+                return Result.error("挑战未成功发起");
+            }
             return Result.error("挑战当前不可操作");
         }
 
@@ -172,6 +188,11 @@ public class FamilyChallengeService {
             return Result.error("你不在该挑战参与名单中");
         }
         if (!"pending".equals(participant.getStatus())) {
+            if ("rejected".equals(participant.getStatus())
+                    && challenge.getRespondDeadline() != null
+                    && new Date().after(challenge.getRespondDeadline())) {
+                return Result.error("回复已超时");
+            }
             return Result.error("该挑战已处理过,请勿重复操作");
         }
 
@@ -221,14 +242,14 @@ public class FamilyChallengeService {
                 challengeScoreService.applyReject(challenge.getFamilyId(), memberId, memberId, agreedMemberIds, challengeId);
             }
 
-            // 除发起人外无剩余参与成员 → cancelled(Q6)
+            // 除发起人外无剩余参与成员 → not_launched(未成功发起)
             int nonRejected = 0;
             for (FamilyChallengeParticipant p : allParticipants) {
                 if (!"rejected".equals(p.getStatus())) nonRejected++;
             }
             // 非 rejected 的只剩发起人本人(且发起人是 participant)
             if (nonRejected <= 1) {
-                challenge.setStatus("cancelled");
+                challenge.setStatus("not_launched");
                 familyChallengeMapper.updateById(challenge);
             }
         }
@@ -252,6 +273,49 @@ public class FamilyChallengeService {
                         .orderByAsc(FamilyChallengeParticipant::getId));
     }
 
+    /**
+     * 回复时效超时处理:pending 成员自动置为 rejected。
+     * 除发起人外全部拒绝 → not_launched(未成功发起);否则若其余参与成员均同意 → active。
+     */
+    private void expirePendingChallenge(FamilyChallenge challenge) {
+        List<FamilyChallengeParticipant> parts = listParticipants(challenge.getId());
+        boolean expired = false;
+        for (FamilyChallengeParticipant p : parts) {
+            if ("pending".equals(p.getStatus())) {
+                p.setStatus("rejected");
+                p.setAgreedAt(new Date());
+                familyChallengeParticipantMapper.updateById(p);
+                expired = true;
+            }
+        }
+        if (!expired) return;
+        log.info("挑战回复时效已过,自动拒绝未回复成员: challengeId={}", challenge.getId());
+
+        // 除发起人外无剩余参与成员 → not_launched(未成功发起)
+        int nonRejected = 0;
+        for (FamilyChallengeParticipant p : parts) {
+            if (!"rejected".equals(p.getStatus())) nonRejected++;
+        }
+        if (nonRejected <= 1) {
+            challenge.setStatus("not_launched");
+            familyChallengeMapper.updateById(challenge);
+            return;
+        }
+        // 否则:除 rejected 外全部 agreed → active
+        boolean allAgreed = true;
+        for (FamilyChallengeParticipant p : parts) {
+            if ("rejected".equals(p.getStatus())) continue;
+            if (!"agreed".equals(p.getStatus())) {
+                allAgreed = false;
+                break;
+            }
+        }
+        if (allAgreed) {
+            challenge.setStatus("active");
+            familyChallengeMapper.updateById(challenge);
+        }
+    }
+
     @Transactional
     public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         if (challengeId == null || childId == null) {
@@ -422,7 +486,7 @@ public class FamilyChallengeService {
         if (familyId == null) return new ArrayList<>();
         LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
                 .eq(FamilyChallenge::getFamilyId, familyId)
-                .eq(FamilyChallenge::getStatus, "completed")
+                .in(FamilyChallenge::getStatus, "completed", "not_launched", "cancelled")
                 .orderByDesc(FamilyChallenge::getEndDate);
         SortUtil.applySort(wrapper);
         List<FamilyChallenge> history = familyChallengeMapper.selectList(wrapper);
@@ -593,6 +657,17 @@ public class FamilyChallengeService {
             return Result.error("家庭挑战至少需要2名成员参与");
         }
 
+        // 回复时效(小时),默认48;可选24/48/72
+        int respondHours = 48;
+        Object rawHours = params.get("respondDeadlineHours");
+        if (rawHours != null) {
+            try {
+                int h = Integer.parseInt(rawHours.toString());
+                if (h > 0) respondHours = h;
+            } catch (NumberFormatException ignored) {
+            }
+        }
+
         // 创建挑战(pending 状态,参与成员同意后转 active)
         FamilyChallenge challenge = new FamilyChallenge();
         challenge.setFamilyId(familyId);
@@ -608,6 +683,7 @@ public class FamilyChallengeService {
         challenge.setEndDate(new Date(System.currentTimeMillis() + durationDays * 24L * 60 * 60 * 1000));
         challenge.setStatus("pending");
         challenge.setCreatedAt(new Date());
+        challenge.setRespondDeadline(new Date(System.currentTimeMillis() + respondHours * 3600L * 1000));
 
         familyChallengeMapper.insert(challenge);
 
@@ -648,9 +724,10 @@ public class FamilyChallengeService {
             return Result.error("无权修改此挑战");
         }
         
-        // 验证挑战状态
-        if (!"active".equals(challenge.getStatus())) {
-            return Result.error("只能修改active状态的挑战");
+        // 验证挑战状态:pending(待同意)/ active(进行中)均可编辑
+        boolean isPending = "pending".equals(challenge.getStatus());
+        if (!isPending && !"active".equals(challenge.getStatus())) {
+            return Result.error("只能修改待同意或进行中的挑战");
         }
         
         // 验证挑战创建者权限
@@ -695,6 +772,63 @@ public class FamilyChallengeService {
         String description = params.get("description") != null ? params.get("description").toString() : challenge.getDescription();
         challenge.setDescription(description);
 
+        // 参与成员更新(仅 pending 状态可增删;active 不允许改动 - 方案2A)
+        Object rawIds = params.get("participantMemberIds");
+        if (rawIds != null) {
+            if (!isPending) {
+                return Result.error("进行中的挑战不能修改参与成员");
+            }
+            List<Long> newIds = new ArrayList<>();
+            if (rawIds instanceof java.util.Collection) {
+                for (Object o : (java.util.Collection<?>) rawIds) {
+                    if (o == null) continue;
+                    newIds.add(Long.valueOf(o.toString()));
+                }
+            }
+            if (newIds.isEmpty()) {
+                return Result.error("参与成员不能为空");
+            }
+            if (newIds.size() < 2) {
+                return Result.error("家庭挑战至少需要2名成员参与");
+            }
+            // 必须包含发起人本人
+            Long creatorMemberId = resolveMemberIdByUserId(userId);
+            if (creatorMemberId != null && !newIds.contains(creatorMemberId)) {
+                return Result.error("参与成员必须包含发起人本人");
+            }
+            // 均属本家庭
+            for (Long pid : newIds) {
+                LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getId, pid)
+                        .eq(FamilyMember::getFamilyId, familyId);
+                if (familyMemberMapper.selectCount(mw) == 0) {
+                    return Result.error("参与成员不属于本家庭");
+                }
+            }
+            // 重建 participant 行:保留已在列表中的,新增插入,移除的删除
+            List<FamilyChallengeParticipant> oldParts = listParticipants(challengeId);
+            Map<Long, FamilyChallengeParticipant> oldMap = new HashMap<>();
+            for (FamilyChallengeParticipant p : oldParts) oldMap.put(p.getMemberId(), p);
+            for (Long pid : newIds) {
+                if (oldMap.remove(pid) != null) continue; // 已在参与名单,保留
+                FamilyChallengeParticipant np = new FamilyChallengeParticipant();
+                np.setChallengeId(challengeId);
+                np.setMemberId(pid);
+                if (creatorMemberId != null && pid.equals(creatorMemberId)) {
+                    np.setStatus("agreed");
+                    np.setAgreedAt(new Date());
+                } else {
+                    np.setStatus("pending");
+                }
+                familyChallengeParticipantMapper.insert(np);
+                log.info("挑战编辑新增参与成员: challengeId={}, memberId={}", challengeId, pid);
+            }
+            for (FamilyChallengeParticipant removed : oldMap.values()) {
+                familyChallengeParticipantMapper.deleteById(removed.getId());
+                log.info("挑战编辑移除参与成员: challengeId={}, memberId={}", challengeId, removed.getMemberId());
+            }
+        }
+
         // 更新挑战
         familyChallengeMapper.updateById(challenge);
         

+ 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

+ 3 - 2
cfc-backend/src/main/resources/schema.sql

@@ -3598,9 +3598,10 @@ CREATE TABLE IF NOT EXISTS family_challenge (
     target_value INT DEFAULT 0 COMMENT '目标值(aggregate=家庭合计值, all_members=每人目标值)',
     start_date DATE COMMENT '开始日期',
     end_date DATE COMMENT '结束日期',
-    status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'pending(待同意)/active(进行中)/completed(已完成)/cancelled(已取消)/not_launched(未成功发起)',
     creator_id BIGINT COMMENT '创建者ID(NULL=系统创建)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    respond_deadline DATETIME NULL COMMENT '回复时效截止时间(超过后未回复的 pending 成员自动置为 rejected)',
     INDEX idx_family_id (family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战';
 
@@ -5670,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
 );

+ 6 - 6
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ArticleControllerTest.java

@@ -65,7 +65,7 @@ public class ArticleControllerTest {
         when(articleService.getPublicList(isNull(), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull()))
             .thenReturn(mockPage);
 
-        Result<Page<Article>> result = controller.list(body, null);
+        Result<Page<Article>> result = controller.list(body, null, null);
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
         assertEquals(1, result.getData().getRecords().size());
@@ -85,7 +85,7 @@ public class ArticleControllerTest {
         when(articleService.getPublicList(eq(1L), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull()))
             .thenReturn(mockPage);
 
-        Result<Page<Article>> result = controller.list(body, null);
+        Result<Page<Article>> result = controller.list(body, null, null);
         assertEquals(200, result.getCode());
         assertEquals(1, result.getData().getRecords().size());
     }
@@ -104,7 +104,7 @@ public class ArticleControllerTest {
         when(articleService.getPublicList(isNull(), eq("测试"), isNull(), eq(1), eq(10), isNull(), isNull(), isNull()))
             .thenReturn(mockPage);
 
-        Result<Page<Article>> result = controller.list(body, null);
+        Result<Page<Article>> result = controller.list(body, null, null);
         assertEquals(200, result.getCode());
         assertEquals("测试文章", result.getData().getRecords().get(0).getTitle());
     }
@@ -127,7 +127,7 @@ public class ArticleControllerTest {
         when(articleService.getPublicList(isNull(), isNull(), isNull(), eq(1), eq(10), eq(1L), eq(100L), eq("planner")))
             .thenReturn(mockPage);
 
-        Result<Page<Article>> result = controller.list(body, 1L);
+        Result<Page<Article>> result = controller.list(body, 1L, null);
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
     }
@@ -172,7 +172,7 @@ public class ArticleControllerTest {
         );
         when(articleService.getFeatured(eq(3), isNull(), isNull(), isNull(), isNull())).thenReturn(featured);
 
-        Result<List<Article>> result = controller.featured(body, null);
+        Result<List<Article>> result = controller.featured(body, null, null);
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
     }
@@ -185,7 +185,7 @@ public class ArticleControllerTest {
         // Default size should be 5
         when(articleService.getFeatured(eq(5), isNull(), isNull(), isNull(), isNull())).thenReturn(featured);
 
-        Result<List<Article>> result = controller.featured(body, null);
+        Result<List<Article>> result = controller.featured(body, null, null);
         assertEquals(200, result.getCode());
         assertEquals(1, result.getData().size());
     }

+ 17 - 5
cfc-frontend/components/FamilyChallengeCard.vue

@@ -1,5 +1,5 @@
 <template>
-  <view class="challenge-card" v-if="challenge">
+  <view class="challenge-card" v-if="challenge" @click="onCardClick">
     <view class="card-header">
       <text class="card-title">{{ challenge.title }}</text>
       <text class="card-status" :class="statusClass">
@@ -10,7 +10,7 @@
 
     <!-- 待同意:显示确认进度(pending 状态挑战) -->
     <view class="pending-section" v-if="challenge.status === 'pending'">
-      <text class="pending-hint">等待成员确认(已同意 {{ agreedCount }}/{{ totalCount }})</text>
+      <text class="pending-hint">等待成员确认(已同意 {{ agreedCount }}/{{ totalCount }}){{ deadlineText }}</text>
     </view>
 
     <!-- 成员状态徽标:rejected 成员显示"已拒绝" -->
@@ -56,13 +56,13 @@
 
     <!-- 待同意:同意/拒绝按钮(仅我的状态=pending 时显示) -->
     <view class="card-action" v-if="challenge.status === 'pending' && challenge.myStatus === 'pending'">
-      <view class="respond-btn respond-agree" @click="onRespond('agree')">✅ 同意</view>
-      <view class="respond-btn respond-reject" @click="onRespond('reject')">✖ 拒绝</view>
+      <view class="respond-btn respond-agree" @click.stop="onRespond('agree')">✅ 同意</view>
+      <view class="respond-btn respond-reject" @click.stop="onRespond('reject')">✖ 拒绝</view>
     </view>
 
     <!-- 进行中:打卡 -->
     <view class="card-action" v-if="showCheckin && challenge.status === 'active'">
-      <view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
+      <view class="checkin-btn" @click.stop="onCheckin">✅ 打卡</view>
     </view>
   </view>
 </template>
@@ -129,6 +129,15 @@ export default {
       if (!dateStr) return ''
       return String(dateStr).slice(0, 10)
     },
+    deadlineText: function() {
+      if (!this.challenge || !this.challenge.respondDeadline) return ''
+      var deadline = new Date(this.challenge.respondDeadline)
+      var now = new Date()
+      var diffDays = Math.round((deadline - now) / (1000 * 60 * 60 * 24))
+      if (diffDays < 0) return '(已超时)'
+      if (diffDays === 0) return '(截止今天)'
+      return '(剩余 ' + diffDays + ' 天)'
+    },
     memberPercent: function(value) {
       var target = this.challenge && this.challenge.targetValue ? this.challenge.targetValue : 1
       if (target <= 0) return 0
@@ -162,6 +171,9 @@ export default {
           }
         }
       })
+    },
+    onCardClick: function() {
+      this.$emit('cardClick', { challengeId: this.challenge.id })
     }
   }
 }

+ 6 - 2
cfc-frontend/pages/article-center/index.vue

@@ -165,15 +165,16 @@ export default {
   components: {
     ArticleBookshelf: () => require('@/components/ArticleBookshelf.vue')
   },
-  onLoad(options) {
+onLoad(options) {
     // 支持从外部传入 dimension 参数,默认选中对应 tab
     if (options && options.dimension) {
       this.currentTab = options.dimension
     }
     this.isLoggedIn = !!uni.getStorageSync('token')
+    this.currentChildId = uni.getStorageSync('currentChildId') || null
     this.loadArticles()
     this.loadRecommendedArticles()
-  },
+},
   onShow() {
     this.isLoggedIn = !!uni.getStorageSync('token')
   },
@@ -191,6 +192,9 @@ export default {
       }
       try {
         var params = { page: this.page, size: this.size }
+        if (this.currentChildId) {
+          params.memberId = parseInt(this.currentChildId)
+        }
         if (this.currentTab) {
           params.dimensionCode = this.currentTab
         }

+ 422 - 9
cfc-frontend/pages/health/challenge-manage.vue

@@ -160,8 +160,23 @@
             </view>
           </view>
 
-          <view class="field" v-if="!editMode">
-            <text class="field-label">参与成员</text>
+          <view class="field-row" v-if="!editMode">
+            <view class="field half">
+              <text class="field-label">回复时效</text>
+              <view class="radio-group">
+                <view class="radio-btn" :class="{ active: form.respondDeadlineHours === '24' }" @click="form.respondDeadlineHours = '24'">24h</view>
+                <view class="radio-btn" :class="{ active: form.respondDeadlineHours === '48' }" @click="form.respondDeadlineHours = '48'">48h</view>
+                <view class="radio-btn" :class="{ active: form.respondDeadlineHours === '72' }" @click="form.respondDeadlineHours = '72'">72h</view>
+              </view>
+            </view>
+            <view class="field half">
+              <text class="field-label">超时处理</text>
+              <view class="field-tip">超时未回复的成员将自动视为拒绝</view>
+            </view>
+          </view>
+
+          <view class="field" v-if="!editMode || editingStatus === 'pending'">
+            <text class="field-label">参与成员 <text class="required" v-if="editMode && editingStatus === 'pending'">(待同意挑战可修改)</text></text>
             <view class="member-grid">
               <view
                 class="member-item"
@@ -183,6 +198,96 @@
       </view>
     </view>
 
+    <!-- 挑战详情弹窗 -->
+    <view class="overlay" v-if="showDetailModal" @click="closeDetailModal">
+      <view class="modal detail-modal" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">挑战详情</text>
+          <text class="modal-close" @click="closeDetailModal">×</text>
+        </view>
+
+        <view v-if="detailChallenge" class="detail-body">
+          <view class="detail-header">
+            <text class="detail-title">{{ detailChallenge.title }}</text>
+            <text class="detail-badge" :class="detailStatusClass">{{ detailStatusText }}</text>
+          </view>
+
+          <text class="detail-desc">{{ detailChallenge.description || '' }}</text>
+
+          <!-- 待同意:显示确认进度(pending 状态挑战) -->
+          <view class="pending-section" v-if="detailChallenge.status === 'pending'">
+            <text class="pending-hint">等待成员确认(已同意 {{ detailAgreedCount }}/{{ detailTotalCount }})</text>
+          </view>
+
+          <!-- 目标展示(两种模式) -->
+          <view class="detail-target">
+            <view class="detail-row">
+              <text class="detail-label">目标模式:</text>
+              <text>{{ detailChallenge.targetMode === 'aggregate' ? '全家合计' : '每人目标' }}</text>
+            </view>
+            <view class="detail-row">
+              <text class="detail-label">目标值:</text>
+              <text>{{ detailChallenge.targetValue }}</text>
+            </view>
+            <view class="detail-row">
+              <text class="detail-label">持续天数:</text>
+              <text>{{ detailChallenge.durationDays }}天</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.startDate">
+              <text class="detail-label">开始日期:</text>
+              <text>{{ detailChallenge.startDate.slice(0,10) }}</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.endDate">
+              <text class="detail-label">截止日期:</text>
+              <text>{{ detailChallenge.endDate.slice(0,10) }}</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.respondDeadline">
+              <text class="detail-label">回复截止:</text>
+              <text :class="detailDeadlinePassed ? 'detail-label detail-label-danger' : 'detail-label'">{{ detailChallenge.respondDeadline.slice(0,16).replace('T',' ') }}{{ detailDeadlinePassed ? '(已超时)' : '' }}</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.rewardPoints">
+              <text class="detail-label">奖励积分:</text>
+              <text class="detail-label detail-label-reward">+{{ detailChallenge.rewardPoints }}分</text>
+            </view>
+          </view>
+
+          <!-- 活动/已完成进度 -->
+          <view class="detail-progress" v-if="detailChallenge.status !== 'pending'">
+            <text class="detail-progress-title">进度</text>
+            <view class="detail-progress-bar-wrap">
+              <view class="detail-progress-bar-bg">
+                <view class="detail-progress-bar-fill" :style="{width: detailProgressPercent + '%'}"></view>
+              </view>
+              <text class="detail-progress-text">{{ detailChallenge.totalProgress || 0 }}/{{ detailChallenge.targetValue }}</text>
+            </view>
+            <view class="detail-member-list" v-if="detailChallenge.memberProgress && detailChallenge.memberProgress.length > 0">
+              <view class="detail-member-row" v-for="mp in detailChallenge.memberProgress" :key="mp.memberId">
+                <text class="detail-member-name">{{ mp.nickname }}</text>
+                <text class="detail-member-val">{{ mp.progressValue || 0 }}/{{ detailChallenge.targetValue }}</text>
+                <text class="detail-member-status" :class="'member-' + mp.status">{{ detailMemberStatusLabel(mp.status) }}</text>
+              </view>
+            </view>
+          </view>
+
+          <!-- 同意/拒绝按钮 -->
+          <view class="detail-action" v-if="detailChallenge.status === 'pending' && detailChallenge.myStatus === 'pending'">
+            <view class="respond-btn respond-agree" @click="onDetailRespond('agree')">✅ 同意</view>
+            <view class="respond-btn respond-reject" @click="onDetailRespond('reject')">✖ 拒绝</view>
+          </view>
+
+          <!-- 编辑/删除按钮(仅创建者) -->
+          <view class="detail-action" v-if="creatorCanOperate">
+            <button class="btn-edit-detail" @click="closeDetailModal; openEditModal(detailChallenge)">编辑</button>
+            <button class="btn-delete-detail" @click="confirmDeleteDetail">删除</button>
+          </view>
+        </view>
+
+        <view class="modal-footer">
+          <button class="btn-cancel" @click="closeDetailModal">关闭</button>
+        </view>
+      </view>
+    </view>
+
     <!-- 家庭成员不足提示弹窗 -->
     <view class="overlay" v-if="showMemberPrompt" @click="showMemberPrompt = false">
       <view class="modal prompt-modal" @click.stop>
@@ -224,7 +329,7 @@
 </template>
 
 <script>
-import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getFamilyMemberList, getMyMembership } from '../../utils/api.js'
+import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getFamilyMemberList, getMyMembership, respondChallenge } from '../../utils/api.js'
 import store from '../../store/index.js'
 
 export default {
@@ -237,10 +342,13 @@ export default {
       familyMembers: [],
       selectedMemberIds: [],
       showModal: false,
+      showDetailModal: false,
+      detailChallenge: null,
       showMemberPrompt: false,
       showAddChildPrompt: false,
       editMode: false,
       editingId: null,
+      editingStatus: null,
       form: {
         title: '',
         description: '',
@@ -248,12 +356,60 @@ export default {
         targetMode: 'all_members',
         targetValue: '7',
         durationDays: '7',
-        rewardPoints: '50'
+        rewardPoints: '50',
+        respondDeadlineHours: '48'
       },
       isFreeMember: false
     }
   },
-  onLoad() {
+  computed: {
+    detailStatusClass: function() {
+      if (!this.detailChallenge) return ''
+      if (this.detailChallenge.status === 'active') return 'status-active'
+      if (this.detailChallenge.status === 'pending') return 'status-pending'
+      if (this.detailChallenge.status === 'completed') return 'status-completed'
+      if (this.detailChallenge.status === 'cancelled') return 'status-cancelled'
+      return 'status-active'
+    },
+    detailStatusText: function() {
+      if (!this.detailChallenge) return ''
+      if (this.detailChallenge.status === 'active') return '进行中'
+      if (this.detailChallenge.status === 'pending') return '待同意'
+      if (this.detailChallenge.status === 'completed') return '已完成'
+      if (this.detailChallenge.status === 'cancelled') return '已取消'
+      return '进行中'
+    },
+    detailAgreedCount: function() {
+      if (!this.detailChallenge || !this.detailChallenge.memberProgress) return 0
+      var count = 0
+      for (var i = 0; i < this.detailChallenge.memberProgress.length; i++) {
+        if (this.detailChallenge.memberProgress[i].status === 'agreed') count++
+      }
+      return count
+    },
+    detailTotalCount: function() {
+      if (!this.detailChallenge || !this.detailChallenge.memberProgress) return 0
+      return this.detailChallenge.memberProgress.length
+    },
+    detailProgressPercent: function() {
+      if (!this.detailChallenge || this.detailChallenge.targetValue <= 0) return 0
+      return Math.round(this.detailChallenge.totalProgress / this.detailChallenge.targetValue * 100)
+    },
+    detailDeadlinePassed: function() {
+      if (!this.detailChallenge || !this.detailChallenge.respondDeadline) return false
+      return new Date().getTime() > new Date(this.detailChallenge.respondDeadline).getTime()
+    },
+    creatorCanOperate: function() {
+      if (!this.detailChallenge) return false
+      return String(this.detailChallenge.creatorId) === String(this.$store.state.userId)
+    },
+    detailMemberStatusLabel: function(status) {
+      var map = { pending: '待确认', agreed: '已同意', rejected: '已拒绝' }
+      return map[status] || status
+    }
+  },
+  onLoad(options) {
+    this._pendingChallengeId = options && options.id
     this.loadTemplates()
     this.loadMembers()
     this.checkMembership()
@@ -283,6 +439,7 @@ export default {
         ])
         this.activeChallenges = Array.isArray(res1.data) ? res1.data : []
         this.historyChallenges = Array.isArray(res2.data) ? res2.data : []
+        this.openPendingChallengeIfNeeded()
       } catch (e) {
         this.activeChallenges = []
         this.historyChallenges = []
@@ -290,6 +447,22 @@ export default {
         this.loading = false
       }
     },
+    // 从首页卡片点击进入时,自动展开对应挑战详情弹窗
+    openPendingChallengeIfNeeded() {
+      var id = this._pendingChallengeId
+      if (!id) return
+      this._pendingChallengeId = null
+      var found = null
+      for (var i = 0; i < this.activeChallenges.length; i++) {
+        if (String(this.activeChallenges[i].id) === String(id)) { found = this.activeChallenges[i]; break }
+      }
+      if (!found) {
+        for (var j = 0; j < this.historyChallenges.length; j++) {
+          if (String(this.historyChallenges[j].id) === String(id)) { found = this.historyChallenges[j]; break }
+        }
+      }
+      if (found) this.viewChallenge(found)
+    },
     async loadTemplates() {
       try {
         const res = await getChallengeTemplates()
@@ -376,7 +549,8 @@ export default {
       this.editingId = null
       this.form = {
         title: '', description: '', challengeType: 'custom',
-        targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50'
+        targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50',
+        respondDeadlineHours: '48'
       }
       this.selectedMemberIds = this.familyMembers.map(m => m.id)
       this.showModal = true
@@ -384,6 +558,7 @@ export default {
     openEditModal(ch) {
       this.editMode = true
       this.editingId = ch.id
+      this.editingStatus = ch.status
       this.form = {
         title: ch.title || '',
         description: ch.description || '',
@@ -393,6 +568,14 @@ export default {
         durationDays: String(ch.durationDays || 7),
         rewardPoints: String(ch.rewardPoints || 50)
       }
+      // 编辑时预填充参与成员:从 memberProgress 读取所有状态的成员
+      var selected = []
+      if (ch.memberProgress && ch.memberProgress.length) {
+        for (var i = 0; i < ch.memberProgress.length; i++) {
+          selected.push(ch.memberProgress[i].memberId)
+        }
+      }
+      this.selectedMemberIds = selected
       this.showModal = true
     },
     closeModal() {
@@ -421,7 +604,8 @@ export default {
         durationDays: parseInt(this.form.durationDays || 7, 10),
         rewardPoints: parseInt(this.form.rewardPoints || 0, 10)
       }
-      if (!this.editMode) data.participantMemberIds = this.selectedMemberIds
+      if (!this.editMode || this.editingStatus === 'pending') data.participantMemberIds = this.selectedMemberIds
+      if (!this.editMode && this.form.respondDeadlineHours) data.respondDeadlineHours = parseInt(this.form.respondDeadlineHours, 10)
 
       this.loading = true
       try {
@@ -464,8 +648,58 @@ export default {
       })
     },
     viewChallenge(ch) {
-      // 跳转到挑战详情
-      uni.navigateTo({ url: '/pages/activity/index' })
+      // 打开挑战详情弹窗
+      this.detailChallenge = ch
+      this.showDetailModal = true
+    },
+    closeDetailModal() {
+      this.showDetailModal = false
+      this.detailChallenge = null
+    },
+    async onDetailRespond(action) {
+      var self = this
+      uni.showModal({
+        title: '确认操作',
+        content: action === 'agree' ? '确认同意参与该挑战吗?' : '确认拒绝该挑战吗?',
+        success: function(res) {
+          if (res.confirm) {
+            respondChallenge({ challengeId: self.detailChallenge.id, action: action }).then(function(resp) {
+              if (resp && resp.code === 200) {
+                uni.showToast({ title: action === 'agree' ? '已同意' : '已拒绝', icon: 'success' })
+                self.closeDetailModal()
+                self.loadData()
+              } else {
+                uni.showToast({ title: (resp && resp.message) || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '操作失败,请重试', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    confirmDeleteDetail() {
+      var self = this
+      if (!this.detailChallenge) return
+      uni.showModal({
+        title: '确认删除',
+        content: '确定要取消这个挑战吗?',
+        success: function(r) {
+          if (r.confirm) {
+            deleteChallenge(self.detailChallenge.id).then(function(res) {
+              if (res.code === 200) {
+                uni.showToast({ title: '已删除', icon: 'success' })
+                self.closeDetailModal()
+                self.loadData()
+              } else {
+                uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '删除失败', icon: 'none' })
+            })
+          }
+        }
+      })
     },
     goToAddMember() {
       this.showMemberPrompt = false
@@ -854,6 +1088,185 @@ export default {
   border-color: #10B981;
 }
 
+/* 详情弹窗 */
+.detail-modal {
+  max-width: 90vw;
+  padding: 32rpx 28rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  box-shadow: 0 8rpx 48rpx rgba(0,0,0,0.12);
+}
+.detail-modal .modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 2rpx solid #E2E8F0;
+}
+.detail-modal .modal-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.detail-modal .modal-close {
+  font-size: 30rpx;
+  color: #64748B;
+  cursor: pointer;
+}
+.detail-modal .modal-close:hover {
+  color: #DC2626;
+}
+.detail-body {
+  font-size: 26rpx;
+  line-height: 1.6;
+  color: #475569;
+}
+.detail-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.detail-header .detail-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.detail-header .detail-badge {
+  font-size: 22rpx;
+  padding: 2rpx 8rpx;
+  border-radius: 999rpx;
+}
+.detail-header .status-active { background: #DCFCE7; color: #16A34A; }
+.detail-header .status-pending { background: #FEF3C7; color: #B45309; }
+.detail-header .status-completed { background: #F1F5F9; color: #64748B; }
+.detail-header .status-cancelled { background: #FEE2E2; color: #DC2626; }
+.detail-header .status-not-launched { background: #E5E7EB; color: #6B7280; }
+.detail-desc {
+  font-size: 24rpx;
+  color: #64748B;
+  line-height: 1.4;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.detail-target {
+  margin-top: 16rpx;
+}
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.detail-row .detail-label {
+  font-size: 24rpx;
+  color: #647489;
+  font-weight: 500;
+}
+.detail-row .detail-value {
+  font-size: 26rpx;
+  color: #10B981;
+  font-weight: 600;
+}
+.detail-row .detail-value-danger {
+  font-size: 26rpx;
+  color: #DC2626;
+}
+.detail-progress {
+  margin-top: 16rpx;
+  padding-top: 16rpx;
+  border-top: 2rpx solid #E2E8F0;
+}
+.detail-progress-title {
+  font-size: 24rpx;
+  color: #64748B;
+  margin-bottom: 12rpx;
+}
+.detail-progress-bar-wrap {
+  height: 10rpx;
+  background: #E2E8F0;
+  border-radius: 999rpx;
+  overflow: hidden;
+  margin-bottom: 8rpx;
+}
+.detail-progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 999rpx;
+  transition: width 0.3s ease;
+}
+.detail-progress-text {
+  display: flex;
+  justify-content: space-between;
+  font-size: 22rpx;
+  color: #64748B;
+}
+.detail-member-list {
+  margin-top: 12rpx;
+}
+.detail-member-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+  font-size: 24rpx;
+}
+.detail-member-name {
+  color: #1E293B;
+  font-weight: 500;
+}
+.detail-member-val {
+  color: #64748B;
+  font-weight: 400;
+}
+.detail-member-status {
+  padding: 2rpx 6rpx;
+  border-radius: 999rpx;
+  font-size: 20rpx;
+  font-weight: 400;
+}
+.detail-member-pending { background: #FEF3C7; color: #B45309; }
+.detail-member-agreed { background: #DCFCE7; color: #16A34A; }
+.detail-member-rejected { background: #FEE2E2; color: #DC2626; }
+.detail-action {
+  display: flex;
+  gap: 12rpx;
+  margin-top: 20rpx;
+}
+.detail-action .respond-btn {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+.detail-action .btn-edit-detail {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 28rpx;
+  background: #F3F4F6;
+  color: #374151;
+  border-radius: 12rpx;
+}
+.detail-action .btn-delete-detail {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 28rpx;
+  background: #FEE2E2;
+  color: #DC2626;
+  border-radius: 12rpx;
+}
+.detail-label-reward {
+  color: #F59E0B;
+  font-weight: 500;
+}
+.detail-label-danger {
+  color: #DC2626;
+}
+
 /* 底部按钮 */
 .modal-footer {
   display: flex;

+ 5 - 2
cfc-frontend/pages/home-pages/parent-index.vue

@@ -53,7 +53,8 @@
         <FamilyChallengeCard
           v-for="ch in activeChallenges"
           :key="ch.id"
-          :challenge="ch" />
+          :challenge="ch"
+          @cardClick="goToChallengeManage(ch.id)" />
       </view>
 
     <!-- ===== 健康圈入口(P3:社区扩散) ===== -->
@@ -1167,7 +1168,9 @@ export default {
       })
     },
     addTask() { uni.navigateTo({ url: '/pages/tasks/create-task' }) },
-    goToChallengeManage() { uni.navigateTo({ url: '/pages/health/challenge-manage' }) },
+    goToChallengeManage: function(id) {
+      uni.navigateTo({ url: '/pages/health/challenge-manage?id=' + id })
+    },
     goToReview() { uni.navigateTo({ url: '/pages/tasks/review' }) },
     goToWishApprove() { uni.navigateTo({ url: '/pages/wishes/approve' }) },
     goToAssessmentReport() { uni.navigateTo({ url: '/pages/assessment/report' }) },

+ 4 - 3
cfc-frontend/pages/index-home/index.vue

@@ -172,7 +172,8 @@
         :challenge="ch"
         :showCheckin="showCheckinBtn"
         @checkin="onChallengeCheckin"
-        @respond="onChallengeRespond" />
+        @respond="onChallengeRespond"
+        @cardClick="goChallengeManage(ch.id)" />
       <view v-if="activeChallenges.length === 0" class="challenge-empty">
         <text class="challenge-empty-icon">🏆</text>
         <text class="challenge-empty-text">您还没有挑战,去添加吧</text>
@@ -1502,8 +1503,8 @@ title: '添加家庭成员',
     goActivities: function() {
       uni.navigateTo({ url: '/pages/activity/index' })
     },
-    goChallengeManage: function() {
-      uni.navigateTo({ url: '/pages/health/challenge-manage' })
+    goChallengeManage: function(id) {
+      uni.navigateTo({ url: '/pages/health/challenge-manage?id=' + id })
     },
     goArticles: function() {
       uni.navigateTo({ url: '/pages/article-center/index' })

+ 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' })
     },

+ 6 - 2
cfc-frontend/pages/mind-detail/articles.vue

@@ -138,9 +138,10 @@ export default {
     }
     this.loadCategories()
   },
-  onShow: function() {
+onShow: function() {
     this.isLoggedIn = !!uni.getStorageSync('token')
-  },
+    this.currentChildId = uni.getStorageSync('currentChildId') || null
+},
   methods: {
     contentTypeLabel: function(type) {
       var labels = { article: '文章', knowledge: '知识点', course: '课程', tip: '贴士' }
@@ -187,6 +188,9 @@ export default {
       var self = this
       try {
         var params = { page: this.page, size: this.size }
+        if (this.currentChildId) {
+          params.memberId = parseInt(this.currentChildId)
+        }
         if (this.currentCategory) {
           params.categoryId = this.currentCategory
         }

+ 8 - 66
cfc-frontend/pages/profile-main/profile.vue

@@ -33,8 +33,9 @@
           <view class="user-meta">
             <view class="user-name-row">
               <text class="nickname">{{ nickname || '未设置昵称' }}</text>
-              <text class="role-tag">{{ roleLabel }}</text>
+              <text v-if="roleLabel" class="role-tag">{{ roleLabel }}</text>
             </view>
+            <text v-if="myReferrer && myReferrer.nickname" class="referrer-line">推荐人:{{ myReferrer.nickname }}</text>
             <view class="user-sub" v-if="memberLevel">
               <view class="level-badge" :class="'level-' + memberLevel">{{ memberLevelText }}</view>
               <text v-if="isLifetime" class="days-left">永久有效</text>
@@ -86,27 +87,6 @@
         </view>
       </view>
 
-      <!-- 我的推荐人卡片 -->
-      <view class="referrer-card" v-if="myReferrer && myReferrer.hasReferrer">
-        <view class="referrer-header">
-          <text class="referrer-title">我的推荐人</text>
-        </view>
-        <view class="referrer-content">
-          <view class="referrer-avatar-wrap">
-            <image v-if="myReferrer.avatar" class="referrer-avatar" :src="myReferrer.avatar" mode="aspectFill"></image>
-            <text v-else class="referrer-avatar-text">{{ myReferrer.nickname ? myReferrer.nickname.charAt(0) : '?' }}</text>
-          </view>
-          <view class="referrer-info">
-            <text class="referrer-name">{{ myReferrer.nickname }}</text>
-            <text class="referrer-code">邀请码:{{ myReferrer.referralCode }}</text>
-            <text class="referrer-time" v-if="myReferrer.bindTime">{{ formatReferralTime(myReferrer.bindTime) }}</text>
-          </view>
-          <view class="referrer-copy-btn" @click="copyReferrerCode">
-            <text>复制</text>
-          </view>
-        </view>
-      </view>
-
       <!-- 个人成就统计 -->
       <view class="achievement-section">
         <view class="section-header-row">
@@ -338,7 +318,7 @@ export default {
       return !!uni.getStorageSync('token')
     },
     roleLabel() {
-      var map = { parent: '家长', child: '孩子', teacher: '规划师' }
+      var map = { parent: '', child: '孩子', teacher: '规划师' }
       return map[this.role] || '用户'
     },
     memberLevelText() {
@@ -531,23 +511,6 @@ export default {
     goTeamTree() {
       uni.navigateTo({ url: '/pages/profile/team-tree' })
     },
-    copyReferrerCode() {
-      if (!this.myReferrer || !this.myReferrer.referralCode) return
-      uni.setClipboardData({
-        data: this.myReferrer.referralCode,
-        success: function() {
-          uni.showToast({ title: '邀请码已复制', icon: 'success' })
-        }
-      })
-    },
-    formatReferralTime(ts) {
-      if (!ts) return ''
-      var d = ts
-      if (typeof d === 'string' && d.indexOf('T') > -1) {
-        d = d.substring(0, 16).replace('T', ' ')
-      }
-      return '绑定于 ' + d
-    },
     goLogin: function() {
       uni.navigateTo({ url: '/pages/login/login' })
     },
@@ -796,6 +759,11 @@ export default {
   padding: 4rpx 14rpx;
   border-radius: 20rpx;
 }
+.referrer-line {
+  font-size: 24rpx;
+  color: rgba(255,255,255,0.7);
+  margin-bottom: 6rpx;
+}
 .user-sub {
   display: flex;
   align-items: center;
@@ -1202,30 +1170,4 @@ export default {
   color: #FF4D4F;
   font-weight: 500;
 }
-
-/* 我的推荐人卡片 */
-.referrer-card {
-  margin: 0 30rpx 20rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.referrer-header { margin-bottom: 16rpx; }
-.referrer-title { font-size: 28rpx; font-weight: 600; color: #1E293B; }
-.referrer-content { display: flex; align-items: center; gap: 20rpx; }
-.referrer-avatar-wrap {
-  width: 72rpx; height: 72rpx; border-radius: 50%; background: #FEF3C7;
-  display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;
-}
-.referrer-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; }
-.referrer-avatar-text { font-size: 28rpx; color: #F97316; }
-.referrer-info { flex: 1; min-width: 0; }
-.referrer-name { font-size: 28rpx; font-weight: 500; color: #1E293B; display: block; }
-.referrer-code { font-size: 24rpx; color: #F97316; margin-top: 4rpx; display: block; }
-.referrer-time { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
-.referrer-copy-btn {
-  font-size: 26rpx; color: #F97316; padding: 8rpx 16rpx; border: 1rpx solid #FED7AA;
-  border-radius: 20rpx; flex-shrink: 0;
-}
 </style>

+ 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 修复