「互选」的严格定义是两侧均为 {@code accepted},而不是「有人点了同意」。
+ * Match 的 {@code activity_mutual} 来源行只有在双确认后才创建,
+ * 但互选**对数**与后续是否走到 {@code status='successful'} 是两件事:
+ * 前者是活动互选环节的产出,后者是关系推进的产出,二者不可互相替代。
+ *
+ * 去重键:{@code id}(同一对用户在同一活动内只有一行)。
+ */
+ @Select("SELECT COUNT(*) FROM activity_mutual_connection_consents "
+ + "WHERE user_a_status = 'accepted' AND user_b_status = 'accepted' AND deleted_at IS NULL")
+ long countBothConfirmed();
+
+ /** 仅单侧表态的记录数(用于观察"已表达但未配成"的漏斗中间态)。 */
+ @Select("SELECT COUNT(*) FROM activity_mutual_connection_consents WHERE deleted_at IS NULL "
+ + "AND (user_a_status = 'accepted' OR user_b_status = 'accepted') "
+ + "AND NOT (user_a_status = 'accepted' AND user_b_status = 'accepted')")
+ long countOneSidedAccepted();
}
diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java b/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java
index ef01e16..fdf06b4 100644
--- a/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java
+++ b/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java
@@ -1,41 +1,30 @@
package com.danyang.match.platform.analytics;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.danyang.match.platform.match.entity.RecommendationRecord;
-import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
-import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
+import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
+import com.danyang.match.platform.analytics.metric.AnalyticsMetricsCalculator;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.mapper.ConversationMapper;
+import com.danyang.match.platform.content.entity.ContentArticle;
+import com.danyang.match.platform.content.mapper.ContentArticleMapper;
+import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
+import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
+import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
+import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
+import com.danyang.match.platform.match.entity.Match;
+import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
-import com.danyang.match.platform.match.entity.Match;
-import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
-import com.danyang.match.platform.match.mapper.MatchMapper;
-import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
import com.danyang.match.platform.risk.entity.Report;
import com.danyang.match.platform.risk.mapper.ReportMapper;
-import com.danyang.match.platform.risk.entity.RiskAction;
-import com.danyang.match.platform.risk.mapper.RiskActionMapper;
-import com.danyang.match.platform.shared.constant.Constants;
+import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
+import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
+import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
+import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
+import com.danyang.match.platform.shared.exception.BusinessException;
+import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
-import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
-import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
-import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
-import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
-import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
-import com.danyang.match.platform.content.mapper.ContentArticleMapper;
-import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
-import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
-import com.danyang.match.platform.activity.mapper.ActivityMapper;
-import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
-import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
-import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
-import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
-import com.danyang.match.platform.content.entity.ContentArticle;
-import com.danyang.match.platform.service_program.entity.ServiceProgram;
-import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
-import com.danyang.match.platform.activity.entity.ActivityRegistration;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -43,43 +32,70 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
-import java.util.stream.Collectors;
/**
- * Analytics 服务
- * 提供:Dashboard / 用户漏斗 / Match漏斗 / 每日汇总 / 趋势图数据
+ * Analytics 服务 —— 对外提供各统计视图的数据装配。
+ *
+ *
Phase 20 重构说明:本类不再**自己**计算任何指标数字,
+ * 一律委托 {@link AnalyticsMetricsCalculator}(唯一口径来源)。
+ *
+ *
修复前的问题:Dashboard / 用户漏斗 / V11 专项仪表盘各自实现了一遍同名指标,
+ * 且互不相同:
+ *
+ * - "实名人数"被算成 {@code profile_status != 'rejected'}(资料没被退回),
+ * 把「资料还在填」和「被退回待改」都算成了已实名;
+ * - "开启聊天数"直接数会话行数,从不看消息类型,一条系统通知即计入;
+ * - "Match 数"混用全部来源,导致活动互选、红娘促成、用户自主匹配
+ * 被当成同一个数字,Gate 要求的「10 对互选 ≠ 10 对 Success」无法体现;
+ * - 活动漏斗全部硬编码为 0,后台永远显示 0。
+ *
+ *
+ * 字段名兼容性:admin-web 已消费的响应字段名({@code verifiedCount} /
+ * {@code totalLiked} / {@code chatsStarted} 等)一律保留,改动只发生在**取值来源**上,
+ * 不破坏前端契约。新增的按来源分离字段为增量添加。
*/
@Service
@RequiredArgsConstructor
public class AnalyticsService {
+ /** 唯一口径来源。 */
+ private final AnalyticsMetricsCalculator metrics;
+
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final MatchMapper matchMapper;
- private final RelationshipSuccessRequestMapper successRequestMapper;
private final ReportMapper reportMapper;
- private final RiskActionMapper riskActionMapper;
- private final RecommendationRecordMapper recommendationRecordMapper;
private final ConversationMapper conversationMapper;
- private final MatchmakerServiceMapper matchmakerServiceMapper;
- private final HomeBannerMapper homeBannerMapper;
private final CounselingServiceMapper counselingServiceMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ServiceAppointmentMapper serviceAppointmentMapper;
- private final ActivityMutualConsentMapper activityMutualConsentMapper;
private final ContentArticleMapper contentArticleMapper;
- private final ServiceProgramMapper serviceProgramMapper;
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
private final ActivityRegistrationMapper activityRegistrationMapper;
// ==================== Dashboard ====================
+ /**
+ * 综合看板。
+ *
+ *
与 {@link #getUserFunnel} / {@link #getMatchFunnel} 共用同一批口径,
+ * 确保 Gate「Dashboard 和专项页面相同指标必须一致」成立。
+ */
public Map getDashboardStats() {
- Map stats = new HashMap<>();
- stats.putAll(userBasicStats());
- stats.putAll(matchFunnelStats());
- stats.putAll(safetyStats());
+ Map stats = new LinkedHashMap<>();
+ stats.put("totalUsers", metrics.registeredUsers());
+ stats.put("verifiedUsers", metrics.realNameVerifiedUsers());
+ stats.put("approvedProfiles", metrics.profileApprovedUsers());
+ stats.put("activeUsers7d", metrics.activeUsers7d());
+
+ stats.put("totalLikes", metrics.likes());
+ stats.put("totalMatches", metrics.matches());
+ stats.put("chatsStarted", metrics.chatsStarted());
+ stats.put("gettingToKnow", matchMapper.countByStatus("getting_to_know"));
+ stats.put("successPairs", metrics.successfulMatches());
+
stats.putAll(activityFunnelStats());
+ stats.putAll(safetyStats());
return stats;
}
@@ -88,45 +104,46 @@ public class AnalyticsService {
public Map getUserFunnel(LocalDate since, LocalDate until) {
if (since == null) since = LocalDate.now().minusMonths(1);
if (until == null) until = LocalDate.now();
- Map result = new HashMap<>();
+ Map result = new LinkedHashMap<>();
- // 各阶段总量
- long totalRegistered = userMapper.selectCount(null);
- long verifiedCount = userProfileMapper.selectCount(
- new LambdaQueryWrapper()
- .ne(UserProfile::getProfileStatus, "rejected"));
- long profileApproved = userProfileMapper.selectCount(
- new LambdaQueryWrapper()
- .eq(UserProfile::getProfileStatus, "approved"));
+ long totalRegistered = metrics.registeredUsers();
+ // Phase 20 D1:实名口径改为 user_identities.verification_status='verified'。
+ long verifiedCount = metrics.realNameVerifiedUsers();
+ long profileApproved = metrics.profileApprovedUsers();
+ // activeUsers 在本视图中的语义是「所选时间范围内登录过」,
+ // 因此使用范围起点而非固定 7 天窗口(与 Dashboard 的 activeUsers7d 语义不同,勿混用)。
+ // deleted_at 必须显式过滤:BaseEntity 无 @TableLogic,selectCount 不会自动排除软删。
long activeUsers = userMapper.selectCount(
new LambdaQueryWrapper()
- .ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant()));
+ .ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant())
+ .isNull(User::getDeletedAt));
result.put("totalRegistered", totalRegistered);
result.put("verifiedCount", verifiedCount);
result.put("profileApproved", profileApproved);
result.put("activeUsers", activeUsers);
- // 转化率
- result.put("verifyRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * verifiedCount / totalRegistered) : "0%");
- result.put("approvalRate", verifiedCount > 0 ? String.format("%.1f%%", 100.0 * profileApproved / verifiedCount) : "0%");
- result.put("activeRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * activeUsers / totalRegistered) : "0%");
+ result.put("verifyRate", rate(verifiedCount, totalRegistered));
+ result.put("approvalRate", rate(profileApproved, verifiedCount));
+ result.put("activeRate", rate(activeUsers, totalRegistered));
- // 每日新增趋势
- List