2026-10-05-health-report-reassign-plan.md 16 KB

Health Report Reallocation 实施计划

面向 AI 代理的工作者:必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标:新增 POST /api/health/report/reassign 接口,支持管理员/规划师重新分配报告归属,并触发五维能量/食材推荐维度重算;同时修复 getUnlinkedReports 查询 bug(familyId → family_id)。

架构:在现有 HealthReportService/HealthReportController 之上增量添加,复用已有的 bindReportToMember + 维度重算逻辑,无新建表/字段。

技术栈:Spring Boot 2.7.18 + MyBatis-Plus + JdbcTemplate + antd Vue 组件库

已完成:设计文档保存至 specs/2026-10-05-health-report-reassign-design.md

计划保存位置:docs/superpowers/plans/2026-10-05-health-report-reassign-plan.md


任务 1:新增 POST /api/health/report/reassign 控制器

文件:/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

位置:在 report/unlinked 方法(约 1375 行)之后、report/bind 方法(约 1359 行)之前新增

代码块:

/**
 * 重新分配报告归属
 *
 * <p>将报告从旧归属切换到新归属,或解除归属(subjectId 设为 null)。
 * 归属变更后自动重新计算五维能量维度和食材推荐指数。</p>
 *
 * <p>权限检查:仅家庭成员或管理员可操作,通过 familyId 验证报告归属。</p>
 *
 * @param params 请求体:reportId (必填), subjectId (可为 null 表示解绑)
 * @param familyId 当前登录用户的家庭ID(由 JwtInterceptor 注入)
 * @return 操作结果
 */
@PostMapping("/report/reassign")
public Result<String> reassignReport(@RequestBody Map<String, Object> params,
                                      @RequestAttribute(value = "familyId", required = false) Long familyId) {
    Long reportId = ParamUtils.getLong(params.get("reportId"));
    Long newSubjectId = ParamUtils.getLong(params.get("subjectId")); // null = unbind
    
    healthReportService.reassignReport(reportId, newSubjectId, familyId);
    return Result.success("归属已更新");
}

验证命令:

mvn clean compile   # 必须通过编译

任务 2:实现 service 层 reassignReport 方法

文件:/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

位置:在 bindReportToMember 方法(约 1185 行)之后、getUnlinkedReports 方法(约 1196 行)之前新增

代码块:

/**
 * 重新分配报告归属
 *
 * <p>将报告从旧归属切换到新归属,或解除归属(subjectId 设为 null)。
 * 归属变更后自动重新计算五维能量维度和食材推荐指数。</p>
 *
 * <p>流程:</p>
 * <ol>
 *   <li>权限检查:确保报告属于当前家庭</li>
 *   <li>记录旧归属 subjectId</li>
 *   <li>更新 health_reports.subject_id</li>
 *   <li>若旧归属不为空:refreshFromGutReport + recalculateForUser</li>
 *   <li>若新归属不为空:refreshFromGutReport + recalculateForUser</li>
 *   <li>记录操作日志</li>
 * </ol>
 *
 * @param reportId 报告ID
 * @param newSubjectId 新的归属成员ID(null 表示解除绑定)
 * @param familyId 家庭ID,用于权限验证
 * @throws RuntimeException 当报告不存在或不属于该家庭时抛出
 */
