面向 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
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 # 必须通过编译
文件:/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 # 必须通过编译
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 # 必须通过编译
文件:/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 预览或运行项目启动检查
文件:/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
文件:/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 # 运行测试,必须全部通过
文件: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
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}' ```
权限说明:
familyId 由 JwtInterceptor 从 Token 中反查得到familyId 等于当前用户家庭 ID 时,操作才有效变更记录:
2026-10-05: 新增 /api/health/report/reassign 接口,并修复 /api/health/report/unlinked 查询条件 bug(familyId→family_id)