ソースを参照

feat: article publish-review system + gut flora report parsing

Backend:
- Article: add submittedBy, reviewedBy, reviewedAt, reviewComment, sourceType, sourceUrl fields
- ArticleService: submit, review, getPendingList, getUserSubmissions methods
- ArticleController: /submit, /my-submissions, /fetch-link, /upload/document endpoints
- AdminArticleController: /review, /pending-list endpoints
- PdfParseService + DTOs: parse gut flora PDF reports (ParsedReportResult, ParsedGutFlora, etc.)
- HealthReport/HealthGutFlora entities: report upload and storage
- HealthReportController: report upload + parsing endpoints
- Activity: add fields and endpoints
- DatabaseInitializer: schema updates

Frontend (mini-program):
- pages.json: register new pages
- body/index.vue + member-body-detail.vue: health report UI
- action/index.vue + member-action-detail.vue: activity UI
- mind: article-detail, index, member-mind-detail updates
- child-index.vue: updates
- api.js: new API endpoints
User 4 ヶ月 前
親
コミット
d4e43d2418
33 ファイル変更、2008 行追加、19 行削除
  1. 7 0
      cfc-backend/pom.xml
  2. 162 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java
  4. 176 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  5. 3 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
  6. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedDiseaseRisk.java
  7. 89 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedGutFlora.java
  8. 53 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedIndicator.java
  9. 180 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportResult.java
  10. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java
  11. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
  12. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleReadingRecord.java
  13. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthGutFlora.java
  14. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java
  15. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java
  16. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskMapper.java
  17. 146 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  18. 183 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  19. 102 1
      cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java
  20. 97 1
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthCheckinService.java
  21. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  22. 574 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java
  23. 4 0
      cfc-backend/src/main/resources/application.yml
  24. 6 0
      cfc-frontend/pages.json
  25. 1 1
      cfc-frontend/pages/action/index.vue
  26. 1 1
      cfc-frontend/pages/action/member-action-detail.vue
  27. 1 1
      cfc-frontend/pages/body/index.vue
  28. 1 1
      cfc-frontend/pages/body/member-body-detail.vue
  29. 1 1
      cfc-frontend/pages/index/child-index.vue
  30. 98 6
      cfc-frontend/pages/mind/article-detail.vue
  31. 1 1
      cfc-frontend/pages/mind/index.vue
  32. 1 1
      cfc-frontend/pages/mind/member-mind-detail.vue
  33. 1 0
      cfc-frontend/utils/api.js

+ 7 - 0
cfc-backend/pom.xml

@@ -119,6 +119,13 @@
             <version>1.8.0</version>
         </dependency>
 
+        <!-- PDFBox -->
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>pdfbox</artifactId>
+            <version>2.0.31</version>
+        </dependency>
+
         <!-- Test -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

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

@@ -973,6 +973,13 @@ log.info("已添加template_id列到tasks表");
             insertSysConfigSeed("withdraw_min_amount", "10000", "最低提现金额(分)");
             insertSysConfigSeed("withdraw_tax_rate", "2000", "佣金税率(bps)");
             insertSysConfigSeed("withdraw_fee_free_threshold", "80000", "免税额度(分)");
+            insertSysConfigSeed("article_energy_rate_normal", "1", "普通文章能量汇率(每100字)");
+            insertSysConfigSeed("article_energy_rate_premium", "2", "优质文章能量汇率(每100字)");
+            insertSysConfigSeed("article_energy_rate_original", "3", "原创文章能量汇率(每100字)");
+            insertSysConfigSeed("article_reading_min_seconds", "60", "文章阅读达标最少秒数");
+            insertSysConfigSeed("activity_checkin_default_points", "5", "活动签到默认积分");
+            insertSysConfigSeed("health_checkin_points", "5", "健康打卡积分");
+            insertSysConfigSeed("finance_checkin_points", "5", "理财打卡积分");
             log.info("SysConfig 种子数据已加载");
         } catch (Exception e) {
             log.warn("SysConfig 种子数据初始化失败: {}", e.getMessage());
@@ -1435,6 +1442,71 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建 health_disease_risk 表失败: {}", e.getMessage());
         }
 
+        // ==================== 迁移: health_reports 表添加新字段 ====================
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN balance_score INT DEFAULT NULL COMMENT '菌群平衡分'");
+            log.info("已添加balance_score列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN diversity_score INT DEFAULT NULL COMMENT '菌群多样性分'");
+            log.info("已添加diversity_score列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN beneficial_score INT DEFAULT NULL COMMENT '有益菌评分'");
+            log.info("已添加beneficial_score列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN harmful_score INT DEFAULT NULL COMMENT '有害菌评分'");
+            log.info("已添加harmful_score列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports ADD COLUMN core_genus_score INT DEFAULT NULL COMMENT '核心菌属评分'");
+            log.info("已添加core_genus_score列到health_reports表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        // ==================== 迁移: health_gut_flora 表添加新字段 ====================
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN population_level VARCHAR(50) DEFAULT NULL COMMENT '处于人群%水平'");
+            log.info("已添加population_level列到health_gut_flora表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN detection_rate VARCHAR(50) DEFAULT NULL COMMENT '%正常人有检出'");
+            log.info("已添加detection_rate列到health_gut_flora表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN description TEXT DEFAULT NULL COMMENT '菌种说明'");
+            log.info("已添加description列到health_gut_flora表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_gut_flora ADD COLUMN `level` VARCHAR(20) DEFAULT 'GENUS' COMMENT '级别: GENUS/SPECIES'");
+            log.info("已添加level列到health_gut_flora表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
         // ==================== 迁移: guides 表统一 ====================
 
         // 迁移: guides 添加 type 字段(区分服务商类型)
@@ -1918,6 +1990,96 @@ log.info("已添加template_id列到tasks表");
             log.warn("检查/添加activities表visible_scope列失败: {}", e.getMessage());
         }
 
+        // ==================== 迁移: articles表添加article_type列 ====================
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'article_type'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN article_type VARCHAR(20) DEFAULT 'normal' COMMENT '文章类型: normal(普通)/premium(优质)/original(原创)' AFTER read_time");
+                log.info("已添加article_type列到articles表");
+            } else {
+                log.info("articles表article_type列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加articles表article_type列失败: {}", e.getMessage());
+        }
+
+        // ==================== 迁移: articles表添加word_count列 ====================
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'word_count'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE articles ADD COLUMN word_count INT DEFAULT 0 COMMENT '文章字数' AFTER article_type");
+                log.info("已添加word_count列到articles表");
+            } else {
+                log.info("articles表word_count列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加articles表word_count列失败: {}", e.getMessage());
+        }
+
+        // ==================== 迁移: article_reading_records表添加task_id列 ====================
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'article_reading_records' AND COLUMN_NAME = 'task_id'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE article_reading_records ADD COLUMN task_id BIGINT DEFAULT NULL COMMENT '关联的任务ID' AFTER duration_seconds");
+                log.info("已添加task_id列到article_reading_records表");
+            } else {
+                log.info("article_reading_records表task_id列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加article_reading_records表task_id列失败: {}", e.getMessage());
+        }
+
+        // ==================== 迁移: tasks表添加source_type列 ====================
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'source_type'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN source_type VARCHAR(20) DEFAULT NULL COMMENT '来源类型: article/activity/manual' AFTER minigame_code");
+                log.info("已添加source_type列到tasks表");
+            } else {
+                log.info("tasks表source_type列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加tasks表source_type列失败: {}", e.getMessage());
+        }
+
+        // ==================== 迁移: tasks表添加source_id列 ====================
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tasks' AND COLUMN_NAME = 'source_id'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN source_id BIGINT DEFAULT NULL COMMENT '来源ID' AFTER source_type");
+                log.info("已添加source_id列到tasks表");
+            } else {
+                log.info("tasks表source_id列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加tasks表source_id列失败: {}", e.getMessage());
+        }
+
+        // ==================== 迁移: activities表添加checkin_points列 ====================
+        try {
+            Integer colExists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'checkin_points'",
+                Integer.class);
+            if (colExists == null || colExists == 0) {
+                jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN checkin_points INT DEFAULT 0 COMMENT '签到积分(0=使用系统默认)' AFTER price");
+                log.info("已添加checkin_points列到activities表");
+            } else {
+                log.info("activities表checkin_points列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加activities表checkin_points列失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
     }
 

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -69,4 +69,19 @@ public class ActivityController {
         if (id == null) return Result.error("id不能为空");
         return activityService.end(id);
     }
+
+    @Operation(summary = "活动签到")
+    @PostMapping("/checkin")
+    public Result<Map<String, Object>> checkin(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+
+        Map<String, Object> checkinResult = activityService.checkin(id, userId, childId);
+        if (checkinResult.containsKey("error")) {
+            return Result.error(checkinResult.get("error").toString());
+        }
+        return Result.success(checkinResult);
+    }
 }

+ 176 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1,20 +1,38 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ParsedDiseaseRisk;
+import com.etotem.cfc.dto.ParsedGutFlora;
+import com.etotem.cfc.dto.ParsedIndicator;
+import com.etotem.cfc.dto.ParsedReportResult;
+import com.etotem.cfc.entity.HealthDiseaseRisk;
+import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.PdfParseService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 /**
  * 健康检测报告接口 — 体检报告/菌群检测报告的录入与查询
  */
+@Slf4j
 @Tag(name = "健康检测报告", description = "健康报告上传、查询、指标明细")
 @RestController
 @RequestMapping("/api/health")