@Transactional
public void reassignReport(Long reportId, Long newSubjectId, Long familyId) {
    HealthReport report = healthReportMapper.selectById(reportId);
    if (report == null) {
        throw new RuntimeException("报告不存在");
    }
    
    // 权限检查:确保报告属于该家庭
    if (!familyId.equals(report.getFamilyId())) {
        throw new RuntimeException("报告不属于该家庭");
    }
    
    Long oldSubjectId = report.getSubjectId();
    
    // 无实际变化则直接返回
    if (oldSubjectId != null && newSubjectId != null && oldSubjectId.equals(newSubjectId)) {
        return;
    }
    
    // 1. 更新 subject_id
    String updateSql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
    jdbcTemplate.update(updateSql, newSubjectId, reportId);
    log.info("报告 {} 归属已更新:oldSubjectId={}, newSubjectId={}", reportId, oldSubjectId, newSubjectId);
    
    // 2. 重算旧成员维度(如果原来有绑定)
    if (oldSubjectId != null) {
        dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
        foodRecommendService.recalculateForUser(oldSubjectId);
        log.info("已重算旧归属成员 {} 的五维能量与食材推荐", oldSubjectId);
    }
    
    // 3. 重算新成员维度(如果有新归属)
    if (newSubjectId != null) {
        dimensionScoreService.refreshFromGutReport(newSubjectId, reportId);
        foodRecommendService.recalculateForUser(newSubjectId);
        log.info("已重算新归属成员 {} 的五维能量与食材推荐", newSubjectId);
    }
    
    // 4. 日志记录(可选:记录操作者)
    String operator = String.valueOf(ThreadLocalUtils.getCurrentUserId());
    log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}, operator={}", 
             reportId, oldSubjectId, newSubjectId, operator);
}

验证命令:

mvn clean compile   # 必须通过编译

任务 3:修复 getUnlinkedReports 查询 bug

文件:/sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

位置:将 getUnlinkedReports(Long userId) 方法(约 1196 行)重构为接受 familyId

代码块:

/**
 * 获取家庭内所有未绑定归属的报告
 *
 * <p>查询条件:family_id = ? 且 subject_id IS NULL</p>
 *
 * @param familyId 家庭ID
 * @return 未绑定归属的报告列表
 */
public List<HealthReport> getUnlinkedReports(Long familyId) {
    return healthReportMapper.selectList(
            new LambdaQueryWrapper<HealthReport>()
                    .eq(HealthReport::getFamilyId, familyId)   // ← 修复:改为 familyId
                    .isNull(HealthReport::getSubjectId)
                    .eq(HealthReport::getStatus, "active")
                    .orderByDesc(HealthReport::getReportDate)
    );
}

验证命令:

mvn clean compile   # 必须通过编译

任务 4:编写管理端 HealthReportReassign.vue 页面

文件:/sc-data/cfc/cfc-web/src/views/admin/HealthReportReassign.vue

位置:新建文件,遵循现有 admin 页面风格(复用 HealthReportAudit.vue 等组件)

代码块(完整组件):

<template>
  <div class="reassign-container">
    <div class="toolbar">
      <span>报告归属管理</span>
      <a-button @click="refresh">刷新</a-button>
    </div>
    
    <a-table
      :data="reports"
      :columns="columns"
      :loading="loading"
      :row-key="row => row.id"
      @selection-change="handleSelectionChange"
    >
      <template #row-actions="{ row }">
        <template v-if="row.subjectId">
          <!-- 已绑定:显示重新分配按钮 -->
          <a-dropdown v-dropdown="dropdown">
            <a-trigger>
              <a-icon type="setting" />
            </a-trigger>
            <a-menu>
              <a-menu-item @click="reassignRow(row)">重新分配</a-menu-item>
            </a-menu>
          </a-dropdown>
        </template>
        <template v-else>
          <!-- 未绑定:显示绑定按钮 -->
          <a-button type="primary" size="small" @click="bindRow(row)">绑定归属</a-button>
        </template>
      </template>
    </a-table>
    
    <!-- 选择成员弹窗 -->
    <a-modal :visible="visible" @ok="doReassign" @cancel="visible = false">
      <template #title>
        <span v="reassignMode">报告重新分配</span>
        <span v-else>绑定归属</span>
      </template>
      <template #body>
        <a-space direction="vertical" :space="8">
          <a-select
            :options="members"
            placeholder="选择家庭成员"
            @change="onMemberSelect"
            disabled="reassignMode && !row.subjectId"
          />
        </a-space>
        <!-- 如为重新分配模式,显示当前归属 -->
        <template v-if="reassignMode && row.subjectId">
          <p class="hint-text">当前归属:{{ getMemberName(row.subjectId) }}</p>
        </template>
      </template>
      <footer>
        <a-button @click="visible = false">取消</a-button>
        <a-button type="primary" @click="doConfirm">确认</a-button>
      </footer>
    </a-modal>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { aTable, aSelect, aModal, aButton, aSpace, aMessage } from '/umi-design'
