面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。本计划覆盖设计文档 P1(数据层)+ P2(归一化管道)+ P3(视图 API)+ P6(校准任务)四个后端分期。小程序(P4)与管理端(P5)是独立计划,不在此文件。
目标: 将 indicator_definitions/indicator_values 升级为全平台统一指标层:真实报告日期、归一化映射管道(exact→fuzzy→AI→惰性注册)、指标变化曲线/变化幅度排名/指标速查 3 个视图接口、指标关联定义与 Spearman 数据校准。
架构: 复用现有 health_reports/health_indicators/food_recommend_idx 作为原始数据,新增 indicator_mapping_rules + indicator_correlations 两表;NormalizeIndicatorPipeline 作为单一写者在报告入库/编辑/食材重算三个钩子处幂等写入 indicator_values(唯一键 (source_type, source_id, definition_id));视图层 IndicatorTrendService 只读 indicator_values,五维/七维评分 API 层单独合入不写入时序;校准用纯 Java 实现的 Spearman 秩相关 + Fisher z 聚合,@Scheduled 每日自动跑 + 管理端手动触发。
技术栈: Java 8(禁止 var/List.of/record)、Spring Boot 2.7.18、MyBatis-Plus(LambdaQueryWrapper)、JUnit5 + Mockito(参照 FoodServiceTest,不加载 Spring 上下文)、MySQL 8(utf8mb4)、FastAPI/Pydantic(cfc-langgraph 侧 AI 归类端点)。
| 文件 | 操作 | 职责 |
|---|---|---|
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java |
改 | 迁移 356~362(见任务 1) |
cfc-backend/src/main/resources/schema.sql |
改 | 同步 indicator_definitions / indicator_values / 新两表的完整 DDL 快照 |
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java |
改 | +5 列字段与 getter/setter |
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java |
改 | +6 列字段与 getter/setter |
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java |
新建 | 映射规则实体(Lombok @Data) |
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java |
新建 | 关联关系实体(Lombok @Data) |
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java |
新建 | 空 BaseMapper |
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java |
新建 | 空 BaseMapper |
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java |
改 | 新增 upsertMapping 幂等写方法(@Insert ON DUPLICATE KEY UPDATE) |
cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java |
新建 | Spearman rho + Fisher z 聚合(纯函数,约 40 行) |
cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java |
新建 | payload 真实报告日期提取 + 历史回填分批任务 |
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java |
新建 | resolve(raw)→definitionId(exact→fuzzy→AI→惰性注册);importSeeds |
cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java |
新建 | 报告指标/菌属 + 食材快照 幂等写入 indicator_values |
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java |
新建 | trend / change-ranking / quick-lookup(含五维七维小节) |
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java |
新建 | 关联 CRUD + 对序强制 + effective_strength + Spearman 校准 |
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java |
新建 | /api/indicator/* 4 个视图接口(subjectId 归属校验) |
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java |
新建 | 关联 save/list/calibrate |
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java |
新建 | 映射 import/list |
cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java |
新建 | @Scheduled 每日 03:00 自动校准 |
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java |
改 | +classifyIndicator(照 generatePortrait 模板) |
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java |
改 | insertReport() subjectId 块后调 pipeline(钩子 1) |
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java |
改 | updateReportFromPayload() 步骤 5 后调 pipeline(钩子 2) |
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java |
改 | recalculateForUser() 尾部调 pipeline.snapshotFoodIndices(钩子 3) |
cfc-langgraph/app/api/indicator_classify.py |
新建 | POST /api/v1/indicator/classify(AI 归类兜底) |
cfc-langgraph/app/main.py |
改 | 注册新 router |
docs/superpowers/api/API_REFERENCE.md |
改 | 同步记录 9 个新接口 |
关键决策(锁定,不得自行更改):
DatabaseInitializer.java L11855-11873)。indicator_values 唯一键 uk_source_definition (source_type, source_id, definition_id) —— 报告指标/菌属写 source_id = health_reports.id,食物指数写 source_id = food_recommend_idx.id。重解析/重算同键覆盖更新,保证验收标准 6(幂等)。idx_def 不再新增:schema.sql 中该表已有 idx_definition (definition_id),语义等价,迁移只加 uk_source_definition 与 idx_subject_def_date,避免重复索引。@Data(与 HealthIndicator/FoodRecommendIndex 一致);IndicatorDefinition/IndicatorValue 维持手工 getter/setter(既有风格,只加字段)。indicator.<category>.<name>(报告指标)/ bacteria.<name>(菌属)/ food.<name>(食材,不依赖 foodId 表,raw_name 即食材名)。source_kind 对应 report_indicator/bacteria/food_index。source_type 统一用报告类型(gut_flora 等):报告指标、菌属、食材三条来源都以 source_type=报告类型 登记,raw_name 互不相同故不冲突;food_recommend 仅作为 indicator_values.source_type 语义保留(食材快照走钩子 3 时 source_type=报告类型,report_id 关联)。report_date = NULL;回填筛选条件带 payload_json LIKE '%reportDate%',避免对提取失败的记录无限重复处理。IndicatorTrendService 内 join 原表(five_dimension_scores / health_dimension_scores)。AiGateway.classifyIndicator 返回建议的 definition code/name,IndicatorMappingService 将其登记为 match_type='ai_fallback', status=0(待确认),不自动启用;调用方拿到建议 definitionId 照常写入(AI 兜底路径允许临时写入,管理端 review 修正规则后重解析自动纠正)。jsonArray() / ensureReportTypeRow() / seedExpectedIndicator() 是 DatabaseInitializer 既有私有助手(L11978-12022),新迁移可复用;ensureColumn(table,column,definition) 在 L4855。updateReportFromPayload 已标 @Transactional,管道与其同事务;钩子 1 的 insertReport 是私有非事务方法(由异步采集调用),管道方法自身标 @Transactional(propagation = REQUIRED) 随调用方;钩子 3 的 recalculateForUser 已标 @Transactional。不要在管道中开启新事务的 REQUIRES_NEW。@RequestAttribute("familyId") 必须存在,IndicatorController 校验 subjectId 属于该家庭(查 family_members 表),不属于则返回 Result.error("成员不属于当前家庭")。文件:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() L129 内、文件末 } L12023 前追加方法 + 在 runMigrations 中调用)cfc-backend/src/main/resources/schema.sql(indicator_definitions 段约 L2501、indicator_values 段约 L2518,末尾追加两新表)在 runMigrations() 末尾(最后一个迁移方法调用之后)追加调用 migrateIndicatorDefinitionsColumns(),并新增该方法:
// 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
private void migrateIndicatorDefinitionsColumns() {
ensureColumn("indicator_definitions", "source_kind",
"VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
ensureColumn("indicator_definitions", "report_type_scope",
"VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
ensureColumn("indicator_definitions", "value_type",
"VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
ensureColumn("indicator_definitions", "good_direction",
"VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
ensureColumn("indicator_definitions", "dimension_code",
"VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
}
// 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
private void migrateIndicatorValuesColumns() {
ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
}
// 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
private void addIndicatorValuesKeys() {
try {
// 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
jdbcTemplate.execute(
"DELETE v FROM indicator_values v " +
"JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
" FROM indicator_values " +
" GROUP BY source_type, source_id, definition_id " +
" HAVING COUNT(*) > 1) d " +
" ON v.source_type = d.source_type AND v.source_id = d.source_id " +
" AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
log.info("迁移358: 已清理 indicator_values 重复行(如有)");
} catch (Exception e) {
log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
}
try {
jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
log.info("迁移358: 已添加 uk_source_definition 唯一键");
} catch (Exception e) {
log.info("迁移358: uk_source_definition 已存在,跳过");
}
try {
jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
log.info("迁移358: 已添加 idx_subject_def_date 索引");
} catch (Exception e) {
log.info("迁移358: idx_subject_def_date 已存在,跳过");
}
}
// 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
private void createIndicatorMappingRulesTable() {
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY," +
"source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
"raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
"alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
"definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
"match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
"priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
"status TINYINT DEFAULT 1," +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
"UNIQUE KEY uk_source_raw (source_type, raw_name)," +
"INDEX idx_def (definition_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
log.info("迁移359: 已创建 indicator_mapping_rules 表");
} catch (Exception e) {
log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
}
}
// 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
private void createIndicatorCorrelationsTable() {
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY," +
"definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
"definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
"relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
"direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
"manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
"computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
"method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
"sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
"confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
"effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
"manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
"cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
"status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
"computed_at DATETIME NULL COMMENT '最后校准时间'," +
"created_by BIGINT NULL COMMENT '创建者'," +
"description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
"source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
"UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
"INDEX idx_def_a (definition_id_a)," +
"INDEX idx_def_b (definition_id_b)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
log.info("迁移360: 已创建 indicator_correlations 表");
} catch (Exception e) {
log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
}
}
// 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
private void ensureIndicatorCollectionDate() {
ensureColumn("health_indicators", "collection_date",
"DATETIME COMMENT '指标采集日期(报告检测日期)'");
log.info("迁移361: health_indicators.collection_date 已确认存在");
}
// 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
private void seedDimensionDefinitions() {
Object[][] dims = {
{"dim.body", "身", "身体", "higher_is_better", "higher_is_better"},
{"dim.mind", "心", "心理", "higher_is_better", "higher_is_better"},
{"dim.wisdom", "智", "智慧", "higher_is_better", "higher_is_better"},
{"dim.action", "行", "行动", "higher_is_better", "higher_is_better"},
{"dim.wealth", "富", "财富", "higher_is_better", "higher_is_better"},
};
for (Object[] d : dims) {
try {
Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
if (exists == null || exists == 0) {
jdbcTemplate.update(
"INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
"source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
"VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
d[1], d[0], d[2], d[4], d[3]);
log.info("迁移362: 已预置维度定义 {}", d[0]);
}
} catch (Exception e) {
log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
}
}
}
在 runMigrations() 末尾追加调用:
// 指标统一层迁移(356~362)
migrateIndicatorDefinitionsColumns();
migrateIndicatorValuesColumns();
addIndicatorValuesKeys();
createIndicatorMappingRulesTable();
createIndicatorCorrelationsTable();
ensureIndicatorCollectionDate();
seedDimensionDefinitions();
indicator_definitions CREATE TABLE(约 L2501)在 sort_order 前追加 5 列,与迁移 356 同义:
CREATE TABLE IF NOT EXISTS indicator_definitions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain VARCHAR(50) NOT NULL COMMENT '领域: physical/mental/nutrition/behavior/social',
category VARCHAR(50) NOT NULL COMMENT '分类: 舌色/苔色/情绪/...',
code VARCHAR(50) NOT NULL UNIQUE COMMENT '编码: tongue_color/coating_color/...',
name VARCHAR(100) NOT NULL COMMENT '显示名: 舌体颜色/苔色/...',
data_type VARCHAR(20) NOT NULL DEFAULT 'enum' COMMENT '数据类型: enum/number/string',
options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
unit VARCHAR(50) COMMENT '单位',
ref_range VARCHAR(200) COMMENT '参考范围',
source_kind VARCHAR(32) COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score',
report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
sort_order INT DEFAULT 0 COMMENT '排序',
status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标定义';
indicator_values CREATE TABLE(约 L2518)替换为:
CREATE TABLE IF NOT EXISTS indicator_values (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_type VARCHAR(50) NOT NULL COMMENT '来源: tongue_record/health_report/assessment',
source_id BIGINT NOT NULL COMMENT '来源记录ID',
definition_id BIGINT NOT NULL COMMENT 'FK→indicator_definitions.id',
value VARCHAR(200) COMMENT '值',
numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
remark TEXT COMMENT '备注',
subject_id BIGINT COMMENT '观测对象(family_members.id)',
family_id BIGINT COMMENT '所属家庭(family_members.family_id)',
report_id BIGINT COMMENT '来源报告(health_reports.id)',
report_date DATE COMMENT '真实报告日期(时序查询键)',
unit VARCHAR(32) COMMENT '观测单位',
status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
INDEX idx_source (source_type, source_id),
INDEX idx_definition (definition_id),
INDEX idx_subject_def_date (subject_id, definition_id, report_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
文件末尾追加 indicator_mapping_rules(与迁移 359 同 DDL)。
文件末尾追加 indicator_correlations(与迁移 360 同 DDL)。
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS(迁移代码幂等,无需运行时验证)
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
cfc-backend/src/main/resources/schema.sql
git commit -m "feat(indicator): 迁移356-362 升级统一指标层(列/键/两新表/维度种子)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.javacfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.javacfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.javacfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.javacfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.javacfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java在 private Integer status; 与 private Date createdAt; 之间加字段,并在对应位置补 getter/setter(文件为手工 getter/setter 风格,维持一致):
private String sourceKind;
private String reportTypeScope;
private String valueType;
private String goodDirection;
private String dimensionCode;
public String getSourceKind() { return sourceKind; }
public void setSourceKind(String sourceKind) { this.sourceKind = sourceKind; }
public String getReportTypeScope() { return reportTypeScope; }
public void setReportTypeScope(String reportTypeScope) { this.reportTypeScope = reportTypeScope; }
public String getValueType() { return valueType; }
public void setValueType(String valueType) { this.valueType = valueType; }
public String getGoodDirection() { return goodDirection; }
public void setGoodDirection(String goodDirection) { this.goodDirection = goodDirection; }
public String getDimensionCode() { return dimensionCode; }
public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
在 private String remark; 与 private Date recordedAt; 之间加字段:
private Long subjectId;
private Long familyId;
private Long reportId;
private java.sql.Date reportDate;
private String unit;
private String status;
并补 getter/setter(java.sql.Date 与 indicator_values.report_date DATE 列类型一致):
public Long getSubjectId() { return subjectId; }
public void setSubjectId(Long subjectId) { this.subjectId = subjectId; }
public Long getFamilyId() { return familyId; }
public void setFamilyId(Long familyId) { this.familyId = familyId; }
public Long getReportId() { return reportId; }
public void setReportId(Long reportId) { this.reportId = reportId; }
public java.sql.Date getReportDate() { return reportDate; }
public void setReportDate(java.sql.Date reportDate) { this.reportDate = reportDate; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
entity/IndicatorMappingRule.java(完整文件):
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.util.Date;
@Data
@TableName("indicator_mapping_rules")
public class IndicatorMappingRule {
@TableId(type = IdType.AUTO)
private Long id;
private String sourceType;
private String rawName;
private String aliasText;
private Long definitionId;
private String matchType;
private Integer priority;
private Integer status;
private Date createdAt;
private Date updatedAt;
}
mapper/IndicatorMappingRuleMapper.java(完整文件):
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.IndicatorMappingRule;
public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
}
entity/IndicatorCorrelation.java(完整文件):
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.math.BigDecimal;
import java.util.Date;
@Data
@TableName("indicator_correlations")
public class IndicatorCorrelation {
@TableId(type = IdType.AUTO)
private Long id;
private Long definitionIdA;
private Long definitionIdB;
private String relationType;
private String direction;
private BigDecimal manualStrength;
private BigDecimal computedStrength;
private String method;
private Integer sampleSize;
private String confidence;
private BigDecimal effectiveStrength;
private Integer manualLocked;
private String cohortScope;
private String status;
private Date computedAt;
private Long createdBy;
private String description;
private String sourceNote;
private Date createdAt;
private Date updatedAt;
}
mapper/IndicatorCorrelationMapper.java(完整文件):
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.IndicatorCorrelation;
public interface IndicatorCorrelationMapper extends BaseMapper<IndicatorCorrelation> {
}
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.IndicatorValue;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {
/** 幂等写:同 (source_type, source_id, definition_id) 覆盖更新(uk_source_definition) */
@Insert("INSERT INTO indicator_values " +
"(source_type, source_id, definition_id, value, numeric_value, remark, " +
" subject_id, family_id, report_id, report_date, unit, status, recorded_at) " +
"VALUES (#{v.sourceType}, #{v.sourceId}, #{v.definitionId}, #{v.value}, #{v.numericValue}, #{v.remark}, " +
" #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, NOW()) " +
"ON DUPLICATE KEY UPDATE " +
" value = VALUES(value), numeric_value = VALUES(numeric_value), remark = VALUES(remark), " +
" subject_id = VALUES(subject_id), family_id = VALUES(family_id), report_id = VALUES(report_id), " +
" report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), recorded_at = NOW()")
int upsertMapping(@Param("v") IndicatorValue v);
}
(若该文件原有其他内容与 import,保留原 import 与原有方法,仅合并本方法。)
src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java:
package com.etotem.cfc.service;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/** IndicatorValueMapper.upsertMapping 仅做 SQL 注解绑定验证(mock 层) */
class IndicatorValueMapperTest {
@Test
void upsertMappingAnnotationCompiles() {
IndicatorValueMapper mapper = Mockito.mock(IndicatorValueMapper.class);
IndicatorValue v = new IndicatorValue();
v.setSourceType("gut_flora");
v.setSourceId(10L);
v.setDefinitionId(3L);
v.setValue("12.8");
v.setNumericValue(new BigDecimal("12.8"));
when(mapper.upsertMapping(any(IndicatorValue.class))).thenReturn(1);
int result = mapper.upsertMapping(v);
assertEquals(1, result);
}
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorValueMapperTest
预期:Tests run: 1, Failures: 0(若 MyBatis 注解 SQL 有语法错误会在启动 bean 时暴露,此处 mock 层验证注解可编译加载)
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
git add cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java \
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java \
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java \
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java \
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java \
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java \
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java \
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java
git commit -m "feat(indicator): 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.javacfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.javapackage com.etotem.cfc.service;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.text.SimpleDateFormat;
import static org.junit.jupiter.api.Assertions.*;
class ReportDateExtractorTest {
private ReportDateExtractor newExtractor() {
ReportDateExtractor e = new ReportDateExtractor();
// 不注入 mapper,仅测纯提取逻辑
return e;
}
@Test
void extractFromNestedSummary() throws Exception {
ReportDateExtractor e = newExtractor();
String json = "{\"summary\":{\"reportDate\":\"2026-08-15\",\"personName\":\"小李\"},\"indicators\":[]}";
java.sql.Date d = e.extractReportDate(json);
assertNotNull(d);
assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
}
@Test
void extractFromFlatPayload() throws Exception {
ReportDateExtractor e = newExtractor();
String json = "{\"reportDate\":\"2026/08/15\"}";
java.sql.Date d = e.extractReportDate(json);
assertNotNull(d);
assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
}
@Test
void extractReturnsNullWhenMissing() {
ReportDateExtractor e = newExtractor();
assertNull(e.extractReportDate("{\"summary\":{\"personName\":\"x\"}}"));
assertNull(e.extractReportDate(null));
assertNull(e.extractReportDate("not json"));
}
@Test
void extractFallsBackToIndicatorCollectionDate() throws Exception {
ReportDateExtractor e = newExtractor();
String json = "{\"summary\":{},\"indicators\":[{\"collectionDate\":\"2026-09-01\"}]}";
java.sql.Date d = e.extractReportDate(json);
assertNotNull(d);
assertEquals("2026-09-01", new SimpleDateFormat("yyyy-MM-dd").format(d));
}
}
运行:cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest
预期:BUILD FAILURE,ReportDateExtractor 不存在(编译错误)
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.HealthReport;
import com.etotem.cfc.mapper.HealthReportMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
/**
* 真实报告日期提取 + 历史回填。
* PDF 解析阶段将「报告日期/采样日期」写入 payload.reportDate 后,
* 入库逻辑(HealthReportService 既有 reportDate 修正)自动生效;
* 本类只负责:(1) 从 payload 提取真实日期(供回填/校验);
* (2) 扫描旧报告幂等回填 report_date(无法提取置 NULL,不伪装成上传时间)。
*/
@Service
public class ReportDateExtractor {
private static final Logger log = LoggerFactory.getLogger(ReportDateExtractor.class);
@Resource
private HealthReportMapper healthReportMapper;
@Resource
private ObjectMapper objectMapper;
/**
* 从 payload JSON 中提取真实报告日期。
* 取值顺序:summary.reportDate → 顶层 reportDate → indicators[].collectionDate(取第一个非空)。
* 支持 yyyy-MM-dd / yyyy/MM/dd;无法提取返回 null。
*/
public java.sql.Date extractReportDate(String payloadJson) {
if (payloadJson == null || payloadJson.isEmpty()) {
return null;
}
try {
JsonNode root = objectMapper.readTree(payloadJson);
String dateStr = null;
JsonNode summary = root.get("summary");
if (summary != null && summary.isObject() && summary.hasNonNull("reportDate")) {
dateStr = summary.get("reportDate").asText();
}
if (dateStr == null || dateStr.trim().isEmpty()) {
if (root.hasNonNull("reportDate")) {
dateStr = root.get("reportDate").asText();
}
}
java.sql.Date parsed = parseDate(dateStr);
if (parsed != null) {
return parsed;
}
// 回退:indicators[].collectionDate 取第一个非空
JsonNode indicators = root.get("indicators");
if (indicators != null && indicators.isArray()) {
for (JsonNode ind : indicators) {
if (ind.hasNonNull("collectionDate")) {
java.sql.Date d = parseDate(ind.get("collectionDate").asText());
if (d != null) {
return d;
}
}
}
}
return null;
} catch (Exception e) {
log.warn("ReportDateExtractor 解析 payload 失败: {}", e.getMessage());
return null;
}
}
private java.sql.Date parseDate(String dateStr) {
if (dateStr == null || dateStr.trim().isEmpty()) {
return null;
}
String s = dateStr.trim();
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Date d = sdf.parse(s);
return new java.sql.Date(d.getTime());
} catch (Exception e) {
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
Date d = sdf.parse(s);
return new java.sql.Date(d.getTime());
} catch (Exception e2) {
return null;
}
}
}
/**
* 历史回填:扫描 report_date 缺失或疑似上传时间的旧报告(且 payload 含 reportDate 字段),
* 提取真实日期并回填;无法提取置 NULL。分批(500/批)、幂等可重跑。
*
* @return 本轮处理的总行数
*/
public int backfillHistoricalReports() {
int total = 0;
while (true) {
List<HealthReport> batch = healthReportMapper.selectList(
new LambdaQueryWrapper<HealthReport>()
.isNull(HealthReport::getReportDate)
.like(HealthReport::getPayloadJson, "reportDate")
.last("LIMIT 500"));
if (batch.isEmpty()) {
break;
}
for (HealthReport report : batch) {
try {
java.sql.Date realDate = extractReportDate(report.getPayloadJson());
if (realDate != null) {
report.setReportDate(realDate);
healthReportMapper.updateById(report);
log.info("回填报告真实日期 reportId={}, reportDate={}", report.getId(), realDate);
} else {
// 置 NULL 并清 payload 中可提取性:无法提取的不再反复处理
report.setReportDate(null);
healthReportMapper.updateById(report);
log.info("报告日期无法提取,置 NULL reportId={}", report.getId());
}
total++;
} catch (Exception e) {
log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
}
}
}
log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
return total;
}
/** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
@Scheduled(cron = "0 10 3 * * ?")
public void scheduledBackfill() {
backfillHistoricalReports();
}
}
运行:cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest
预期:Tests run: 4, Failures: 0, Errors: 0
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java \
cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java
git commit -m "feat(indicator): ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.javacfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.javapackage com.etotem.cfc.util;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SpearmanUtilTest {
@Test
void perfectPositiveRankCorrelation() {
// x 与 y 完全同序 → rho = 1
List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
double rho = SpearmanUtil.spearmanRho(x, y);
assertEquals(1.0, rho, 1e-9);
}
@Test
void perfectNegativeRankCorrelation() {
List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
List<BigDecimal> y = Arrays.asList(new BigDecimal("4"), new BigDecimal("3"), new BigDecimal("2"), new BigDecimal("1"));
double rho = SpearmanUtil.spearmanRho(x, y);
assertEquals(-1.0, rho, 1e-9);
}
@Test
void tiedRanksHandled() {
// 有并列值时使用平均秩;x 与 y 逐项同值(含并列) → rho = 1
List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
double rho = SpearmanUtil.spearmanRho(x, y);
assertEquals(1.0, rho, 1e-9);
}
@Test
void shortSeriesReturnsZero() {
// 少于 3 个配对点不计算(对应设计:单对象 ≥3 配对才参与)
List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
assertEquals(0.0, SpearmanUtil.spearmanRho(x, y), 1e-9);
}
@Test
void fisherZAverageRestoresPositiveRho() {
// 两个 rho=0.6 的对象 z 均值还原 → 仍约 0.6
double avg = SpearmanUtil.fisherZAverage(new double[]{0.6, 0.6});
assertEquals(0.6, avg, 1e-6);
}
@Test
void fisherZAverageHandlesExtremeRho() {
// rho 接近 ±1 时 z 变换仍有限(这里验证不抛异常且符号正确)
double avg = SpearmanUtil.fisherZAverage(new double[]{0.99, 0.95});
assertTrue(avg > 0.9);
}
}
运行:cd cfc-backend && mvn test -Dtest=SpearmanUtilTest
预期:BUILD FAILURE,SpearmanUtil 不存在
package com.etotem.cfc.util;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 秩相关计算(纯函数,无外部依赖)。
* 菌群丰度偏态分布,Spearman 比 Pearson 稳健;Java 8 无内置实现,此处手工实现,约 40 行。
*/
public final class SpearmanUtil {
private SpearmanUtil() {
}
/** 计算 Spearman 秩相关系数;配对点 < 3 返回 0(样本不足不误导)。 */
public static double spearmanRho(List<BigDecimal> x, List<BigDecimal> y) {
if (x == null || y == null || x.size() != y.size() || x.size() < 3) {
return 0.0;
}
double[] rankX = rank(x);
double[] rankY = rank(y);
double sumD2 = 0;
int n = x.size();
for (int i = 0; i < n; i++) {
double d = rankX[i] - rankY[i];
sumD2 += d * d;
}
double denom = (double) n * (n * n - 1);
if (denom == 0) {
return 0.0;
}
double rho = 1 - (6 * sumD2) / denom;
if (rho > 1) rho = 1;
if (rho < -1) rho = -1;
return rho;
}
/** 平均秩;并列值取平均(tie handling)。 */
private static double[] rank(List<BigDecimal> values) {
int n = values.size();
double[] ranks = new double[n];
// 按值排序的索引
List<Integer> idx = new ArrayList<>();
for (int i = 0; i < n; i++) idx.add(i);
idx.sort(Comparator.comparing(values::get));
Map<Integer, Double> rankMap = new HashMap<>();
int i = 0;
while (i < n) {
int j = i;
while (j + 1 < n && values.get(idx.get(j + 1)).compareTo(values.get(idx.get(i))) == 0) {
j++;
}
// [i, j] 为并列区间,平均秩 = (i+1 + j+1) / 2
double avgRank = ((i + 1) + (j + 1)) / 2.0;
for (int k = i; k <= j; k++) {
rankMap.put(idx.get(k), avgRank);
}
i = j + 1;
}
for (int k = 0; k < n; k++) {
ranks[k] = rankMap.get(k);
}
return ranks;
}
/**
* Fisher z 聚合:各对象 rho → z = artanh(rho) → 均值 → rho = tanh(z̄)。
* 设计文档 4.6:每对象算 rho → z 变换 → 全对象 z 均值 → 还原为平均 rho。
*/
public static double fisherZAverage(double[] rhos) {
if (rhos == null || rhos.length == 0) {
return 0.0;
}
double sumZ = 0;
for (double r : rhos) {
double v = Math.max(-0.999999, Math.min(0.999999, r));
sumZ += 0.5 * Math.log((1 + v) / (1 - v));
}
double zAvg = sumZ / rhos.length;
return Math.tanh(zAvg);
}
}
运行:cd cfc-backend && mvn test -Dtest=SpearmanUtilTest
预期:Tests run: 6, Failures: 0, Errors: 0
git add cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java \
cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java
git commit -m "feat(indicator): SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.javacfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.javapackage com.etotem.cfc.service;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorMappingRule;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class IndicatorMappingServiceTest {
private IndicatorMappingService service;
private IndicatorMappingRuleMapper ruleMapper;
private IndicatorDefinitionMapper definitionMapper;
private AiGateway aiGateway;
@BeforeEach
void setUp() {
service = new IndicatorMappingService();
ruleMapper = Mockito.mock(IndicatorMappingRuleMapper.class);
definitionMapper = Mockito.mock(IndicatorDefinitionMapper.class);
aiGateway = Mockito.mock(AiGateway.class);
ReflectionTestUtils.setField(service, "ruleMapper", ruleMapper);
ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
ReflectionTestUtils.setField(service, "aiGateway", aiGateway);
}
@Test
void resolveExactHitReturnsDefinitionId() {
IndicatorMappingRule rule = new IndicatorMappingRule();
rule.setDefinitionId(7L);
when(ruleMapper.selectOne(any())).thenReturn(rule);
Long defId = service.resolve("gut_flora", "双歧杆菌属");
assertEquals(7L, defId);
verify(ruleMapper).selectOne(any());
}
@Test
void resolveMissLazilyRegistersDefinitionAndRule() {
when(ruleMapper.selectOne(any())).thenReturn(null);
IndicatorDefinition created = new IndicatorDefinition();
created.setId(99L);
when(definitionMapper.insert(any(IndicatorDefinition.class))).thenAnswer(inv -> {
IndicatorDefinition d = inv.getArgument(0);
d.setId(99L);
return 1;
});
Long defId = service.resolve("gut_flora", "新出现的菌X");
assertEquals(99L, defId);
// 惰性注册:定义 + auto_created 规则
verify(definitionMapper).insert(any(IndicatorDefinition.class));
verify(ruleMapper).insert(any(IndicatorMappingRule.class));
}
@Test
void resolveFuzzyHitPicksBestCandidate() {
// exact 未命中 → fuzzy 候选命中(别名/近似名)
when(ruleMapper.selectOne(any())).thenReturn(null);
IndicatorMappingRule candidate = new IndicatorMappingRule();
candidate.setDefinitionId(3L);
candidate.setRawName("双歧杆菌"); // 与 "双歧杆菌属" 高相似
when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
Long defId = service.resolve("gut_flora", "双歧杆菌属");
assertEquals(3L, defId);
}
@Test
void resolveFuzzyMissFallsBackToAiSuggestion() {
when(ruleMapper.selectOne(any())).thenReturn(null);
when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
// AI 建议命中
when(aiGateway.classifyIndicator(eq("gut_flora"), eq("神秘指标"), anyList()))
.thenReturn("{\"code\":\"indicator.x.y\",\"name\":\"神秘指标\"}");
IndicatorDefinition suggested = new IndicatorDefinition();
suggested.setId(41L);
when(definitionMapper.selectOne(any())).thenReturn(suggested);
Long defId = service.resolve("gut_flora", "神秘指标");
assertEquals(41L, defId);
// AI 建议登记为 pending(status=0)规则
verify(ruleMapper).insert(argThat(r -> r.getMatchType().equals("ai_fallback") && r.getStatus() == 0));
}
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest
预期:BUILD FAILURE,IndicatorMappingService 不存在
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorMappingRule;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 指标归一化映射:raw_name → definition_id。
* 解析顺序:exact 命中映射规则 → fuzzy(CJK 二元组相似度)→ AI 兜底(登记 pending)→ 惰性注册新定义。
* 单一写者模式的「身份解析」环节;规则表是权威,管理端可 review 修正。
*/
@Service
public class IndicatorMappingService {
private static final Logger log = LoggerFactory.getLogger(IndicatorMappingService.class);
@Resource
private IndicatorMappingRuleMapper ruleMapper;
@Resource
private IndicatorDefinitionMapper definitionMapper;
@Resource
private AiGateway aiGateway;
@Resource
private ObjectMapper objectMapper;
/** fuzzy 相似度阈值(CJK 二元组 Dice 系数),低于则视为未命中 */
private static final double FUZZY_THRESHOLD = 0.5;
/**
* 解析原始指标名为 definition_id。
*
* @param sourceType 报告类型(gut_flora 等)
* @param rawName 报告原始名称(指标名/菌名/食材名,已 trim)
*/
public Long resolve(String sourceType, String rawName) {
if (sourceType == null || rawName == null || rawName.trim().isEmpty()) {
return null;
}
String name = rawName.trim();
// 1) exact 命中规则表
IndicatorMappingRule rule = ruleMapper.selectOne(new LambdaQueryWrapper<IndicatorMappingRule>()
.eq(IndicatorMappingRule::getSourceType, sourceType)
.eq(IndicatorMappingRule::getRawName, name)
.orderByDesc(IndicatorMappingRule::getPriority)
.last("LIMIT 1"));
if (rule != null && rule.getDefinitionId() != null) {
return rule.getDefinitionId();
}
// 2) fuzzy:与同来源全部启用规则比较 CJK 二元组相似度
List<IndicatorMappingRule> candidates = ruleMapper.selectList(new LambdaQueryWrapper<IndicatorMappingRule>()
.eq(IndicatorMappingRule::getSourceType, sourceType)
.eq(IndicatorMappingRule::getStatus, 1));
IndicatorMappingRule best = null;
double bestScore = 0;
for (IndicatorMappingRule c : candidates) {
double s = bigramSimilarity(name, c.getRawName());
if (s > bestScore) {
bestScore = s;
best = c;
}
}
if (best != null && bestScore >= FUZZY_THRESHOLD) {
return best.getDefinitionId();
}
// 3) AI 兜底:只建议,登记 pending(status=0)规则待管理端确认
String aiJson = aiGateway != null
? aiGateway.classifyIndicator(sourceType, name, Collections.emptyList())
: null;
if (aiJson != null) {
try {
JsonNode suggestion = objectMapper.readTree(aiJson);
if (suggestion != null && suggestion.hasNonNull("code")) {
IndicatorDefinition def = definitionMapper.selectOne(new LambdaQueryWrapper<IndicatorDefinition>()
.eq(IndicatorDefinition::getCode, suggestion.get("code").asText())
.last("LIMIT 1"));
if (def != null) {
registerRule(sourceType, name, def.getId(), "ai_fallback", 0);
log.info("AI 兜底建议:sourceType={}, raw={} → defId={}(pending 待确认)",
sourceType, name, def.getId());
return def.getId();
}
}
} catch (Exception e) {
log.warn("AI 归类建议解析失败 raw={}: {}", name, e.getMessage());
}
}
// 4) 惰性注册:新 definition + auto_created 规则
return lazyRegister(sourceType, name);
}
/** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
private Long lazyRegister(String sourceType, String rawName) {
DefinitionCode code = buildCode(sourceType, rawName);
IndicatorDefinition def = new IndicatorDefinition();
def.setDomain("physical");
def.setCategory(code.category);
def.setCode(code.code);
def.setName(rawName);
def.setDataType("number");
def.setSourceKind(code.sourceKind);
def.setReportTypeScope(sourceType);
def.setValueType(inferValueType(sourceType));
def.setGoodDirection("neutral");
def.setSortOrder(0);
def.setStatus(1);
def.setCreatedAt(new Date());
def.setUpdatedAt(new Date());
definitionMapper.insert(def);
registerRule(sourceType, rawName, def.getId(), "auto_created", 1);
log.info("惰性注册指标定义 sourceType={}, raw={} → defId={}", sourceType, rawName, def.getId());
return def.getId();
}
private void registerRule(String sourceType, String rawName, Long definitionId, String matchType, int status) {
IndicatorMappingRule rule = new IndicatorMappingRule();
rule.setSourceType(sourceType);
rule.setRawName(rawName);
rule.setDefinitionId(definitionId);
rule.setMatchType(matchType);
rule.setPriority(0);
rule.setStatus(status);
rule.setCreatedAt(new Date());
rule.setUpdatedAt(new Date());
try {
ruleMapper.insert(rule);
} catch (Exception e) {
log.warn("登记映射规则冲突(已存在?),忽略 sourceType={}, raw={}: {}", sourceType, rawName, e.getMessage());
}
}
// ---------- 内部工具 ----------
private static class DefinitionCode {
String code;
String category;
String sourceKind;
}
private DefinitionCode buildCode(String sourceType, String rawName) {
DefinitionCode dc = new DefinitionCode();
if ("food_recommend".equals(sourceType)) {
dc.sourceKind = "food_index";
dc.category = "食材";
dc.code = "food." + rawName;
} else if (isBacteriaContext(sourceType)) {
dc.sourceKind = "bacteria";
dc.category = "菌属";
dc.code = "bacteria." + rawName;
} else {
dc.sourceKind = "report_indicator";
dc.category = "报告指标";
dc.code = "indicator." + rawName;
}
// code 有唯一约束,过长/含特殊字符时安全截断
if (dc.code.length() > 190) {
dc.code = dc.code.substring(0, 190);
}
return dc;
}
private boolean isBacteriaContext(String sourceType) {
return sourceType != null && sourceType.startsWith("gut_flora");
}
private String inferValueType(String sourceType) {
return isBacteriaContext(sourceType) ? "numeric" : "numeric";
}
/**
* CJK 二元组 Dice 相似度:中文按字符二元组,ASCII 按词/字符二元组混合。
* 0~1;1 = 完全相同(但 exact 已先行命中)。
*/
double bigramSimilarity(String a, String b) {
if (a == null || b == null) {
return 0;
}
if (a.equals(b)) {
return 1;
}
List<String> gramsA = bigrams(a);
List<String> gramsB = bigrams(b);
if (gramsA.isEmpty() || gramsB.isEmpty()) {
return 0;
}
int common = 0;
for (String g : gramsA) {
if (gramsB.contains(g)) {
common++;
}
}
return (2.0 * common) / (gramsA.size() + gramsB.size());
}
private List<String> bigrams(String s) {
if (s == null || s.length() < 2) {
List<String> single = new ArrayList<>();
if (s != null && !s.isEmpty()) {
single.add(s);
}
return single;
}
List<String> result = new ArrayList<>();
for (int i = 0; i < s.length() - 1; i++) {
result.add(s.substring(i, i + 2));
}
return result;
}
/** 数值型解析:去掉 %/~/>/< 与空格后尝试 BigDecimal */
public BigDecimal parseNumericValue(String raw) {
if (raw == null) {
return null;
}
String cleaned = raw.replaceAll("[%<>~\\s]", "").trim();
if (cleaned.isEmpty()) {
return null;
}
try {
return new BigDecimal(cleaned);
} catch (Exception e) {
return null;
}
}
/** 批量种子导入(管理端调用):seed 列表 [{rawName, definitionId?, category?, unit?}] */
public int importSeeds(String sourceType, List<Map<String, Object>> seeds) {
int count = 0;
for (Map<String, Object> seed : seeds) {
String rawName = seed.get("rawName") != null ? seed.get("rawName").toString() : null;
if (rawName == null || rawName.trim().isEmpty()) {
continue;
}
Object defIdObj = seed.get("definitionId");
Long defId;
if (defIdObj instanceof Number) {
defId = ((Number) defIdObj).longValue();
} else if (defIdObj != null) {
defId = Long.valueOf(defIdObj.toString());
} else {
defId = lazyRegister(sourceType, rawName);
}
registerRule(sourceType, rawName.trim(), defId, "exact", 1);
count++;
}
log.info("种子导入完成 sourceType={}, seeds={}", sourceType, count);
return count;
}
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest
预期:Tests run: 4, Failures: 0, Errors: 0
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java \
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java
git commit -m "feat(indicator): IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.javacfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java(insertReport 钩子)cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java(updateReportFromPayload 钩子)cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java(recalculateForUser 钩子)cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.javapackage com.etotem.cfc.service;
import com.etotem.cfc.dto.ParsedReportPayload;
import com.etotem.cfc.entity.HealthReport;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class NormalizeIndicatorPipelineTest {
private NormalizeIndicatorPipeline pipeline;
private IndicatorMappingService mappingService;
private IndicatorValueMapper valueMapper;
@BeforeEach
void setUp() {
pipeline = new NormalizeIndicatorPipeline();
mappingService = Mockito.mock(IndicatorMappingService.class);
valueMapper = Mockito.mock(IndicatorValueMapper.class);
ReflectionTestUtils.setField(pipeline, "mappingService", mappingService);
ReflectionTestUtils.setField(pipeline, "valueMapper", valueMapper);
}
@Test
void processReportWritesIndicatorsAndFlora() {
HealthReport report = new HealthReport();
report.setId(10L);
report.setFamilyId(2L);
report.setSubjectId(3L);
report.setReportType("gut_flora");
report.setReportDate(new Date());
when(mappingService.resolve(eq("gut_flora"), eq("双歧杆菌属"))).thenReturn(1L);
ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
ParsedReportPayload.Indicator ind = new ParsedReportPayload.Indicator();
ind.setIndicatorName("双歧杆菌属");
ind.setIndicatorValue("12.8");
ind.setUnit("%");
ind.setStatus("偏高");
payload.setIndicators(Collections.singletonList(ind));
pipeline.processReport(report, payload);
verify(valueMapper).upsertMapping(any(IndicatorValue.class));
}
@Test
void processReportSkipsWhenNoSubject() {
HealthReport report = new HealthReport();
report.setId(10L);
report.setReportType("gut_flora");
report.setSubjectId(null);
ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
payload.setIndicators(Collections.emptyList());
pipeline.processReport(report, payload);
verify(valueMapper, never()).upsertMapping(any());
}
@Test
void snapshotFoodIndicesWritesFoodValues() {
com.etotem.cfc.entity.FoodRecommendIndex idx = new com.etotem.cfc.entity.FoodRecommendIndex();
idx.setId(5L);
idx.setUserId(3L);
idx.setFoodId(9L);
idx.setFoodName("燕麦");
idx.setIndexScore(88);
idx.setHealthReportId(10L);
idx.setCalculatedAt(new Date());
when(mappingService.resolve(eq("food_recommend"), eq("燕麦"))).thenReturn(6L);
pipeline.snapshotFoodIndices(3L, java.util.Collections.singletonList(idx));
verify(valueMapper).upsertMapping(argThat(v -> v.getSourceType().equals("food_recommend")
&& v.getSourceId().equals(5L)
&& v.getDefinitionId().equals(6L)));
}
}
运行:cd cfc-backend && mvn test -Dtest=NormalizeIndicatorPipelineTest
预期:BUILD FAILURE,NormalizeIndicatorPipeline 不存在
package com.etotem.cfc.service;
import com.etotem.cfc.dto.ParsedReportPayload;
import com.etotem.cfc.entity.FoodRecommendIndex;
import com.etotem.cfc.entity.HealthReport;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.List;
/**
* 归一化管道 —— 统一指标层「单一写者」。
* 报告入库/编辑流程照旧写 health_indicators(不动现有消费者),
* 本管道在同流程/同事务内将 指标+菌属+食材 幂等写入 indicator_values。
* 唯一键 (source_type, source_id, definition_id) 保证重解析只覆盖更新。
*/
@Service
public class NormalizeIndicatorPipeline {
private static final Logger log = LoggerFactory.getLogger(NormalizeIndicatorPipeline.class);
@Resource
private IndicatorMappingService mappingService;
@Resource
private IndicatorValueMapper valueMapper;
/**
* 报告指标 + 菌属归一化写入。
* 调用点:insertReport()(subjectId 块后)/ updateReportFromPayload()(步骤 5 后)。
* 无 subject 的报告不入时序(无法归属观测对象)。
*
* @param report 已入库/已更新的报告(reportDate 须为真实日期或 null)
* @param payload 解析载荷(gutFlora 从 payload 读;indicators 从 payload 读,与 health_indicators 同源)
*/
public void processReport(HealthReport report, ParsedReportPayload.Payload payload) {
if (report == null || report.getSubjectId() == null || report.getId() == null) {
return;
}
Long subjectId = report.getSubjectId();
Long familyId = report.getFamilyId();
Long reportId = report.getId();
String sourceType = report.getReportType();
Date reportDate = report.getReportDate() != null
? new Date(report.getReportDate().getTime()) : null;
int written = 0;
if (payload != null && payload.getIndicators() != null) {
for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
if (ind.getIndicatorName() == null || ind.getIndicatorName().trim().isEmpty()) {
continue;
}
Long defId = mappingService.resolve(sourceType, ind.getIndicatorName().trim());
if (defId == null) {
continue;
}
IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
v.setValue(ind.getIndicatorValue());
v.setNumericValue(mappingService.parseNumericValue(ind.getIndicatorValue()));
v.setUnit(ind.getUnit());
v.setStatus(ind.getStatus());
valueMapper.upsertMapping(v);
written++;
}
}
if (payload != null && payload.getGutFlora() != null) {
for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
if (flora.getBacteriaName() == null || flora.getBacteriaName().trim().isEmpty()) {
continue;
}
Long defId = mappingService.resolve(sourceType, flora.getBacteriaName().trim());
if (defId == null) {
continue;
}
IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
v.setValue(flora.getBacteriaValue());
v.setNumericValue(mappingService.parseNumericValue(flora.getBacteriaValue()));
v.setUnit("%");
v.setStatus(flora.getStatus());
valueMapper.upsertMapping(v);
written++;
}
}
log.info("归一化管道 reportId={}, subjectId={}, type={},写入 {} 条观测", reportId, subjectId, sourceType, written);
}
/**
* 食材推荐指数全量快照(钩子 3:FoodRecommendService.recalculateForUser 尾部调用)。
* source_type=food_recommend,source_id=food_recommend_idx.id,幂等覆盖。
*/
public void snapshotFoodIndices(Long userId, List<FoodRecommendIndex> indices) {
if (userId == null || indices == null || indices.isEmpty()) {
return;
}
for (FoodRecommendIndex idx : indices) {
if (idx.getFoodName() == null || idx.getFoodName().trim().isEmpty()) {
continue;
}
Long defId = mappingService.resolve("food_recommend", idx.getFoodName().trim());
if (defId == null) {
continue;
}
IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null);
v.setValue(idx.getIndexScore() != null ? idx.getIndexScore().toString() : null);
v.setNumericValue(idx.getIndexScore() != null
? BigDecimal.valueOf(idx.getIndexScore()) : null);
v.setReportId(idx.getHealthReportId());
valueMapper.upsertMapping(v);
}
log.info("食材指数快照 userId={}, 共 {} 条", userId, indices.size());
}
private IndicatorValue baseValue(String sourceType, Long sourceId, Long defId,
Long subjectId, Long familyId, Date reportDate) {
IndicatorValue v = new IndicatorValue();
v.setSourceType(sourceType);
v.setSourceId(sourceId);
v.setDefinitionId(defId);
v.setSubjectId(subjectId);
v.setFamilyId(familyId);
v.setReportDate(reportDate);
return v;
}
}
钩子 1 — ReportCollectService.insertReport()(L596-652):在 reportBlockService.save(...) try 块(L641-646)之后、markCollectCompleted(L648)之前插入:
// 归一化管道:统一指标层幂等写入(同流程,subjectId 非空才有效)
try {
normalizeIndicatorPipeline.processReport(created, payload);
} catch (Exception e) {
log.warn("归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
}
并在类顶部加注入(与既有 @Resource 并列):
@Resource
private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
钩子 2 — HealthReportService.updateReportFromPayload():在步骤 6(payload 快照,L1886-1904)之后、方法结束前插入:
// 归一化管道:编辑后重写统一指标层(同事务;payload 为编辑后最新值)
try {
ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
oldReport.setSubjectId(subjectId != null && subjectId > 0 ? subjectId : oldSubjectId);
normalizeIndicatorPipeline.processReport(oldReport, editedPayload);
} catch (Exception e) {
log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
}
(若类中已注入 normalizeIndicatorPipeline,复用;未注入则在字段区补 @Resource private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;)
钩子 3 — FoodRecommendService.recalculateForUser():在方法末尾(existingMap 删除循环 L143-159 之后、} 之前)插入:
// 归一化管道:全量快照最新食材推荐指数到 indicator_values(幂等覆盖)
try {
List<FoodRecommendIndex> finalIndices = foodRecommendIndexMapper.selectList(
new LambdaQueryWrapper<FoodRecommendIndex>()
.eq(FoodRecommendIndex::getUserId, userId));
normalizeIndicatorPipeline.snapshotFoodIndices(userId, finalIndices);
} catch (Exception e) {
log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
}
并补注入字段:
@Resource
private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
运行:cd cfc-backend && mvn test -Dtest=NormalizeIndicatorPipelineTest
预期:Tests run: 3, Failures: 0, Errors: 0
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
git add cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java \
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java \
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java \
cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java
git commit -m "feat(indicator): NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.javacfc-langgraph/app/api/indicator_classify.pycfc-langgraph/app/main.py在 AiGateway.java 的 generatePortrait 方法(L392-416)之后追加(全套模板与既有方法一致:!enabled || isCircuitOpen() 短路 + createJsonHeaders() + postForEntity + consecutiveFailures.set(0)/recordFailure()):
/**
* 指标归类兜底:将未命中的原始指标名交给 LangGraph 归类为已有/新定义。
* @return 建议 JSON 字符串(含 code/name)或 null(关闭/熔断/失败)
*/
public String classifyIndicator(String sourceType, String rawName, java.util.List<String> candidates) {
if (!enabled || isCircuitOpen()) {
return null;
}
try {
ObjectNode body = objectMapper.createObjectNode();
body.put("source_type", sourceType);
body.put("raw_name", rawName);
if (candidates != null) {
com.fasterxml.jackson.databind.node.ArrayNode arr = body.putArray("candidates");
candidates.forEach(arr::add);
}
HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
String url = baseUrl + "/api/v1/indicator/classify";
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
consecutiveFailures.set(0);
return response.getBody();
}
return null;
} catch (Exception e) {
log.warn("AiGateway classifyIndicator 调用失败: {}", e.getMessage());
recordFailure();
return null;
}
}
(若文件顶部未 import ObjectNode/ArrayNode/HttpEntity/ResponseEntity,与既有方法共用全限定名或补充 import;与 generatePortrait 使用一致即可。)
cfc-langgraph/app/api/indicator_classify.py(完整文件,沿用 report_parse.py 的 router + pydantic 模式;LLM 调用沿用 self_check_analysis_graph 的 get_llm().ainvoke 模式):
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
from app.llm.client import get_llm
from langchain_core.messages import SystemMessage, HumanMessage
import logging
import json
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["indicator_classify"])
_llm = None
def get_llm_instance():
global _llm
if _llm is None:
_llm = get_llm()
return _llm
class ClassifyRequest(BaseModel):
source_type: str
raw_name: str
candidates: Optional[List[str]] = None
class ClassifyResponse(BaseModel):
code: int = 200
message: str = "ok"
data: dict = {}
SYSTEM_PROMPT = (
"你是健康报告指标归一化归类助手。给定一个报告中的原始指标/菌属/食材名称,"
"把它归类为统一指标字典中的一个标准定义。只输出 JSON:"
'{"code": "indicator.<类别>.<标准名>" | "bacteria.<标准名>" | "food.<标准名>", '
'"name": "标准显示名", "category": "分类"}。'
"若候选列表非空,优先从候选中选择语义最接近的标准 code。"
)
@router.post("/indicator/classify", response_model=ClassifyResponse)
async def classify_indicator(req: ClassifyRequest):
logger.info("indicator_classify: source=%s raw=%s", req.source_type, req.raw_name)
llm = get_llm_instance()
prompt = f"来源类型: {req.source_type}\n原始名称: {req.raw_name}\n"
if req.candidates:
prompt += "候选标准定义: " + ", ".join(req.candidates) + "\n"
try:
messages = [
SystemMessage(content=SYSTEM_PROMPT),
HumanMessage(content=prompt),
]
response = await llm.ainvoke(messages)
text = response.content.strip()
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].strip()
data = json.loads(text)
return ClassifyResponse(data=data)
except Exception as e:
logger.error("指标归类失败: %s", e, exc_info=True)
return ClassifyResponse(code=500, message=f"归类失败: {str(e)}", data={})
在 cfc-langgraph/app/main.py 的 include_router 区块(L18 起)追加注册:
from app.api import indicator_classify
...
app.include_router(indicator_classify.router)
(import 放文件顶部 import 区;按项目既有 from app.api import ... 风格。注意:self_check.router 被注册了两次是既有 bug,不要模仿重复注册;本 router 只注册一次。)
运行:cd cfc-langgraph && python -m py_compile app/api/indicator_classify.py app/main.py
预期:退出码 0,无语法错误
git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java \
cfc-langgraph/app/api/indicator_classify.py \
cfc-langgraph/app/main.py
git commit -m "feat(indicator): AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.javacfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.javapackage com.etotem.cfc.service;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class IndicatorTrendServiceTest {
private IndicatorTrendService service;
private IndicatorValueMapper valueMapper;
@BeforeEach
void setUp() {
service = new IndicatorTrendService();
valueMapper = mock(IndicatorValueMapper.class);
ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
}
private IndicatorValue value(Long defId, String date, String val) {
IndicatorValue v = new IndicatorValue();
v.setDefinitionId(defId);
v.setReportDate(Date.valueOf(date));
v.setValue(val);
v.setNumericValue(new BigDecimal(val));
return v;
}
@Test
void trendReturnsAscendingPoints() {
List<IndicatorValue> rows = new ArrayList<>();
rows.add(value(1L, "2026-08-15", "10"));
rows.add(value(1L, "2026-10-01", "12.8"));
when(valueMapper.selectList(any())).thenReturn(rows);
Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
List<?> items = (List<?>) data.get("items");
assertEquals(1, items.size());
Map<?, ?> item = (Map<?, ?>) items.get(0);
assertEquals("1", String.valueOf(item.get("definitionId")));
List<?> points = (List<?>) item.get("points");
assertEquals(2, points.size());
Map<?, ?> first = (Map<?, ?>) points.get(0);
assertEquals("2026-08-15", first.get("reportDate"));
}
@Test
void changeRankingAdjacentComputesDeltaAndTrendLabel() {
// 双歧杆菌属 higher_is_better:升幅 → trendLabel=改善
List<IndicatorValue> rows = new ArrayList<>();
rows.add(value(1L, "2026-08-15", "10"));
rows.add(value(1L, "2026-10-01", "12.8"));
when(valueMapper.selectList(any())).thenReturn(rows);
java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
List<?> items = (List<?>) data.get("items");
assertEquals(1, items.size());
Map<?, ?> item = (Map<?, ?>) items.get(0);
assertEquals("up", item.get("movement"));
assertEquals("改善", item.get("trendLabel"));
Map<?, ?> overall = (Map<?, ?>) data.get("overall");
assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
}
@Test
void quickLookupReturnsItemsAndDimensions() {
List<IndicatorValue> rows = new ArrayList<>();
rows.add(value(1L, "2026-10-01", "12.8"));
when(valueMapper.selectList(any())).thenReturn(rows);
Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
assertTrue(data.containsKey("items"));
assertTrue(data.containsKey("categories"));
assertEquals(1, ((List<?>) data.get("items")).size());
}
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest
预期:BUILD FAILURE,IndicatorTrendService 不存在
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.SimpleDateFormat;
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;
import java.util.stream.Collectors;
/**
* 指标视图服务:trend(曲线)/ change-ranking(变化排名)/ quick-lookup(速查)。
* 只读 indicator_values;五维/七维评分不写时序,quick-lookup API 层单独合入 dimensionScores 小节。
*/
@Service
public class IndicatorTrendService {
private static final Logger log = LoggerFactory.getLogger(IndicatorTrendService.class);
@Resource
private IndicatorValueMapper valueMapper;
@Resource
private IndicatorDefinitionMapper definitionMapper;
@Resource
private FiveDimensionScoreMapper fiveDimensionScoreMapper;
@Resource
private HealthDimensionScoreMapper healthDimensionScoreMapper;
/** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
Map<String, Object> data = new LinkedHashMap<>();
List<Map<String, Object>> items = new ArrayList<>();
if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
data.put("items", items);
return data;
}
List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getSubjectId, subjectId)
.in(IndicatorValue::getDefinitionId, definitionIds)
.isNotNull(IndicatorValue::getReportDate)
.orderByAsc(IndicatorValue::getReportDate));
Map<Long, List<IndicatorValue>> byDef = rows.stream()
.collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
LinkedHashMap::new, Collectors.toList()));
Map<Long, IndicatorDefinition> defCache = new HashMap<>();
for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("definitionId", e.getKey());
IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
id -> definitionMapper.selectById(id));
item.put("name", def != null ? def.getName() : "指标" + e.getKey());
item.put("unit", def != null ? def.getUnit() : null);
List<Map<String, Object>> points = new ArrayList<>();
for (IndicatorValue v : e.getValue()) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("reportDate", v.getReportDate() != null
? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
p.put("value", v.getValue());
p.put("numericValue", v.getNumericValue());
p.put("unit", v.getUnit());
p.put("status", v.getStatus());
points.add(p);
}
item.put("points", points);
items.add(item);
}
data.put("items", items);
return data;
}
/** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
String scope, String orderBy, Integer limit, Long familyId) {
Map<String, Object> data = new LinkedHashMap<>();
List<Map<String, Object>> items = new ArrayList<>();
int improved = 0;
int worsened = 0;
int flat = 0;
LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getSubjectId, subjectId)
.isNotNull(IndicatorValue::getReportDate);
if (reportType != null && !reportType.isEmpty()) {
qw.eq(IndicatorValue::getSourceType, reportType);
}
List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
// 按 definitionId 分组
Map<Long, List<IndicatorValue>> byDef = rows.stream()
.collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
Map<Long, IndicatorDefinition> defCache = new HashMap<>();
for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
List<IndicatorValue> sorted = e.getValue().stream()
.sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
.collect(Collectors.toList());
if (sorted.size() < 2) {
continue;
}
IndicatorValue before = sorted.get(0);
IndicatorValue after = sorted.get(sorted.size() - 1);
if ("adjacent".equals(scope)) {
before = sorted.get(sorted.size() - 2);
}
IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
id -> definitionMapper.selectById(id));
String defCategory = def != null ? def.getCategory() : null;
if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
continue;
}
BigDecimal bv = before.getNumericValue();
BigDecimal av = after.getNumericValue();
if (bv == null || av == null) {
continue;
}
BigDecimal delta = av.subtract(bv);
BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
String movement = "flat";
if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
String trendLabel = trendLabel(def, movement, delta);
if ("改善".equals(trendLabel)) improved++;
else if ("恶化".equals(trendLabel)) worsened++;
else flat++;
Map<String, Object> item = new LinkedHashMap<>();
item.put("definitionId", e.getKey());
item.put("name", def != null ? def.getName() : "指标" + e.getKey());
item.put("category", defCategory);
item.put("sourceKind", def != null ? def.getSourceKind() : null);
item.put("unit", def != null ? def.getUnit() : null);
item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
Map<String, Object> beforeMap = new LinkedHashMap<>();
beforeMap.put("reportDate", fmtDate(before.getReportDate()));
beforeMap.put("value", before.getValue());
Map<String, Object> afterMap = new LinkedHashMap<>();
afterMap.put("reportDate", fmtDate(after.getReportDate()));
afterMap.put("value", after.getValue());
item.put("before", beforeMap);
item.put("after", afterMap);
item.put("delta", delta);
item.put("deltaPct", deltaPct);
item.put("movement", movement);
item.put("trendLabel", trendLabel);
items.add(item);
}
// 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
if ("up".equals(orderBy)) {
items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
} else if ("down".equals(orderBy)) {
items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
} else {
items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
.compareTo(((BigDecimal) a.get("delta")).abs()));
}
int limitVal = limit != null && limit > 0 ? limit : 50;
if (items.size() > limitVal) {
items = new ArrayList<>(items.subList(0, limitVal));
}
data.put("items", items);
Map<String, Object> overall = new LinkedHashMap<>();
overall.put("improvedCount", improved);
overall.put("worsenedCount", worsened);
overall.put("flatCount", flat);
data.put("overall", overall);
return data;
}
/** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
Integer pageSize, Long familyId) {
Map<String, Object> data = new LinkedHashMap<>();
int pageVal = page != null && page > 0 ? page : 1;
int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
// 该成员全部观测(按时序取最新一条)
List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getSubjectId, subjectId)
.isNotNull(IndicatorValue::getDefinitionId));
Map<Long, List<IndicatorValue>> byDef = rows.stream()
.collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
Map<Long, IndicatorDefinition> defCache = new HashMap<>();
List<Map<String, Object>> allItems = new ArrayList<>();
for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
id -> definitionMapper.selectById(id));
if (def == null) {
continue;
}
if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
continue;
}
List<IndicatorValue> sorted = e.getValue().stream()
.sorted((a, b) -> a.getReportDate() == null ? -1
: (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
.collect(Collectors.toList());
IndicatorValue latest = sorted.get(0);
Map<String, Object> item = new LinkedHashMap<>();
item.put("definitionId", e.getKey());
item.put("name", def.getName());
item.put("category", def.getCategory());
item.put("value", latest.getValue());
item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
item.put("refRange", def.getRefRange());
item.put("status", latest.getStatus());
item.put("trend", trendOf(sorted));
item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
item.put("latestReportDate", latest.getReportDate() != null
? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
item.put("pointsCount", sorted.size());
allItems.add(item);
}
// categories 汇总(含 total)
List<Map<String, Object>> categories = new ArrayList<>();
Map<String, Long> catCount = allItems.stream()
.collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
catCount.forEach((name, total) -> {
Map<String, Object> c = new LinkedHashMap<>();
c.put("name", name);
c.put("total", total);
categories.add(c);
});
// 分页
int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
List<Map<String, Object>> pageItems = allItems.isEmpty()
? new ArrayList<>()
: new ArrayList<>(allItems.subList(fromIndex, toIndex));
data.put("categories", categories);
data.put("items", pageItems);
data.put("page", pageVal);
data.put("pageSize", sizeVal);
data.put("total", allItems.size());
data.put("dimensionScores", loadDimensionScores(subjectId));
return data;
}
/** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
List<Map<String, Object>> result = new ArrayList<>();
// 五维:five_dimension_scores 按 memberId + assessedAt 最新
try {
result.addAll(fiveDimensionScoreMapper.selectList(
new LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionScore>()
.eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId)
.orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt)
.last("LIMIT 10"))
.stream().map(s -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("dimensionCode", s.getDimensionCode());
m.put("score", s.getScore());
m.put("assessedAt", s.getAssessedAt() != null
? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessedAt()) : null);
m.put("source", "five_dimension");
return m;
}).collect(Collectors.toList()));
} catch (Exception e) {
log.warn("加载五维评分失败 subjectId={}: {}", subjectId, e.getMessage());
}
// 七维:health_dimension_scores 按 memberId + assessDate 最新
try {
result.addAll(healthDimensionScoreMapper.selectList(
new LambdaQueryWrapper<com.etotem.cfc.entity.HealthDimensionScore>()
.eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId)
.orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate)
.last("LIMIT 10"))
.stream().map(s -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("dimensionCode", s.getDimension());
m.put("score", s.getScore());
m.put("assessedAt", s.getAssessDate() != null
? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessDate()) : null);
m.put("source", "health_dimension");
return m;
}).collect(Collectors.toList()));
} catch (Exception e) {
log.warn("加载七维评分失败 subjectId={}: {}", subjectId, e.getMessage());
}
return result;
}
// ---------- 工具 ----------
private String fmtDate(Date d) {
return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
}
/** 序列趋势:末尾两条比较 */
private String trendOf(List<IndicatorValue> sorted) {
if (sorted.size() < 2) {
return "flat";
}
IndicatorValue latest = sorted.get(0);
IndicatorValue prev = sorted.get(1);
if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
return "flat";
}
int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
}
private BigDecimal deltaOf(List<IndicatorValue> sorted) {
if (sorted.size() < 2) {
return BigDecimal.ZERO;
}
IndicatorValue latest = sorted.get(0);
IndicatorValue prev = sorted.get(1);
if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
return BigDecimal.ZERO;
}
return latest.getNumericValue().subtract(prev.getNumericValue());
}
/**
* 变化着色/标签规则(设计第 8 节):
* good_direction × 数值变化方向 → 改善/恶化/中性。
*/
private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
return "中性";
}
if (def == null || def.getGoodDirection() == null) {
return "中性";
}
boolean up = "up".equals(movement);
switch (def.getGoodDirection()) {
case "higher_is_better":
return up ? "改善" : "恶化";
case "lower_is_better":
return up ? "恶化" : "改善";
case "in_range_is_better":
// 一期简化:无 refRange 解析,回落为中性
return "中性";
default:
return "中性";
}
}
}
需要
HealthDimensionScoreMapper(若不存在则先创建空 BaseMapper;FiveDimensionScoreMapper若已有则复用)。若health_dimension_scores表访问导致编译失败,先确认两个 mapper 是否存在:
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.HealthDimensionScore;
public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest
预期:Tests run: 3, Failures: 0, Errors: 0
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java \
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java \
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java
git commit -m "feat(indicator): IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.javacfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.javapackage com.etotem.cfc.service;
import com.etotem.cfc.entity.IndicatorCorrelation;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class IndicatorCorrelationServiceTest {
private IndicatorCorrelationService service;
private IndicatorCorrelationMapper correlationMapper;
private IndicatorDefinitionMapper definitionMapper;
@BeforeEach
void setUp() {
service = new IndicatorCorrelationService();
correlationMapper = mock(IndicatorCorrelationMapper.class);
definitionMapper = mock(IndicatorDefinitionMapper.class);
ReflectionTestUtils.setField(service, "correlationMapper", correlationMapper);
ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
}
@Test
void saveOrdersByCodeLexicographic() {
// 定义 A code="bacteria.bb" 应存 a;定义 B code="bacteria.aa" 应存 a
IndicatorDefinition defAA = new IndicatorDefinition();
defAA.setId(1L);
defAA.setCode("bacteria.aa");
IndicatorDefinition defBB = new IndicatorDefinition();
defBB.setId(2L);
defBB.setCode("bacteria.bb");
when(definitionMapper.selectById(1L)).thenReturn(defAA);
when(definitionMapper.selectById(2L)).thenReturn(defBB);
service.saveCorrelation(1L, 2L, "positive", new BigDecimal("0.72"),
"描述", false, "来源", 9L);
org.mockito.ArgumentCaptor<IndicatorCorrelation> captor =
org.mockito.ArgumentCaptor.forClass(IndicatorCorrelation.class);
verify(correlationMapper).insert(captor.capture());
IndicatorCorrelation saved = captor.getValue();
// 1L 的 code("bacteria.aa") 字典序更小 → 应存 definition_id_a
assertEquals(1L, saved.getDefinitionIdA());
assertEquals(2L, saved.getDefinitionIdB());
// effective_strength 初始 = manual(未校准)
assertEquals(0, new BigDecimal("0.72").compareTo(saved.getEffectiveStrength()));
}
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest
预期:BUILD FAILURE,IndicatorCorrelationService 不存在
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.IndicatorCorrelation;
import com.etotem.cfc.entity.IndicatorDefinition;
import com.etotem.cfc.entity.IndicatorValue;
import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
import com.etotem.cfc.mapper.IndicatorValueMapper;
import com.etotem.cfc.util.SpearmanUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 指标关联:人工定义(候选对+方向+人工强度)+ 真实数据校准(Spearman)。
* 对序规则:写入前按 definition.code 字典序比较,小的存 a、大的存 b(唯一键对称)。
* effective_strength:有效样本 >= 30 取 computed,否则取 manual;manual_locked=1 时校准不覆盖。
*/
@Service
public class IndicatorCorrelationService {
private static final Logger log = LoggerFactory.getLogger(IndicatorCorrelationService.class);
@Resource
private IndicatorCorrelationMapper correlationMapper;
@Resource
private IndicatorDefinitionMapper definitionMapper;
@Resource
private IndicatorValueMapper valueMapper;
/** 有效样本阈值:>=30 才用 computed 覆盖 manual(设计 4.6) */
private static final int EFFECTIVE_SAMPLE_THRESHOLD = 30;
/** 人工定义/编辑关联对(强制对序) */
public void saveCorrelation(Long defA, Long defB, String direction, BigDecimal manualStrength,
String description, Boolean manualLocked, String sourceNote, Long createdBy) {
if (defA == null || defB == null || defA.equals(defB)) {
throw new IllegalArgumentException("关联对不能为空且不能相同");
}
IndicatorDefinition da = definitionMapper.selectById(defA);
IndicatorDefinition db = definitionMapper.selectById(defB);
if (da == null || db == null) {
throw new IllegalArgumentException("指标定义不存在: " + defA + "/" + defB);
}
Long a = da.getCode().compareTo(db.getCode()) <= 0 ? defA : defB;
Long b = da.getCode().compareTo(db.getCode()) <= 0 ? defB : defA;
// 已存在则该对更新
IndicatorCorrelation existing = correlationMapper.selectOne(new LambdaQueryWrapper<IndicatorCorrelation>()
.eq(IndicatorCorrelation::getDefinitionIdA, a)
.eq(IndicatorCorrelation::getDefinitionIdB, b)
.last("LIMIT 1"));
IndicatorCorrelation c = existing != null ? existing : new IndicatorCorrelation();
c.setDefinitionIdA(a);
c.setDefinitionIdB(b);
if (c.getRelationType() == null) c.setRelationType("correlation");
c.setDirection(direction);
c.setManualStrength(manualStrength);
c.setEffectiveStrength(manualStrength); // 未校准时 = manual
if (manualLocked != null) c.setManualLocked(manualLocked ? 1 : 0);
if (c.getManualLocked() == null) c.setManualLocked(0);
c.setDescription(description);
c.setSourceNote(sourceNote);
c.setCreatedBy(createdBy);
if (c.getCohortScope() == null) c.setCohortScope("subject");
if (c.getStatus() == null || !c.getStatus().equals("inactive")) c.setStatus("active");
if (existing != null) {
c.setUpdatedAt(new Date());
correlationMapper.updateById(c);
} else {
c.setCreatedAt(new Date());
c.setUpdatedAt(new Date());
correlationMapper.insert(c);
}
log.info("关联对已保存: {} ({} ↔ {})", a, b, direction);
}
/** 关联查询(设计 6.4):返回该指标的全部关联 + 当前成员的配对证据 */
public Map<String, Object> listRelations(Long subjectId, Long definitionId) {
Map<String, Object> data = new LinkedHashMap<>();
List<Map<String, Object>> relations = new ArrayList<>();
if (definitionId == null) {
data.put("relations", relations);
return data;
}
List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
.and(w -> w.eq(IndicatorCorrelation::getDefinitionIdA, definitionId)
.or().eq(IndicatorCorrelation::getDefinitionIdB, definitionId))
.eq(IndicatorCorrelation::getStatus, "active"));
for (IndicatorCorrelation pair : pairs) {
Long otherId = pair.getDefinitionIdA().equals(definitionId)
? pair.getDefinitionIdB() : pair.getDefinitionIdA();
IndicatorDefinition other = definitionMapper.selectById(otherId);
Map<String, Object> rel = new LinkedHashMap<>();
rel.put("pairId", pair.getId());
Map<String, Object> indA = new LinkedHashMap<>();
indA.put("definitionId", pair.getDefinitionIdA());
IndicatorDefinition defA = definitionMapper.selectById(pair.getDefinitionIdA());
indA.put("name", defA != null ? defA.getName() : null);
rel.put("indicatorA", indA);
Map<String, Object> indB = new LinkedHashMap<>();
indB.put("definitionId", pair.getDefinitionIdB());
indB.put("name", other != null ? other.getName() : null);
rel.put("indicatorB", indB);
rel.put("direction", pair.getDirection());
rel.put("effectiveStrength", pair.getEffectiveStrength());
rel.put("strengthSource", pair.getComputedStrength() != null
&& pair.getSampleSize() != null && pair.getSampleSize() >= EFFECTIVE_SAMPLE_THRESHOLD
? "computed" : "manual");
rel.put("sampleSize", pair.getSampleSize());
rel.put("confidence", pair.getConfidence());
rel.put("manualLocked", pair.getManualLocked() != null && pair.getManualLocked() == 1);
rel.put("description", pair.getDescription());
// 该成员自己的配对数据支撑(如有)
Map<String, Object> evidence = subjectEvidence(subjectId, pair.getDefinitionIdA(), pair.getDefinitionIdB());
rel.put("subjectEvidence", evidence);
relations.add(rel);
}
data.put("relations", relations);
return data;
}
/** 成员级配对证据:点数 + 个人 rho + 是否足够(>=3) */
private Map<String, Object> subjectEvidence(Long subjectId, Long defA, Long defB) {
Map<String, Object> evidence = new LinkedHashMap<>();
evidence.put("points", 0);
evidence.put("personalRho", 0.0);
evidence.put("hasEnough", false);
if (subjectId == null) {
return evidence;
}
List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getSubjectId, subjectId)
.eq(IndicatorValue::getDefinitionId, defA)
.isNotNull(IndicatorValue::getReportDate)
.orderByAsc(IndicatorValue::getReportDate));
List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getSubjectId, subjectId)
.eq(IndicatorValue::getDefinitionId, defB)
.isNotNull(IndicatorValue::getReportDate)
.orderByAsc(IndicatorValue::getReportDate));
if (rowsA.size() < 3 || rowsB.size() < 3) {
return evidence;
}
// 按 report_date 对齐:累计最近 12 个共同日期点
List<java.math.BigDecimal> xs = new ArrayList<>();
List<java.math.BigDecimal> ys = new ArrayList<>();
int i = 0;
int j = 0;
while (i < rowsA.size() && j < rowsB.size() && xs.size() < 12) {
int cmp = rowsA.get(i).getReportDate().compareTo(rowsB.get(j).getReportDate());
if (cmp == 0) {
if (rowsA.get(i).getNumericValue() != null && rowsB.get(j).getNumericValue() != null) {
xs.add(rowsA.get(i).getNumericValue());
ys.add(rowsB.get(j).getNumericValue());
}
i++;
j++;
} else if (cmp < 0) {
i++;
} else {
j++;
}
}
if (xs.size() >= 3) {
evidence.put("points", xs.size());
evidence.put("personalRho", SpearmanUtil.spearmanRho(xs, ys));
evidence.put("hasEnough", true);
}
return evidence;
}
/**
* 全量校准(管理端手动触发 / @Scheduled 每日):
* 对 status=active 且 manual_locked=0 的关联对,按 subject 分组配对序列 → Spearman → Fisher z 聚合。
*/
public int calibrateAll() {
List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
.eq(IndicatorCorrelation::getStatus, "active"));
int updated = 0;
for (IndicatorCorrelation pair : pairs) {
if (pair.getManualLocked() != null && pair.getManualLocked() == 1) {
continue;
}
if (calibratePair(pair)) {
updated++;
}
}
log.info("指标关联校准完成,共更新 {} 对", updated);
return updated;
}
private boolean calibratePair(IndicatorCorrelation pair) {
// 全部观测(含所有 subject),按 (subject_id, report_date) 分组配对
List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdA())
.isNotNull(IndicatorValue::getReportDate));
List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
.eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdB())
.isNotNull(IndicatorValue::getReportDate));
// key = subjectId + '-' + reportDate
Map<String, IndicatorValue> mapA = rowsA.stream().filter(v -> v.getSubjectId() != null)
.collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
Map<String, IndicatorValue> mapB = rowsB.stream().filter(v -> v.getSubjectId() != null)
.collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
// 按 subject 分组配对点
Map<Long, List<java.math.BigDecimal[]>> bySubject = new java.util.HashMap<>();
int sampleSize = 0;
for (Map.Entry<String, IndicatorValue> e : mapA.entrySet()) {
IndicatorValue vb = mapB.get(e.getKey());
if (vb == null) continue;
IndicatorValue va = e.getValue();
if (va.getNumericValue() == null || vb.getNumericValue() == null) continue;
bySubject.computeIfAbsent(va.getSubjectId(), k -> new ArrayList<>())
.add(new java.math.BigDecimal[]{va.getNumericValue(), vb.getNumericValue()});
sampleSize++;
}
if (sampleSize < 3) {
return false;
}
List<Double> rhos = new ArrayList<>();
for (List<java.math.BigDecimal[]> points : bySubject.values()) {
if (points.size() < 3) {
continue;
}
List<java.math.BigDecimal> xs = points.stream().map(p -> p[0]).collect(Collectors.toList());
List<java.math.BigDecimal> ys = points.stream().map(p -> p[1]).collect(Collectors.toList());
rhos.add(SpearmanUtil.spearmanRho(xs, ys));
}
if (rhos.isEmpty()) {
return false;
}
double avgRho = SpearmanUtil.fisherZAverage(rhos.stream().mapToDouble(Double::doubleValue).toArray());
String confidence = sampleSize >= 30 ? "high" : (sampleSize >= 10 ? "medium" : "low");
if (sampleSize >= EFFECTIVE_SAMPLE_THRESHOLD) {
pair.setEffectiveStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
}
pair.setComputedStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
pair.setMethod("spearman");
pair.setSampleSize(sampleSize);
pair.setConfidence(confidence);
pair.setComputedAt(new Date());
pair.setUpdatedAt(new Date());
correlationMapper.updateById(pair);
log.info("校准关联对 {}: rho={}, n={}, confidence={}",
pair.getId(), avgRho, sampleSize, confidence);
return true;
}
}
运行:cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest
预期:Tests run: 1, Failures: 0, Errors: 0
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java \
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java
git commit -m "feat(indicator): IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.javacfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.javacfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.javacfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.javapackage com.etotem.cfc.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FamilyMember;
import com.etotem.cfc.mapper.FamilyMemberMapper;
import com.etotem.cfc.service.IndicatorCorrelationService;
import com.etotem.cfc.service.IndicatorTrendService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/indicator")
public class IndicatorController {
@Resource
private IndicatorTrendService indicatorTrendService;
@Resource
private IndicatorCorrelationService indicatorCorrelationService;
@Resource
private FamilyMemberMapper familyMemberMapper;
/** 校验 subjectId 属于当前用户家庭(familyId 由 JwtInterceptor 注入,必存在) */
private Result<Void> checkSubjectBelongs(Long subjectId, Long familyId) {
if (subjectId == null) {
return Result.error("subjectId不能为空");
}
FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
.eq(FamilyMember::getId, subjectId)
.eq(FamilyMember::getFamilyId, familyId)
.last("LIMIT 1"));
if (member == null) {
return Result.error("成员不属于当前家庭");
}
return null;
}
/** 指标曲线(设计 6.1) */
@PostMapping("/trend")
public Result<?> trend(@RequestBody Map<String, Object> params,
@RequestAttribute("familyId") Long familyId) {
Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
Result<Void> check = checkSubjectBelongs(subjectId, familyId);
if (check != null) {
return check;
}
@SuppressWarnings("unchecked")
List<Object> rawIds = (List<Object>) params.get("definitionIds");
List<Long> definitionIds = new ArrayList<>();
if (rawIds != null) {
for (Object o : rawIds) {
if (o instanceof Number) {
definitionIds.add(((Number) o).longValue());
} else if (o != null) {
definitionIds.add(Long.valueOf(o.toString()));
}
}
}
return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
}
/** 变化幅度排名(设计 6.2) */
@PostMapping("/change-ranking")
public Result<?> changeRanking(@RequestBody Map<String, Object> params,
@RequestAttribute("familyId") Long familyId) {
Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
Result<Void> check = checkSubjectBelongs(subjectId, familyId);
if (check != null) {
return check;
}
String reportType = params.get("reportType") != null ? params.get("reportType").toString() : null;
String category = params.get("category") != null ? params.get("category").toString() : null;
String scope = params.get("scope") != null ? params.get("scope").toString() : "adjacent";
String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
Integer limit = params.get("limit") != null
? Integer.valueOf(params.get("limit").toString()) : 50;
return Result.success(indicatorTrendService.changeRanking(
subjectId, reportType, category, scope, orderBy, limit, familyId));
}
/** 指标速查(设计 6.3) */
@PostMapping("/quick-lookup")
public Result<?> quickLookup(@RequestBody Map<String, Object> params,
@RequestAttribute("familyId") Long familyId) {
Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
Result<Void> check = checkSubjectBelongs(subjectId, familyId);
if (check != null) {
return check;
}
String category = params.get("category") != null ? params.get("category").toString() : null;
Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
}
/** 指标关联查询(设计 6.4) */
@PostMapping("/correlation/list")
public Result<?> correlationList(@RequestBody Map<String, Object> params,
@RequestAttribute("familyId") Long familyId) {
Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
Long definitionId = com.etotem.cfc.util.ParamUtils.getLong(params.get("definitionId"));
Result<Void> check = checkSubjectBelongs(subjectId, familyId);
if (check != null) {
return check;
}
return Result.success(indicatorCorrelationService.listRelations(subjectId, definitionId));
}
}
备注:
FamilyMemberMapper与FamilyMember实体若已存在则直接复用;若不存在,按既有实体/Mapper 模式创建一个最小版本(id/familyId 字段 + BaseMapper)。
package com.etotem.cfc.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.IndicatorCorrelation;
import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
import com.etotem.cfc.service.IndicatorCorrelationService;
import com.etotem.cfc.util.ParamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/indicator/correlation")
public class IndicatorCorrelationAdminController {
@Resource
private IndicatorCorrelationService indicatorCorrelationService;
@Resource
private IndicatorCorrelationMapper indicatorCorrelationMapper;
/** 人工定义/编辑关联对 */
@PostMapping("/save")
public Result<?> save(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId,
@RequestAttribute("role") String role) {
if (!"admin".equals(role)) {
return Result.error("无权操作");
}
Long defA = ParamUtils.getLong(params.get("definitionIdA"));
Long defB = ParamUtils.getLong(params.get("definitionIdB"));
if (defA == null || defB == null) {
return Result.error("definitionIdA/definitionIdB 不能为空");
}
String direction = params.get("direction") != null ? params.get("direction").toString() : "positive";
BigDecimal manualStrength = params.get("manualStrength") != null
? new BigDecimal(params.get("manualStrength").toString()) : BigDecimal.ZERO;
String description = params.get("description") != null ? params.get("description").toString() : null;
Boolean manualLocked = params.get("manualLocked") != null
&& Boolean.parseBoolean(params.get("manualLocked").toString());
String sourceNote = params.get("sourceNote") != null ? params.get("sourceNote").toString() : null;
try {
indicatorCorrelationService.saveCorrelation(
defA, defB, direction, manualStrength, description, manualLocked, sourceNote, userId);
return Result.success(null);
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
/** 关联对分页列表(含校准状态) */
@PostMapping("/list")
public Result<Page<IndicatorCorrelation>> list(@RequestBody Map<String, Object> params,
@RequestAttribute("role") String role) {
if (!"admin".equals(role)) {
return Result.error("无权操作");
}
Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
Page<IndicatorCorrelation> p = new Page<>(page, size);
indicatorCorrelationMapper.selectPage(p,
new LambdaQueryWrapper<IndicatorCorrelation>()
.orderByDesc(IndicatorCorrelation::getUpdatedAt));
return Result.success(p);
}
/** 手动触发全校准或单对校准(单对:body 传 definitionIdA/definitionIdB 或 pairId) */
@PostMapping("/calibrate")
public Result<?> calibrate(@RequestBody Map<String, Object> params,
@RequestAttribute("role") String role) {
if (!"admin".equals(role)) {
return Result.error("无权操作");
}
Long pairId = ParamUtils.getLong(params.get("pairId"));
if (pairId != null) {
IndicatorCorrelation pair = indicatorCorrelationMapper.selectById(pairId);
if (pair == null) {
return Result.error("关联对不存在");
}
// 单对校准通过全量校准的按对逻辑实现(此处简化为触发全量,全量内部逐对幂等)
int n = indicatorCorrelationService.calibrateAll();
return Result.success("校准完成,共更新 " + n + " 对");
}
int n = indicatorCorrelationService.calibrateAll();
return Result.success("校准完成,共更新 " + n + " 对");
}
}
package com.etotem.cfc.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.IndicatorMappingRule;
import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
import com.etotem.cfc.service.IndicatorMappingService;
import com.etotem.cfc.util.ParamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/indicator/mapping")
public class IndicatorMappingAdminController {
@Resource
private IndicatorMappingService indicatorMappingService;
@Resource
private IndicatorMappingRuleMapper indicatorMappingRuleMapper;
/** 批量导入映射种子(JSON/CSV 数组:[{rawName, definitionId?}]) */
@PostMapping("/import")
public Result<?> importSeeds(@RequestBody Map<String, Object> params,
@RequestAttribute("role") String role) {
if (!"admin".equals(role)) {
return Result.error("无权操作");
}
String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
if (sourceType == null || sourceType.isEmpty()) {
return Result.error("sourceType 不能为空");
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> seeds = (List<Map<String, Object>>) params.get("seeds");
if (seeds == null || seeds.isEmpty()) {
return Result.error("seeds 不能为空");
}
int count = indicatorMappingService.importSeeds(sourceType, seeds);
return Result.success("导入完成,共 " + count + " 条");
}
/** 映射规则分页(含未匹配 raw 的 review 列表:status=0 为 ai_fallback 待确认) */
@PostMapping("/list")
public Result<Page<IndicatorMappingRule>> list(@RequestBody Map<String, Object> params,
@RequestAttribute("role") String role) {
if (!"admin".equals(role)) {
return Result.error("无权操作");
}
Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
Page<IndicatorMappingRule> p = new Page<>(page, size);
LambdaQueryWrapper<IndicatorMappingRule> qw = new LambdaQueryWrapper<IndicatorMappingRule>()
.orderByAsc(IndicatorMappingRule::getSourceType)
.orderByAsc(IndicatorMappingRule::getRawName);
Object status = params.get("status");
if (status != null) {
qw.eq(IndicatorMappingRule::getStatus, Integer.valueOf(status.toString()));
}
Object sourceType = params.get("sourceType");
if (sourceType != null && !sourceType.toString().isEmpty()) {
qw.eq(IndicatorMappingRule::getSourceType, sourceType.toString());
}
indicatorMappingRuleMapper.selectPage(p, qw);
return Result.success(p);
}
}
package com.etotem.cfc.job;
import com.etotem.cfc.service.IndicatorCorrelationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/** 指标关联每日自动校准(Spearman;样本不足保留 manual_strength) */
@Component
public class IndicatorCalibrationJob {
private static final Logger log = LoggerFactory.getLogger(IndicatorCalibrationJob.class);
@Resource
private IndicatorCorrelationService indicatorCorrelationService;
/** 每日 03:00 全量校准 */
@Scheduled(cron = "0 0 3 * * ?")
public void calibrateDaily() {
try {
int updated = indicatorCorrelationService.calibrateAll();
log.info("每日指标关联校准完成,更新 {} 对", updated);
} catch (Exception e) {
log.error("每日指标关联校准失败: {}", e.getMessage(), e);
}
}
}
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
确认无 /api/indicator/trend、/api/indicator/change-ranking、/api/indicator/quick-lookup、/api/indicator/correlation/list、/api/admin/indicator/correlation/*、/api/admin/indicator/mapping/* 重复。
git add cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java \
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java \
cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java
git commit -m "feat(indicator): 视图+管理控制器(4+5接口) + 每日校准Job"
文件:
docs/superpowers/api/API_REFERENCE.md在 API_REFERENCE.md 中新增 9 个接口记录(前端封装的唯一规范入口,遵守去重规范):
| 接口 | 方法 | 请求要点 | 用途 |
|---|---|---|---|
POST /api/indicator/trend |
POST | {subjectId, definitionIds:[...]} |
指标曲线(多指标点序列) |
POST /api/indicator/change-ranking |
POST | {subjectId, reportType?, category?, scope:adjacent\|first-vs-latest, orderBy:up\|down\|magnitude, limit} |
变化幅度排名 |
POST /api/indicator/quick-lookup |
POST | {subjectId, category?, page, pageSize} |
指标速查(含 dimensionScores 小节) |
POST /api/indicator/correlation/list |
POST | {subjectId, definitionId} |
指标关联查询(含 subjectEvidence) |
POST /api/admin/indicator/correlation/save |
POST | {definitionIdA, definitionIdB, direction, manualStrength, description, manualLocked, sourceNote} |
关联对定义/编辑 |
POST /api/admin/indicator/correlation/list |
POST | {page, size} |
关联对分页 |
POST /api/admin/indicator/correlation/calibrate |
POST | {pairId?} |
手动触发校准 |
POST /api/admin/indicator/mapping/import |
POST | {sourceType, seeds:[{rawName, definitionId?}]} |
映射种子批量导入 |
POST /api/admin/indicator/mapping/list |
POST | {page, size, status?, sourceType?} |
映射规则分页/review |
(markdown 表格置于既有「指标」或新增「指标统一层」章节;保持既有文档风格。)
运行:
cd cfc-backend && mvn clean compile && mvn test -Dtest='IndicatorValueMapperTest,ReportDateExtractorTest,SpearmanUtilTest,IndicatorMappingServiceTest,NormalizeIndicatorPipelineTest,IndicatorTrendServiceTest,IndicatorCorrelationServiceTest'
预期:BUILD SUCCESS,Tests run: 22(合计),Failures: 0, Errors: 0
运行:cd cfc-backend && mvn test
预期:既有全部测试(含原有测试类)Failures: 0, Errors: 0
git add docs/superpowers/api/API_REFERENCE.md
git commit -m "docs(api): 同步指标统一层 9 个新接口契约"
1. 规格覆盖度(对照设计文档 第 10 节文件清单 后端部分):
2. 验收标准映射:
isNull 筛选 → 结果一致)3. 类型一致性:
IndicatorValue.reportDate 用 java.sql.Date,与 IndicatorValueMapper.upsertMapping 的 #{v.reportDate} 及 trend/changeRanking 的 fmtDate() 参数类型一致IndicatorMappingService.resolve(sourceType, rawName) 三处调用(pipeline 指标/菌属/食材)签名一致NormalizeIndicatorPipeline.processReport(HealthReport, ParsedReportPayload.Payload) 在钩子 1 传入 (created, payload)、钩子 2 传入 (oldReport, editedPayload),两处均与签名一致IndicatorTrendService.changeRanking 返回 Map<String,Object>(items/overall),IndicatorController 直接 Result.success(map) 透传4. 占位符扫描:
HealthDimensionScoreMapper/FiveDimensionScoreMapper/FamilyMemberMapper 若项目中已存在则直接复用,不存在时按任务内提供的创建模板补齐(任务 8 步骤 3 注释中已给出 HealthDimensionScoreMapper 模板)