Browse Source

feat(indicator): 视图+管理控制器(4+5接口) + 每日校准Job

iwt 2 days ago
parent
commit
bcfde2a23a

+ 118 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -0,0 +1,118 @@
+package 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));
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java

@@ -0,0 +1,95 @@
+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 + " 对");
+    }
+}

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java

@@ -0,0 +1,74 @@
+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);
+    }
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java

@@ -0,0 +1,30 @@
+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);
+        }
+    }
+}