Parcourir la source

feat(health-report): 跨家庭报告转移申请,同MD5报告需原家庭同意

liaoxg il y a 6 jours
Parent
commit
f933765e89

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

@@ -10932,6 +10932,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         seedTaskReminderTemplate();
         backfillHealthPlanTaskPrerequisite();
         backfillTemplateInstanceFamilyMemberId();
+        createReportTransferRequestsTable();
 
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
@@ -11618,4 +11619,31 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移344: 创建 supply_customer_relation 表失败: {}", e.getMessage());
         }
     }
+
+    // 迁移349: 创建 report_transfer_requests 表(健康报告跨家庭转移申请)
+    // 同一份报告(file_hash 相同)被其他家庭上传时,申请方家庭发起转移申请,
+    // 原归属家庭同意后 health_reports.family_id 转移至申请方,原家庭不再可见。
+    private void createReportTransferRequestsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_transfer_requests (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "report_id BIGINT NOT NULL COMMENT '报告ID(health_reports.id)'," +
+                    "from_family_id BIGINT NOT NULL COMMENT '原归属家庭ID(需同意方)'," +
+                    "to_family_id BIGINT NOT NULL COMMENT '申请转入家庭ID(申请方)'," +
+                    "requester_user_id BIGINT NOT NULL COMMENT '申请人用户ID(users.id)'," +
+                    "status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=待同意 approved=已同意 denied=已拒绝'," +
+                    "handled_by BIGINT DEFAULT NULL COMMENT '处理人用户ID'," +
+                    "handled_at DATETIME DEFAULT NULL COMMENT '处理时间'," +
+                    "remark VARCHAR(200) DEFAULT NULL COMMENT '备注(拒绝原因)'," +
+                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "INDEX idx_report_id (report_id)," +
+                    "INDEX idx_from_family_status (from_family_id, status)," +
+                    "INDEX idx_to_family_status (to_family_id, status)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告跨家庭转移申请'");
+            log.info("迁移349: 已创建 report_transfer_requests 表");
+        } catch (Exception e) {
+            log.warn("迁移349: 创建 report_transfer_requests 表失败: {}", e.getMessage());
+        }
+    }
 }

+ 102 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -20,6 +20,7 @@ import com.etotem.cfc.entity.HealthGutFlora;
 import com.etotem.cfc.entity.HealthIndicator;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.ReportTransferRequest;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
 import com.etotem.cfc.entity.User;
@@ -37,6 +38,7 @@ import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.ReportCollectService;
+import com.etotem.cfc.service.ReportTransferService;
 import com.etotem.cfc.service.ReportBlockAssembler;
 import com.etotem.cfc.service.ReportBlockService;
 import com.etotem.cfc.service.StorageService;