@@ -23,6 +41,9 @@ public class HealthReportController {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private PdfParseService pdfParseService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -180,6 +201,130 @@ public class HealthReportController {
         return Result.success(detail);
     }
 
+    /**
+     * 上传并解析肠道菌群报告PDF
+     */
+    @Operation(summary = "上传并解析肠道菌群报告PDF")
+    @PostMapping("/report/upload")
+    public Result<Map<String, Object>> uploadReport(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("childId") Long childId,
+            @RequestParam(value = "familyId", required = false) Long familyId) {
+
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+
+        // 验证文件类型
+        String contentType = file.getContentType();
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null || (!originalFilename.toLowerCase().endsWith(".pdf")
+                && (contentType == null || !contentType.equalsIgnoreCase("application/pdf")))) {
+            return Result.error("仅支持PDF文件");
+        }
+
+        try {
+            // 1. 解析PDF
+            ParsedReportResult parsed = pdfParseService.parse(file.getInputStream());
+            if (parsed.getOverallScore() == null && parsed.getGutHealthScore() == null) {
+                return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
+            }
+
+            // 2. 保存文件到本地存储
+            String fileUrl = saveUploadFile(file, childId);
+
+            // 3. 构建健康报告实体
+            HealthReport report = new HealthReport();
+            report.setUserId(childId);
+            report.setFamilyId(familyId);
+            report.setReportType("gut_flora");
+            report.setReportDate(new Date());
+            report.setOverallScore(parsed.getOverallScore());
+            report.setGutHealthScore(parsed.getGutHealthScore());
+            report.setChronicDiseaseScore(parsed.getChronicDiseaseScore());
+            report.setNutritionScore(parsed.getNutritionScore());
+            report.setBalanceScore(parsed.getBalanceScore());
+            report.setDiversityScore(parsed.getDiversityScore());
+            report.setBeneficialScore(parsed.getBeneficialScore());
+            report.setHarmfulScore(parsed.getHarmfulScore());
+            report.setCoreGenusScore(parsed.getCoreGenusScore());
+            report.setGutAge(parsed.getGutAge());
+            report.setGutType(parsed.getGutType());
+            report.setFileUrl(fileUrl);
+            report.setRemark("募极生物肠道菌群检测报告");
+
+            // 4. 转换指标列表
+            List<HealthIndicator> indicators = new ArrayList<>();
+            if (parsed.getIndicators() != null) {
+                for (int i = 0; i < parsed.getIndicators().size(); i++) {
+                    ParsedIndicator pi = parsed.getIndicators().get(i);
+                    HealthIndicator ind = new HealthIndicator();
+                    ind.setCategory(pi.getCategory());
+                    ind.setIndicatorName(pi.getIndicatorName());
+                    ind.setIndicatorValue(pi.getIndicatorValue());
+                    ind.setStatus(pi.getStatus());
+                    ind.setRefRange(pi.getRefRange());
+                    ind.setSortOrder(i);
+                    indicators.add(ind);
+                }
+            }
+
+            // 5. 转换菌群数据(核心菌属 + 有害菌属)
+            List<HealthGutFlora> gutFloraList = new ArrayList<>();
+            if (parsed.getGutFlora() != null) {
+                for (ParsedGutFlora pgf : parsed.getGutFlora()) {
+                    HealthGutFlora gf = new HealthGutFlora();
+                    gf.setBacteriaName(pgf.getBacteriaName());
+                    gf.setBacteriaValue(pgf.getBacteriaValue());
+                    gf.setNormalRange(pgf.getNormalRange());
+                    gf.setPopulationLevel(pgf.getPopulationLevel());
+                    gf.setDetectionRate(pgf.getDetectionRate());
+                    gf.setDescription(pgf.getDescription());
+                    gf.setCategory(pgf.getCategory());
+                    gf.setLevel(pgf.getLevel());
+                    gutFloraList.add(gf);
+                }
+            }
+
+            // 6. 转换益生菌(level=SPECIES)
+            if (parsed.getProbioticSpecies() != null) {
+                for (ParsedGutFlora pgf : parsed.getProbioticSpecies()) {
+                    HealthGutFlora gf = new HealthGutFlora();
+                    gf.setBacteriaName(pgf.getBacteriaName());
+                    gf.setBacteriaValue(pgf.getBacteriaValue());
+                    gf.setPopulationLevel(pgf.getPopulationLevel());
+                    gf.setDetectionRate(pgf.getDetectionRate());
+                    gf.setCategory(pgf.getCategory());
+                    gf.setLevel(pgf.getLevel());
+                    gutFloraList.add(gf);
+                }
+            }
+
+            // 7. 转换疾病风险评估
+            List<HealthDiseaseRisk> diseaseRiskList = new ArrayList<>();
+            if (parsed.getDiseaseRisks() != null) {
+                for (ParsedDiseaseRisk pdr : parsed.getDiseaseRisks()) {
+                    HealthDiseaseRisk dr = new HealthDiseaseRisk();
+                    dr.setDiseaseName(pdr.getDiseaseName());
+                    dr.setRiskValue(pdr.getRiskValue());
+                    dr.setRiskLevel(pdr.getRiskLevel());
+                    diseaseRiskList.add(dr);
+                }
+            }
+
+            // 8. 创建完整报告
+            HealthReport created = healthReportService.createReport(report, indicators, gutFloraList, diseaseRiskList);
+
+            // 9. 返回详情
+            Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
+            return Result.success(detail);
+
+        } catch (IOException e) {
+            log.error("PDF解析失败", e);
+            return Result.error("PDF解析失败: " + e.getMessage());
+        }
+    }
+
     /**
      * 归档报告
      */
@@ -195,4 +340,35 @@ public class HealthReportController {
         healthReportService.archiveReport(reportId);
         return Result.success("报告已归档");
     }
+
+    /**
+     * 保存上传文件到本地存储
+     */
+    private String saveUploadFile(MultipartFile file, Long childId) throws IOException {
+        String uploadDir = "uploads" + File.separator + "health-reports" + File.separator + childId;
+        File dir = new File(uploadDir);
+        if (!dir.exists()) {
+            dir.mkdirs();
+        }
+
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null) {
+            originalFilename = "report.pdf";
+        }
+
+        // 文件名: timestamp_uuid.extension
+        String extension = "";
+        int dotIdx = originalFilename.lastIndexOf('.');
+        if (dotIdx > 0) {
+            extension = originalFilename.substring(dotIdx);
+        }
+        String savedFilename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + extension;
+        Path filePath = Paths.get(uploadDir, savedFilename);
+
+        Files.copy(file.getInputStream(), filePath);
+
+        String relativePath = uploadDir.replace("\\", "/") + "/" + savedFilename;
+        log.info("报告文件已保存: {}", relativePath);
+        return relativePath;
+    }
 }

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

@@ -97,7 +97,7 @@ public class ArticleController {
     }
 
     @PostMapping("/record-read")
