Просмотр исходного кода

fix: resolve .last_build_commit conflict

Xiaogang Liao 4 месяцев назад
Родитель
Сommit
2e54458639

+ 140 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.controller;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.HealthAnalysisResult;
 import com.etotem.cfc.dto.HealthAnalysisResult;
+import com.etotem.cfc.dto.MemberMatchResult;
 import com.etotem.cfc.dto.ParsedDiseaseRisk;
 import com.etotem.cfc.dto.ParsedDiseaseRisk;
 import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedIndicator;
 import com.etotem.cfc.dto.ParsedIndicator;
@@ -19,6 +20,7 @@ import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.HealthReportDraft;
 import com.etotem.cfc.entity.HealthReportDraft;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.FamilyMemberNutritionProfileService;
 import com.etotem.cfc.service.FamilyMemberNutritionProfileService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.DimensionScoreService;
@@ -98,6 +100,9 @@ public class HealthReportController {
     @Resource
     @Resource
     private FamilyMemberMapper familyMemberMapper;
     private FamilyMemberMapper familyMemberMapper;
 
 
+    @Resource
+    private HealthReportDraftMapper healthReportDraftMapper;
+
     /**
     /**
      * 创建健康报告(含指标明细)
      * 创建健康报告(含指标明细)
      */
      */
@@ -391,17 +396,25 @@ public class HealthReportController {
             HealthReportDraft draft = healthReportDraftService.createDraft(
             HealthReportDraft draft = healthReportDraftService.createDraft(
                     userId, familyId, "gut_flora", fileUrl, originalFilename, payloadJson);
                     userId, familyId, "gut_flora", fileUrl, originalFilename, payloadJson);
 
 
-            Long matchedMemberId = null;
-            if (familyId != null && parsed.getPersonName() != null) {
-                matchedMemberId = healthReportService.matchFamilyMemberByName(parsed.getPersonName(), familyId);
-            }
+            // 使用多字段匹配(姓名+性别+年龄)
+            MemberMatchResult matchResult = healthReportService.matchMemberByProfile(
+                    parsed.getPersonName(),
+                    parsed.getGender(),
+                    parsed.getAge(),
+                    familyId
+            );
 
 
             Map<String, Object> result = new LinkedHashMap<>();
             Map<String, Object> result = new LinkedHashMap<>();
             result.put("draftId", draft.getId());
             result.put("draftId", draft.getId());
             result.put("payload", payload);
             result.put("payload", payload);
-            result.put("matchedMemberId", matchedMemberId);
+            result.put("matchedMemberId", matchResult.getMatchedMemberId());
+            result.put("confidence", matchResult.getConfidence());
+            result.put("matchLabel", matchResult.getMatchLabel());
+            result.put("candidates", matchResult.getCandidates());
             result.put("extractedName", parsed.getPersonName());
             result.put("extractedName", parsed.getPersonName());
-            result.put("needBind", matchedMemberId == null && familyId != null);
+            result.put("extractedGender", parsed.getGender());
+            result.put("extractedAge", parsed.getAge());
+            result.put("needBind", matchResult.getMatchedMemberId() == null && familyId != null);
             return Result.success(result);
             return Result.success(result);
 
 
         } catch (IOException e) {
         } catch (IOException e) {
@@ -838,6 +851,124 @@ public class HealthReportController {
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    /**
+     * 自动创建家庭成员(从PDF身份信息)
+     */
+    @Operation(summary = "自动创建家庭成员")
+    @PostMapping("/report/auto-create-member")
+    public Result<Map<String, Object>> autoCreateMember(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+
+        String name = (String) params.get("name");
+        String gender = (String) params.get("gender");
+        Object ageObj = params.get("age");
+        Integer age = ageObj != null ? Integer.parseInt(ageObj.toString()) : null;
+        Long familyId = params.get("familyId") != null
+                ? Long.valueOf(params.get("familyId").toString()) : null;
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+
+        if (name == null || name.trim().isEmpty()) {
+            return Result.error("姓名不能为空");
+        }
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+
+        try {
+            String fileUrl = null;
+            String originalFilename = null;
+            String payloadJson = null;
+
+            if (draftId != null) {
+                HealthReportDraft draft = healthReportDraftService.getActiveDraft(draftId);
+                if (draft != null) {
+                    fileUrl = draft.getFileUrl();
+                    originalFilename = draft.getOriginalFilename();
+                    payloadJson = draft.getPayloadJson();
+                }
+            }
+
+            Long memberId = healthReportService.autoCreateMemberFromReport(
+                    name, gender, age, familyId, "gut_flora", fileUrl, originalFilename, payloadJson);
+
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("memberId", memberId);
+            result.put("isNew", true);
+            result.put("name", name.trim());
+            return Result.success(result);
+
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("自动创建成员失败", e);
+            return Result.error("自动创建成员失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 管理端:获取待审核报告草稿列表
+     */
+    @Operation(summary = "管理端:获取待审核报告草稿列表")
+    @PostMapping("/report/audit/pending")
+    public Result<List<HealthReportDraft>> listPendingAudits(
+            @RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 20;
+        List<HealthReportDraft> drafts = healthReportService.listPendingAudits(page, size);
+        return Result.success(drafts);
+    }
+
+    /**
+     * 管理端:审核确认报告
+     */
+    @Operation(summary = "管理端:审核确认报告")
+    @PostMapping("/report/audit/confirm")
+    public Result<Map<String, Object>> auditConfirm(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long auditorId) {
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+        Long subjectId = params.get("subjectId") != null
+                ? Long.valueOf(params.get("subjectId").toString()) : null;
+
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+
+        try {
+            Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, auditorId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("审核确认失败", e);
+            return Result.error("审核确认失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 管理端:审核丢弃报告
+     */
+    @Operation(summary = "管理端:审核丢弃报告")
+    @PostMapping("/report/audit/discard")
+    public Result<String> auditDiscard(
+            @RequestBody Map<String, Object> params) {
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+
+        try {
+            healthReportService.auditDiscard(draftId);
+            return Result.success("报告已丢弃");
+        } catch (Exception e) {
+            log.error("审核丢弃失败", e);
+            return Result.error("审核丢弃失败: " + e.getMessage());
+        }
+    }
+
     private ParsedReportPayload.Payload convertToPayload(ParsedReportResult parsed) {
     private ParsedReportPayload.Payload convertToPayload(ParsedReportResult parsed) {
         ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
         ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
 
 
@@ -856,6 +987,9 @@ public class HealthReportController {
         summary.setReportNumber(parsed.getReportNumber());
         summary.setReportNumber(parsed.getReportNumber());
         summary.setReportDate(parsed.getReportDate());
         summary.setReportDate(parsed.getReportDate());
         summary.setPersonName(parsed.getPersonName());
         summary.setPersonName(parsed.getPersonName());
+        summary.setGender(parsed.getGender());
+        summary.setAge(parsed.getAge());
+        summary.setBirthday(parsed.getBirthday());
         payload.setSummary(summary);
         payload.setSummary(summary);
 
 
         if (parsed.getIndicators() != null) {
         if (parsed.getIndicators() != null) {

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java

@@ -24,6 +24,9 @@ public class ParsedReportPayload {
         private String reportNumber;
         private String reportNumber;
         private String reportDate;
         private String reportDate;
         private String personName;
         private String personName;
+        private String gender;
+        private Integer age;
+        private String birthday;
 
 
         public Integer getOverallScore() { return overallScore; }
         public Integer getOverallScore() { return overallScore; }
         public void setOverallScore(Integer overallScore) { this.overallScore = overallScore; }
         public void setOverallScore(Integer overallScore) { this.overallScore = overallScore; }
@@ -53,6 +56,12 @@ public class ParsedReportPayload {
         public void setReportDate(String reportDate) { this.reportDate = reportDate; }
         public void setReportDate(String reportDate) { this.reportDate = reportDate; }
         public String getPersonName() { return personName; }
         public String getPersonName() { return personName; }
         public void setPersonName(String personName) { this.personName = personName; }
         public void setPersonName(String personName) { this.personName = personName; }
+        public String getGender() { return gender; }
+        public void setGender(String gender) { this.gender = gender; }
+        public Integer getAge() { return age; }
+        public void setAge(Integer age) { this.age = age; }
+        public String getBirthday() { return birthday; }
+        public void setBirthday(String birthday) { this.birthday = birthday; }
     }
     }
 
 
     public static class Indicator {
     public static class Indicator {

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

@@ -51,6 +51,15 @@ public class ParsedReportResult {
     /** 被检测人姓名 */
     /** 被检测人姓名 */
     private String personName;
     private String personName;
 
 
+    /** 性别: male/female/unknown */
+    private String gender;
+
+    /** 年龄(从PDF提取的数字年龄) */
+    private Integer age;
+
+    /** 出生日期(从PDF提取,格式 yyyy-MM-dd) */
+    private String birthday;
+
     // ==================== 列表数据 ====================
     // ==================== 列表数据 ====================
 
 
     /** 疾病风险评估 */
     /** 疾病风险评估 */
@@ -182,6 +191,30 @@ public class ParsedReportResult {
         this.personName = personName;
         this.personName = personName;
     }
     }
 
 
+    public String getGender() {
+        return gender;
+    }
+
+    public void setGender(String gender) {
+        this.gender = gender;
+    }
+
+    public Integer getAge() {
+        return age;
+    }
+
+    public void setAge(Integer age) {
+        this.age = age;
+    }
+
+    public String getBirthday() {
+        return birthday;
+    }
+
+    public void setBirthday(String birthday) {
+        this.birthday = birthday;
+    }
+
     public List<ParsedDiseaseRisk> getDiseaseRisks() {
     public List<ParsedDiseaseRisk> getDiseaseRisks() {
         return diseaseRisks;
         return diseaseRisks;
     }
     }

+ 474 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1,14 +1,19 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.dto.MemberMatchResult;
+import com.etotem.cfc.dto.ParsedReportPayload;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
 import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
 import com.etotem.cfc.mapper.HealthGutFloraMapper;
 import com.etotem.cfc.mapper.HealthGutFloraMapper;
 import com.etotem.cfc.mapper.HealthIndicatorMapper;
 import com.etotem.cfc.mapper.HealthIndicatorMapper;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.HealthReportPermissionMapper;
 import com.etotem.cfc.mapper.HealthReportPermissionMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
@@ -16,8 +21,9 @@ import org.springframework.transaction.annotation.Transactional;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.Collections;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
@@ -51,6 +57,12 @@ public class HealthReportService {
     @Resource
     @Resource
     private HealthReportPermissionMapper healthReportPermissionMapper;
     private HealthReportPermissionMapper healthReportPermissionMapper;
 
 
+    @Resource
+    private HealthReportDraftMapper draftMapper;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
     /**
     /**
      * 创建健康报告(含指标列表)
      * 创建健康报告(含指标列表)
      */
      */
@@ -460,6 +472,203 @@ public class HealthReportService {
         return null;
         return null;
     }
     }
 
 
+    /**
+     * 多字段匹配家庭成员 — 基于姓名+性别+年龄综合评分
+     * 返回置信度最高的匹配结果及候选列表
+     */
+    public MemberMatchResult matchMemberByProfile(String name, String gender, Integer age, Long familyId) {
+        MemberMatchResult result = new MemberMatchResult();
+        result.setCandidates(new ArrayList<>());
+
+        if (familyId == null) {
+            result.setMatchedMemberId(null);
+            result.setConfidence(0.0);
+            result.setMatchLabel("no_match");
+            return result;
+        }
+
+        // 收集所有候选成员(FamilyMember + User)
+        List<FamilyMember> familyMembers = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+        );
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId)
+        );
+
+        List<MemberMatchResult.MemberCandidate> allCandidates = new ArrayList<>();
+
+        // 评估 FamilyMember
+        if (familyMembers != null) {
+            for (FamilyMember fm : familyMembers) {
+                MemberMatchResult.MemberCandidate candidate = new MemberMatchResult.MemberCandidate();
+                candidate.setId(fm.getId());
+                candidate.setNickname(fm.getNickname());
+                candidate.setName(fm.getNickname());
+                candidate.setGender(fm.getGender());
+                candidate.setAge(fm.getAge());
+
+                double score = 0.0;
+                List<String> matchedFields = new ArrayList<>();
+
+                if (name != null && !name.trim().isEmpty()) {
+                    double nameScore = computeNameSimilarity(name.trim(), fm.getNickname());
+                    score += nameScore * 0.6;
+                    if (nameScore > 0.5) matchedFields.add("name");
+                }
+
+                if (gender != null && !gender.trim().isEmpty()) {
+                    String dbGender = fm.getGender();
+                    if ("male".equalsIgnoreCase(gender) && "male".equals(dbGender)) {
+                        score += 0.2;
+                        matchedFields.add("gender");
+                    } else if ("female".equalsIgnoreCase(gender) && "female".equals(dbGender)) {
+                        score += 0.2;
+                        matchedFields.add("gender");
+                    }
+                }
+
+                if (age != null && age > 0) {
+                    int dbAge = fm.getAge();
+                    if (dbAge > 0) {
+                        int diff = Math.abs(age - dbAge);
+                        if (diff <= 1) {
+                            score += 0.2;
+                            matchedFields.add("age");
+                        } else if (diff <= 3) {
+                            score += 0.1;
+                            matchedFields.add("age");
+                        } else if (diff <= 5) {
+                            score += 0.05;
+                            matchedFields.add("age");
+                        }
+                    }
+                }
+
+                score = Math.min(score, 1.0);
+                candidate.setSimilarityScore(Math.round(score * 1000.0) / 1000.0);
+                candidate.setMatchFields(String.join(",", matchedFields));
+
+                if (score > 0) {
+                    allCandidates.add(candidate);
+                }
+            }
+        }
+
+        // 评估 User(父母)
+        if (users != null) {
+            for (User u : users) {
+                String displayName = u.getRealName() != null ? u.getRealName() : u.getNickname();
+                if (displayName == null || displayName.trim().isEmpty()) continue;
+
+                MemberMatchResult.MemberCandidate candidate = new MemberMatchResult.MemberCandidate();
+                candidate.setId(u.getId());
+                candidate.setName(displayName);
+                candidate.setGender(u.getGender());
+                candidate.setNickname(u.getNickname());
+
+                // 从生日计算年龄
+                Integer userAge = null;
+                if (u.getBirthday() != null) {
+                    java.time.LocalDate birth = u.getBirthday().toInstant()
+                            .atZone(java.time.ZoneId.systemDefault()).toLocalDate();
+                    userAge = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
+                }
+                candidate.setAge(userAge);
+
+                double score = 0.0;
+                List<String> matchedFields = new ArrayList<>();
+
+                if (name != null && !name.trim().isEmpty()) {
+                    double nameScore = computeNameSimilarity(name.trim(), displayName);
+                    score += nameScore * 0.6;
+                    if (nameScore > 0.5) matchedFields.add("name");
+                }
+
+                if (gender != null && !gender.trim().isEmpty()) {
+                    String dbGender = u.getGender();
+                    if ("male".equalsIgnoreCase(gender) && "male".equals(dbGender)) {
+                        score += 0.2;
+                        matchedFields.add("gender");
+                    } else if ("female".equalsIgnoreCase(gender) && "female".equals(dbGender)) {
+                        score += 0.2;
+                        matchedFields.add("gender");
+                    }
+                }
+
+                if (age != null && age > 0 && userAge != null && userAge > 0) {
+                    int diff = Math.abs(age - userAge);
+                    if (diff <= 2) {
+                        score += 0.2;
+                        matchedFields.add("age");
+                    } else if (diff <= 5) {
+                        score += 0.1;
+                        matchedFields.add("age");
+                    }
+                }
+
+                score = Math.min(score, 1.0);
+                candidate.setSimilarityScore(Math.round(score * 1000.0) / 1000.0);
+                candidate.setMatchFields(String.join(",", matchedFields));
+
+                if (score > 0) {
+                    allCandidates.add(candidate);
+                }
+            }
+        }
+
+        // 按相似度降序排序
+        allCandidates.sort(Comparator.comparing(MemberMatchResult.MemberCandidate::getSimilarityScore).reversed());
+
+        // 取前5个候选
+        int limit = Math.min(5, allCandidates.size());
+        result.setCandidates(allCandidates.subList(0, limit));
+
+        if (limit > 0 && allCandidates.get(0).getSimilarityScore() >= 0.6) {
+            result.setMatchedMemberId(allCandidates.get(0).getId());
+            result.setConfidence(allCandidates.get(0).getSimilarityScore());
+            result.setMatchLabel(allCandidates.get(0).getSimilarityScore() >= 0.9 ? "exact" : "profile_match");
+        } else {
+            result.setMatchedMemberId(null);
+            result.setConfidence(limit > 0 ? allCandidates.get(0).getSimilarityScore() : 0.0);
+            result.setMatchLabel("no_match");
+        }
+
+        return result;
+    }
+
+    /**
+     * 计算两个姓名的相似度(0.0-1.0)
+     */
+    private double computeNameSimilarity(String reportName, String dbName) {
+        if (reportName == null || dbName == null) return 0.0;
+        String a = reportName.trim().toLowerCase();
+        String b = dbName.trim().toLowerCase();
+
+        // 精确匹配
+        if (a.equals(b)) return 1.0;
+
+        // 互相包含
+        if (a.contains(b) || b.contains(a)) return 0.85;
+
+        // 前2字匹配
+        if (a.length() >= 2 && b.length() >= 2) {
+            String aPrefix = a.substring(0, 2);
+            String bPrefix = b.substring(0, 2);
+            if (aPrefix.equals(bPrefix)) return 0.7;
+        }
+
+        // 最后一个字相同
+        if (a.length() > 0 && b.length() > 0) {
+            if (String.valueOf(a.charAt(a.length() - 1)).equals(String.valueOf(b.charAt(b.length() - 1)))) {
+                return 0.4;
+            }
+        }
+
+        return 0.0;
+    }
+
     /**
     /**
      * Get mind-related indicators from the latest gut microbiome report.
      * Get mind-related indicators from the latest gut microbiome report.
      * Filters indicators by categories related to mood/emotion:
      * Filters indicators by categories related to mood/emotion:
@@ -569,4 +778,268 @@ public class HealthReportService {
                         .eq(HealthReportPermission::getUserId, userId)
                         .eq(HealthReportPermission::getUserId, userId)
         ) > 0;
         ) > 0;
     }
     }
+
+    // ======================== 自动创建成员 + 审核 ========================
+
+    /**
+     * 从PDF身份信息自动创建家庭成员
+     * 如果姓名已存在则返回已有成员ID;否则创建新成员并返回新ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Long autoCreateMemberFromReport(String name, String gender, Integer age,
+                                           Long familyId, String reportType,
+                                           String fileUrl, String originalFilename,
+                                           String payloadJson) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new IllegalArgumentException("姓名不能为空");
+        }
+        if (familyId == null) {
+            throw new IllegalArgumentException("familyId不能为空");
+        }
+
+        String cleanName = name.trim().replaceAll("\\s+", "");
+
+        // 第1步:尝试匹配已有成员
+        Long existingMemberId = matchFamilyMemberByName(cleanName, familyId);
+        if (existingMemberId != null) {
+            log.info("姓名[{}]已匹配到现有成员 id={}", cleanName, existingMemberId);
+            return existingMemberId;
+        }
+
+        // 第2步:创建新家庭成员
+        FamilyMember newMember = new FamilyMember();
+        newMember.setFamilyId(familyId);
+        newMember.setNickname(cleanName);
+        newMember.setGender(gender);
+        newMember.setRoleOverride("auto");
+        newMember.setShowToFamily(1);
+        newMember.setTotalPoints(0);
+        Date now = new Date();
+        newMember.setCreatedAt(now);
+        newMember.setUpdatedAt(now);
+
+        // 如果有年龄,反推出生日期
+        if (age != null && age > 0) {
+            java.time.LocalDate birthDate = java.time.LocalDate.now()
+                    .minusYears(age)
+                    .minusDays(java.time.LocalDate.now().getDayOfYear() - 1);
+            newMember.setBirthday(java.sql.Date.valueOf(birthDate));
+        }
+
+        familyMemberMapper.insert(newMember);
+        log.info("已从健康报告自动创建新成员: id={}, name={}, gender={}, age={}, familyId={}",
+                newMember.getId(), cleanName, gender, age, familyId);
+
+        return newMember.getId();
+    }
+
+    /**
+     * 审核确认报告 — 将草稿转为正式报告并绑定到指定成员
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> auditConfirm(Long draftId, Long subjectId, Long auditorId) {
+        HealthReportDraft draft = getActiveDraftOrThrow(draftId);
+        if (draft == null) {
+            throw new IllegalArgumentException("草稿不存在或已过期");
+        }
+
+        try {
+            ParsedReportPayload.Payload payload = objectMapper.readValue(
+                    draft.getPayloadJson(), ParsedReportPayload.Payload.class);
+
+            Long matchedMemberId = subjectId != null ? subjectId
+                    : matchFamilyMemberByName(
+                            payload.getSummary() != null ? payload.getSummary().getPersonName() : null,
+                            draft.getFamilyId());
+
+            HealthReport report = buildReportFromPayload(draft, payload, matchedMemberId);
+            List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
+            List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
+            List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
+
+            HealthReport created = createReport(report, indicators, gutFlora, diseaseRisks);
+
+            if (matchedMemberId != null) {
+                bindReportToMember(created.getId(), matchedMemberId);
+            }
+
+            markDraftConfirmed(draftId, created.getId());
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("reportId", created.getId());
+            result.put("subjectId", matchedMemberId);
+            result.put("status", "confirmed");
+            return result;
+
+        } catch (Exception e) {
+            log.error("审核确认失败", e);
+            throw new RuntimeException("审核确认失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 审核丢弃报告 — 删除草稿,不入库
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void auditDiscard(Long draftId) {
+        HealthReportDraft draft = getActiveDraftOrThrow(draftId);
+        if (draft == null) {
+            throw new IllegalArgumentException("草稿不存在或已过期");
+        }
+        markDraftDiscarded(draftId);
+        log.info("审核丢弃草稿: draftId={}", draftId);
+    }
+
+    /**
+     * 获取待审核的草稿列表(分页)
+     */
+    public List<HealthReportDraft> listPendingAudits(int page, int size) {
+        // MyBatis-Plus 分页查询
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<HealthReportDraft> p =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        LambdaQueryWrapper<HealthReportDraft> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(HealthReportDraft::getStatus, "pending")
+                .orderByDesc(HealthReportDraft::getCreatedAt);
+        p = draftMapper.selectPage(p, wrapper);
+        return p.getRecords();
+    }
+
+    /**
+     * 获取草稿(内部方法)
+     */
+    private HealthReportDraft getActiveDraftOrThrow(Long draftId) {
+        HealthReportDraft draft = draftMapper.selectById(draftId);
+        if (draft == null || !"pending".equals(draft.getStatus())) {
+            return null;
+        }
+        if (draft.getExpireAt() != null && draft.getExpireAt().before(new Date())) {
+            return null;
+        }
+        return draft;
+    }
+
+    private void markDraftConfirmed(Long draftId, Long reportId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getStatus, "confirmed")
+          .set(HealthReportDraft::getConfirmedReportId, reportId)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    private void markDraftDiscarded(Long draftId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getStatus, "discarded")
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    // ======================== Payload builders (shared with controller) ========================
+
+    HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
+        HealthReport report = new HealthReport();
+        report.setUserId(draft.getUserId());
+        report.setFamilyId(draft.getFamilyId());
+        report.setSubjectId(subjectId);
+        report.setReportType(draft.getReportType());
+        report.setFileUrl(draft.getFileUrl());
+
+        ParsedReportPayload.Summary s = payload.getSummary();
+        if (s != null) {
+            report.setOverallScore(s.getOverallScore());
+            report.setGutHealthScore(s.getGutHealthScore());
+            report.setChronicDiseaseScore(s.getChronicDiseaseScore());
+            report.setNutritionScore(s.getNutritionScore());
+            report.setBalanceScore(s.getBalanceScore());
+            report.setDiversityScore(s.getDiversityScore());
+            report.setBeneficialScore(s.getBeneficialScore());
+            report.setHarmfulScore(s.getHarmfulScore());
+            report.setCoreGenusScore(s.getCoreGenusScore());
+            report.setGutAge(s.getGutAge());
+            report.setGutType(s.getGutType());
+            report.setReportNumber(s.getReportNumber());
+            report.setPersonName(s.getPersonName());
+
+            if (s.getReportDate() != null && !s.getReportDate().isEmpty()) {
+                try {
+                    java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                    report.setReportDate(sdf.parse(s.getReportDate()));
+                } catch (Exception e) {
+                    report.setReportDate(new Date());
+                }
+            } else {
+                report.setReportDate(new Date());
+            }
+        } else {
+            report.setReportDate(new Date());
+        }
+
+        report.setRemark("募极生物肠道菌群检测报告");
+        return report;
+    }
+
+    List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
+        List<ParsedReportPayload.Indicator> list = payload.getIndicators();
+        if (list == null || list.isEmpty()) return new ArrayList<>();
+
+        List<HealthIndicator> indicators = new ArrayList<>();
+        for (int i = 0; i < list.size(); i++) {
+            ParsedReportPayload.Indicator pi = list.get(i);
+            HealthIndicator ind = new HealthIndicator();
+            ind.setCategory(pi.getCategory());
+            ind.setIndicatorName(pi.getIndicatorName());
+            ind.setIndicatorValue(pi.getIndicatorValue());
+            ind.setUnit(pi.getUnit());
+            ind.setRefRange(pi.getRefRange());
+            ind.setStatus(pi.getStatus());
+            ind.setSymptoms(pi.getSymptoms());
+            ind.setSortOrder(i);
+            indicators.add(ind);
+        }
+        return indicators;
+    }
+
+    List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
+        List<HealthGutFlora> result = new ArrayList<>();
+        if (payload.getGutFlora() != null) {
+            for (ParsedReportPayload.Flora pf : payload.getGutFlora()) {
+                result.add(convertToGutFlora(pf));
+            }
+        }
+        if (payload.getProbioticSpecies() != null) {
+            for (ParsedReportPayload.Flora pf : payload.getProbioticSpecies()) {
+                result.add(convertToGutFlora(pf));
+            }
+        }
+        return result;
+    }
+
+    private HealthGutFlora convertToGutFlora(ParsedReportPayload.Flora pf) {
+        HealthGutFlora gf = new HealthGutFlora();
+        gf.setBacteriaName(pf.getBacteriaName());
+        gf.setBacteriaValue(pf.getBacteriaValue());
+        gf.setNormalRange(pf.getNormalRange());
+        gf.setPopulationLevel(pf.getPopulationLevel());
+        gf.setDetectionRate(pf.getDetectionRate());
+        gf.setDescription(pf.getDescription());
+        gf.setCategory(pf.getCategory());
+        gf.setLevel(pf.getLevel());
+        return gf;
+    }
+
+    List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
+        List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
+        if (list == null || list.isEmpty()) return new ArrayList<>();
+
+        List<HealthDiseaseRisk> risks = new ArrayList<>();
+        for (ParsedReportPayload.DiseaseRisk pr : list) {
+            HealthDiseaseRisk dr = new HealthDiseaseRisk();
+            dr.setDiseaseName(pr.getDiseaseName());
+            dr.setRiskValue(pr.getRiskValue());
+            dr.setRiskLevel(pr.getRiskLevel());
+            risks.add(dr);
+        }
+        return risks;
+    }
 }
 }

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

@@ -151,6 +151,15 @@ public class PdfParseService {
         // 被检测人姓名
         // 被检测人姓名
         result.setPersonName(findPersonName(lines));
         result.setPersonName(findPersonName(lines));
 
 
+        // 被检测人性别
+        result.setGender(findGender(lines));
+
+        // 被检测人年龄
+        result.setAge(findAge(lines));
+
+        // 被检测人出生日期
+        result.setBirthday(findBirthday(lines));
+
         // 肠道预测年龄
         // 肠道预测年龄
         result.setGutAge(findTextAfter(lines, "肠道预测年龄:"));
         result.setGutAge(findTextAfter(lines, "肠道预测年龄:"));
 
 
@@ -256,6 +265,145 @@ public class PdfParseService {
         return null;
         return null;
     }
     }
 
 
+    /**
+     * 从lines中查找性别
+     * 支持: 性别:, 性别:, 男/女 等
+     */
+    private String findGender(List<String> lines) {
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i).trim();
+            // 精确匹配 "性别: 男" 或 "性别: 女" 格式
+            if (line.startsWith("性别:") || line.equals("性别")) {
+                String value = line;
+                if (line.equals("性别")) {
+                    // 关键词在前一行,值在后一行
+                    if (i + 1 < lines.size()) {
+                        value = lines.get(i + 1).trim();
+                    }
+                } else {
+                    // "性别: 男" 格式,提取冒号后的内容
+                    int colonIdx = line.indexOf(':');
+                    if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
+                        value = line.substring(colonIdx + 1).trim();
+                    }
+                }
+                if ("男".equals(value) || "male".equalsIgnoreCase(value) || "M".equalsIgnoreCase(value)) {
+                    return "male";
+                }
+                if ("女".equals(value) || "female".equalsIgnoreCase(value) || "F".equalsIgnoreCase(value)) {
+                    return "female";
+                }
+            }
+            // 也支持 "性别:男"(中文冒号)
+            if (line.startsWith("性别\uFF1A") || line.equals("性别\uFF1A")) {
+                String value = line;
+                if (line.equals("性别\uFF1A")) {
+                    if (i + 1 < lines.size()) {
+                        value = lines.get(i + 1).trim();
+                    }
+                } else {
+                    int colonIdx = line.indexOf('\uFF1A');
+                    if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
+                        value = line.substring(colonIdx + 1).trim();
+                    }
+                }
+                if ("男".equals(value) || "male".equalsIgnoreCase(value) || "M".equalsIgnoreCase(value)) {
+                    return "male";
+                }
+                if ("女".equals(value) || "female".equalsIgnoreCase(value) || "F".equalsIgnoreCase(value)) {
+                    return "female";
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 从lines中查找年龄
+     * 支持: 年龄:, 几岁:, X岁 等
+     */
+    private Integer findAge(List<String> lines) {
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i).trim();
+            // 精确匹配 "年龄: XX岁" 或 "年龄: XX"
+            if (line.startsWith("年龄:") || line.equals("年龄")) {
+                String value = line;
+                if (line.equals("年龄")) {
+                    if (i + 1 < lines.size()) {
+                        value = lines.get(i + 1).trim();
+                    }
+                } else {
+                    int colonIdx = line.indexOf(':');
+                    if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
+                        value = line.substring(colonIdx + 1).trim();
+                    }
+                }
+                // 提取数字
+                String numStr = value.replaceAll("[^0-9]", "").trim();
+                if (!numStr.isEmpty()) {
+                    try {
+                        return Integer.parseInt(numStr);
+                    } catch (NumberFormatException e) {
+                        // ignore
+                    }
+                }
+            }
+            // 中文冒号
+            if (line.startsWith("年龄\uFF1A") || line.equals("年龄\uFF1A")) {
+                String value = line;
+                if (line.equals("年龄\uFF1A")) {
+                    if (i + 1 < lines.size()) {
+                        value = lines.get(i + 1).trim();
+                    }
+                } else {
+                    int colonIdx = line.indexOf('\uFF1A');
+                    if (colonIdx >= 0 && colonIdx + 1 < line.length()) {
+                        value = line.substring(colonIdx + 1).trim();
+                    }
+                }
+                String numStr = value.replaceAll("[^0-9]", "").trim();
+                if (!numStr.isEmpty()) {
+                    try {
+                        return Integer.parseInt(numStr);
+                    } catch (NumberFormatException e) {
+                        // ignore
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 从lines中查找出生日期
+     * 支持: 出生日期:, 出生年月:, 生日:, 出生: 等
+     */
+    private String findBirthday(List<String> lines) {
+        String[] keywords = {"出生日期:", "出生年月:", "生日:", "出生:"};
+        for (String keyword : keywords) {
+            String value = findTextAfter(lines, keyword);
+            if (value != null && !value.isEmpty()) {
+                String cleaned = value.trim();
+                // 尝试解析并标准化日期格式
+                // 支持: 2024年01月15日, 2024-01-15, 2024/01/15, 20240115
+                cleaned = cleaned.replaceAll("[年月日]", "-").replaceAll("[/]", "-");
+                cleaned = cleaned.replaceAll("-+", "-").replaceAll("-$", "");
+                if (cleaned.matches("\\d{4}-\\d{2}-\\d{2}")) {
+                    return cleaned;
+                }
+                // 如果是 YYYY-MM 格式,补全为月初
+                if (cleaned.matches("\\d{4}-\\d{2}$")) {
+                    return cleaned + "-01";
+                }
+                // 如果是 YYYYMMDD 格式
+                if (cleaned.matches("\\d{8}")) {
+                    return cleaned.substring(0, 4) + "-" + cleaned.substring(4, 6) + "-" + cleaned.substring(6, 8);
+                }
+            }
+        }
+        return null;
+    }
+
     // ======================== 疾病风险评估 ========================
     // ======================== 疾病风险评估 ========================
 
 
     private void parseDiseaseRisks(List<String> lines, List<ParsedDiseaseRisk> result) {
     private void parseDiseaseRisks(List<String> lines, List<ParsedDiseaseRisk> result) {

+ 55 - 0
cfc-web/CHANGELOG.md

@@ -1,6 +1,61 @@
 # 更新日志 (Changelog)
 # 更新日志 (Changelog)
 
 
 此文件记录所有构建版本的变更。
 此文件记录所有构建版本的变更。
+## v1.0.6 (2026-07-06)
+
+### Bug 修复
+- product order stock check by product type (physical only)
+- create cart table migration and sync to schema.sql
+- add relationship score column migrations for family_members table
+- add require_registration, require_checkin_review, energy_penalty_unregistered migrations for activities table
+- add role_override column migration for family_members table
+- add default_role column migration for relationship_types table
+- add remaining child-specific column migrations for family_members table
+- activities表添加审核相关字段迁移(迁移25)
+- package.json 版本更新
+- add id_card column migration for family_members table
+- resolve ArticleService.java merge conflict (autoGenerateTags + submitForReview/withdraw/reDraft)
+- 活动创建后弹窗提供继续添加/返回列表/直接发布选项 + 表单必填校验
+- TabTransition 默认 true 确保首次渲染即出现,消除页面框架闪现
+- 首次进入首页时 TabTransition 不应展示(由 splash overlay 覆盖)
+
+### 新功能
+- fuse Western astrology with Chinese zodiac for annual energy calculation
+- 文章活动工作流状态管理前端页面
+- 文章活动工作流状态管理API
+- 饮食/文章管理完善 + 供需分发系统文档
+
+### 其他
+- - Add Jupiter 12yr and Saturn 29.5yr cycle phase calculation
+- - Blend 60% Western + 40% Chinese zodiac with Tai Sui relation modifiers
+- - Update controller to accept birthMonth/birthDay, return full fusion result
+- 
+- - 6步标准流程:错误定位→查实体→检查schema.sql→加迁移→同步schema.sql→编译验证
+- - ensureColumn() 辅助方法优先使用
+- - 幂等性要求:所有迁移必须可重复执行
+- - 同步原则:schema.sql 与 DatabaseInitializer 保持最终一致
+- - 4种场景代码模板:ADD COLUMN / CREATE TABLE / ADD INDEX / MODIFY COLUMN
+- 
+- activity.js: 新增 getAdminList/updateStatus/batchUpdateStatus API
+- ArticleManage.vue: 新增状态筛选/审批操作列/排序拖拽/发布状态切换
+- ArticleEdit.vue: 优化表单校验/状态联动/发布时间记录
+- Activities.vue: 重构列表页 - 新增状态标签/审批弹窗/批量操作
+- ActivityEdit.vue: 优化表单 - 新增状态流转控制/发布时间
+- 
+- AdminArticleController: 新增文章管理分页/审核/排序端点
+- Activity: 新增 status/approvalStatus/remark/publishedAt 字段
+- ActivityAdminService: 新增分页查询 + 状态流转(待审核→发布/驳回/下架)
+- AdminActivityController: 新增活动管理完整CRUD + 状态审核端点
+- schema.sql: 新增 ACTIVITY_WAITING_APPROVAL 状态到状态约束
+- 
+
+### 文档
+- 添加数据库迁移工作流规范
+
+### 工程化
+- 构建版本更新 v1.0.5
+
+
 ## v1.0.4 (2026-07-06)
 ## v1.0.4 (2026-07-06)
 
 
 ### Bug 修复
 ### Bug 修复