@@ -102,6 +104,9 @@ public class HealthReportController {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private ReportTransferService reportTransferService;
+
     @Resource
     private PdfParseService pdfParseService;
 
@@ -698,7 +703,17 @@ public class HealthReportController {
                 Map<String, Object> dup = new LinkedHashMap<>();
                 dup.put("duplicate", true);
                 dup.put("reportId", existingReportId);
-                dup.put("message", "该报告已上传过,无需重复解析");
+                HealthReport existingReport = healthReportService.getReportById(existingReportId);
+                boolean crossFamily = existingReport != null
+                        && existingReport.getFamilyId() != null
+                        && (familyId == null || !familyId.equals(existingReport.getFamilyId()));
+                if (crossFamily) {
+                    // 同一份报告已被其他家庭上传:需原家庭同意后方可转入本家庭,禁止直接跳详情
+                    dup.put("crossFamily", true);
+                    dup.put("message", "该报告已由其他家庭上传,发起转移申请并经对方同意后,报告将转入您的家庭");
+                } else {
+                    dup.put("message", "该报告已上传过,无需重复解析");
+                }
                 return Result.success(dup);
             }
             Long collectingDraftId = healthReportService.findCollectingDraftIdByFileHash(fileHash);
@@ -1390,6 +1405,92 @@ public class HealthReportController {
         return Result.success("归属已更新");
     }
 
+    /**
+     * 发起报告跨家庭转移申请(申请方家庭)
+     *
+     * <p>同一份报告(文件MD5相同)已被其他家庭上传时,上传接口返回 crossFamily=true,
+     * 前端弹窗确认后调用本接口发起申请;原家庭在「健康报告」列表同意后报告转入本家庭。</p>
+     *
+     * @param params   请求体:reportId (必填)
+     * @param familyId 当前登录用户的家庭ID(申请方,由 JwtInterceptor 注入)
+     * @param userId   当前登录用户ID
+     * @return 转移申请摘要 {requestId, status}
+     */
+    @PostMapping("/report/transfer/request")
+    public Result<Map<String, Object>> requestReportTransfer(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        try {
+            ReportTransferRequest request = reportTransferService.createRequest(reportId, familyId, userId);
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("requestId", request.getId());
+            data.put("status", request.getStatus());
+            return Result.success(data);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 同意转移(原归属家庭):报告 family_id 转入申请方,本家庭不再可见
+     */
+    @PostMapping("/report/transfer/approve")
+    public Result<String> approveReportTransfer(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long requestId = ParamUtils.getLong(params.get("requestId"));
+        try {
+            reportTransferService.approve(requestId, familyId, userId);
+            return Result.success("已同意转移,报告已转入对方家庭");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 拒绝转移(原归属家庭):报告留在本家庭
+     */
+    @PostMapping("/report/transfer/deny")
+    public Result<String> denyReportTransfer(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute("userId") Long userId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long requestId = ParamUtils.getLong(params.get("requestId"));
+        String remark = params.get("remark") != null ? String.valueOf(params.get("remark")) : null;
+        try {
+            reportTransferService.deny(requestId, familyId, userId, remark);
+            return Result.success("已拒绝该转移申请");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 查询本家庭相关的转移申请列表
+     *
+     * @return {incoming: 待本家庭处理(原归属方), outgoing: 本家庭发起的(申请方)}
+     */
+    @PostMapping("/report/transfer/list")
+    public Result<Map<String, Object>> listReportTransfers(
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        return Result.success(reportTransferService.listRequests(familyId));
+    }
+
     /**
      * 获取某家庭中所有未绑定成员的报告列表
      */

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportTransferRequest.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 健康报告跨家庭转移申请
+ *
+ * <p>同一份报告(file_hash 相同)被不同家庭上传时,申请方家庭发起转移申请,
+ * 原归属家庭同意后 health_reports.family_id 转移至申请方,原家庭不再可见。</p>
+ */
+@Data
+@TableName("report_transfer_requests")
+public class ReportTransferRequest implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 状态:待同意 */
+    public static final String STATUS_PENDING = "pending";
+    /** 状态:已同意(报告已转入申请方家庭) */
+    public static final String STATUS_APPROVED = "approved";
+    /** 状态:已拒绝 */
+    public static final String STATUS_DENIED = "denied";
+
+    /** 主键ID */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 报告ID(health_reports.id) */
+    private Long reportId;
+
+    /** 原归属家庭ID(需同意方) */
+    private Long fromFamilyId;
+
+    /** 申请转入家庭ID(申请方) */
+    private Long toFamilyId;
+
+    /** 申请人用户ID(users.id) */
+    private Long requesterUserId;
+
+    /** 状态: pending=待同意 approved=已同意 denied=已拒绝 */
+    private String status;
+
+    /** 处理人用户ID */
+    private Long handledBy;
+
+    /** 处理时间 */
+    private Date handledAt;
+
+    /** 备注(拒绝原因) */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportTransferRequestMapper.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ReportTransferRequest;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 健康报告跨家庭转移申请 Mapper
+ */
+@Mapper
+public interface ReportTransferRequestMapper extends BaseMapper<ReportTransferRequest> {
+}

+ 327 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportTransferService.java

@@ -0,0 +1,327 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.ReportTransferRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.ReportTransferRequestMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 健康报告跨家庭转移服务
+ *
+ * <p>同一份报告(file_hash 相同)被不同家庭上传时的归属转移审批:</p>
+ * <ol>
+ *   <li>申请方家庭发起转移申请(createRequest)→ 通知原家庭</li>
+ *   <li>原归属家庭同意(approve)→ health_reports.family_id 转移至申请方、subject_id 置空、
+ *       原成员重算维度分与食材推荐、原家庭不再可见(列表按 family_id 过滤)</li>
+ *   <li>原归属家庭拒绝(deny)→ 报告留在原家庭</li>
+ * </ol>
+ */
+@Slf4j
+@Service
+public class ReportTransferService {
+
+    @Resource
+    private ReportTransferRequestMapper transferRequestMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
+    @Resource
+    private FoodRecommendService foodRecommendService;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    /**
+     * 发起跨家庭报告转移申请(申请方家庭)
+     *
+     * @param reportId          报告ID(上传重复检测返回的既有报告)
+     * @param requesterFamilyId 申请方家庭ID(当前登录用户所属家庭)
+     * @param requesterUserId   申请方用户ID
+     * @return 创建的转移申请
+     */
+    public ReportTransferRequest createRequest(Long reportId, Long requesterFamilyId, Long requesterUserId) {
+        if (reportId == null || requesterFamilyId == null) {
+            throw new RuntimeException("参数不完整");
+        }
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report == null || !"active".equals(report.getStatus())) {
+            throw new RuntimeException("报告不存在或已删除");
+        }
+        if (report.getFamilyId() == null) {
+            throw new RuntimeException("报告未归属任何家庭,无需转移");
+        }
+        if (requesterFamilyId.equals(report.getFamilyId())) {
+            throw new RuntimeException("该报告已归属当前家庭");
+        }
+        // 同报告同申请家庭只允许一条待处理申请
+        List<ReportTransferRequest> pending = transferRequestMapper.selectList(
+                new LambdaQueryWrapper<ReportTransferRequest>()
+                        .eq(ReportTransferRequest::getReportId, reportId)
+                        .eq(ReportTransferRequest::getToFamilyId, requesterFamilyId)
+                        .eq(ReportTransferRequest::getStatus, ReportTransferRequest.STATUS_PENDING)
+                        .last("LIMIT 1"));
+        if (!pending.isEmpty()) {
+            throw new RuntimeException("转移申请已提交,等待原家庭同意");
+        }
+
+        ReportTransferRequest request = new ReportTransferRequest();
+        request.setReportId(reportId);
+        request.setFromFamilyId(report.getFamilyId());
+        request.setToFamilyId(requesterFamilyId);
+        request.setRequesterUserId(requesterUserId);
+        request.setStatus(ReportTransferRequest.STATUS_PENDING);
+        request.setCreatedAt(new Date());
+        request.setUpdatedAt(new Date());
+        transferRequestMapper.insert(request);
+        log.info("跨家庭报告转移申请已创建: requestId={}, reportId={}, fromFamily={}, toFamily={}",
+                request.getId(), reportId, report.getFamilyId(), requesterFamilyId);
+
+        // 通知原家庭(含报告上传者)
+        notifyFamilyUsers(report.getFamilyId(), report.getUserId(), "report_transfer_request",
+                "报告转移申请",
+                "有其他家庭申请将您的健康报告转入其家庭,请到「健康报告」列表处理。",
+                request.getId());
+        return request;
+    }
+
+    /**
+     * 同意转移(原归属家庭)
+     *
+     * <p>报告 family_id 转移至申请方、subject_id 置空(由新家庭重新归属);
+     * 原成员重算维度分与食材推荐;同报告其他待处理申请改由新归属家庭处理。</p>
+     *
+     * @param requestId         转移申请ID
+     * @param approverFamilyId  同意方家庭ID(必须为申请的原归属家庭)
+     * @param approverUserId    同意方用户ID
+     */
+    @Transactional
+    public void approve(Long requestId, Long approverFamilyId, Long approverUserId) {
+        ReportTransferRequest request = getRequest(requestId);
+        if (!ReportTransferRequest.STATUS_PENDING.equals(request.getStatus())) {
+            throw new RuntimeException("该申请已处理,请勿重复操作");
+        }
+        if (!request.getFromFamilyId().equals(approverFamilyId)) {
+            throw new RuntimeException("无权处理该申请");
+        }
+        HealthReport report = healthReportMapper.selectById(request.getReportId());
+        if (report == null || !"active".equals(report.getStatus())) {
+            throw new RuntimeException("报告不存在或已删除");
+        }
+        if (!request.getFromFamilyId().equals(report.getFamilyId())) {
+            throw new RuntimeException("报告归属已变更,请刷新后重试");
+        }
+
+        Long oldSubjectId = report.getSubjectId();
+
+        // 1. 报告转入申请方家庭,解除原成员归属(JdbcTemplate 以支持 subject_id = NULL)
+        jdbcTemplate.update(
+                "UPDATE health_reports SET family_id = ?, subject_id = NULL, updated_at = NOW() WHERE id = ?",
+                request.getToFamilyId(), report.getId());
+
+        // 2. 原成员重算维度分与食材推荐(移除该报告影响,与 deleteReport 同惯例)
+        if (oldSubjectId != null) {
+            try {
+                dimensionScoreService.refreshFromGutReport(oldSubjectId, report.getId());
+                log.info("报告转移后已重算原成员{}的7维健康评分", oldSubjectId);
+            } catch (Exception e) {
+                log.warn("报告转移后刷新原成员{}健康评分失败: {}", oldSubjectId, e.getMessage());
+            }
+            try {
+                foodRecommendService.recalculateForUser(oldSubjectId);
+            } catch (Exception e) {
+                log.warn("报告转移后刷新原成员{}食材推荐指数失败: {}", oldSubjectId, e.getMessage());
+            }
+        }
+
+        // 3. 同报告其他待处理申请改由新归属家庭处理(原家庭已不再拥有该报告)
+        transferRequestMapper.update(null, new LambdaUpdateWrapper<ReportTransferRequest>()
+                .eq(ReportTransferRequest::getReportId, report.getId())
+                .eq(ReportTransferRequest::getStatus, ReportTransferRequest.STATUS_PENDING)
+                .ne(ReportTransferRequest::getId, request.getId())
+                .set(ReportTransferRequest::getFromFamilyId, request.getToFamilyId())
+                .set(ReportTransferRequest::getUpdatedAt, new Date()));
+
+        // 4. 更新本申请状态
+        request.setStatus(ReportTransferRequest.STATUS_APPROVED);
+        request.setHandledBy(approverUserId);
+        request.setHandledAt(new Date());
+        request.setUpdatedAt(new Date());
+        transferRequestMapper.updateById(request);
+
+        // 5. 通知申请方
+        notifyFamilyUsers(request.getToFamilyId(), request.getRequesterUserId(), "report_transfer_result",
+                "报告转移申请已同意",
+                "您申请的健康报告已转入您的家庭,请到「健康报告」列表查看。",
+                request.getId());
+        log.info("跨家庭报告转移申请已同意: requestId={}, reportId={}, fromFamily={}, toFamily={}",
+                request.getId(), report.getId(), request.getFromFamilyId(), request.getToFamilyId());
+    }
+
+    /**
+     * 拒绝转移(原归属家庭)
+     *
+     * @param requestId        转移申请ID
+     * @param approverFamilyId 拒绝方家庭ID(必须为申请的原归属家庭)
+     * @param approverUserId   拒绝方用户ID
+     * @param remark           拒绝备注(可空)
+     */
+    public void deny(Long requestId, Long approverFamilyId, Long approverUserId, String remark) {
+        ReportTransferRequest request = getRequest(requestId);
+        if (!ReportTransferRequest.STATUS_PENDING.equals(request.getStatus())) {
+            throw new RuntimeException("该申请已处理,请勿重复操作");
+        }
+        if (!request.getFromFamilyId().equals(approverFamilyId)) {
+            throw new RuntimeException("无权处理该申请");
+        }
+        request.setStatus(ReportTransferRequest.STATUS_DENIED);
+        request.setHandledBy(approverUserId);
+        request.setHandledAt(new Date());
+        request.setRemark(remark);
+        request.setUpdatedAt(new Date());
+        transferRequestMapper.updateById(request);
+
+        // 通知申请方
+        notifyFamilyUsers(request.getToFamilyId(), request.getRequesterUserId(), "report_transfer_result",
+                "报告转移申请被拒绝",
+                "您申请的健康报告转移未获对方家庭同意。",
+                request.getId());
+        log.info("跨家庭报告转移申请已拒绝: requestId={}, reportId={}, byFamily={}",
+                request.getId(), request.getReportId(), approverFamilyId);
+    }
+
+    /**
+     * 查询本家庭相关的转移申请
+     *
+     * @param familyId 当前登录用户所属家庭
+     * @return {incoming: 待本家庭处理(原归属方), outgoing: 本家庭发起的(申请方)}
+     */
+    public Map<String, Object> listRequests(Long familyId) {
+        List<ReportTransferRequest> incoming = transferRequestMapper.selectList(
+                new LambdaQueryWrapper<ReportTransferRequest>()
+                        .eq(ReportTransferRequest::getFromFamilyId, familyId)
+                        .eq(ReportTransferRequest::getStatus, ReportTransferRequest.STATUS_PENDING)
+                        .orderByDesc(ReportTransferRequest::getCreatedAt));
+        List<ReportTransferRequest> outgoing = transferRequestMapper.selectList(
+                new LambdaQueryWrapper<ReportTransferRequest>()
+                        .eq(ReportTransferRequest::getToFamilyId, familyId)
+                        .orderByDesc(ReportTransferRequest::getCreatedAt)
+                        .last("LIMIT 50"));
+        Map<String, Object> result = new HashMap<>();
+        result.put("incoming", enrichWithReport(incoming));
+        result.put("outgoing", enrichWithReport(outgoing));
+        return result;
+    }
+
+    private ReportTransferRequest getRequest(Long requestId) {
+        if (requestId == null) {
+            throw new RuntimeException("申请ID不能为空");
+        }
+        ReportTransferRequest request = transferRequestMapper.selectById(requestId);
+        if (request == null) {
+            throw new RuntimeException("申请不存在");
+        }
+        return request;
+    }
+
+    /**
+     * 批量拼接报告摘要(personName/reportType/reportDate/overallScore),供前端列表展示
+     */
+    private List<Map<String, Object>> enrichWithReport(List<ReportTransferRequest> requests) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        if (requests == null || requests.isEmpty()) {
+            return list;
+        }
+        Set<Long> reportIds = new LinkedHashSet<>();
+        for (ReportTransferRequest r : requests) {
+            reportIds.add(r.getReportId());
+        }
+        Map<Long, HealthReport> reportMap = new HashMap<>();
+        List<HealthReport> reports = healthReportMapper.selectBatchIds(reportIds);
+        for (HealthReport r : reports) {
+            reportMap.put(r.getId(), r);
+        }
+
+        for (ReportTransferRequest r : requests) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("id", r.getId());
+            m.put("reportId", r.getReportId());
+            m.put("status", r.getStatus());
+            m.put("remark", r.getRemark());
+            m.put("createdAt", r.getCreatedAt());
+            m.put("handledAt", r.getHandledAt());
+            HealthReport report = reportMap.get(r.getReportId());
+            if (report != null) {
+                m.put("personName", report.getPersonName());
+                m.put("reportType", report.getReportType());
+                m.put("reportDate", report.getReportDate());
+                m.put("overallScore", report.getOverallScore());
+                m.put("reportDeleted", false);
+            } else {
+                m.put("personName", "报告已删除");
+                m.put("reportType", null);
+                m.put("reportDate", null);
+                m.put("overallScore", null);
+                m.put("reportDeleted", true);
+            }
+            list.add(m);
+        }
+        return list;
+    }
+
+    /**
+     * 站内通知指定家庭的全部可登录用户(兜底额外通知者,如报告上传者)
+     *
+     * <p>模板缺失时回退默认文案,恒走 in_app 通道;通知失败不影响主流程。</p>
+     */
+    private void notifyFamilyUsers(Long familyId, Long fallbackUserId, String type,
+                                   String title, String content, Long refId) {
+        try {
+            Set<Long> userIds = new LinkedHashSet<>();
+            if (familyId != null) {
+                List<User> users = userMapper.selectList(new LambdaQueryWrapper<User>()
+                        .eq(User::getFamilyId, familyId));
+                for (User u : users) {
+                    if (u.getId() != null) {
+                        userIds.add(u.getId());
+                    }
+                }
+            }
+            if (fallbackUserId != null) {
+                userIds.add(fallbackUserId);
+            }
+            for (Long uid : userIds) {
+                notificationDispatchService.sendByTemplate(type, uid, "report_transfer_request", refId, title, content);
+            }
+        } catch (Exception e) {
+            log.warn("报告转移通知发送失败: {}", e.getMessage());
+        }
+    }
+}

+ 18 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1907,6 +1907,24 @@ CREATE TABLE IF NOT EXISTS health_report_edit_logs (
     INDEX idx_report_id (report_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告修改留痕';
 
+-- 健康报告跨家庭转移申请表(同一份报告被不同家庭上传时的归属转移审批)
+CREATE TABLE IF NOT EXISTS report_transfer_requests (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    report_id BIGINT NOT NULL COMMENT '报告ID(health_reports.id)',
+    from_family_id BIGINT NOT NULL COMMENT '原归属家庭ID(需同意方)',
+    to_family_id BIGINT NOT NULL COMMENT '申请转入家庭ID(申请方)',
+    requester_user_id BIGINT NOT NULL COMMENT '申请人用户ID(users.id)',
+    status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending=待同意 approved=已同意 denied=已拒绝',
+    handled_by BIGINT DEFAULT NULL COMMENT '处理人用户ID',
+    handled_at DATETIME DEFAULT NULL COMMENT '处理时间',
+    remark VARCHAR(200) DEFAULT NULL COMMENT '备注(拒绝原因)',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_report_id (report_id),
+    INDEX idx_from_family_status (from_family_id, status),
+    INDEX idx_to_family_status (to_family_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康报告跨家庭转移申请';
+
 -- 菌群检测专项数据表(针对 gut_flora 类型报告)
 CREATE TABLE IF NOT EXISTS health_gut_flora (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',

+ 166 - 4
cfc-frontend/pages/health/report-list.vue

@@ -20,6 +20,31 @@
       </scroll-view>
     </view>
 
+    <!-- 跨家庭报告转移申请 -->
+    <view class="transfer-wrap" v-if="transferIncoming.length > 0 || transferOutgoing.length > 0">
+      <text class="transfer-section-title">报告转移申请</text>
+      <!-- 待本家庭处理:对方家庭申请将本家庭报告转入其家庭 -->
+      <view class="transfer-card" v-for="item in transferIncoming" :key="item.id">
+        <view class="transfer-info">
+          <text class="transfer-person">{{ item.personName || '健康报告' }}</text>
+          <text class="transfer-meta">报告日期:{{ formatDate(item.reportDate) || '--' }}</text>
+          <text class="transfer-meta">申请时间:{{ formatDateTime(item.createdAt) }}</text>
+        </view>
+        <view class="transfer-actions">
+          <view class="transfer-btn transfer-deny" @click.stop="denyTransfer(item)">拒绝</view>
+          <view class="transfer-btn transfer-approve" @click.stop="approveTransfer(item)">同意</view>
+        </view>
+      </view>
+      <!-- 本家庭发起的申请 -->
+      <view class="transfer-card" v-for="item in transferOutgoing" :key="item.id">
+        <view class="transfer-info">
+          <text class="transfer-person">{{ item.personName || '健康报告' }}</text>
+          <text class="transfer-meta">申请时间:{{ formatDateTime(item.createdAt) }}</text>
+          <text class="transfer-status" :class="'status-' + item.status">{{ transferStatusText(item.status) }}</text>
+        </view>
+      </view>
+    </view>
+
     <!-- 采集中草稿 -->
     <view class="collecting-wrap" v-if="collectingReports.length > 0">
       <view class="collecting-card" v-for="item in collectingReports" :key="item.draftId">
@@ -135,7 +160,7 @@
 
 <script>
 import { parseDate } from '../../utils/format.js'
-import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports, reassignReport } from '../../utils/api.js'
+import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports, reassignReport, getReportTransferList, approveReportTransfer, denyReportTransfer } from '../../utils/api.js'
 
 export default {
   data() {
@@ -166,15 +191,21 @@ export default {
       candidates: [],
       showAutoCreateForm: false,
       newMemberName: '',
-      newMemberGender: 'male'
+      newMemberGender: 'male',
+      // 跨家庭报告转移申请
+      transferIncoming: [],
+      transferOutgoing: []
     }
   },
   onLoad(options) {
-    // 导航参数可带初始 memberId(预选对应成员);未传时默认"全部"
-    this.memberId = options.memberId ? parseInt(options.memberId) : null
+    // 从 storage 获取当前用户ID(优先使用 currentChildId,其次 currentMemberId)
+    var currentUserId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
+    this.memberId = options.memberId ? parseInt(options.memberId) : (currentUserId ? parseInt(currentUserId) : null)
+    // 默认显示当前用户的报告;"全部"可切换查看所有成员
     this.selectedMemberId = this.memberId
     this.loadMembers()
     this.loadData()
+    this.loadTransferRequests()
     this.startCollectPolling()
   },
   onShow() {
@@ -182,6 +213,7 @@ export default {
     if (this._pageShown) {
       this.loadData()
       this.loadCollectingReports()
+      this.loadTransferRequests()
     }
     this._pageShown = true
   },
@@ -328,6 +360,66 @@ export default {
         self.loading = false
       })
     },
+    // 跨家庭报告转移申请列表(incoming=待本家庭处理 / outgoing=本家庭发起)
+    loadTransferRequests() {
+      var self = this
+      getReportTransferList().then(function(res) {
+        if (res && res.code === 200) {
+          var data = res.data || {}
+          self.transferIncoming = data.incoming instanceof Array ? data.incoming : []
+          self.transferOutgoing = data.outgoing instanceof Array ? data.outgoing : []
+        }
+      }).catch(function(e) {
+        console.log('获取转移申请失败', e)
+      })
+    },
+    transferStatusText(status) {
+      if (status === 'approved') return '对方已同意,报告已转入'
+      if (status === 'denied') return '对方已拒绝'
+      return '等待对方同意'
+    },
+    approveTransfer(item) {
+      var self = this
+      uni.showModal({
+        title: '同意转移',
+        content: '同意后该报告将转入对方家庭,您的家庭将不再显示此报告。是否同意?',
+        confirmColor: '#F56C6C',
+        success: function(res) {
+          if (!res.confirm) return
+          approveReportTransfer(item.id).then(function(r) {
+            if (r && r.code === 200) {
+              uni.showToast({ title: '已同意,报告已转入对方家庭', icon: 'none' })
+              self.loadTransferRequests()
+              self.loadData()
+            } else {
+              uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '操作失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    denyTransfer(item) {
+      var self = this
+      uni.showModal({
+        title: '拒绝转移',
+        content: '拒绝后该报告继续留在您的家庭。',
+        success: function(res) {
+          if (!res.confirm) return
+          denyReportTransfer(item.id, '').then(function(r) {
+            if (r && r.code === 200) {
+              uni.showToast({ title: '已拒绝', icon: 'none' })
+              self.loadTransferRequests()
+            } else {
+              uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '操作失败', icon: 'none' })
+          })
+        }
+      })
+    },
     genderText(gender) {
       if (gender === 'male') return '男'
       if (gender === 'female') return '女'
@@ -490,6 +582,76 @@ export default {
   color: #fff;
 }
 
+/* ===== 跨家庭报告转移申请 ===== */
+.transfer-wrap {
+  margin: 20rpx 30rpx 0;
+}
+.transfer-section-title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 12rpx;
+}
+.transfer-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  background: #fff;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 16rpx;
+}
+.transfer-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.transfer-person {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+}
+.transfer-meta {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-top: 6rpx;
+}
+.transfer-actions {
+  display: flex;
+  flex-direction: row;
+  flex-shrink: 0;
+}
+.transfer-btn {
+  font-size: 24rpx;
+  padding: 10rpx 28rpx;
+  border-radius: 24rpx;
+  margin-left: 12rpx;
+}
+.transfer-deny {
+  background: #F1F5F9;
+  color: #64748B;
+}
+.transfer-approve {
+  background: #5B9BD5;
+  color: #fff;
+}
+.transfer-status {
+  font-size: 24rpx;
+  margin-top: 6rpx;
+}
+.status-pending {
+  color: #F0B429;
+}
+.status-approved {
+  color: #34C759;
+}
+.status-denied {
+  color: #94A3B8;
+}
+
 /* ===== 加载状态 ===== */
 .loading-wrap {
   display: flex;

+ 27 - 2
cfc-frontend/pages/health/report-upload.vue

@@ -137,7 +137,7 @@
 </template>
 
 <script>
-import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList } from '../../utils/api.js'
+import { uploadReportOnly, getReportCollectStatus, getFamilyMemberList, requestReportTransfer } from '../../utils/api.js'
 
 export default {
   data() {
@@ -338,8 +338,12 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
             self.collectError = data.message || '该报告正在解析中,请稍候'
             self.collectFileName = fileName || '报告文件'
             self.startCollectPolling(data.draftId)
+          } else if (data.crossFamily) {
+            // 同一份报告已被其他家庭上传:弹窗确认后向原家庭发起转移申请,不跳详情
+            self.confirmCrossFamilyTransfer(data)
+            return
           } else {
-            // 已完成,跳转详情
+            // 本家庭已完成,跳转详情
             var reportId = data.reportId
             if (reportId) {
               self.goToDetail(reportId, 'gut_flora')
@@ -365,6 +369,27 @@ if (options.type === 'a2' || options.reportKind === 'a2') {
         uni.showToast({ title: msg, icon: 'none' })
       })
     },
+    // 跨家庭重复上传:弹窗确认后向原家庭发起转移申请(原家庭同意后报告才转入本家庭)
+    confirmCrossFamilyTransfer: function(data) {
+      uni.showModal({
+        title: '申请转移报告',
+        content: '该报告已由其他家庭上传。发起申请后需对方家庭同意,报告才会转入您的家庭;同意前您无法查看该报告。是否发起申请?',
+        confirmText: '发起申请',
+        cancelText: '取消',
+        success: function(res) {
+          if (!res.confirm) return
+          requestReportTransfer(data.reportId).then(function(r) {
+            if (r && r.code === 200) {
+              uni.showToast({ title: '申请已提交,等待对方家庭同意', icon: 'none', duration: 3000 })
+            } else {
+              uni.showToast({ title: (r && r.message) || '申请失败', icon: 'none' })
+            }
+          }).catch(function() {
+            uni.showToast({ title: '申请失败,请重试', icon: 'none' })
+          })
+        }
+      })
+    },
     stopCollectPolling: function() {
       if (this.collectTimer) {
         clearInterval(this.collectTimer)

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

@@ -2371,6 +2371,12 @@ export const reassignReport = (reportId, subjectId) => request('/api/health/repo
 
 export const deleteReport = (reportId) => request('/api/health/report/delete', 'POST', { reportId })
 
+// 跨家庭报告转移:同一份报告(MD5相同)被其他家庭上传时,申请转入本家庭(需原家庭同意后报告才转入)
+export const requestReportTransfer = (reportId) => request('/api/health/report/transfer/request', 'POST', { reportId })
+export const approveReportTransfer = (requestId) => request('/api/health/report/transfer/approve', 'POST', { requestId })
+export const denyReportTransfer = (requestId, remark) => request('/api/health/report/transfer/deny', 'POST', { requestId, remark })
+export const getReportTransferList = () => request('/api/health/report/transfer/list', 'POST', {})
+
 export const downloadReportFile = (reportId) => request('/api/health/report/download', 'POST', { reportId })
 
 export const queryKnowledgeBase = (itemType, itemName) => request('/api/health/knowledge/query', 'POST', { itemType, itemName })