|
@@ -28,10 +28,19 @@ import java.util.Set;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 家庭×食材 持久化最小值总览服务(T28)。
|
|
* 家庭×食材 持久化最小值总览服务(T28)。
|
|
|
- * 口径:某家庭成员某项食材的"最新值" = food_recommend_idx 中该 user_id+food_id 的 index_score
|
|
|
|
|
- * (recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max);
|
|
|
|
|
- * "最小值" = 该家庭可登录成员(family_members.user_id 非空且非回收箱)中对该食材有指数记录的成员取 MIN。
|
|
|
|
|
- * 无记录的成员不计入 min;全员无记录则该食材不出现在总览。
|
|
|
|
|
|
|
+ *
|
|
|
|
|
+ * 键语义(T28-FIX,AC-09 对齐):
|
|
|
|
|
+ * - food_recommend_idx.user_id 列【当前写侧语义是 memberId = family_members.id】——
|
|
|
|
|
+ * 报告确认链路 resolveSubjectToMemberId 把 subject 归一到 family_members.id,
|
|
|
|
|
+ * FoodRecommendService.recalculateForUser 原样落库。因此读侧必须按 memberId 匹配该列,
|
|
|
|
|
+ * 同时兼容历史按 users.id(family_members.user_id)写入的行(双轨查询)。
|
|
|
|
|
+ * - memberScores[].userId 字段【遗留命名债】:字段名沿用 "userId",实际存的是 memberId
|
|
|
|
|
+ * (family_members.id),不是 users.id。历史 userId 键行在归一时统一换算为成员本人 memberId。
|
|
|
|
|
+ * 后续可 DDL 改列名(food_recommend_idx.user_id → member_id)消除命名债,本轮不做列名变更。
|
|
|
|
|
+ * - 口径:某家庭成员某项食材的"最新值" = food_recommend_idx 中该 键(memberId|userId)+food_id
|
|
|
|
|
+ * 的 index_score(recalculateForUser 全量覆盖维护,天然最新,无需按时间取 max);
|
|
|
|
|
+ * "最小值" = 该家庭非回收箱成员(含纯档案成员 userId=null)中对该食材有指数记录的成员取 MIN。
|
|
|
|
|
+ * 无记录的成员不计入 min;全员无记录则该食材不出现在总览。
|
|
|
*
|
|
*
|
|
|
* 重要:本服务不标 @Transactional(与 NormalizeIndicatorPipeline 一致)——由
|
|
* 重要:本服务不标 @Transactional(与 NormalizeIndicatorPipeline 一致)——由
|
|
|
* FoodRecommendService.recalculateForUser 末尾触发时,若本服务抛异常被调用方 catch 住,
|
|
* FoodRecommendService.recalculateForUser 末尾触发时,若本服务抛异常被调用方 catch 住,
|
|
@@ -113,17 +122,17 @@ public class FamilyFoodOverviewService {
|
|
|
if (familyId == null) return 0;
|
|
if (familyId == null) return 0;
|
|
|
int changed = 0;
|
|
int changed = 0;
|
|
|
|
|
|
|
|
- // 1. 可登录成员:family_members.user_id 非空且非回收箱(成员被移出家庭后不计入,AC-10)
|
|
|
|
|
- // SQL 条件 + Java 侧二次校验(口径显式化,避免回收箱成员被计入 min)
|
|
|
|
|
|
|
+ // 1. 家庭成员:本家庭全部非回收箱成员(含纯档案成员 userId=null)。
|
|
|
|
|
+ // T28-FIX:不能再以 user_id IS NOT NULL 过滤——food_recommend_idx.user_id 当前写侧语义是
|
|
|
|
|
+ // memberId(family_members.id),纯档案成员(userId=null)也可能有 memberId 键行,必须纳入。
|
|
|
List<FamilyMember> queryMembers = familyMemberMapper.selectList(
|
|
List<FamilyMember> queryMembers = familyMemberMapper.selectList(
|
|
|
new LambdaQueryWrapper<FamilyMember>()
|
|
new LambdaQueryWrapper<FamilyMember>()
|
|
|
.eq(FamilyMember::getFamilyId, familyId)
|
|
.eq(FamilyMember::getFamilyId, familyId)
|
|
|
- .isNotNull(FamilyMember::getUserId)
|
|
|
|
|
.ne(FamilyMember::getStatus, "recycled"));
|
|
.ne(FamilyMember::getStatus, "recycled"));
|
|
|
List<FamilyMember> members = new ArrayList<>();
|
|
List<FamilyMember> members = new ArrayList<>();
|
|
|
if (queryMembers != null) {
|
|
if (queryMembers != null) {
|
|
|
for (FamilyMember m : queryMembers) {
|
|
for (FamilyMember m : queryMembers) {
|
|
|
- if (m == null || m.getUserId() == null) continue;
|
|
|
|
|
|
|
+ if (m == null) continue;
|
|
|
if ("recycled".equals(m.getStatus())) continue;
|
|
if ("recycled".equals(m.getStatus())) continue;
|
|
|
members.add(m);
|
|
members.add(m);
|
|
|
}
|
|
}
|
|
@@ -136,38 +145,52 @@ public class FamilyFoodOverviewService {
|
|
|
return changed;
|
|
return changed;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- List<Long> userIds = new ArrayList<>();
|
|
|
|
|
- Map<Long, String> nicknameByUser = new HashMap<>();
|
|
|
|
|
|
|
+ // 2. 双轨键空间:
|
|
|
|
|
+ // - memberId 键(当前写侧语义):food_recommend_idx.user_id = family_members.id
|
|
|
|
|
+ // - userId 键(历史遗留语义):food_recommend_idx.user_id = family_members.user_id(users.id)
|
|
|
|
|
+ // 两个键空间求并集一次性 IN 批量查询(不逐成员 N 次单查),memberId 键行与历史 userId 键行都能命中;
|
|
|
|
|
+ // users.id ≠ family_members.id,桥梁是 family_members.user_id(外键可空,纯档案成员为 null)。
|
|
|
|
|
+ Map<Long, FamilyMember> memberByMemberId = new HashMap<>(); // key = family_members.id(memberId)
|
|
|
|
|
+ Map<Long, FamilyMember> memberByUserId = new HashMap<>(); // key = family_members.user_id(users.id)
|
|
|
|
|
+ Set<Long> queryKeys = new HashSet<>();
|
|
|
for (FamilyMember m : members) {
|
|
for (FamilyMember m : members) {
|
|
|
- if (m.getUserId() == null) continue;
|
|
|
|
|
- userIds.add(m.getUserId());
|
|
|
|
|
- nicknameByUser.put(m.getUserId(), m.getNickname() != null ? m.getNickname() : "");
|
|
|
|
|
|
|
+ if (m.getId() != null) {
|
|
|
|
|
+ memberByMemberId.put(m.getId(), m);
|
|
|
|
|
+ queryKeys.add(m.getId());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (m.getUserId() != null) {
|
|
|
|
|
+ memberByUserId.put(m.getUserId(), m);
|
|
|
|
|
+ queryKeys.add(m.getUserId());
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- if (userIds.isEmpty()) {
|
|
|
|
|
|
|
+ if (queryKeys.isEmpty()) {
|
|
|
|
|
+ // 防御:members 非空则 id 必非空,理论不可达
|
|
|
changed += familyFoodOverviewMapper.delete(
|
|
changed += familyFoodOverviewMapper.delete(
|
|
|
new LambdaQueryWrapper<FamilyFoodOverview>()
|
|
new LambdaQueryWrapper<FamilyFoodOverview>()
|
|
|
.eq(FamilyFoodOverview::getFamilyId, familyId));
|
|
.eq(FamilyFoodOverview::getFamilyId, familyId));
|
|
|
return changed;
|
|
return changed;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 2. 批量查 food_recommend_idx(IN 批量,不逐成员单查)
|
|
|
|
|
|
|
+ // 3. 批量查 food_recommend_idx(双轨:user_id IN (memberIds ∪ userIds))
|
|
|
List<FoodRecommendIndex> indices = foodRecommendIndexMapper.selectList(
|
|
List<FoodRecommendIndex> indices = foodRecommendIndexMapper.selectList(
|
|
|
new LambdaQueryWrapper<FoodRecommendIndex>()
|
|
new LambdaQueryWrapper<FoodRecommendIndex>()
|
|
|
- .in(FoodRecommendIndex::getUserId, userIds));
|
|
|
|
|
|
|
+ .in(FoodRecommendIndex::getUserId, queryKeys));
|
|
|
|
|
|
|
|
- // 只统计本家庭可登录成员(user_id 已在 family_members 中确认)的指数行,
|
|
|
|
|
- // 双重防御:即使查询结果混入非成员行(如返回箱成员残留),也不计入 min
|
|
|
|
|
- Set<Long> memberUserIdSet = new HashSet<>(userIds);
|
|
|
|
|
|
|
+ // 只统计能归一到本家庭成员的指数行(双重防御:回收箱成员残留/他家庭数据的键都能被剔除)。
|
|
|
|
|
+ // 同一成员可能同时存在"memberId 键行"与"历史 userId 键行"(新旧键并存过渡期),
|
|
|
|
|
+ // 因此聚合去重按【成员本人】(memberId)而非按键值。
|
|
|
|
|
|
|
|
- // 3. 按 food_id 聚合(同一成员同食材多行时保留 calculated_at 最新的一行)
|
|
|
|
|
|
|
+ // 4. 按 food_id 聚合(同一成员同食材多行时保留 calculated_at 最新的一行)
|
|
|
Map<Long, List<FoodRecommendIndex>> byFood = new HashMap<>();
|
|
Map<Long, List<FoodRecommendIndex>> byFood = new HashMap<>();
|
|
|
for (FoodRecommendIndex idx : indices) {
|
|
for (FoodRecommendIndex idx : indices) {
|
|
|
- if (idx.getUserId() == null || !memberUserIdSet.contains(idx.getUserId())) continue;
|
|
|
|
|
if (idx.getFoodId() == null || idx.getIndexScore() == null) continue;
|
|
if (idx.getFoodId() == null || idx.getIndexScore() == null) continue;
|
|
|
|
|
+ Long ownerMemberId = resolveIndexOwnerId(idx.getUserId(), memberByMemberId, memberByUserId);
|
|
|
|
|
+ if (ownerMemberId == null) continue; // 非本家庭成员行
|
|
|
List<FoodRecommendIndex> rows = byFood.computeIfAbsent(idx.getFoodId(), k -> new ArrayList<>());
|
|
List<FoodRecommendIndex> rows = byFood.computeIfAbsent(idx.getFoodId(), k -> new ArrayList<>());
|
|
|
FoodRecommendIndex exist = null;
|
|
FoodRecommendIndex exist = null;
|
|
|
for (FoodRecommendIndex r : rows) {
|
|
for (FoodRecommendIndex r : rows) {
|
|
|
- if (r.getUserId() != null && r.getUserId().equals(idx.getUserId())) { exist = r; break; }
|
|
|
|
|
|
|
+ Long rMemberId = resolveIndexOwnerId(r.getUserId(), memberByMemberId, memberByUserId);
|
|
|
|
|
+ if (rMemberId != null && rMemberId.equals(ownerMemberId)) { exist = r; break; }
|
|
|
}
|
|
}
|
|
|
if (exist == null) {
|
|
if (exist == null) {
|
|
|
rows.add(idx);
|
|
rows.add(idx);
|
|
@@ -194,7 +217,7 @@ public class FamilyFoodOverviewService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 4. 逐 food_id 计算 min / member_count / member_scores / fingerprint
|
|
|
|
|
|
|
+ // 5. 逐 food_id 计算 min / member_count / member_scores / fingerprint
|
|
|
Set<Long> currentFoodIds = new HashSet<>();
|
|
Set<Long> currentFoodIds = new HashSet<>();
|
|
|
for (Map.Entry<Long, List<FoodRecommendIndex>> e : byFood.entrySet()) {
|
|
for (Map.Entry<Long, List<FoodRecommendIndex>> e : byFood.entrySet()) {
|
|
|
Long foodId = e.getKey();
|
|
Long foodId = e.getKey();
|
|
@@ -203,23 +226,30 @@ public class FamilyFoodOverviewService {
|
|
|
int min = Integer.MAX_VALUE;
|
|
int min = Integer.MAX_VALUE;
|
|
|
List<Map<String, Object>> memberScores = new ArrayList<>();
|
|
List<Map<String, Object>> memberScores = new ArrayList<>();
|
|
|
StringBuilder fpBuf = new StringBuilder();
|
|
StringBuilder fpBuf = new StringBuilder();
|
|
|
- // 按 userId 稳定排序,保证指纹确定性
|
|
|
|
|
|
|
+ // 按成员(memberId)稳定排序,保证指纹确定性(不随键是 memberId 还是 userId 而变化)
|
|
|
List<FoodRecommendIndex> sorted = new ArrayList<>(rows);
|
|
List<FoodRecommendIndex> sorted = new ArrayList<>(rows);
|
|
|
sorted.sort((a, b) -> {
|
|
sorted.sort((a, b) -> {
|
|
|
- long ua = a.getUserId() != null ? a.getUserId() : 0L;
|
|
|
|
|
- long ub = b.getUserId() != null ? b.getUserId() : 0L;
|
|
|
|
|
- return Long.compare(ua, ub);
|
|
|
|
|
|
|
+ long ma = resolveIndexOwnerId(a.getUserId(), memberByMemberId, memberByUserId) != null
|
|
|
|
|
+ ? resolveIndexOwnerId(a.getUserId(), memberByMemberId, memberByUserId) : 0L;
|
|
|
|
|
+ long mb = resolveIndexOwnerId(b.getUserId(), memberByMemberId, memberByUserId) != null
|
|
|
|
|
+ ? resolveIndexOwnerId(b.getUserId(), memberByMemberId, memberByUserId) : 0L;
|
|
|
|
|
+ return Long.compare(ma, mb);
|
|
|
});
|
|
});
|
|
|
for (FoodRecommendIndex r : sorted) {
|
|
for (FoodRecommendIndex r : sorted) {
|
|
|
Integer score = r.getIndexScore();
|
|
Integer score = r.getIndexScore();
|
|
|
if (score == null) continue;
|
|
if (score == null) continue;
|
|
|
|
|
+ Long memberId = resolveIndexOwnerId(r.getUserId(), memberByMemberId, memberByUserId);
|
|
|
|
|
+ FamilyMember member = resolveIndexOwner(r.getUserId(), memberByMemberId, memberByUserId);
|
|
|
|
|
+ if (memberId == null || member == null) continue;
|
|
|
min = Math.min(min, score);
|
|
min = Math.min(min, score);
|
|
|
Map<String, Object> detail = new LinkedHashMap<>();
|
|
Map<String, Object> detail = new LinkedHashMap<>();
|
|
|
- detail.put("userId", r.getUserId());
|
|
|
|
|
- detail.put("nickname", nicknameByUser.getOrDefault(r.getUserId(), ""));
|
|
|
|
|
|
|
+ // 语义(T28-FIX):字段名"userId"为遗留命名债,值 = memberId(family_members.id)。
|
|
|
|
|
+ // 历史 userId 键行在此统一换算为成员本人 memberId,保证 memberScores 语义一致。
|
|
|
|
|
+ detail.put("userId", memberId);
|
|
|
|
|
+ detail.put("nickname", member.getNickname() != null ? member.getNickname() : "");
|
|
|
detail.put("score", score);
|
|
detail.put("score", score);
|
|
|
memberScores.add(detail);
|
|
memberScores.add(detail);
|
|
|
- fpBuf.append(r.getUserId()).append(':').append(r.getFoodId()).append(':').append(score).append(';');
|
|
|
|
|
|
|
+ fpBuf.append(memberId).append(':').append(r.getFoodId()).append(':').append(score).append(';');
|
|
|
}
|
|
}
|
|
|
if (memberScores.isEmpty()) continue; // 全员无该食材记录 → 不出现在总览
|
|
if (memberScores.isEmpty()) continue; // 全员无该食材记录 → 不出现在总览
|
|
|
|
|
|
|
@@ -263,7 +293,7 @@ public class FamilyFoodOverviewService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 5. 删除该家庭不再出现的食材行(全量覆盖)
|
|
|
|
|
|
|
+ // 6. 删除该家庭不再出现的食材行(全量覆盖)
|
|
|
for (Map.Entry<Long, FamilyFoodOverview> e : existingMap.entrySet()) {
|
|
for (Map.Entry<Long, FamilyFoodOverview> e : existingMap.entrySet()) {
|
|
|
if (!currentFoodIds.contains(e.getKey())) {
|
|
if (!currentFoodIds.contains(e.getKey())) {
|
|
|
familyFoodOverviewMapper.deleteById(e.getValue().getId());
|
|
familyFoodOverviewMapper.deleteById(e.getValue().getId());
|
|
@@ -273,6 +303,32 @@ public class FamilyFoodOverviewService {
|
|
|
return changed;
|
|
return changed;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 将 food_recommend_idx.user_id 键归一到本家庭成员本人(返回成员实体),双轨解析:
|
|
|
|
|
+ * 1) 先按 memberId 键:user_id ∈ family_members.id(当前写侧语义);
|
|
|
|
|
+ * 2) 再按 userId 键:user_id ∈ family_members.user_id(历史遗留按 users.id 写入的行)。
|
|
|
|
|
+ * 两个键空间理论上不重叠;若出现极端碰撞(某成员 id 恰等于另一成员 userId),按 memberId 轨优先
|
|
|
|
|
+ * (与当前写侧语义一致)。归一不到任何成员(null / 回收箱残留 / 他家庭数据)返回 null。
|
|
|
|
|
+ */
|
|
|
|
|
+ private FamilyMember resolveIndexOwner(Long key,
|
|
|
|
|
+ Map<Long, FamilyMember> memberByMemberId,
|
|
|
|
|
+ Map<Long, FamilyMember> memberByUserId) {
|
|
|
|
|
+ if (key == null) return null;
|
|
|
|
|
+ FamilyMember byMemberId = memberByMemberId.get(key);
|
|
|
|
|
+ if (byMemberId != null) return byMemberId;
|
|
|
|
|
+ return memberByUserId.get(key);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 双轨归一的成员本人 id(family_members.id = memberId);归一到 → 返回 memberId,否则 null。
|
|
|
|
|
+ */
|
|
|
|
|
+ private Long resolveIndexOwnerId(Long key,
|
|
|
|
|
+ Map<Long, FamilyMember> memberByMemberId,
|
|
|
|
|
+ Map<Long, FamilyMember> memberByUserId) {
|
|
|
|
|
+ FamilyMember m = resolveIndexOwner(key, memberByMemberId, memberByUserId);
|
|
|
|
|
+ return m != null ? m.getId() : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private List<Map<String, Object>> parseMemberScores(String json) {
|
|
private List<Map<String, Object>> parseMemberScores(String json) {
|
|
|
if (json == null || json.trim().isEmpty()) return new ArrayList<>();
|
|
if (json == null || json.trim().isEmpty()) return new ArrayList<>();
|
|
|
try {
|
|
try {
|