import { request } from '/utils/api'

const props = defineProps({
  familyId: { type: Number, required: true }
})

const members = ref([]) // 从 API 获取家庭成员列表
const reports = ref([])
const loading = ref(false)
const visible = ref(false)
let currentRow = null
let reassignMode = false // true = 重新分配,false = 绑定

// 列定义
const columns = [
  { title: '报告ID', dataIndex: 'id', width: 120 },
  { title: '上传日期', dataIndex: 'reportDate', width: 140 },
  { title: '报告类型', dataIndex: 'reportType', width: 100 },
  { title: '主体', dataIndex: 'subjectDisplay', width: 120 },
  { title: '操作', dataIndex: 'actions', width: 140 }
]

// 从后端获取成员列表
const fetchMembers = async () => {
  const { data } = await request('/api/family/member/list', {
    includeFamily: true
  })
  members.value = data.result || data.list || []
}

// 刷新列表
const refresh = async () => {
  loading.value = true
  const { data } = await request('/api/health/report/unlinked', { familyId: props.familyId })
  reports.value = data.result || data
  loading.value = false
  await fetchMembers()
}

// 绑定/重新分配行
const bindRow = (row) => {
  currentRow = row
  reassignMode = false
  visible.value = true
}

const reassignRow = (row) => {
  currentRow = row
  reassignMode = true
  visible.value = true
}

// 成员选择变化
const onMemberSelect = (e) => {
  // 选中后直接在 doConfirm 中使用 selectedMemberId
}

// 获取成员名称
const getMemberName = (memberId) => {
  const m = members.value.find(x => x.id === memberId)
  return m ? (m.nickname || m.userId.toString()) : '未知'
}

// 确认操作
const doConfirm = async () => {
  if (currentRow.subjectId && reassignMode) {
    // 重新分配:发送新的 subjectId
    await request('/api/health/report/reassign', {
      reportId: currentRow.id,
      subjectId: selectedMemberId
    })
  } else if (!currentRow.subjectId && !reassignMode) {
    // 绑定:发送当前可选的 memberId(此处需从 select 选项中获取)
    // 简化处理:绑定到第一个可用成员
    await request('/api/health/report/reassign', {
      reportId: currentRow.id,
      subjectId: selectedMemberId
    })
  }
  visible.value = false
  await refresh()
}
</script>

