2026-10-05-health-report-reassign-design.md 9.2 KB

Health Report Reallocation Design

1. Overview

Enable reassigning report ownership (subject_id) for health reports. Reports can exist in two states:

  • Bound: subject_id set to a family member ID — used for plan generation and dimension calculations
  • Unbound: subject_id is null — not used for plan generation, can be reassigned to a member

2. Current State

  • health_reports.subject_id already exists, nullable — no code change needed for the field
  • triggerAutoPlanAfterConfirm already skips plan generation when subjectId == null
  • Bug: /api/health/report/unlinked passes familyId but service queries by user_id
  • No dedicated reassign endpoint beyond the heavy /report/edit

3. Required Changes

3.1 Backend: New POST /api/health/report/reassign endpoint

Controller (HealthReportController.java):

@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("归属已更新");
}

Service (HealthReportService.java):

@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);
    
    // 2. 重算旧成员维度(如果原来有绑定)
    if (oldSubjectId != null) {
        dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
        foodRecommendService.recalculateForUser(oldSubjectId);
    }
    
    // 3. 重算新成员维度(如果有新归属)
    if (newSubjectId != null) {
        dimensionScoreService.refreshFromGutReport(newSubjectId, reportId);
        foodRecommendService.recalculateForUser(newSubjectId);
    }
    
    // 4. 日志记录
    String operator = Spel.eval("#{requestAttributes.currentAuthentication.principal}", String.class);
    log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}, operator={}",
             reportId, oldSubjectId, newSubjectId, operator);
}

3.2 Backend: Fix getUnlinkedReports query bug

Service (HealthReportService.java): Change method signature and query:

// Before (bug): public List<HealthReport> getUnlinkedReports(Long userId) {
//   ... .eq(HealthReport::getUserId, userId) ... // wrong: queries by upload user
// }

// After (fix): public List<HealthReport> getUnlinkedReports(Long familyId) {
//   return healthReportMapper.selectList(
//       new LambdaQueryWrapper<HealthReport>()
//           .eq(HealthReport::getFamilyId, familyId)   // ← 改为 familyId
//           .isNull(HealthReport::getSubjectId)
//           .eq(HealthReport::getStatus, "active")
//   );
// }

Controller (HealthReportController.java): No change needed — already passes familyId parameter.

3.3 Frontend: Management端新增报告归属管理页面

文件: cfc-web/src/views/admin/HealthReportReassign.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"
      @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="row.subjectId">{{ row.title }} - 重新分配</span>
        <span v-else>绑定归属</span>
      </template>
      <template #body>
        <a-space direction="vertical" :space="8">
          <a-select
            v-if="mode === 'reassign'"
            :options="members"
            placeholder="选择新归属成员"
            @change="onMemberSelect"
          />
          <a-select
            v-else
            :options="members"
            placeholder="选择归属成员"
            @change="onMemberSelect"
          />
        </a-space>
      </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'

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

const members = ref([]) // 从 API 获取家庭成员列表
const reports = ref([]) // 列表数据
const loading = ref(false)
const columns = [...]

const visible = ref(false)
let currentRow = null
let mode = 'bind' // 'bind' | 'reassign'

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

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

const reassignRow = (row) => {
  currentRow = row
  mode = 'reassign'
  visible.value = true
}

// 成员选择变化
const onMemberSelect = (e) => { ... }

// 确认操作
const doConfirm = async () => {
  if (mode === 'bind') {
    await request('/api/health/report/bind', {
      reportId: currentRow.id,
      subjectId: currentRow.subjectId // 需要从选项中获取
    })
  } else {
    await request('/api/health/report/reassign', {
      reportId: currentRow.id,
      subjectId: selectedMemberId
    })
  }
  visible.value = false
  await refresh()
}
</script>

3.4 Frontend: 小程序端新增入口

页面: cfc-frontend/pages/health/report-list.vue

在报告卡片右上角增加「调整归属」按钮(仅在 subjectId === null 时显示):

<!-- 在 report-item 组件内 -->
<view v-if="report.subjectId === null">
  <view class="action-btns">
    <button type="primary" bindtap="adjustBinding">调整归属</button>
  </view>
</view>

逻辑: 弹出家庭成员选择器,选择后调用后端新接口。

3.5 数据库层面无需变更

subject_id 已存在且为 nullable,schema.sql 无需迁移。

4. API 变更概览

方法 路径 说明
POST /api/health/report/reassign 重新分配报告归属(含维度重算)
GET /api/health/report/unlinked 获取家庭内所有无归属报告(已修复查询 bug)

5. 完整性检查

  • subject_id 已在 schema.sql 中定义,无需迁移
  • 方案生成已过滤 subjectId == null 报告,无需额外改动
  • 新接口已完整设计:控制器、服务、事务边界
  • 前端管理端页面已设计(复用已有 UI 组件库)
  • 前端小程序端已设计(在现有卡片上增量添加)
  • getUnlinkedReports 查询 bug 已修复(familyId → family_id)
  • 权限检查:仅家庭成员/管理员可操作,通过 familyId 验证
  • 无归属报告在列表中标注展示,点击后可重新分配

6. 风险与边界

  1. 并发:同一报告并发重分配时,事务隔离保证更新原子性
  2. 旧数据:历史报告若 subject_id 为 0 或负值,需在 service 中统一视为 null 处理
  3. 权限边界:service 内已通过 familyId 验证报告归属,防止跨家庭操作
  4. 无影响区:subject_id 为 null 的报告在生成方案时已被自动跳过,本设计不改变此行为

7. 待办

  • 新增 POST /api/health/report/reassign 接口
  • 修复 getUnlinkedReports 查询条件(familyId → family_id)
  • 编写管理端 HealthReportReassign.vue 页面
  • 编写小程序报告列表增量改动
  • 单元测试:reassign 接口的事务边界与权限检查
  • 文档更新:API_REFERENCE.md 登记新接口