-    public Result<String> recordRead(@RequestBody Map<String, Object> body,
+    public Result<Map<String, Object>> recordRead(@RequestBody Map<String, Object> body,
                                       @RequestAttribute(value = "userId", required = false) Long userId) {
         Long articleId = Long.valueOf(body.get("id").toString());
         int durationSeconds = body.get("durationSeconds") != null
@@ -105,8 +105,8 @@ public class ArticleController {
         Long childId = body.get("childId") != null
                 ? Long.valueOf(body.get("childId").toString()) : null;
 
-        articleService.recordRead(articleId, userId, childId, durationSeconds);
-        return Result.success("ok");
+        Map<String, Object> readResult = articleService.recordRead(articleId, userId, childId, durationSeconds);
+        return Result.success(readResult);
     }
 
     @PostMapping("/reading-stats")

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedDiseaseRisk.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.dto;
+
+/**
+ * 从PDF中解析的疾病风险评估数据
+ */
+public class ParsedDiseaseRisk {
+
+    private String diseaseName;
+    private String riskValue;
+    private String riskLevel;
+
+    public String getDiseaseName() {
+        return diseaseName;
+    }
+
+    public void setDiseaseName(String diseaseName) {
+        this.diseaseName = diseaseName;
+    }
+
+    public String getRiskValue() {
+        return riskValue;
+    }
+
+    public void setRiskValue(String riskValue) {
+        this.riskValue = riskValue;
+    }
+
+    public String getRiskLevel() {
+        return riskLevel;
+    }
+
+    public void setRiskLevel(String riskLevel) {
+        this.riskLevel = riskLevel;
+    }
+}

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedGutFlora.java

@@ -0,0 +1,89 @@
+package com.etotem.cfc.dto;
+
+/**
+ * 从PDF中解析的菌群数据
+ */
+public class ParsedGutFlora {
+
+    private String bacteriaName;
+    private String bacteriaValue;   // 丰度%
+    private String normalRange;     // 正常范围%
+    private String populationLevel; // 处于人群%水平
+    private String detectionRate;   // %正常人有检出
+    private String description;     // 菌种说明
+    private String category;        // 分类: 核心菌属/有害菌属/益生菌
+    private String level;           // GENUS/SPECIES
+    private String status;          // 状态: 正常/偏低/偏高
+
+    public String getBacteriaName() {
+        return bacteriaName;
+    }
+
+    public void setBacteriaName(String bacteriaName) {
+        this.bacteriaName = bacteriaName;
+    }
+
+    public String getBacteriaValue() {
+        return bacteriaValue;
+    }
+
+    public void setBacteriaValue(String bacteriaValue) {
+        this.bacteriaValue = bacteriaValue;
+    }
+
+    public String getNormalRange() {
+        return normalRange;
+    }
+
+    public void setNormalRange(String normalRange) {
+        this.normalRange = normalRange;
+    }
+
+    public String getPopulationLevel() {
+        return populationLevel;
+    }
+
+    public void setPopulationLevel(String populationLevel) {
+        this.populationLevel = populationLevel;
+    }
+
+    public String getDetectionRate() {
+        return detectionRate;
+    }
+
+    public void setDetectionRate(String detectionRate) {
+        this.detectionRate = detectionRate;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    public String getCategory() {
+        return category;
+    }
+
+    public void setCategory(String category) {
+        this.category = category;
+    }
+
+    public String getLevel() {
+        return level;
+    }
+
+    public void setLevel(String level) {
+        this.level = level;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+}

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedIndicator.java

@@ -0,0 +1,53 @@
+package com.etotem.cfc.dto;
+
+/**
+ * 从PDF中解析的健康指标
+ */
+public class ParsedIndicator {
+
+    private String category;        // 指标分类
+    private String indicatorName;   // 指标名称
+    private String indicatorValue;  // 检测值
+    private String status;          // 状态: 正常/偏低/缺乏/偏高/不足等
+    private String refRange;        // 参考范围
+
+    public String getCategory() {
+        return category;
+    }
+
+    public void setCategory(String category) {
+        this.category = category;
+    }
+
+    public String getIndicatorName() {
+        return indicatorName;
+    }
+
+    public void setIndicatorName(String indicatorName) {
+        this.indicatorName = indicatorName;
+    }
+
+    public String getIndicatorValue() {
+        return indicatorValue;
+    }
+
+    public void setIndicatorValue(String indicatorValue) {
+        this.indicatorValue = indicatorValue;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public void setStatus(String status) {
+        this.status = status;
+    }
+
+    public String getRefRange() {
+        return refRange;
+    }
+
+    public void setRefRange(String refRange) {
+        this.refRange = refRange;
+    }
+}

+ 180 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportResult.java

@@ -0,0 +1,180 @@
+package com.etotem.cfc.dto;
+
+import java.util.List;
+
+/**
+ * PDF解析结果 — 包含所有从募极生物肠道菌群报告中提取的数据
+ */
+public class ParsedReportResult {
+
+    // ==================== 汇总 ====================
+
+    /** 健康总分 */
+    private Integer overallScore;
+
+    /** 菌群健康 */
+    private Integer gutHealthScore;
+
+    /** 慢病控制 */
+    private Integer chronicDiseaseScore;
+
+    /** 营养均衡 */
+    private Integer nutritionScore;
+
+    /** 肠道菌群平衡 */
+    private Integer balanceScore;
+
+    /** 菌群多样性 */
+    private Integer diversityScore;
+
+    /** 有益菌 */
+    private Integer beneficialScore;
+
+    /** 有害菌 */
+    private Integer harmfulScore;
+
+    /** 核心菌属 */
+    private Integer coreGenusScore;
+
+    /** 肠道预测年龄 */
+    private String gutAge;
+
+    /** 肠型 */
+    private String gutType;
+
+    // ==================== 列表数据 ====================
+
+    /** 疾病风险评估 */
+    private List<ParsedDiseaseRisk> diseaseRisks;
+
+    /** 核心菌属 + 有害菌属 (level=GENUS) */
+    private List<ParsedGutFlora> gutFlora;
+
+    /** 益生菌种 (level=SPECIES) */
+    private List<ParsedGutFlora> probioticSpecies;
+
+    /** 健康指标 — 营养、屏障、短链脂肪酸、神经递质、抗生素耐药等 */
+    private List<ParsedIndicator> indicators;
+
+    // ==================== getters/setters ====================
+
+    public Integer getOverallScore() {
+        return overallScore;
+    }
+
+    public void setOverallScore(Integer overallScore) {
+        this.overallScore = overallScore;
+    }
+
+    public Integer getGutHealthScore() {
+        return gutHealthScore;
+    }
+
+    public void setGutHealthScore(Integer gutHealthScore) {
+        this.gutHealthScore = gutHealthScore;
+    }
+
+    public Integer getChronicDiseaseScore() {
+        return chronicDiseaseScore;
+    }
+
+    public void setChronicDiseaseScore(Integer chronicDiseaseScore) {
+        this.chronicDiseaseScore = chronicDiseaseScore;
+    }
+
+    public Integer getNutritionScore() {
+        return nutritionScore;
+    }
+
+    public void setNutritionScore(Integer nutritionScore) {
+        this.nutritionScore = nutritionScore;
+    }
+
+    public Integer getBalanceScore() {
+        return balanceScore;
+    }
+
+    public void setBalanceScore(Integer balanceScore) {
+        this.balanceScore = balanceScore;
+    }
+
+    public Integer getDiversityScore() {
+        return diversityScore;
+    }
+
+    public void setDiversityScore(Integer diversityScore) {
+        this.diversityScore = diversityScore;
+    }
+
+    public Integer getBeneficialScore() {
+        return beneficialScore;
+    }
+
+    public void setBeneficialScore(Integer beneficialScore) {
+        this.beneficialScore = beneficialScore;
+    }
+
+    public Integer getHarmfulScore() {
+        return harmfulScore;
+    }
+
+    public void setHarmfulScore(Integer harmfulScore) {
+        this.harmfulScore = harmfulScore;
+    }
+
+    public Integer getCoreGenusScore() {
+        return coreGenusScore;
+    }
+
+    public void setCoreGenusScore(Integer coreGenusScore) {
+        this.coreGenusScore = coreGenusScore;
+    }
+
+    public String getGutAge() {
+        return gutAge;
+    }
+
+    public void setGutAge(String gutAge) {
+        this.gutAge = gutAge;
+    }
+
+    public String getGutType() {
+        return gutType;
+    }
+
+    public void setGutType(String gutType) {
+        this.gutType = gutType;
+    }
+
+    public List<ParsedDiseaseRisk> getDiseaseRisks() {
+        return diseaseRisks;
+    }
+
+    public void setDiseaseRisks(List<ParsedDiseaseRisk> diseaseRisks) {
+        this.diseaseRisks = diseaseRisks;
+    }
+
+    public List<ParsedGutFlora> getGutFlora() {
+        return gutFlora;
+    }
+
+    public void setGutFlora(List<ParsedGutFlora> gutFlora) {
+        this.gutFlora = gutFlora;
+    }
+
+    public List<ParsedGutFlora> getProbioticSpecies() {
+        return probioticSpecies;
+    }
+
+    public void setProbioticSpecies(List<ParsedGutFlora> probioticSpecies) {
+        this.probioticSpecies = probioticSpecies;
+    }
+
+    public List<ParsedIndicator> getIndicators() {
+        return indicators;
+    }
+
+    public void setIndicators(List<ParsedIndicator> indicators) {
+        this.indicators = indicators;
+    }
+}

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

@@ -42,6 +42,9 @@ public class Activity implements Serializable {
 
     private Integer price;
 
+    /** 签到积分(0=使用系统默认) */
+    private Integer checkinPoints;
+
     /** public(公开)/restricted(私密)/all(默认)/member */
     private String visibility;
 

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -20,6 +20,10 @@ public class Article implements Serializable {
     private String tags;
     private String author;
     private Integer readTime;
+    // 文章类型: normal(普通) / premium(优质) / original(原创)
+    private String articleType;
+    // 文章字数(自动计算,用于阅读能量换算)
+    private Integer wordCount;
     private String relatedDimensions;
     private String visibility;
     private String visibleTo;

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

@@ -23,6 +23,9 @@ public class ArticleReadingRecord implements Serializable {
 
     private Integer durationSeconds;
 
+    // 关联的任务ID(阅读达标后自动创建的任务)
+    private Long taskId;
+
     private Date readAt;
 
     private Date createdAt;

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthGutFlora.java

@@ -40,5 +40,17 @@ public class HealthGutFlora implements Serializable {
     /** 排序 */
     private Integer sortOrder;
 
+    /** 处于人群%水平 (e.g., "35%", "50%") */
+    private String populationLevel;
+
+    /** %正常人有检出 (e.g., "98.56%", "91.83%") */
+    private String detectionRate;
+
+    /** 菌种说明 (long text describing the bacteria) */
+    private String description;
+
+    /** 级别: GENUS(菌属)/SPECIES(菌种), default GENUS */
+    private String level;
+
     private Date createdAt;
 }

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -76,6 +76,21 @@ public class HealthReport implements Serializable {
     /** 标注信息(如生成报告的机构名) */
     private String remark;
 
+    /** 菌群平衡分 (肠道菌群平衡) */
+    private Integer balanceScore;
+
+    /** 菌群多样性分 (菌群多样性) */
+    private Integer diversityScore;
+
+    /** 有益菌评分 (有益菌) */
+    private Integer beneficialScore;
+
+    /** 有害菌评分 (有害菌) */
+    private Integer harmfulScore;
+
+    /** 核心菌属评分 (核心菌属) */
+    private Integer coreGenusScore;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Task.java

@@ -90,6 +90,12 @@ private Date latestEnd;
     // 小游戏代码(当category为'小游戏类'时使用): schulte/1a2b/sudoku
     private String minigameCode;
 
+    // 来源类型: article(文章阅读) / activity(活动签到) / manual(手动创建)
+    private String sourceType;
+
+    // 来源ID(如文章ID、活动ID)
+    private Long sourceId;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskMapper.java

@@ -3,7 +3,12 @@ package com.etotem.cfc.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.Task;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 @Mapper
 public interface TaskMapper extends BaseMapper<Task> {
+
+    @Select("SELECT * FROM tasks WHERE source_type = #{sourceType} AND source_id = #{sourceId} LIMIT 1")
+    Task selectBySource(@Param("sourceType") String sourceType, @Param("sourceId") Long sourceId);
 }

+ 146 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -6,17 +6,42 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ActivityDTO;
 import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
+import javax.annotation.Resource;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
 
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
     public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
         LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
                 .eq(Activity::getStatus, "published")
@@ -91,4 +116,125 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         this.updateById(activity);
         return Result.success("活动已结束");
     }
+
+    @Transactional
+    public Map<String, Object> checkin(Long activityId, Long userId, Long childId) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("taskId", null);
+        result.put("pointsEarned", 0);
+
+        // 1. Validate activity exists and is active
+        Activity activity = this.getById(activityId);
+        if (activity == null) {
+            result.put("error", "活动不存在");
+            return result;
+        }
+        if (!"published".equals(activity.getStatus())) {
+            result.put("error", "活动未发布或已结束");
+            return result;
+        }
+
+        // 2. If no childId, can't create task
+        if (childId == null) {
+            result.put("error", "需要孩子身份签到");
+            return result;
+        }
+
+        // 3. Check if already checked in (dedup by sourceType + sourceId + childId)
+        Task existingTask = taskMapper.selectOne(
+            new LambdaQueryWrapper<Task>()
+                .eq(Task::getSourceType, "activity")
+                .eq(Task::getSourceId, activityId)
+                .eq(Task::getChildId, childId)
+                .last("LIMIT 1")
+        );
+        if (existingTask != null) {
+            result.put("taskId", existingTask.getId());
+            result.put("pointsEarned", 0);
+            result.put("alreadyCompleted", true);
+            return result;
+        }
+
+        // 4. Determine points — use activity's checkinPoints or sysconfig default
+        int points = 5; // default
+        if (activity.getCheckinPoints() != null && activity.getCheckinPoints() > 0) {
+            points = activity.getCheckinPoints();
+        } else {
+            String defaultPointsStr = sysConfigService.getValue("activity_checkin_default_points");
+            if (defaultPointsStr != null && !defaultPointsStr.isEmpty()) {
+                try { points = Integer.parseInt(defaultPointsStr); } catch (NumberFormatException e) { /* use default */ }
+            }
+        }
+
+        // 5. Create task with status='completed' (auto-completed by check-in)
+        Task task = new Task();
+        Child child = childMapper.selectById(childId);
+        if (child != null) {
+            task.setFamilyId(child.getFamilyId());
+        }
+        task.setCreatorId(userId);
+        task.setExecutorType("child");
+        task.setExecutorId(childId);
+        task.setChildId(childId);
+        task.setTitle("活动签到: " + activity.getTitle());
+        task.setDescription("自动生成 - 签到活动《" + activity.getTitle() + "》");
+        task.setPoints(points);
+        task.setRepeatType("none");
+        task.setCategory("行动类");
+        task.setNeedReview(0);
+        task.setReviewByCategory(0);
+        task.setTaskType("onetime");
+        task.setSourceType("activity");
+        task.setSourceId(activityId);
+        task.setStatus("completed");
+        task.setCompletedAt(new Date());
+        task.setCreatedAt(new Date());
+        task.setUpdatedAt(new Date());
+
+        // Set deadline to today end
+        java.util.Calendar cal = java.util.Calendar.getInstance();
+        cal.set(java.util.Calendar.HOUR_OF_DAY, 23);
+        cal.set(java.util.Calendar.MINUTE, 59);
+        cal.set(java.util.Calendar.SECOND, 59);
+        task.setDeadline(cal.getTime());
+
+        taskMapper.insert(task);
+
+        // 6. Update activity currentParticipants
+        activity.setCurrentParticipants(activity.getCurrentParticipants() != null ? activity.getCurrentParticipants() + 1 : 1);
+        activity.setUpdatedAt(new Date());
+        this.updateById(activity);
+
+        // 7. Update child totalPoints
+        if (child != null) {
+            child.setTotalPoints(child.getTotalPoints() + points);
+            child.setUpdatedAt(new Date());
+            childMapper.updateById(child);
+        }
+
+        // 8. Insert PointsLog
+        PointsLog pointsLog = new PointsLog();
+        pointsLog.setChildId(childId);
+        pointsLog.setTaskId(task.getId());
+        pointsLog.setAmount(points);
+        pointsLog.setType("earn");
+        pointsLog.setCategory("task");
+        pointsLog.setDescription("活动签到: " + activity.getTitle());
+        pointsLog.setCreatedAt(new Date());
+        pointsLogMapper.insert(pointsLog);
+
+        // 9. Award energy via EnergyService
+        try {
+            energyService.awardEnergy(childId, "activity", activityId, points,
+                "活动签到: " + activity.getTitle(), null);
+        } catch (Exception e) {
+            log.warn("能量发放失败(不影响积分): childId={}, activityId={}, error={}",
+                childId, activityId, e.getMessage());
+        }
+
+        // 10. Return result
+        result.put("taskId", task.getId());
+        result.put("pointsEarned", points);
+        return result;
+    }
 }

+ 183 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -6,8 +6,14 @@ import com.etotem.cfc.dto.ReadingStatsDTO;
 import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ArticleCategory;
 import com.etotem.cfc.entity.ArticleReadingRecord;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.mapper.ArticleMapper;
 import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskMapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
@@ -34,6 +40,21 @@ public class ArticleService {
     @Resource
     private ArticleReadingRecordMapper articleReadingRecordMapper;
 
+    @Resource
+    private SysConfigService sysConfigService;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private EnergyService energyService;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
@@ -94,12 +115,161 @@ public class ArticleService {
         return article;
     }
 
+    private int calculateWordCount(String htmlContent) {
+        if (htmlContent == null || htmlContent.isEmpty()) return 0;
+        // Strip HTML tags
+        String text = htmlContent.replaceAll("<[^>]*>", "");
+        // Strip HTML entities
+        text = text.replaceAll("&[a-zA-Z]+;", " ");
+        // Remove extra whitespace
+        text = text.replaceAll("\\s+", " ").trim();
+        // Count characters (Chinese text: each character is a "word")
+        return text.length();
+    }
+
     @Transactional
-    public void recordRead(Long articleId, Long userId, Long childId, int durationSeconds) {
+    public Map<String, Object> recordRead(Long articleId, Long userId, Long childId, int durationSeconds) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("taskId", null);
+        result.put("pointsEarned", 0);
+
+        // 1. Increment view count
         LambdaUpdateWrapper<Article> wrapper = Wrappers.lambdaUpdate(Article.class)
                 .setSql("view_count = view_count + 1")
                 .eq(Article::getId, articleId);
         articleMapper.update(null, wrapper);
+
+        // 2. Save reading record
+        ArticleReadingRecord record = new ArticleReadingRecord();
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setContent("");
+        record.setDurationSeconds(durationSeconds);
+        record.setReadAt(new Date());
+        record.setCreatedAt(new Date());
+        articleReadingRecordMapper.insert(record);
+
+        // 3. If no childId, can't create task (reading as parent, not child)
+        if (childId == null) {
+            return result;
+        }
+
+        // 4. Check reading duration threshold
+        int minSeconds = 60; // default
+        String minSecondsStr = sysConfigService.getValue("article_reading_min_seconds");
+        if (minSecondsStr != null && !minSecondsStr.isEmpty()) {
+            try { minSeconds = Integer.parseInt(minSecondsStr); } catch (NumberFormatException e) { /* use default */ }
+        }
+
+        if (durationSeconds < minSeconds) {
+            result.put("belowThreshold", true);
+            result.put("minSeconds", minSeconds);
+            return result;
+        }
+
+        // 5. Check if a task already exists for this article+child (avoid duplicates)
+        Task existingTask = taskMapper.selectOne(
+                new LambdaQueryWrapper<Task>()
+                        .eq(Task::getSourceType, "article")
+                        .eq(Task::getSourceId, articleId)
+                        .eq(Task::getChildId, childId)
+                        .last("LIMIT 1")
+        );
+        if (existingTask != null) {
+            result.put("taskId", existingTask.getId());
+            result.put("pointsEarned", 0);
+            result.put("alreadyCompleted", true);
+            return result;
+        }
+
+        // 6. Load article to get wordCount and articleType
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) return result;
+
+        int wordCount = article.getWordCount() != null ? article.getWordCount() : 0;
+        String articleType = article.getArticleType() != null ? article.getArticleType() : "normal";
+
+        // 7. Calculate points based on wordCount × type rate
+        int rate = 1; // default for 'normal'
+        String rateStr = sysConfigService.getValue("article_energy_rate_" + articleType);
+        if (rateStr != null && !rateStr.isEmpty()) {
+            try { rate = Integer.parseInt(rateStr); } catch (NumberFormatException e) { /* use default */ }
+        }
+        int points = Math.max(1, wordCount / 100 * rate);
+
+        // 8. Create task with status='completed' (auto-completed by reading)
+        Task task = new Task();
+        task.setFamilyId(null); // will set after getting child
+        task.setCreatorId(userId);
+        task.setExecutorType("child");
+        task.setExecutorId(childId);
+        task.setChildId(childId);
+        task.setTitle("阅读文章: " + article.getTitle());
+        task.setDescription("自动生成 - 阅读文章《" + article.getTitle() + "》" + durationSeconds + "秒");
+        task.setPoints(points);
+        task.setRepeatType("none");
+        task.setCategory("学习类");
+        task.setNeedReview(0);
+        task.setReviewByCategory(0);
+        task.setTaskType("onetime");
+        task.setSourceType("article");
+        task.setSourceId(articleId);
+        task.setStatus("completed");
+        task.setCompletedAt(new Date());
+        task.setCreatedAt(new Date());
+        task.setUpdatedAt(new Date());
+
+        // Set deadline to today end
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        task.setDeadline(cal.getTime());
+
+        // Get child to set familyId
+        Child child = childMapper.selectById(childId);
+        if (child != null) {
+            task.setFamilyId(child.getFamilyId());
+        }
+
+        taskMapper.insert(task);
+
+        // 9. Update child totalPoints
+        if (child != null) {
+            child.setTotalPoints(child.getTotalPoints() + points);
+            child.setUpdatedAt(new Date());
+            childMapper.updateById(child);
+        }
+
+        // 10. Insert PointsLog
+        PointsLog pointsLog = new PointsLog();
+        pointsLog.setChildId(childId);
+        pointsLog.setTaskId(task.getId());
+        pointsLog.setAmount(points);
+        pointsLog.setType("earn");
+        pointsLog.setCategory("task");
+        pointsLog.setDescription("阅读文章: " + article.getTitle());
+        pointsLog.setCreatedAt(new Date());
+        pointsLogMapper.insert(pointsLog);
+
+        // 11. Award energy via EnergyService
+        try {
+            energyService.awardEnergy(childId, "article", articleId, points,
+                "阅读文章: " + article.getTitle(), null);
+        } catch (Exception e) {
+            log.warn("能量发放失败(不影响积分): childId={}, articleId={}, error={}",
+                childId, articleId, e.getMessage());
+        }
+
+        // 12. Link taskId to reading record
+        record.setTaskId(task.getId());
+        articleReadingRecordMapper.updateById(record);
+
+        // 13. Return result
+        result.put("taskId", task.getId());
+        result.put("pointsEarned", points);
+        result.put("belowThreshold", false);
+        return result;
     }
 
     public void enrichWithCategoryName(List<Article> articles) {
@@ -125,6 +295,14 @@ public class ArticleService {
 
     @Transactional
     public void create(Article article, Long adminId) {
+        // Auto-calculate wordCount from content
+        if (article.getContent() != null) {
+            article.setWordCount(calculateWordCount(article.getContent()));
+        }
+        // Default articleType to 'normal' if not set
+        if (article.getArticleType() == null) {
+            article.setArticleType("normal");
+        }
         article.setCreatedBy(adminId);
         article.setViewCount(0);
         article.setCreatedAt(new Date());
@@ -137,6 +315,10 @@ public class ArticleService {
 
     @Transactional
     public void update(Article article) {
+        // Recalculate wordCount if content changed
+        if (article.getContent() != null) {
+            article.setWordCount(calculateWordCount(article.getContent()));
+        }
         article.setUpdatedAt(new Date());
         articleMapper.updateById(article);
     }

+ 102 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FinanceCheckinService.java

@@ -3,6 +3,12 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.FinanceCheckin;
 import com.etotem.cfc.mapper.FinanceCheckinMapper;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -23,6 +29,18 @@ public class FinanceCheckinService {
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
     public List<FinanceCheckin> getCheckins(Long userId, Long childId, String yearMonth) {
         LambdaQueryWrapper<FinanceCheckin> wrapper = new LambdaQueryWrapper<FinanceCheckin>()
                 .eq(FinanceCheckin::getUserId, userId);
@@ -40,13 +58,96 @@ public class FinanceCheckinService {
     @Transactional
     public FinanceCheckin createCheckin(FinanceCheckin checkin) {
         checkin.setCreatedAt(new Date());
+        if (checkin.getEarnedEnergy() == null) {
+            checkin.setEarnedEnergy(5);
+        }
         financeCheckinMapper.insert(checkin);
 
+        // Create task for the finance checkin (energy now flows through task system)
+        try {
+            Long childId = checkin.getChildId();
+            if (childId != null) {
+                // Determine points from SysConfig or default
+                int points = 5;
+                String pointsStr = sysConfigService.getValue("finance_checkin_points");
+                if (pointsStr != null && !pointsStr.isEmpty()) {
+                    try { points = Integer.parseInt(pointsStr); } catch (NumberFormatException e) { /* use default */ }
+                }
+
+                // Dedup: check if task already exists
+                Task existingTask = taskMapper.selectOne(
+                    new LambdaQueryWrapper<Task>()
+                        .eq(Task::getSourceType, "finance_checkin")
+                        .eq(Task::getSourceId, checkin.getId())
+                        .eq(Task::getChildId, childId)
+                        .last("LIMIT 1")
+                );
+                if (existingTask == null) {
+                    Task task = new Task();
+                    Child child = childMapper.selectById(childId);
+                    if (child != null) {
+                        task.setFamilyId(child.getFamilyId());
+                    }
+                    task.setCreatorId(checkin.getUserId());
+                    task.setExecutorType("child");
+                    task.setExecutorId(childId);
+                    task.setChildId(childId);
+                    task.setTitle("理财打卡");
+                    task.setDescription("自动生成 - 理财打卡签到");
+                    task.setPoints(points);
+                    task.setRepeatType("none");
+                    task.setCategory("财富类");
+                    task.setNeedReview(0);
+                    task.setReviewByCategory(0);
+                    task.setTaskType("onetime");
+                    task.setSourceType("finance_checkin");
+                    task.setSourceId(checkin.getId());
+                    task.setStatus("completed");
+                    task.setCompletedAt(new Date());
+                    task.setCreatedAt(new Date());
+                    task.setUpdatedAt(new Date());
+                    java.util.Calendar cal = java.util.Calendar.getInstance();
+                    cal.set(java.util.Calendar.HOUR_OF_DAY, 23);
+                    cal.set(java.util.Calendar.MINUTE, 59);
+                    cal.set(java.util.Calendar.SECOND, 59);
+                    task.setDeadline(cal.getTime());
+                    taskMapper.insert(task);
+
+                    // Update child totalPoints
+                    if (child != null) {
+                        child.setTotalPoints(child.getTotalPoints() + points);
+                        child.setUpdatedAt(new Date());
+                        childMapper.updateById(child);
+                    }
+
+                    // Insert PointsLog
+                    PointsLog pointsLog = new PointsLog();
+                    pointsLog.setChildId(childId);
+                    pointsLog.setTaskId(task.getId());
+                    pointsLog.setAmount(points);
+                    pointsLog.setType("earn");
+                    pointsLog.setCategory("task");
+                    pointsLog.setDescription("理财打卡");
+                    pointsLog.setCreatedAt(new Date());
+                    pointsLogMapper.insert(pointsLog);
+
+                    // Update checkin earnedEnergy to match
+                    if (checkin.getEarnedEnergy() == null) {
+                        checkin.setEarnedEnergy(points);
+                        financeCheckinMapper.updateById(checkin);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("理财打卡任务创建失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
+        }
+
         // Award energy points for completing a checkin
         try {
             Long childId = checkin.getChildId();
             if (childId != null) {
-                energyService.awardEnergy(childId, "checkin", checkin.getId(), 5,
+                int energyPoints = checkin.getEarnedEnergy() != null ? checkin.getEarnedEnergy() : 5;
+                energyService.awardEnergy(childId, "checkin", checkin.getId(), energyPoints,
                         "完成理财打卡", null);
             }
         } catch (Exception e) {

+ 97 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthCheckinService.java

@@ -5,6 +5,12 @@ import com.etotem.cfc.entity.EnergyLog;
 import com.etotem.cfc.entity.HealthCheckin;
 import com.etotem.cfc.mapper.EnergyLogMapper;
 import com.etotem.cfc.mapper.HealthCheckinMapper;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -26,6 +32,18 @@ public class HealthCheckinService {
     @Resource
     private EnergyLogMapper energyLogMapper;
 
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
     public List<HealthCheckin> getCheckins(Long userId, Long childId, String yearMonth) {
         LambdaQueryWrapper<HealthCheckin> wrapper = new LambdaQueryWrapper<HealthCheckin>()
                 .eq(HealthCheckin::getUserId, userId);
@@ -58,11 +76,89 @@ public class HealthCheckinService {
         }
         healthCheckinMapper.insert(checkin);
 
+        // Create task for the health checkin (energy now flows through task system)
+        try {
+            Long childId = checkin.getChildId();
+            if (childId != null) {
+                // Determine points from SysConfig or default
+                int points = 5;
+                String pointsStr = sysConfigService.getValue("health_checkin_points");
+                if (pointsStr != null && !pointsStr.isEmpty()) {
+                    try { points = Integer.parseInt(pointsStr); } catch (NumberFormatException e) { /* use default */ }
+                }
+
+                // Dedup: check if task already exists
+                Task existingTask = taskMapper.selectOne(
+                    new LambdaQueryWrapper<Task>()
+                        .eq(Task::getSourceType, "health_checkin")
+                        .eq(Task::getSourceId, checkin.getId())
+                        .eq(Task::getChildId, childId)
+                        .last("LIMIT 1")
+                );
+                if (existingTask == null) {
+                    Task task = new Task();
+                    Child child = childMapper.selectById(childId);
+                    if (child != null) {
+                        task.setFamilyId(child.getFamilyId());
+                    }
+                    task.setCreatorId(checkin.getUserId());
+                    task.setExecutorType("child");
+                    task.setExecutorId(childId);
+                    task.setChildId(childId);
+                    task.setTitle("健康打卡");
+                    task.setDescription("自动生成 - 健康打卡签到");
+                    task.setPoints(points);
+                    task.setRepeatType("none");
+                    task.setCategory("身体类");
+                    task.setNeedReview(0);
+                    task.setReviewByCategory(0);
+                    task.setTaskType("onetime");
+                    task.setSourceType("health_checkin");
+                    task.setSourceId(checkin.getId());
+                    task.setStatus("completed");
+                    task.setCompletedAt(new Date());
+                    task.setCreatedAt(new Date());
+                    task.setUpdatedAt(new Date());
+                    java.util.Calendar cal = java.util.Calendar.getInstance();
+                    cal.set(java.util.Calendar.HOUR_OF_DAY, 23);
+                    cal.set(java.util.Calendar.MINUTE, 59);
+                    cal.set(java.util.Calendar.SECOND, 59);
+                    task.setDeadline(cal.getTime());
+                    taskMapper.insert(task);
+
+                    // Update child totalPoints
+                    if (child != null) {
+                        child.setTotalPoints(child.getTotalPoints() + points);
+                        child.setUpdatedAt(new Date());
+                        childMapper.updateById(child);
+                    }
+
+                    // Insert PointsLog
+                    PointsLog pointsLog = new PointsLog();
+                    pointsLog.setChildId(childId);
+                    pointsLog.setTaskId(task.getId());
+                    pointsLog.setAmount(points);
+                    pointsLog.setType("earn");
+                    pointsLog.setCategory("task");
+                    pointsLog.setDescription("健康打卡");
+                    pointsLog.setCreatedAt(new Date());
+                    pointsLogMapper.insert(pointsLog);
+
+                    // Update checkin earnedEnergy to match
+                    checkin.setEarnedEnergy(points);
+                    healthCheckinMapper.updateById(checkin);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("健康打卡任务创建失败: checkinId={}, error={}", checkin.getId(), e.getMessage());
+        }
+
         // Award energy points to body dimension
         try {
             Long childId = checkin.getChildId();
             if (childId != null) {
-                energyService.awardEnergy(childId, "health_checkin", checkin.getId(), 5,
+                int energyPoints = checkin.getEarnedEnergy() != null ? checkin.getEarnedEnergy() : 5;
+                energyService.awardEnergy(childId, "health_checkin", checkin.getId(), energyPoints,
                         "健康打卡", 30);
             }
         } catch (Exception e) {

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -65,6 +65,28 @@ public class HealthReportService {
         return report;
     }
 
+    /**
+     * 创建健康报告(含指标列表 + 菌群数据 + 疾病风险评估)
+     */
+    @Transactional
+    public HealthReport createReport(HealthReport report, List<HealthIndicator> indicators,
+                                      List<HealthGutFlora> gutFlora, List<HealthDiseaseRisk> diseaseRisks) {
+        // 调用已有逻辑创建报告和指标
+        createReport(report, indicators);
+
+        // 保存菌群数据
+        if (gutFlora != null && !gutFlora.isEmpty()) {
+            saveGutFlora(report.getId(), gutFlora);
+        }
+
+        // 保存疾病风险评估
+        if (diseaseRisks != null && !diseaseRisks.isEmpty()) {
+            saveDiseaseRisks(report.getId(), diseaseRisks);
+        }
+
+        return report;
+    }
+
     /**
      * 获取孩子最新的健康报告
      */

+ 574 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java

@@ -0,0 +1,574 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedDiseaseRisk;
+import com.etotem.cfc.dto.ParsedGutFlora;
+import com.etotem.cfc.dto.ParsedIndicator;
+import com.etotem.cfc.dto.ParsedReportResult;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 募极生物 (Muji Bio) 肠道菌群报告 PDF 解析服务
+ */
+@Slf4j
+@Service
+public class PdfParseService {
+
+    /**
+     * 解析 PDF 输入流,返回结构化的报告数据
+     */
+    public ParsedReportResult parse(InputStream inputStream) throws IOException {
+        try (PDDocument document = PDDocument.load(inputStream)) {
+            PDFTextStripper stripper = new PDFTextStripper();
+            stripper.setSortByPosition(true);
+            String fullText = stripper.getText(document);
+            return parseText(fullText);
+        }
+    }
+
+    /**
+     * 从纯文本中解析报告数据
+     */
+    public ParsedReportResult parseText(String fullText) {
+        ParsedReportResult result = new ParsedReportResult();
+
+        // 按行分割
+        String[] lines = fullText.split("\\r?\\n");
+        List<String> lineList = new ArrayList<>();
+        for (String line : lines) {
+            String trimmed = line.trim();
+            if (!trimmed.isEmpty()) {
+                // 去除PDF提取中的特殊Unicode箭头字符(如  )
+                trimmed = trimmed.replaceAll("[\\uF000-\\uFFFF]", "").trim();
+                if (!trimmed.isEmpty()) {
+                    lineList.add(trimmed);
+                }
+            }
+        }
+
+        // 解析各部分
+        List<ParsedDiseaseRisk> diseaseRisks = new ArrayList<>();
+        List<ParsedGutFlora> gutFlora = new ArrayList<>();
+        List<ParsedGutFlora> probioticSpecies = new ArrayList<>();
+        List<ParsedIndicator> indicators = new ArrayList<>();
+
+        // 收集summary页面中的所有数据
+        parseSummary(lineList, result);
+
+        // 解析疾病风险评估
+        parseDiseaseRisks(lineList, diseaseRisks);
+
+        // 解析各类指标(营养、氨基酸、维生素、微量元素)
+        parseIndicatorSection(lineList, "主要营养评估", indicators);
+        parseIndicatorSection(lineList, "氨基酸评估", indicators);
+        parseIndicatorSection(lineList, "维生素评估", indicators);
+        parseIndicatorSection(lineList, "微量元素评估", indicators);
+
+        // 解析需要"正常范围"列的区域(肠道屏障、短链脂肪酸、神经递质)
+        parseIndicatorWithRange(lineList, "肠道屏障及菌群代谢物", "肠道屏障功能", indicators);
+        parseIndicatorWithRange(lineList, "短链脂肪酸", "短链脂肪酸", indicators);
+        parseIndicatorWithRange(lineList, "神经递质及激素指标", "神经递质与激素", indicators);
+
+        // 解析抗生素风险评估(3行一组)
+        parseSimpleIndicator(lineList, "抗生素风险评估", "抗生素耐药", indicators);
+
+        // 解析核心菌属构成表
+        parseGutFloraTables(lineList, "核心菌属构成表", "核心菌属", gutFlora);
+        // 解析有害菌属构成表
+        parseGutFloraTables(lineList, "有害菌属构成表", "有害菌", gutFlora);
+
+        // 解析益生菌
+        parseProbioticSection(lineList, probioticSpecies);
+
+        result.setDiseaseRisks(diseaseRisks);
+        result.setGutFlora(gutFlora);
+        result.setProbioticSpecies(probioticSpecies);
+        result.setIndicators(indicators);
+
+        return result;
+    }
+
+    // ======================== 解析页面1:汇总 ========================
+
+    private void parseSummary(List<String> lines, ParsedReportResult result) {
+        // 健康总分
+        Integer overallScore = findScoreAfter(lines, "健康总分");
+        if (overallScore != null) result.setOverallScore(overallScore);
+
+        // 菌群健康
+        Integer gutHealth = findScoreAfter(lines, "菌群健康");
+        if (gutHealth != null) result.setGutHealthScore(gutHealth);
+
+        // 慢病控制
+        Integer chronic = findScoreAfter(lines, "慢病控制");
+        if (chronic != null) result.setChronicDiseaseScore(chronic);
+
+        // 营养均衡
+        Integer nutrition = findScoreAfter(lines, "营养均衡");
+        if (nutrition != null) result.setNutritionScore(nutrition);
+
+        // 肠道菌群平衡
+        Integer balance = findScoreAfter(lines, "肠道菌群平衡");
+        if (balance != null) result.setBalanceScore(balance);
+
+        // 菌群多样性
+        Integer diversity = findScoreAfter(lines, "菌群多样性");
+        if (diversity != null) result.setDiversityScore(diversity);
+
+        // 有益菌 — 只取数字评分(跳过后面章节中的文字)
+        Integer beneficial = findScoreAfter(lines, "有益菌");
+        if (beneficial != null) result.setBeneficialScore(beneficial);
+
+        // 有害菌
+        Integer harmful = findScoreAfter(lines, "有害菌");
+        if (harmful != null) result.setHarmfulScore(harmful);
+
+        // 核心菌属
+        Integer coreGenus = findScoreAfter(lines, "核心菌属");
+        if (coreGenus != null) result.setCoreGenusScore(coreGenus);
+
+        // 肠道预测年龄
+        result.setGutAge(findTextAfter(lines, "肠道预测年龄:"));
+
+        // 肠型
+        result.setGutType(findTextAfter(lines, "肠型:"));
+    }
+
+    /**
+     * 在lines中查找关键词,取下一行作为数字评分
+     */
+    private Integer findScoreAfter(List<String> lines, String keyword) {
+        for (int i = 0; i < lines.size() - 1; i++) {
+            if (lines.get(i).equals(keyword)) {
+                String nextLine = lines.get(i + 1).replaceAll("[^0-9.-]", "").trim();
+                try {
+                    // 只取纯数字部分
+                    String numStr = nextLine.replaceAll("[^\\d]", "").trim();
+                    if (!numStr.isEmpty()) {
+                        return Integer.parseInt(numStr);
+                    }
+                } catch (NumberFormatException e) {
+                    // 忽略
+                }
+                break;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 在lines中查找关键词,取下一行文本
+     */
+    private String findTextAfter(List<String> lines, String keyword) {
+        for (int i = 0; i < lines.size() - 1; i++) {
+            if (lines.get(i).equals(keyword)) {
+                return lines.get(i + 1);
+            }
+        }
+        return null;
+    }
+
+    // ======================== 疾病风险评估 ========================
+
+    private void parseDiseaseRisks(List<String> lines, List<ParsedDiseaseRisk> result) {
+        int startIdx = -1;
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i);
+            if (line.contains("疾病风险评估") && !line.contains("注:")) {
+                startIdx = i + 1;
+                break;
+            }
+        }
+        if (startIdx < 0) return;
+
+        // 从 startIdx 开始,每3行一组: name, value, level
+        for (int i = startIdx; i < lines.size() - 2; i += 3) {
+            String name = lines.get(i);
+            String value = lines.get(i + 1);
+            String level = lines.get(i + 2);
+
+            // 跳过非疾病风险行
+            if (name.contains("指标范围") || name.contains("注:") || name.contains("本检测")
+                    || name.contains("健康评估") || name.contains("疾病认定")) {
+                break;
+            }
+
+            // 验证value是数字格式
+            if (isNumeric(value)) {
+                ParsedDiseaseRisk risk = new ParsedDiseaseRisk();
+                risk.setDiseaseName(name);
+                risk.setRiskValue(value);
+                risk.setRiskLevel(level);
+                result.add(risk);
+            } else {
+                // 如果不是数字,可能是下一页内容,停止
+                break;
+            }
+        }
+    }
+
+    // ======================== 指标解析(3行一组) ========================
+
+    /**
+     * 解析 名称 / 数值 / 状态 三行一组的指标区域
+     */
+    private void parseSimpleIndicator(List<String> lines, String sectionHeader, String category,
+                                       List<ParsedIndicator> result) {
+        int startIdx = findSectionStart(lines, sectionHeader);
+        if (startIdx < 0) return;
+
+        // 跳过表头行
+        int dataStart = startIdx;
+        // 跳过可能的空行和表头
+        while (dataStart < lines.size() && !isNumeric(lines.get(dataStart))) {
+            String line = lines.get(dataStart);
+            // 如果看起来像表头或者section标题,跳过
+            dataStart++;
+        }
+        dataStart--; // 回到可能的数据开始位置
+        if (dataStart < startIdx) dataStart = startIdx;
+
+        // 从 dataStart 开始扫描,每3行一组: name, value, status
+        for (int i = dataStart; i < lines.size() - 2; i++) {
+            String name = lines.get(i);
+            // 跳过明显的非数据行
+            if (name.contains("指标范围") || name.contains("抗生素风险评估") || name.contains("超出范围")) {
+                continue;
+            }
+            String valueLine = lines.get(i + 1);
+            String statusLine = lines.get(i + 2);
+
+            // 验证第二行是数字
+            if (isNumeric(valueLine) && !isNumeric(name)) {
+                ParsedIndicator ind = new ParsedIndicator();
+                ind.setCategory(category);
+                ind.setIndicatorName(name);
+                ind.setIndicatorValue(valueLine);
+                ind.setStatus(statusLine);
+                result.add(ind);
+                i += 2; // 跳过已处理的行
+            }
+        }
+    }
+
+    // ======================== 指标解析(名称 / value status / range / desc) ========================
+
+    /**
+     * 解析带参考范围的指标区域(肠道屏障、短链脂肪酸、神经递质等)
+     */
+    private void parseIndicatorWithRange(List<String> lines, String sectionHeader, String category,
+                                          List<ParsedIndicator> result) {
+        int startIdx = findSectionStart(lines, sectionHeader);
+        if (startIdx < 0) return;
+
+        // 跳过表头行
+        int idx = startIdx;
+        while (idx < lines.size()) {
+            String line = lines.get(idx);
+            // 跳过表头:名称、评估值、正常范围等
+            if (line.contains("名称") || line.contains("评估值") || line.contains("正常范围")
+                    || line.contains("过量") || line.contains("缺乏")) {
+                idx++;
+                continue;
+            }
+
+            // 数据行:名称
+            String name = line;
+            if (name.contains("指标范围") || name.contains(sectionHeader)) {
+                idx++;
+                continue;
+            }
+            if (idx + 1 >= lines.size()) break;
+
+            idx++;
+            String valueStatus = lines.get(idx);  // "16 正常" 或 "14 不足"
+
+            // 解析value和status
+            String value = valueStatus;
+            String status = "";
+            // 尝试从 valueStatus 中分离出数字和文本
+            String[] parts = valueStatus.split("\\s+");
+            if (parts.length >= 2 && isNumeric(parts[0])) {
+                value = parts[0];
+                StringBuilder sb = new StringBuilder();
+                for (int j = 1; j < parts.length; j++) {
+                    if (sb.length() > 0) sb.append(" ");
+                    sb.append(parts[j]);
+                }
+                status = sb.toString();
+            } else if (parts.length >= 1 && isNumeric(parts[0])) {
+                value = parts[0];
+            }
+
+            // 下一行是参考范围
+            if (idx + 1 < lines.size()) {
+                idx++;
+                String range = lines.get(idx);
+
+                // 下一行是描述/说明(跳过)
+                if (idx + 1 < lines.size()) {
+                    idx++;
+                    String desc = lines.get(idx); // 只是跳过
+                }
+
+                ParsedIndicator ind = new ParsedIndicator();
+                ind.setCategory(category);
+                ind.setIndicatorName(name);
+                ind.setIndicatorValue(value);
+                ind.setStatus(status);
+                ind.setRefRange(range);
+                result.add(ind);
+            }
+            idx++;
+        }
+    }
+
+    // ======================== 营养/氨基酸/维生素/微量元素(3行一组) ========================
+
+    private void parseIndicatorSection(List<String> lines, String sectionHeader, List<ParsedIndicator> result) {
+        int startIdx = findSectionStart(lines, sectionHeader);
+        if (startIdx < 0) return;
+
+        // 从startIdx开始,跳过表头行
+        int idx = startIdx;
+        while (idx < lines.size() && !isNumeric(lines.get(idx))) {
+            idx++;
+        }
+        idx -= 2; // 回退到数据开始前
+        if (idx < startIdx) idx = startIdx;
+
+        // 3行一组: name, value, status
+        for (int i = idx; i < lines.size() - 2; i++) {
+            String name = lines.get(i);
+            String valLine = lines.get(i + 1);
+            String stLine = lines.get(i + 2);
+
+            // 检测是否为有效数据行
+            if (isNumeric(valLine) && !isNumeric(name) && !stLine.contains("评估")) {
+                // 确定category
+                String category = resolveCategory(sectionHeader);
+                ParsedIndicator ind = new ParsedIndicator();
+                ind.setCategory(category);
+                ind.setIndicatorName(name);
+                ind.setIndicatorValue(valLine);
+                ind.setStatus(stLine);
+                result.add(ind);
+                i += 2;
+            }
+        }
+    }
+
+    private String resolveCategory(String sectionHeader) {
+        if (sectionHeader.contains("主要营养")) return "主要营养评估";
+        if (sectionHeader.contains("氨基酸")) return "氨基酸评估";
+        if (sectionHeader.contains("维生素")) return "维生素评估";
+        if (sectionHeader.contains("微量元素")) return "微量元素评估";
+        return sectionHeader;
+    }
+
+    // ======================== 菌群表解析(核心菌属/有害菌属) ========================
+
+    /**
+     * 解析菌属构成表
+     * 格式: 名称, 丰度%, 正常范围%, 处于人群%水平, %正常人有检出, [可选说明]
+     */
+    private void parseGutFloraTables(List<String> lines, String tableHeader, String category,
+                                      List<ParsedGutFlora> result) {
+        int tableNum = 1;
+        while (true) {
+            String header = tableHeader + tableNum;
+            int startIdx = findSectionStart(lines, header);
+            if (startIdx < 0) {
+                // 可能表头没有序号,尝试直接搜索
+                if (tableNum == 1) {
+                    startIdx = findSectionStart(lines, tableHeader);
+                }
+                if (startIdx < 0) break;
+            }
+
+            // 跳过表头行(名称, 丰度%, 正常范围%, 处于人群%水平, %正常人有检出)
+            int idx = startIdx;
+            while (idx < lines.size()) {
+                String line = lines.get(idx);
+                if (line.contains("名称") || line.contains("丰度") || line.contains("正常范围")
+                        || line.contains("处于人群") || line.contains("检出")) {
+                    idx++;
+                    continue;
+                }
+                break;
+            }
+
+            // 解析数据行:5-6行一组
+            while (idx < lines.size()) {
+                String nameLine = lines.get(idx);
+                // 判断是否到达表尾
+                if (nameLine.contains(tableHeader) || nameLine.contains("核心菌属")
+                        || nameLine.contains("有害菌属") || nameLine.contains("其他重要菌属")
+                        || nameLine.contains("病原菌属") || nameLine.contains("其他菌属")
+                        || nameLine.contains("---")) {
+                    break;
+                }
+                // 跳过说明行和空行
+                if (nameLine.startsWith("说明:") || nameLine.startsWith("改善方式")) {
+                    idx++;
+                    continue;
+                }
+
+                if (idx + 4 >= lines.size()) break;
+
+                String abundance = lines.get(idx + 1);
+                String normalRange = lines.get(idx + 2);
+                String popLevel = lines.get(idx + 3);
+                String detRate = lines.get(idx + 4);
+
+                // 验证丰度值格式
+                if (isAbundanceValue(abundance) && (normalRange.contains("-") || normalRange.contains("~"))) {
+                    ParsedGutFlora flora = new ParsedGutFlora();
+                    flora.setBacteriaName(nameLine);
+                    flora.setBacteriaValue(cleanNumericValue(abundance));
+                    flora.setNormalRange(normalRange);
+                    flora.setPopulationLevel(popLevel.endsWith("%") ? popLevel : popLevel + "%");
+                    flora.setDetectionRate(detRate.endsWith("%") ? detRate : detRate + "%");
+                    flora.setCategory(category);
+                    flora.setLevel("GENUS");
+
+                    // 检查是否有说明行(第6行)
+                    if (idx + 5 < lines.size() && lines.get(idx + 5).startsWith("说明:")) {
+                        flora.setDescription(lines.get(idx + 5).replace("说明:", "").trim());
+                        idx += 6;
+                    } else {
+                        idx += 5;
+                    }
+
+                    result.add(flora);
+                } else {
+                    idx++;
+                }
+            }
+
+            tableNum++;
+        }
+    }
+
+    // ======================== 益生菌解析 ========================
+
+    /**
+     * 解析益生菌列表
+     * 格式: 名称, 丰度%, ⼈群⽔平%, %⼈检出
+     */
+    private void parseProbioticSection(List<String> lines, List<ParsedGutFlora> result) {
+        int startIdx = -1;
+        // 查找"益生菌"标题(注意不是"有益菌")
+        for (int i = 0; i < lines.size() - 1; i++) {
+            String line = lines.get(i);
+            if (line.equals("益生菌") && i > 0) {
+                // 确认前一行不是副标题相关内容
+                startIdx = i + 1;
+                break;
+            }
+        }
+        if (startIdx < 0) return;
+
+        // 跳过表头:名称, 丰度%, ⼈群⽔平%, %⼈检出
+        int idx = startIdx;
+        while (idx < lines.size() && (lines.get(idx).contains("名称") || lines.get(idx).contains("丰度")
+                || lines.get(idx).contains("人群") || lines.get(idx).contains("检出"))) {
+            idx++;
+        }
+
+        // 4行一组: name, abundance, popLevel, detRate
+        while (idx + 3 < lines.size()) {
+            String name = lines.get(idx);
+            String value = lines.get(idx + 1);
+            String popLevel = lines.get(idx + 2);
+            String detRate = lines.get(idx + 3);
+
+            // 检查是否仍然是益生菌数据(丰度值含有%或ND)
+            if (name.contains("益生菌") || name.contains("我的益生菌") || name.contains("建议")
+                    || name.contains("双歧杆菌") && !value.contains("%") && !value.equals("ND")) {
+                break;
+            }
+
+            // 跳过说明行
+            if (name.startsWith("仅列出") || name.startsWith("双歧杆菌") || name.startsWith("乳杆菌")) {
+                // 这是表头后的第一行实际数据(属名)
+                if (name.contains("双歧杆菌属") || name.contains("乳杆菌属")) {
+                    // 这是实际数据,继续
+                } else {
+                    idx++;
+                    continue;
+                }
+            }
+
+            // 验证value格式
+            if (value.contains("%") || value.equals("ND") || isNumeric(value.replaceAll("[^0-9.]", ""))) {
+                ParsedGutFlora flora = new ParsedGutFlora();
+                flora.setBacteriaName(name);
+                flora.setBacteriaValue(cleanNumericValue(value));
+                flora.setPopulationLevel(popLevel);
+                flora.setDetectionRate(detRate);
+                flora.setCategory("益生菌");
+                flora.setLevel("SPECIES");
+                result.add(flora);
+                idx += 4;
+            } else {
+                idx++;
+            }
+        }
+    }
+
+    // ======================== 工具方法 ========================
+
+    /**
+     * 查找section标题所在行
+     */
+    private int findSectionStart(List<String> lines, String header) {
+        for (int i = 0; i < lines.size(); i++) {
+            if (lines.get(i).contains(header)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private boolean isNumeric(String str) {
+        if (str == null || str.isEmpty()) return false;
+        String cleaned = str.replaceAll("[,\\s%]", "").trim();
+        if (cleaned.isEmpty()) return false;
+        try {
+            Double.parseDouble(cleaned);
+            return true;
+        } catch (NumberFormatException e) {
+            return false;
+        }
+    }
+
+    /**
+     * 判断是否为丰度值格式(如 "0.1712", "ND", "76.721%")
+     */
+    private boolean isAbundanceValue(String str) {
+        if (str == null || str.isEmpty()) return false;
+        if (str.equals("ND")) return true;
+        String cleaned = str.replaceAll("[^0-9.]", "").trim();
+        return !cleaned.isEmpty() && cleaned.matches("\\d+\\.?\\d*");
+    }
+
+    /**
+     * 清理数值中的特殊字符
+     */
+    private String cleanNumericValue(String str) {
+        if (str == null) return null;
+        if (str.equals("ND")) return "ND";
+        // 保留百分比,"ND"
+        return str.replaceAll("[\\uF000-\\uFFFF]", "").trim();
+    }
+}

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

@@ -8,6 +8,10 @@ server:
       Strict-Transport-Security: max-age=31536000; includeSubDomains
 
 spring:
+  servlet:
+    multipart:
+      max-file-size: 50MB
+      max-request-size: 50MB
   application:
     name: cfc-backend
   datasource:

+ 6 - 0
cfc-frontend/pages.json

@@ -195,6 +195,12 @@
       "style": {
         "navigationBarTitleText": "商品详情"
       }
+    },
+    {
+      "path": "pages/discover/activity-detail/activity-detail",
+      "style": {
+        "navigationBarTitleText": "活动详情"
+      }
     },
         // ===== AI 聊天 =====
     {

+ 1 - 1
cfc-frontend/pages/action/index.vue

@@ -347,7 +347,7 @@ export default {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=action' })

+ 1 - 1
cfc-frontend/pages/action/member-action-detail.vue

@@ -199,7 +199,7 @@ export default {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=action' })

+ 1 - 1
cfc-frontend/pages/body/index.vue

@@ -469,7 +469,7 @@ export default {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=body' })

+ 1 - 1
cfc-frontend/pages/body/member-body-detail.vue

@@ -199,7 +199,7 @@ export default {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=body' })

+ 1 - 1
cfc-frontend/pages/index/child-index.vue

@@ -666,7 +666,7 @@ export default {
 
     // ======================== 维度导航 ========================
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=child' })

+ 98 - 6
cfc-frontend/pages/mind/article-detail.vue

@@ -55,7 +55,19 @@
 
       <!-- 底部完成按钮 -->
       <view class="detail-footer">
-        <button class="read-btn" @click="onReadComplete">阅读完成</button>
+        <!-- 定时器显示 -->
+        <view class="reading-timer" v-if="readingSeconds >= 0">
+          <text class="timer-icon">⏱</text>
+          <text class="timer-text">{{ formatTime(readingSeconds) }}</text>
+          <text class="timer-hint" v-if="readingSeconds < 60">阅读{{ 60 - readingSeconds }}秒可获取积分</text>
+          <text class="timer-hint timer-ready" v-else>已达标,可领取积分</text>
+        </view>
+        <!-- 完成按钮 -->
+        <button
+          class="read-btn"
+          :class="{ 'read-btn-active': readingSeconds >= 60 }"
+          @click="onReadComplete"
+        >阅读完成</button>
       </view>
     </view>
 
@@ -78,19 +90,29 @@ export default {
       loading: true,
       error: false,
       errorMsg: '',
-      tagList: []
+      tagList: [],
+      readingSeconds: 0,
+      readingTimer: null,
+      readRecorded: false
     }
   },
   onLoad(options) {
     if (options && options.id) {
       this.articleId = options.id
       this.loadDetail(options.id)
+      this.startReadingTimer()
     } else {
       this.error = true
       this.errorMsg = '参数错误'
       this.loading = false
     }
   },
+  onHide() {
+    this.stopReadingTimer()
+  },
+  onUnload() {
+    this.stopReadingTimer()
+  },
   methods: {
     async loadDetail(id) {
       this.loading = true
@@ -125,13 +147,52 @@ export default {
       }
       return ''
     },
+    startReadingTimer() {
+      var self = this
+      this.readingTimer = setInterval(function() {
+        self.readingSeconds = self.readingSeconds + 1
+        // Auto-submit when reaching 60 seconds (threshold)
+        // But don't auto-submit - just indicate readiness
+      }, 1000)
+    },
+    stopReadingTimer() {
+      if (this.readingTimer) {
+        clearInterval(this.readingTimer)
+        this.readingTimer = null
+      }
+    },
+    formatTime(seconds) {
+      var m = Math.floor(seconds / 60)
+      var s = seconds % 60
+      return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
+    },
     async onReadComplete() {
+      if (this.readRecorded) {
+        uni.navigateBack()
+        return
+      }
+      this.stopReadingTimer()
       try {
-        await recordArticleRead({ id: this.article.id })
-        uni.showToast({ title: '阅读记录已保存', icon: 'success' })
+        var childId = uni.getStorageSync('currentChildId')
+        var res = await recordArticleRead({
+          id: this.article.id,
+          durationSeconds: this.readingSeconds,
+          childId: childId || undefined
+        })
+        this.readRecorded = true
+        if (res.data && res.data.pointsEarned > 0) {
+          uni.showToast({ title: '获得' + res.data.pointsEarned + '积分!', icon: 'success' })
+        } else if (res.data && res.data.belowThreshold) {
+          uni.showToast({ title: '阅读时间未达标', icon: 'none' })
+        } else if (res.data && res.data.alreadyCompleted) {
+          uni.showToast({ title: '已读过此文', icon: 'none' })
+        } else {
+          uni.showToast({ title: '阅读记录已保存', icon: 'success' })
+        }
       } catch (e) {
-        // 静默处理
+        uni.showToast({ title: '记录保存失败', icon: 'none' })
       }
+      var self = this
       setTimeout(function() {
         uni.navigateBack()
       }, 1500)
@@ -287,6 +348,33 @@ export default {
   word-break: break-word;
 }
 
+/* ===== 定时器 ===== */
+.reading-timer {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  padding: 16rpx 0 20rpx;
+}
+.timer-icon {
+  font-size: 32rpx;
+  margin-right: 8rpx;
+}
+.timer-text {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-right: 16rpx;
+}
+.timer-hint {
+  font-size: 22rpx;
+  color: #999;
+}
+.timer-ready {
+  color: #22c55e;
+  font-weight: bold;
+}
+
 /* ===== 底部按钮 ===== */
 .detail-footer {
   padding: 40rpx 60rpx;
@@ -295,13 +383,17 @@ export default {
   width: 100%;
   height: 88rpx;
   line-height: 88rpx;
-  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  background: #ddd;
   color: #fff;
   font-size: 32rpx;
   font-weight: bold;
   border-radius: 44rpx;
   text-align: center;
   border: none;
+  transition: background 0.3s;
+}
+.read-btn-active {
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
 }
 .read-btn::after {
   border: none;

+ 1 - 1
cfc-frontend/pages/mind/index.vue

@@ -887,7 +887,7 @@ export default {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       var dimCode = this.currentDimensionCode

+ 1 - 1
cfc-frontend/pages/mind/member-mind-detail.vue

@@ -199,7 +199,7 @@ export default {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
     goActivityDetail: function(act) {
-      if (act && act.id) uni.navigateTo({ url: '/pages/discover/index?type=activity&id=' + act.id })
+      if (act && act.id) uni.navigateTo({ url: '/pages/discover/activity-detail/activity-detail?id=' + act.id })
     },
     goMoreActivities: function() {
       uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=mind' })

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

@@ -1257,6 +1257,7 @@ export const healthCheckinDelete = (id) => request('/api/health/checkin/delete',
 // ===== 活动模块(维度筛选) =====
 export const getActivityList = (data) => request('/api/activity/list', 'POST', data)
 export const getActivityDetail = (id) => request('/api/activity/detail', 'POST', { id })
+export const activityCheckin = (data) => request('/api/activity/checkin', 'POST', data)
 
 // ===== 维度任务(带 category 筛选) =====
 export const getTodayTasksByCategory = (childId, category) => {