<style scoped>
.reassign-container { margin: 16px; }
.hint-text { color: #888; font-size: 12px; margin-top: 4px; }
</style>

验证命令:

# 语法检查(Vue 文件无编译,确认无语法错误即可)
# 通过 IDE 预览或运行项目启动检查

任务 5:编写小程序报告列表增量改动

文件:/sc-data/cfc/cfc-frontend/pages/health/report-list.vue

位置:在报告卡片组件内,subjectId === null 时在右上角增加按钮

代码块(在 report-item 组件内,靠近报告基础信息展示处):

<!-- 在报告基本信息区域的右上角 -->
<view class="report-actions">
  <view v-if="report.subjectId === null" class="action-btns">
    <button type="primary" size="small" bindtap="adjustBinding">调整归属</button>
  </view>
</view>

逻辑处理(在页面 script 中添加):

// 调整归属(小程序端)
const adjustBinding = () => {
  // 弹出家庭成员选择器
  wx.showActionSheet({
    itemList: ['绑定新成员', '解除归属'],
    itemColor: '#1890ff',
    success: (res) => {
      if (res.tapIndex === 0) {
        // 绑定新成员:弹出选择器并调用后端
        wx.showModal({
          content: '选择归属成员',
          confirmText: '确定',
          success: (modalRes) => {
            // 此处应跳转到成员选择页面或调用 API
            // 简化:直接提示
            aMessage.info('功能待前端完整实现,请在管理端操作')
          }
        })
      } else {
        // 解除归属:subjectId 设为 null
        wx.request({
          url: '/api/health/report/reassign',
          method: 'POST',
          data: {
            reportId: report.id,
            subjectId: null
          },
          success: (resp) => {
            if (resp.data.code === 0) {
              aMessage.success('归属已解除')
              // 刷新当前页面或返回上一级
              wx.navigateBack()
            }
          }
        })
      }
    }
  })
}

验证命令:

# 小程序语法检查通过 wxss/wxml 编译器验证
# 实际开发中使用微信开发者工具进行 preview 和 compile

任务 6:编写单元测试

文件:/sc-data/cfc/cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceTest.java

新增测试用例:

@Test
void testReassignReport_UnbindThenRebind() {
    // 1. 先上传一份无归属报告(subjectId = null)
    // 2. 调用 reassignReport 将其解绑(newSubjectId = null,旧值已为 null,直接返回或无操作)
    // 3. 再次调用 reassignReport 绑定到特定成员
    // 4. 验证数据库 subject_id 已更新
    // 5. 验证 dimensionScoreService.refreshFromGutReport 被调用
    // 6. 验证 foodRecommendService.recalculateForUser 被调用
    
    // 由于需要 Mock 依赖,重点测试:事务边界、权限检查、SQL 更新语句
    HealthReport report = new HealthReport();
    report.setId(1L);
    report.setFamilyId(100L);
    report.setSubjectId(200L);
    
    // Test: 无实际变化直接返回
    when(report.getSubjectId()).thenReturn(200L);
    reassignReport(1L, 200L, 100L);
    // 验证未更新(无变化快路径)
    verify(jdbcTemplate, never()).update(anyString(), anyLong(), anyLong());
    
    // Test: 实际变更 - 解绑
    reassignReport(1L, null, 100L);
    verify(jdbcTemplate).update(anyString(), eq(null), eq(1L));
    verify(dimensionScoreService).refreshFromGutReport(eq(200L), eq(1L));
    verify(foodRecommendService).recalculateForUser(eq(200L));
    
    // Test: 实际变更 - 绑定到新成员
    reassignReport(1L, 300L, 100L);
    verify(jdbcTemplate).update(anyString(), eq(300L), eq(1L));
    verify(dimensionScoreService).refreshFromGutReport(eq(300L), eq(1L));
    verify(foodRecommendService).recalculateForUser(eq(300L));
}

验证命令:

mvn test -Dtest=HealthReportServiceTest   # 运行测试,必须全部通过

任务 7:文档更新

文件:docs/superpowers/api/API_REFERENCE.md

位置:在现有健康报告章节之后,新增重新分配归属接口文档

代码块(在 docs/superpowers/api/API_REFERENCE.md 中添加):

### 健康报告归属管理

| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/health/report/reassign` | 重新分配报告归属(含维度重算)<br>`{reportId, subjectId}`<br>`subjectId` 可为 null 表示解绑 |
| `POST` | `/api/health/report/unlinked` | 获取家庭内所有未绑定归属的报告<br>**(注:查询条件已修复,现正确使用 family_id 字段)** |

**使用示例**:

bash

重新分配报告归属到成员 ID 300

curl -X POST http://localhost:9082/api/health/report/reassign \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{"reportId": 1, "subjectId": 300}'

解除报告归属

curl -X POST http://localhost:9082/api/health/report/reassign \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{"reportId": 1, "subjectId": null}' ```

权限说明:

  • 需要登录态(JWT Token),familyId 由 JwtInterceptor 从 Token 中反查得到
  • 仅当报告的 familyId 等于当前用户家庭 ID 时,操作才有效
  • 管理员/规划师可对家庭内任何报告进行归属管理

变更记录:

  • 2026-10-05: 新增 /api/health/report/reassign 接口,并修复 /api/health/report/unlinked 查询条件 bug(familyId→family_id)