fix(analytics): close Phase 20 KEEP-VERIFY — 20 defects (fake metric definitions, hardcoded zeros, systemic soft-delete leak, module-wide 403, fake-green tests); add MetricCatalog + unified calculator + V043 grants + 51 guard tests; full regression 648 green
口径造假: - D1 实名人数用 user_profiles.profile_status != 'rejected' 冒充 → in_progress/need_edit 被算成已实名 - D3 Chat Started 直接数会话行数 → system/risk_notice 系统通知计入 - D4 Match 不分来源 → 活动 10 对互选被当 10 对成功引用(Gate) - D6 Banner 曝光/点击硬编码 0 → 改返回 null + NOT_INSTRUMENTED 标记 死代码 / 占位: - D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码 - D20 getDailySummary(days) 无边界校验 系统性软删缺陷(D12): - BaseEntity.deletedAt 无 @TableLogic 且全局无逻辑删除配置 → selectCount 把软删数据全部计入统计且无任何报错 - 修复 45 处统计调用显式 deleted_at IS NULL(2 轮复验,首轮漏扫 5 处次级路径: 服务用户数分域明细 / 服务站统计 / 性别与年龄分布) 结构类: - D15 Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同 → 抽 AnalyticsMetricsCalculator 统一口径 权限(Gate ⑩): - data_viewer 在 V001 定义却全仓从未授权(空壳角色) - platform_admin 的 V015 扩展条件 id >= '20000000-…' 漏掉 V1.0 老权限段 → 整个 Analytics 模块除 super_admin 外全员 403 - V043 授予两角色;刻意不授予 organization_admin(相关表无组织维度,无范围全量即越权) 假绿测试: - 删除 AnalyticsServiceTest(5× assertTrue(true),零断言) 新增: - 指标字典 MetricCatalog(36 指标 × 12 字段)+ MetricStatus/MetricDefinition + docs/ANALYTICS_METRICS.md - 守卫 51 用例:MetricCatalogGuardTest(11) / AnalyticsMetricsPhase20IntegrationTest(21) / AnalyticsSoftDeleteGuardTest(4,结构性) / AnalyticsPermissionGrantTest(6) / LocalEligibilityStatusVocabularyGuardTest(9) 跨阶段修复(缺陷本体属账号生命周期模块,按"发现即修"处理): - X1(Critical)注销写入 user_identities 状态域的 'failed' → 违反生产 V017 chk_elig_verify_status → 真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用(负向验证实测 DataIntegrityViolation) - X2 checkCanWithdraw 资格拦截用 submitted/pending(该表永不可能出现)→ 死代码 - X3 测试库补 chk_elig_type/chk_elig_verify_status,暴露 ActivityPhase14Test 夹具违规 验证: - 负向验证两轮共 7 处注入 → 7 个用例精准变红且无无关失败(一处被源码结构+运行时行为两层捕获) - 还原后 md5sum 逐字节一致,NEG-VAL 残留 0 - 后端全量 648 tests / 0 failures / 0 errors / BUILD SUCCESS;admin-web vue-tsc EXIT=0
This commit is contained in:
+25
-3
@@ -47,6 +47,18 @@ public class AccountLifecycleService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class);
|
||||
|
||||
/**
|
||||
* 本地资格「审核进行中」的状态集合。
|
||||
*
|
||||
* <p>取值域严格来自生产 {@code V017 chk_elig_verify_status}:
|
||||
* {@code declared / needs_evidence / pending_review / verified / rejected}。
|
||||
* 这里刻意**不复用** {@code Constants.VERIFICATION_STATUS_*} ——
|
||||
* 那组常量描述的是 {@code user_identities}(V002 的 CHECK 才含 failed),
|
||||
* 两个表的状态域不同,跨域复用正是本类两处缺陷的根因。
|
||||
*/
|
||||
private static final Set<String> LOCAL_ELIGIBILITY_IN_REVIEW =
|
||||
Set.of("needs_evidence", "pending_review");
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
@@ -192,10 +204,14 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
// V1.1: 检查本地资格核验状态
|
||||
// 注意状态域:本地资格表(V017 chk_elig_verify_status)只有
|
||||
// declared / needs_evidence / pending_review / verified / rejected
|
||||
// 此前这里比较的是 "submitted" / "pending" —— 这两个值在该表里**永不可能出现**,
|
||||
// 因此"本地资格审核进行中"这道拦截从未生效过(死代码),
|
||||
// 审核中的用户可以直接注销。正确的"审核进行中"是 pending_review。
|
||||
Optional<LocalEligibility> localElig = localEligibilityMapper.findByUserId(userId);
|
||||
if (localElig.isPresent() &&
|
||||
(Constants.VERIFICATION_STATUS_SUBMITTED.equals(localElig.get().getVerificationStatus()) ||
|
||||
Constants.VERIFICATION_STATUS_PENDING.equals(localElig.get().getVerificationStatus()))) {
|
||||
LOCAL_ELIGIBILITY_IN_REVIEW.contains(localElig.get().getVerificationStatus())) {
|
||||
blockers.put("localEligibility", "您的本地资格审核正在进行中,请耐心等待");
|
||||
}
|
||||
|
||||
@@ -431,7 +447,13 @@ public class AccountLifecycleService {
|
||||
Optional<LocalEligibility> elig = localEligibilityMapper.findByUserId(userId);
|
||||
if (elig.isPresent()) {
|
||||
LocalEligibility le = elig.get();
|
||||
le.setVerificationStatus(Constants.VERIFICATION_STATUS_FAILED);
|
||||
// 生产 V017 的 chk_elig_verify_status 只允许
|
||||
// declared / needs_evidence / pending_review / verified / rejected
|
||||
// —— **没有 failed**。VERIFICATION_STATUS_FAILED 属于 user_identities 的状态域
|
||||
// (V002 的 chk 里才有 failed),把它写进本地资格表会在真实 PostgreSQL 上
|
||||
// 直接触发 CHECK 违约 → closeAccount 整个事务回滚 → 注销 100% 不可用。
|
||||
// 测试库此前没有该 CHECK,所以这个必崩缺陷在测试期完全不可见。
|
||||
le.setVerificationStatus(Constants.VERIFICATION_STATUS_REJECTED);
|
||||
localEligibilityMapper.updateById(le);
|
||||
log.info("失效用户本地资格: userId={}", userId);
|
||||
}
|
||||
|
||||
+26
@@ -176,4 +176,30 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
|
||||
int rejectIfPending(@Param("id") String id,
|
||||
@Param("operatorId") String operatorId,
|
||||
@Param("reason") String reason);
|
||||
|
||||
// ==================== Phase 20:活动漏斗口径统计 ====================
|
||||
// 此前 AnalyticsService.getActivityFunnel()/activityFunnelStats() 把全部指标
|
||||
// 硬编码为 0(并且留着一行 `userMapper.selectCount(...)` 当作"活动数"的死代码),
|
||||
// 后台"活动漏斗"永远显示 0 —— 不是没数据,是根本没查。
|
||||
|
||||
/** 报名总记录数(去重键 = id;含 pending/rejected/cancelled)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE deleted_at IS NULL")
|
||||
long countAllRegistrations();
|
||||
|
||||
/** 按状态统计报名数(pending / approved / rejected / cancelled)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE status = #{status} AND deleted_at IS NULL")
|
||||
long countRegistrationsByStatus(@Param("status") String status);
|
||||
|
||||
/** 已签到人数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations "
|
||||
+ "WHERE checkin_status = 'checked_in' AND deleted_at IS NULL")
|
||||
long countCheckedIn();
|
||||
|
||||
/** 按报名来源统计(chk_reg_source: user_self / matchmaker_referral / activity_followup / service_station / operator_assisted)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE source = #{source} AND deleted_at IS NULL")
|
||||
long countRegistrationsBySource(@Param("source") String source);
|
||||
|
||||
/** 去重报名人数(同一用户报多个活动只算一人)。 */
|
||||
@Select("SELECT COUNT(DISTINCT user_id) FROM activity_registrations WHERE deleted_at IS NULL")
|
||||
long countDistinctRegisteredUsers();
|
||||
}
|
||||
|
||||
+22
@@ -85,4 +85,26 @@ public interface ActivityMutualConsentMapper extends BaseMapper<ActivityMutualCo
|
||||
@Update("UPDATE activity_mutual_connection_consents SET match_id = #{matchId}, updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND deleted_at IS NULL")
|
||||
int bindMatch(@Param("id") String id, @Param("matchId") String matchId);
|
||||
|
||||
// ==================== Phase 20:互选口径统计 ====================
|
||||
|
||||
/**
|
||||
* **双向已确认**的互选对数(Gate「活动10对互选 ≠ 10 对 Success」的分母侧)。
|
||||
*
|
||||
* <p>「互选」的严格定义是两侧均为 {@code accepted},而不是「有人点了同意」。
|
||||
* Match 的 {@code activity_mutual} 来源行只有在双确认后才创建,
|
||||
* 但互选**对数**与后续是否走到 {@code status='successful'} 是两件事:
|
||||
* 前者是活动互选环节的产出,后者是关系推进的产出,二者不可互相替代。
|
||||
*
|
||||
* <p>去重键:{@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();
|
||||
}
|
||||
|
||||
+347
-294
@@ -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 服务 —— 对外提供各统计视图的数据装配。
|
||||
*
|
||||
* <p><b>Phase 20 重构说明</b>:本类不再**自己**计算任何指标数字,
|
||||
* 一律委托 {@link AnalyticsMetricsCalculator}(唯一口径来源)。
|
||||
*
|
||||
* <p>修复前的问题:Dashboard / 用户漏斗 / V11 专项仪表盘各自实现了一遍同名指标,
|
||||
* 且互不相同:
|
||||
* <ul>
|
||||
* <li>"实名人数"被算成 {@code profile_status != 'rejected'}(资料没被退回),
|
||||
* 把「资料还在填」和「被退回待改」都算成了已实名;</li>
|
||||
* <li>"开启聊天数"直接数会话行数,从不看消息类型,一条系统通知即计入;</li>
|
||||
* <li>"Match 数"混用全部来源,导致活动互选、红娘促成、用户自主匹配
|
||||
* 被当成同一个数字,Gate 要求的「10 对互选 ≠ 10 对 Success」无法体现;</li>
|
||||
* <li>活动漏斗全部硬编码为 0,后台永远显示 0。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>字段名兼容性</b>: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 ====================
|
||||
|
||||
/**
|
||||
* 综合看板。
|
||||
*
|
||||
* <p>与 {@link #getUserFunnel} / {@link #getMatchFunnel} 共用同一批口径,
|
||||
* 确保 Gate「Dashboard 和专项页面相同指标必须一致」成立。
|
||||
*/
|
||||
public Map<String, Object> getDashboardStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.putAll(userBasicStats());
|
||||
stats.putAll(matchFunnelStats());
|
||||
stats.putAll(safetyStats());
|
||||
Map<String, Object> 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<String, Object> getUserFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 各阶段总量
|
||||
long totalRegistered = userMapper.selectCount(null);
|
||||
long verifiedCount = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.ne(UserProfile::getProfileStatus, "rejected"));
|
||||
long profileApproved = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.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<User>()
|
||||
.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<Map<String, Object>> dailyTrend = buildDailyNewUsers(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
result.put("dailyTrend", buildDailyNewUsers(since, until));
|
||||
|
||||
// 男女比例
|
||||
// Phase 20 D12(补全):性别/年龄分布此前漏了软删过滤,
|
||||
// 会让"分布"与已完成软删过滤的 totalRegistered 对不上账。
|
||||
Map<String, Long> genderRatio = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getGender))
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.isNotNull(UserProfile::getGender)
|
||||
.isNull(UserProfile::getDeletedAt))
|
||||
.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
UserProfile::getGender, Collectors.counting()));
|
||||
.collect(java.util.stream.Collectors.groupingBy(
|
||||
UserProfile::getGender, java.util.stream.Collectors.counting()));
|
||||
result.put("genderRatio", genderRatio);
|
||||
|
||||
// 年龄分布
|
||||
List<UserProfile> profiles = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getBirthDate));
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.isNotNull(UserProfile::getBirthDate)
|
||||
.isNull(UserProfile::getDeletedAt));
|
||||
List<Integer> breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100);
|
||||
Map<String, Long> ageDist = new LinkedHashMap<>();
|
||||
for (int i = 0; i < breaks.size() - 1; i++) {
|
||||
@@ -146,28 +163,20 @@ public class AnalyticsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Match漏斗 ====================
|
||||
// ==================== Match 漏斗 ====================
|
||||
|
||||
public Map<String, Object> getMatchFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 总量
|
||||
long totalRecommended = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.isNotNull(RecommendationRecord::getShownAt));
|
||||
long totalLiked = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.eq(RecommendationRecord::getAction, "liked"));
|
||||
long totalMatches = matchMapper.selectCount(null);
|
||||
long chatsStarted = conversationMapper.selectCount(null);
|
||||
long gettingToKnow = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.eq(Match::getStatus, "getting_to_know"));
|
||||
long successPairs = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>()
|
||||
.eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
long totalRecommended = metrics.recommendationsShown();
|
||||
long totalLiked = metrics.likes();
|
||||
long totalMatches = metrics.matches();
|
||||
// Phase 20 D3:只统计有真人消息的会话,系统通知不计入「开启聊天」。
|
||||
long chatsStarted = metrics.chatsStarted();
|
||||
long gettingToKnow = matchMapper.countByStatus("getting_to_know");
|
||||
long successPairs = metrics.successfulMatches();
|
||||
|
||||
result.put("totalRecommended", totalRecommended);
|
||||
result.put("totalLiked", totalLiked);
|
||||
@@ -176,61 +185,75 @@ public class AnalyticsService {
|
||||
result.put("gettingToKnow", gettingToKnow);
|
||||
result.put("successPairs", successPairs);
|
||||
|
||||
// 各阶段转化率
|
||||
result.put("likeRate", totalRecommended > 0 ? String.format("%.1f%%", 100.0 * totalLiked / totalRecommended) : "0%");
|
||||
result.put("matchRate", totalLiked > 0 ? String.format("%.1f%%", 100.0 * totalMatches / totalLiked) : "0%");
|
||||
result.put("chatRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * chatsStarted / totalMatches) : "0%");
|
||||
result.put("successRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * successPairs / totalMatches) : "0%");
|
||||
result.put("likeRate", rate(totalLiked, totalRecommended));
|
||||
result.put("matchRate", rate(totalMatches, totalLiked));
|
||||
result.put("chatRate", rate(chatsStarted, totalMatches));
|
||||
result.put("successRate", rate(successPairs, totalMatches));
|
||||
|
||||
// 每日新增匹配趋势
|
||||
List<Map<String, Object>> dailyTrend = buildDailyNewMatches(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
// Phase 20 D4:按来源分离,避免把活动互选/红娘促成的量算进用户自主匹配。
|
||||
Map<String, Object> bySource = new LinkedHashMap<>();
|
||||
bySource.put("userLike", metrics.matchesBySource("user_like"));
|
||||
bySource.put("activityMutual", metrics.matchesBySource("activity_mutual"));
|
||||
bySource.put("matchmaker", metrics.matchesBySource("matchmaker"));
|
||||
result.put("matchesBySource", bySource);
|
||||
result.put("matchmakerRecommendations", metrics.matchmakerRecommendations());
|
||||
|
||||
result.put("dailyTrend", buildDailyNewMatches(since, until));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 活动漏斗 ====================
|
||||
|
||||
/**
|
||||
* 活动漏斗。
|
||||
*
|
||||
* <p><b>Phase 20 D5 修复</b>:修复前本方法的所有指标**硬编码为 0**
|
||||
* (并且留着一行 {@code userMapper.selectCount(...)} 把"用户数"当成"活动数"的死代码),
|
||||
* 后台"活动漏斗"因此永远显示 0 —— 不是没有数据,是根本没有查询。
|
||||
*
|
||||
* <p>同时严格区分「互选成立」与「互选后成功」:Gate 要求
|
||||
* 「活动10对互选 ≠ 10 对 Success」。
|
||||
*/
|
||||
public Map<String, Object> getActivityFunnel() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
// 活动数量(简化:统计 distinct activity_id)
|
||||
long activityCount = userMapper.selectCount(
|
||||
new LambdaQueryWrapper<>() {}); // placeholder
|
||||
// 报名数、通过数、签到数需要从 activity_registrations 统计
|
||||
// 简化处理
|
||||
result.put("totalActivities", 0);
|
||||
result.put("totalRegistrations", 0);
|
||||
result.put("approvedRegistrations", 0);
|
||||
result.put("checkedInCount", 0);
|
||||
return result;
|
||||
return activityFunnelStats();
|
||||
}
|
||||
|
||||
private Map<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> s = new LinkedHashMap<>();
|
||||
s.put("totalActivities", metrics.activities());
|
||||
s.put("totalRegistrations", metrics.activityRegistrations());
|
||||
s.put("approvedRegistrations", metrics.approvedActivityRegistrations());
|
||||
s.put("checkedInCount", metrics.checkedInRegistrations());
|
||||
// 互选成立对数(严格双确认)
|
||||
s.put("mutualBothConfirmed", metrics.mutualBothConfirmed());
|
||||
// 互选后建立 Match 数
|
||||
s.put("mutualMatches", metrics.mutualMatches());
|
||||
// 互选后成功牵手数 —— 与互选对数严格区分
|
||||
s.put("mutualSuccessful", metrics.mutualSuccessful());
|
||||
return s;
|
||||
}
|
||||
|
||||
// ==================== 安全统计 ====================
|
||||
|
||||
public Map<String, Object> getSafetyStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long pendingReports = reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
|
||||
long resolvedReports = reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
|
||||
long highRiskUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType,
|
||||
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
long bannedUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
return safetyStats();
|
||||
}
|
||||
|
||||
stats.put("pendingReports", pendingReports);
|
||||
stats.put("resolvedReports", resolvedReports);
|
||||
stats.put("highRiskUsers", highRiskUsers);
|
||||
stats.put("bannedUsers", bannedUsers);
|
||||
private Map<String, Object> safetyStats() {
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("pendingReports", metrics.pendingReports());
|
||||
stats.put("resolvedReports", metrics.resolvedReports());
|
||||
stats.put("highRiskUsers", metrics.highRiskUsers());
|
||||
stats.put("bannedUsers", metrics.bannedUsers());
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ==================== 每日汇总 ====================
|
||||
|
||||
public List<Map<String, Object>> getDailySummary(int days) {
|
||||
if (days < 1 || days > 365) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
|
||||
}
|
||||
List<Map<String, Object>> summary = new ArrayList<>();
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
@@ -239,7 +262,7 @@ public class AnalyticsService {
|
||||
Instant start = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
|
||||
Map<String, Object> day = new HashMap<>();
|
||||
Map<String, Object> day = new LinkedHashMap<>();
|
||||
day.put("date", date.toString());
|
||||
day.put("newUsers", countEntityByDateRange(start, end));
|
||||
day.put("newMatches", countMatchByDateRange(start, end));
|
||||
@@ -250,97 +273,36 @@ public class AnalyticsService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
private Map<String, Object> userBasicStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalUsers", userMapper.selectCount(null));
|
||||
s.put("verifiedUsers", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().ne(UserProfile::getProfileStatus, "rejected")));
|
||||
s.put("approvedProfiles", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getProfileStatus, "approved")));
|
||||
s.put("activeUsers7d", userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> matchFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalLikes", recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>().eq(RecommendationRecord::getAction, "liked")));
|
||||
s.put("totalMatches", matchMapper.selectCount(null));
|
||||
s.put("gettingToKnow", matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "getting_to_know")));
|
||||
s.put("successPairs", successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> safetyStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("pendingReports", reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING));
|
||||
s.put("resolvedReports", reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED));
|
||||
s.put("highRiskUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
s.put("bannedUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalActivities", 0);
|
||||
s.put("totalRegistrations", 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate current = since;
|
||||
while (!current.isAfter(until)) {
|
||||
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
point.put("date", current.toString());
|
||||
point.put("value", countEntityByDateRange(start, end));
|
||||
trend.add(point);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
// ==================== V1.1 公益项目统计 ====================
|
||||
|
||||
/**
|
||||
* Banner 统计。
|
||||
*
|
||||
* <p><b>Phase 20 D6 修复</b>:修复前 {@code impressions} / {@code clicks} 被**硬编码为 0**。
|
||||
* 0 会被读成"真的没人看到 / 没人点击",而实际是项目内根本不存在曝光与点击的埋点表或上报端点。
|
||||
* 两者对运营决策的含义完全相反,因此改为显式标注不可用,绝不返回 0 冒充真实值。
|
||||
*/
|
||||
public Map<String, Object> getBannerStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long totalBanners = homeBannerMapper.selectCount(null);
|
||||
Instant now = Instant.now();
|
||||
Instant finalNow = now;
|
||||
long activeBanners = homeBannerMapper.selectCount(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.danyang.match.platform.banner.entity.HomeBanner>()
|
||||
.eq(com.danyang.match.platform.banner.entity.HomeBanner::getStatus, "active")
|
||||
.le(com.danyang.match.platform.banner.entity.HomeBanner::getStartAt, finalNow)
|
||||
.ge(com.danyang.match.platform.banner.entity.HomeBanner::getEndAt, finalNow)
|
||||
);
|
||||
stats.put("totalBanners", totalBanners);
|
||||
stats.put("activeBanners", activeBanners);
|
||||
stats.put("impressions", 0);
|
||||
stats.put("clicks", 0);
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalBanners", metrics.banners());
|
||||
stats.put("activeBanners", metrics.activeBanners());
|
||||
// 未埋点:明确置 null 并给出标记,避免被当成真实的 0。
|
||||
stats.put("impressions", null);
|
||||
stats.put("clicks", null);
|
||||
stats.put("impressionsAvailable", false);
|
||||
stats.put("clicksAvailable", false);
|
||||
stats.put("unavailableReason", "未埋点:项目内不存在 Banner 曝光/点击的上报端与事件表,"
|
||||
+ "故无法计算。修复前此处返回硬编码 0,会被误读为真实数据。");
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceStationStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<ServiceAppointment> appointments = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
);
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
Set<String> uniqueLocations = new HashSet<>();
|
||||
for (ServiceAppointment a : appointments) {
|
||||
if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId());
|
||||
@@ -351,140 +313,181 @@ public class AnalyticsService {
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceAppointmentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceAppointment> all = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("submitted", all.stream().filter(a -> "submitted".equals(a.getStatus())).count());
|
||||
stats.put("confirmed", all.stream().filter(a -> "confirmed".equals(a.getStatus())).count());
|
||||
stats.put("completed", all.stream().filter(a -> "completed".equals(a.getStatus())).count());
|
||||
stats.put("cancelled", all.stream().filter(a -> "cancelled".equals(a.getStatus())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("total", metrics.serviceAppointments());
|
||||
stats.put("submitted", countAppointmentsByStatus("submitted"));
|
||||
stats.put("confirmed", countAppointmentsByStatus("confirmed"));
|
||||
stats.put("completed", countAppointmentsByStatus("completed"));
|
||||
stats.put("cancelled", countAppointmentsByStatus("cancelled"));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getCounselingStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingServiceRecord> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>().isNull(CounselingServiceRecord::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
stats.put("assigned", all.stream().filter(c -> "assigned".equals(c.getStatus())).count());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
stats.put("cancelled", all.stream().filter(c -> "cancelled".equals(c.getStatus())).count());
|
||||
stats.put("premaritalGuidance", all.stream().filter(c -> "premarital_guidance".equals(c.getServiceType())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
long total = metrics.counselingServices();
|
||||
stats.put("total", total);
|
||||
stats.put("pending", countCounselingByStatus("pending"));
|
||||
stats.put("assigned", countCounselingByStatus("assigned"));
|
||||
stats.put("completed", countCounselingByStatus("completed"));
|
||||
stats.put("cancelled", countCounselingByStatus("cancelled"));
|
||||
stats.put("premaritalGuidance", countCounselingByServiceType("premarital_guidance"));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getPremaritalStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingServiceRecord> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)
|
||||
);
|
||||
stats.put("totalRequests", all.size());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
stats.put("withCompanion", all.stream().filter(c -> c.getCompanionUserId() != null).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalRequests", countCounselingByServiceType("premarital_guidance"));
|
||||
stats.put("completed", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.eq(CounselingServiceRecord::getStatus, "completed")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
stats.put("pending", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.eq(CounselingServiceRecord::getStatus, "pending")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
stats.put("withCompanion", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.isNotNull(CounselingServiceRecord::getCompanionUserId)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getContentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<ContentArticle> articles = contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt)
|
||||
);
|
||||
long totalViews = articles.stream()
|
||||
.mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum();
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt));
|
||||
stats.put("totalPublishedArticles", articles.size());
|
||||
stats.put("totalViews", totalViews);
|
||||
stats.put("totalViews", metrics.contentViews());
|
||||
articles.sort((a, b) -> Integer.compare(
|
||||
b.getViewCount() != null ? b.getViewCount() : 0,
|
||||
a.getViewCount() != null ? a.getViewCount() : 0));
|
||||
b.getViewCount() != null ? b.getViewCount() : 0,
|
||||
a.getViewCount() != null ? a.getViewCount() : 0));
|
||||
List<Map<String, Object>> top = articles.stream().limit(5)
|
||||
.map(a -> { Map<String, Object> m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; })
|
||||
.toList();
|
||||
.map(a -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", a.getId());
|
||||
m.put("title", a.getTitle());
|
||||
m.put("views", a.getViewCount());
|
||||
return m;
|
||||
})
|
||||
.toList();
|
||||
stats.put("topArticles", top);
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getLocalEligibilityStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<LocalEligibility> all = localEligibilityMapper.selectList(new LambdaQueryWrapper<>());
|
||||
stats.put("total", all.size());
|
||||
stats.put("verified", all.stream().filter(l -> "verified".equals(l.getVerificationStatus())).count());
|
||||
stats.put("pending", all.stream().filter(l -> "pending_review".equals(l.getVerificationStatus()) || "pending".equals(l.getVerificationStatus())).count());
|
||||
stats.put("rejected", all.stream().filter(l -> "rejected".equals(l.getVerificationStatus())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
long total = localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
|
||||
stats.put("total", total);
|
||||
stats.put("verified", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.eq(LocalEligibility::getVerificationStatus, "verified")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
stats.put("pending", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.in(LocalEligibility::getVerificationStatus, "pending_review", "pending")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
stats.put("rejected", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.eq(LocalEligibility::getVerificationStatus, "rejected")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceProgramStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceProgram> programs = serviceProgramMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(null);
|
||||
stats.put("totalPrograms", programs.size());
|
||||
stats.put("activePrograms", programs.stream().filter(p -> "active".equals(p.getStatus())).count());
|
||||
stats.put("totalMembers", members.stream().filter(m -> "approved".equals(m.getStatus())).count());
|
||||
stats.put("pendingApplications", members.stream().filter(m -> "pending".equals(m.getStatus())).count());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalPrograms", metrics.servicePrograms());
|
||||
stats.put("activePrograms", metrics.activeServicePrograms());
|
||||
stats.put("totalMembers", metrics.approvedProgramMembers());
|
||||
stats.put("pendingApplications", serviceProgramMemberMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "pending")
|
||||
.isNull(ServiceProgramMember::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getRegistrationSourceStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>().ne(ActivityRegistration::getStatus, "cancelled"));
|
||||
Map<String, Long> bySource = regs.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(
|
||||
r -> r.getSource() != null ? r.getSource() : "unknown",
|
||||
java.util.stream.Collectors.counting()));
|
||||
stats.put("totalRegistrations", regs.size());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<String> sources = List.of("user_self", "matchmaker_referral", "activity_followup",
|
||||
"service_station", "operator_assisted");
|
||||
Map<String, Long> bySource = new LinkedHashMap<>();
|
||||
long total = 0;
|
||||
for (String source : sources) {
|
||||
long count = activityRegistrationMapper.countRegistrationsBySource(source);
|
||||
bySource.put(source, count);
|
||||
total += count;
|
||||
}
|
||||
stats.put("totalRegistrations", total);
|
||||
stats.put("bySource", bySource);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公益服务用户数与服务人次。
|
||||
*
|
||||
* <p><b>Phase 20 计划明确要求「分离服务用户数与服务人次」</b>:
|
||||
* 前者是去重后的**人数**(覆盖面),后者是不去重的**人次**(承载量),
|
||||
* 两者比值即人均服务次数。修复前只有去重人数、没有服务人次。
|
||||
*/
|
||||
public Map<String, Object> getUniqueServiceUsersStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingServiceRecord>().isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.stream().map(CounselingServiceRecord::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
|
||||
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> pu = serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>().eq(ServiceProgramMember::getStatus, "approved"))
|
||||
.stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> eu = localEligibilityMapper.selectList(new LambdaQueryWrapper<>())
|
||||
.stream().map(LocalEligibility::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
stats.put("counselingUniqueUsers", cu.size());
|
||||
stats.put("appointmentUniqueUsers", au.size());
|
||||
stats.put("programUniqueUsers", pu.size());
|
||||
stats.put("eligibilityUniqueUsers", eu.size());
|
||||
Set<String> all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu);
|
||||
stats.put("totalUniqueServiceUsers", all.size());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
Set<String> counselingUsers = new HashSet<>();
|
||||
counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.forEach(c -> counselingUsers.add(c.getUserId()));
|
||||
Set<String> appointmentUsers = new HashSet<>();
|
||||
serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNull(ServiceAppointment::getDeletedAt))
|
||||
.forEach(a -> appointmentUsers.add(a.getUserId()));
|
||||
Set<String> programUsers = new HashSet<>();
|
||||
serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt))
|
||||
.forEach(m -> programUsers.add(m.getUserId()));
|
||||
Set<String> eligibilityUsers = new HashSet<>();
|
||||
localEligibilityMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.isNull(LocalEligibility::getDeletedAt))
|
||||
.forEach(e -> eligibilityUsers.add(e.getUserId()));
|
||||
|
||||
stats.put("counselingUniqueUsers", sizeOf(counselingUsers));
|
||||
stats.put("appointmentUniqueUsers", sizeOf(appointmentUsers));
|
||||
stats.put("programUniqueUsers", sizeOf(programUsers));
|
||||
stats.put("eligibilityUniqueUsers", sizeOf(eligibilityUsers));
|
||||
|
||||
// 服务用户数(跨域去重)
|
||||
stats.put("totalUniqueServiceUsers", metrics.uniqueServiceUsers());
|
||||
// 服务人次(不去重)—— 与上面的"用户数"是两个不同指标
|
||||
stats.put("totalServiceVisits", metrics.serviceVisits());
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getEffectiveConnectionStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long activeMatches = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "active"));
|
||||
long matchesWithChat = conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().in(Conversation::getStatus, "active", "muted"));
|
||||
long successRequests = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
long activeMatches = matchMapper.countByStatus("active");
|
||||
long matchesWithChat = metrics.chatsStarted();
|
||||
long successRequests = metrics.successfulMatches();
|
||||
stats.put("activeMatches", activeMatches);
|
||||
stats.put("matchesWithChat", matchesWithChat);
|
||||
stats.put("successConnections", successRequests);
|
||||
double rate = activeMatches > 0 ? (double) matchesWithChat / activeMatches : 0;
|
||||
stats.put("effectiveConnectionRate", Math.round(rate * 1000.0) / 10.0);
|
||||
// 口径未确认,必须显式标注,禁止对外宣传。
|
||||
stats.put("effectiveMatchesProvisional", true);
|
||||
stats.put("provisionalNote", "「有效交友匹配」口径尚未经业务确认,仅内部参考,禁止对外宣传。");
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getV11ServiceDashboard() {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
d.put("banners", getBannerStats());
|
||||
d.put("serviceStations", getServiceStationStats());
|
||||
d.put("serviceAppointments", getServiceAppointmentStats());
|
||||
@@ -496,50 +499,100 @@ public class AnalyticsService {
|
||||
d.put("registrationSources", getRegistrationSourceStats());
|
||||
d.put("uniqueServiceUsers", getUniqueServiceUsersStats());
|
||||
d.put("effectiveConnections", getEffectiveConnectionStats());
|
||||
// 活跃活动漏斗(含互选/成功分离)
|
||||
d.put("activityFunnel", getActivityFunnel());
|
||||
return d;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助 ====================
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
private static int sizeOf(Set<String> set) {
|
||||
set.remove(null);
|
||||
return set.size();
|
||||
}
|
||||
|
||||
/** 统一的比率格式化,保证同一比率在任何视图里呈现一致。 */
|
||||
private static String rate(long numerator, long denominator) {
|
||||
if (denominator <= 0) return "0%";
|
||||
return String.format("%.1f%%", 100.0 * numerator / denominator);
|
||||
}
|
||||
|
||||
private long countAppointmentsByStatus(String status) {
|
||||
return serviceAppointmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.eq(ServiceAppointment::getStatus, status)
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countCounselingByStatus(String status) {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getStatus, status)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countCounselingByServiceType(String serviceType) {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, serviceType)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
return buildDailyTrend(since, until, this::countEntityByDateRange);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
return buildDailyTrend(since, until, this::countMatchByDateRange);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyTrend(LocalDate since, LocalDate until,
|
||||
java.util.function.BiFunction<Instant, Instant, Long> counter) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate current = since;
|
||||
while (!current.isAfter(until)) {
|
||||
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Map<String, Object> point = new HashMap<>();
|
||||
Map<String, Object> point = new LinkedHashMap<>();
|
||||
point.put("date", current.toString());
|
||||
point.put("value", countMatchByDateRange(start, end));
|
||||
point.put("value", counter.apply(start, end));
|
||||
trend.add(point);
|
||||
current = current.plusDays(1);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
// 以下四个"按日区间"计数同样必须显式过滤软删,
|
||||
// 否则每日汇总里的"新增用户/新增 Match"会把已删除记录继续算进当天。
|
||||
private long countEntityByDateRange(Instant start, Instant end) {
|
||||
return userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getCreatedAt, start)
|
||||
.le(User::getCreatedAt, end));
|
||||
.le(User::getCreatedAt, end)
|
||||
.isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countMatchByDateRange(Instant start, Instant end) {
|
||||
return matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.ge(Match::getMatchedAt, start)
|
||||
.le(Match::getMatchedAt, end));
|
||||
.le(Match::getMatchedAt, end)
|
||||
.isNull(Match::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countConversationByDateRange(Instant start, Instant end) {
|
||||
return conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>()
|
||||
.ge(Conversation::getCreatedAt, start)
|
||||
.le(Conversation::getCreatedAt, end));
|
||||
.le(Conversation::getCreatedAt, end)
|
||||
.isNull(Conversation::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countReportByDateRange(Instant start, Instant end) {
|
||||
return reportMapper.selectCount(
|
||||
new LambdaQueryWrapper<Report>()
|
||||
.ge(Report::getCreatedAt, start)
|
||||
.le(Report::getCreatedAt, end));
|
||||
.le(Report::getCreatedAt, end)
|
||||
.isNull(Report::getDeletedAt));
|
||||
}
|
||||
}
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.banner.entity.HomeBanner;
|
||||
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.chat.mapper.MessageMapper;
|
||||
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.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
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.ServiceProgram;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 统一口径计算层 —— 所有统计数字的**唯一**出处。
|
||||
*
|
||||
* <p>为什么要单独抽一层:Phase 20 的 Gate 是「Dashboard 和专项页面相同指标必须一致」。
|
||||
* 修复前 {@code AnalyticsService} 里同一个指标有 2~3 份实现
|
||||
* ({@code userBasicStats} / {@code getUserFunnel} / {@code getV11ServiceDashboard}),
|
||||
* 名字还各不相同({@code verifiedUsers} vs {@code verifiedCount}),
|
||||
* 只要有一处漏改就永久不一致,且没有任何测试能发现。
|
||||
*
|
||||
* <p>本类中每个方法都对应 {@link MetricCatalog} 里的一个已登记指标,
|
||||
* 方法上标注的指标编码即其口径来源。
|
||||
*
|
||||
* <h2>为什么每个查询都显式写 {@code deleted_at IS NULL}</h2>
|
||||
*
|
||||
* <p><b>Phase 20 D12</b>:{@code BaseEntity.deletedAt} **没有** {@code @TableLogic} 注解,
|
||||
* 项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。这意味着
|
||||
* {@code selectCount(null)} 生成的是 {@code SELECT COUNT(*) FROM t} ——
|
||||
* **软删除的数据会被完整计入统计**。
|
||||
*
|
||||
* <p>实测影响:用户注销/被删、Banner 下架删除、活动软删之后,
|
||||
* 所有看板数字依然把它们算在内。这类偏差不会有任何报错,
|
||||
* 只会让"累计注册用户数"永远只增不减。因此本层所有查询统一显式过滤。
|
||||
*
|
||||
* <p><b>关于性能</b>:少量需要"跨域去重"的指标(如服务用户数)仍采用
|
||||
* 「查出各域 user_id 再内存去重」的写法。这在单区县体量下无问题;
|
||||
* 若数据量增长到十万级,应改为数据库侧 {@code UNION} 去重聚合。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsMetricsCalculator {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserIdentityMapper userIdentityMapper;
|
||||
private final MessageMapper messageMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
private final ActivityMutualConsentMapper activityMutualConsentMapper;
|
||||
private final ServiceAppointmentMapper serviceAppointmentMapper;
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final ServiceProgramMapper serviceProgramMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final ContentArticleMapper contentArticleMapper;
|
||||
private final HomeBannerMapper homeBannerMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
|
||||
// ==================== 用户域 ====================
|
||||
|
||||
/** {@code user.registered.total} —— 累计注册用户数(不含软删)。 */
|
||||
public long registeredUsers() {
|
||||
return userMapper.selectCount(new LambdaQueryWrapper<User>().isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code user.identity.verified} —— 实名认证通过用户数。
|
||||
*
|
||||
* <p><b>Phase 20 D1 修复点</b>:此前该口径被写成
|
||||
* {@code user_profiles.profile_status != 'rejected'}("资料没被退回"),
|
||||
* 把 in_progress(资料还在填)、need_edit(被退回待改)等状态全都算成了"已实名",
|
||||
* 与 Gate「注册10 / 实名7 / 审核通过4」中的实名口径完全不符。
|
||||
*/
|
||||
public long realNameVerifiedUsers() {
|
||||
return userIdentityMapper.countVerifiedUsers();
|
||||
}
|
||||
|
||||
/** {@code user.profile.approved} —— 资料审核通过人数(不含软删)。 */
|
||||
public long profileApprovedUsers() {
|
||||
return userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.eq(UserProfile::getProfileStatus, "approved")
|
||||
.isNull(UserProfile::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code user.active.7d} —— 近 7 日活跃用户数(不含软删)。 */
|
||||
public long activeUsers7d() {
|
||||
return userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))
|
||||
.isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
// ==================== 推荐 / 匹配域 ====================
|
||||
|
||||
/** {@code like.total} —— 用户 Like 总数(单向行为)。 */
|
||||
public long likes() {
|
||||
return recommendationRecordMapper.countByAction("liked");
|
||||
}
|
||||
|
||||
/** {@code recommendation.shown.total} —— 已展示推荐总数。 */
|
||||
public long recommendationsShown() {
|
||||
return recommendationRecordMapper.countShown();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code recommendation.shown.matchmaker} —— 红娘发出的推荐数。
|
||||
*
|
||||
* <p>按 {@code source='matchmaker'} 过滤,避免把用户端自动推荐(daily / discover)
|
||||
* 算进红娘业绩。
|
||||
*/
|
||||
public long matchmakerRecommendations() {
|
||||
return recommendationRecordMapper.countShownBySource("matchmaker");
|
||||
}
|
||||
|
||||
/** {@code match.total} —— Match 总数(全来源)。 */
|
||||
public long matches() {
|
||||
return matchMapper.countAllMatches();
|
||||
}
|
||||
|
||||
/** 按来源统计 Match 数({@code user_like} / {@code activity_mutual} / {@code matchmaker})。 */
|
||||
public long matchesBySource(String source) {
|
||||
return matchMapper.countBySource(source);
|
||||
}
|
||||
|
||||
/** {@code match.successful} —— 成功牵手数。 */
|
||||
public long successfulMatches() {
|
||||
return matchMapper.countByStatus("successful");
|
||||
}
|
||||
|
||||
/** {@code match.effective}(PROVISIONAL)—— 已建 Match 且有过真人消息的配对数。 */
|
||||
public long effectiveMatches() {
|
||||
// PROVISIONAL:口径未确认(「有效」是否要求见面/持续 N 天由业务方裁决)。
|
||||
// 当前取「真人会话数」与「Match 数」的较小值作为保守估计,
|
||||
// 绝不可当作正式成功率对外引用。
|
||||
return Math.min(messageMapper.countConversationsWithRealMessages(), matchMapper.countAllMatches());
|
||||
}
|
||||
|
||||
/** {@code chat.started} —— 开启聊天会话数(排除 system / risk_notice)。 */
|
||||
public long chatsStarted() {
|
||||
return messageMapper.countConversationsWithRealMessages();
|
||||
}
|
||||
|
||||
/** {@code chat.conversation.total} —— 会话总数(不含软删)。 */
|
||||
public long conversations() {
|
||||
return conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
|
||||
.isNull(com.danyang.match.platform.chat.entity.Conversation::getDeletedAt));
|
||||
}
|
||||
|
||||
// ==================== 活动域 ====================
|
||||
|
||||
/** {@code activity.total} —— 活动总数(不含软删)。 */
|
||||
public long activities() {
|
||||
return activityMapper.selectCount(new LambdaQueryWrapper<Activity>().isNull(Activity::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code activity.registration.total} —— 活动报名人次(不去重)。 */
|
||||
public long activityRegistrations() {
|
||||
return activityRegistrationMapper.countAllRegistrations();
|
||||
}
|
||||
|
||||
/** {@code activity.registration.approved} —— 报名通过人次。 */
|
||||
public long approvedActivityRegistrations() {
|
||||
return activityRegistrationMapper.countRegistrationsByStatus("approved");
|
||||
}
|
||||
|
||||
/** {@code activity.registration.checked_in} —— 实际签到人次。 */
|
||||
public long checkedInRegistrations() {
|
||||
return activityRegistrationMapper.countCheckedIn();
|
||||
}
|
||||
|
||||
/** {@code activity.mutual.both_confirmed} —— 活动互选成立对数(严格双确认)。 */
|
||||
public long mutualBothConfirmed() {
|
||||
return activityMutualConsentMapper.countBothConfirmed();
|
||||
}
|
||||
|
||||
/** {@code activity.mutual.match} —— 互选后建立 Match 数。 */
|
||||
public long mutualMatches() {
|
||||
return matchMapper.countBySource("activity_mutual");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code activity.mutual.successful} —— 互选后成功牵手数。
|
||||
*
|
||||
* <p>与 {@link #mutualBothConfirmed()} **严格区分**:
|
||||
* 10 对互选成立 ≠ 10 对成功,中间还有聊天、相处、确认关系。
|
||||
*/
|
||||
public long mutualSuccessful() {
|
||||
return matchMapper.countBySourceAndStatus("activity_mutual", "successful");
|
||||
}
|
||||
|
||||
// ==================== 公益服务域 ====================
|
||||
|
||||
/** {@code service.appointment.total} —— 服务预约人次(不含已取消、不含软删)。 */
|
||||
public long serviceAppointments() {
|
||||
return serviceAppointmentMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code counseling.total} —— 婚恋咨询受理量(不含软删)。 */
|
||||
public long counselingServices() {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code service.program.member.approved} —— 专项项目在册成员数(不含软删)。 */
|
||||
public long approvedProgramMembers() {
|
||||
return serviceProgramMemberMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code service.visit.total} —— 公益服务人次(**不去重**)。
|
||||
*
|
||||
* <p>与 {@link #uniqueServiceUsers()} 是**两个不同的指标**:
|
||||
* 本指标衡量服务承载量,同一用户来 3 次算 3 人次。
|
||||
*/
|
||||
public long serviceVisits() {
|
||||
return serviceAppointments()
|
||||
+ counselingServices()
|
||||
+ approvedProgramMembers()
|
||||
+ localEligibilityCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code service.user.unique} —— 服务用户数(**跨域去重**)。
|
||||
*
|
||||
* <p>同一用户既预约又咨询又参加项目,只算 1 人。与 {@link #serviceVisits()}
|
||||
* 的比值即人均服务次数,因此两者绝不可混用或互相替代。
|
||||
*/
|
||||
public long uniqueServiceUsers() {
|
||||
Set<String> users = new HashSet<>();
|
||||
serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNull(ServiceAppointment::getDeletedAt))
|
||||
.forEach(a -> users.add(a.getUserId()));
|
||||
counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.forEach(c -> users.add(c.getUserId()));
|
||||
serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt))
|
||||
.forEach(m -> users.add(m.getUserId()));
|
||||
localEligibilityMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt))
|
||||
.forEach(e -> users.add(e.getUserId()));
|
||||
users.remove(null);
|
||||
return users.size();
|
||||
}
|
||||
|
||||
private long localEligibilityCount() {
|
||||
return localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 专项项目总数(不含软删),供服务仪表盘使用。 */
|
||||
public long servicePrograms() {
|
||||
return serviceProgramMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 状态为 active 的专项项目数(不含软删)。 */
|
||||
public long activeServicePrograms() {
|
||||
return serviceProgramMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgram>()
|
||||
.eq(ServiceProgram::getStatus, "active")
|
||||
.isNull(ServiceProgram::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code content.published.total} —— 已发布内容数(不含软删)。 */
|
||||
public long publishedArticles() {
|
||||
return contentArticleMapper.selectCount(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code content.views.total} —— 内容累计阅读量。 */
|
||||
public long contentViews() {
|
||||
return contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt))
|
||||
.stream()
|
||||
.mapToLong(a -> a.getViewCount() != null ? a.getViewCount() : 0)
|
||||
.sum();
|
||||
}
|
||||
|
||||
// ==================== Banner 域 ====================
|
||||
|
||||
/** {@code banner.total} —— Banner 总数(不含软删)。 */
|
||||
public long banners() {
|
||||
return homeBannerMapper.selectCount(
|
||||
new LambdaQueryWrapper<HomeBanner>().isNull(HomeBanner::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code banner.active} —— 生效中的 Banner 数(不含软删)。 */
|
||||
public long activeBanners() {
|
||||
Instant now = Instant.now();
|
||||
return homeBannerMapper.selectCount(
|
||||
new LambdaQueryWrapper<HomeBanner>()
|
||||
.eq(HomeBanner::getStatus, "active")
|
||||
.isNull(HomeBanner::getDeletedAt)
|
||||
.le(HomeBanner::getStartAt, now)
|
||||
.ge(HomeBanner::getEndAt, now));
|
||||
}
|
||||
|
||||
// ==================== 安全域 ====================
|
||||
|
||||
/** {@code risk.report.pending} —— 待处理举报数(自定义 SQL 已含软删过滤)。 */
|
||||
public long pendingReports() {
|
||||
return reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
|
||||
}
|
||||
|
||||
/** {@code risk.report.resolved} —— 已处理举报数(自定义 SQL 已含软删过滤)。 */
|
||||
public long resolvedReports() {
|
||||
return reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
|
||||
}
|
||||
|
||||
/** {@code risk.user.high_risk} —— 高风险用户数(生效中的封禁或临时停用,不含软删)。 */
|
||||
public long highRiskUsers() {
|
||||
return riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.in(RiskAction::getActionType,
|
||||
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active")
|
||||
.isNull(RiskAction::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 仅封禁用户数(高风险用户的子集)。 */
|
||||
public long bannedUsers() {
|
||||
return riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")
|
||||
.isNull(RiskAction::getDeletedAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 全部统计指标的注册表 —— Phase 20 的**单一事实来源**。
|
||||
*
|
||||
* <p>修复前的状态:同一个概念在 {@code AnalyticsService} 里有 2~3 份独立实现
|
||||
* ({@code userBasicStats()} / {@code getUserFunnel()} / {@code getV11ServiceDashboard()}
|
||||
* 各算一遍),且**同名不同义**(Dashboard 叫 {@code totalLikes},漏斗叫 {@code totalLiked};
|
||||
* Dashboard 叫 {@code verifiedUsers},漏斗叫 {@code verifiedCount})。
|
||||
* Phase 20 的 Gate 明确要求「Dashboard 和专项页面相同指标必须一致」,
|
||||
* 靠"记得同步改两处"是守不住的,必须有唯一来源。
|
||||
*
|
||||
* <p>本类同时是 {@code docs/ANALYTICS_METRICS.md} 的生成依据,
|
||||
* 由 {@code MetricCatalogGuardTest} 校验「文档覆盖全部指标」与「口径不自相矛盾」。
|
||||
*/
|
||||
public final class MetricCatalog {
|
||||
|
||||
/** 本项目统计口径统一时区(丹阳本地时间;跨时区部署会改变"某天"的边界)。 */
|
||||
public static final String TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
/** 当前口径版本;任何口径变更都必须递增,否则历史数据不可比。 */
|
||||
public static final String VERSION = "v1";
|
||||
|
||||
private MetricCatalog() {
|
||||
}
|
||||
|
||||
private static final List<MetricDefinition> ALL = List.of(
|
||||
|
||||
// ==================== 用户域 ====================
|
||||
new MetricDefinition(
|
||||
"user.registered.total", "累计注册用户数",
|
||||
"所有已注册用户账号数。含未完善资料、未实名、被封禁的账号(只要账号行存在即计入)。",
|
||||
"COUNT(users.id)", "—", "users.id", "users.created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "users"),
|
||||
|
||||
new MetricDefinition(
|
||||
"user.identity.verified", "实名认证通过用户数",
|
||||
"实名认证状态为 verified 的用户数。不含 verifying(认证中)、failed(失败)、"
|
||||
+ "manual_review(人工复核)、not_verified。**不等于**资料审核通过数。",
|
||||
"COUNT(DISTINCT user_identities.user_id) WHERE verification_status='verified'",
|
||||
"—", "user_identities.user_id", "user_identities.verified_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "user_identities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"user.profile.approved", "资料审核通过人数",
|
||||
"资料审核状态为 approved 的用户数。仅表示资料通过审核,**不表示已实名**。",
|
||||
"COUNT(DISTINCT user_profiles.user_id) WHERE profile_status='approved'",
|
||||
"—", "user_profiles.user_id", "user_profiles.updated_at", TIMEZONE, VERSION, "审核员",
|
||||
MetricStatus.OFFICIAL, "user_profiles"),
|
||||
|
||||
new MetricDefinition(
|
||||
"user.active.7d", "近 7 日活跃用户数",
|
||||
"最近 7 天内登录过的用户数。",
|
||||
"COUNT(users.id) WHERE last_login_at >= NOW() - 7d", "—", "users.id",
|
||||
"users.last_login_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "users"),
|
||||
|
||||
// ==================== 推荐 / 匹配域 ====================
|
||||
new MetricDefinition(
|
||||
"recommendation.shown.total", "已展示推荐总数",
|
||||
"系统向用户展示过的推荐记录数(含全部来源 daily/discover/matchmaker/activity)。",
|
||||
"COUNT(recommendation_records.id) WHERE shown_at IS NOT NULL", "—",
|
||||
"recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "recommendation_records"),
|
||||
|
||||
new MetricDefinition(
|
||||
"recommendation.shown.matchmaker", "红娘发出的推荐数",
|
||||
"来源为 matchmaker 的推荐记录数 = 红娘实际推给用户的候选数。"
|
||||
+ "**不含**红娘在候选池里浏览看过但未发出的候选,也不含用户端自动推荐。",
|
||||
"COUNT(recommendation_records.id) WHERE source='matchmaker' AND shown_at IS NOT NULL", "—",
|
||||
"recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "红娘主管",
|
||||
MetricStatus.OFFICIAL, "recommendation_records"),
|
||||
|
||||
new MetricDefinition(
|
||||
"like.total", "用户 Like 总数",
|
||||
"action='liked' 的推荐记录数(单向行为,A 喜欢 B 记 1 条)。"
|
||||
+ "两条 Like(A→B 与 B→A)才可能形成 1 个 Match,因此 Like 数约为 Mutual Match 数的 2 倍量级。",
|
||||
"COUNT(recommendation_records.id) WHERE action='liked'", "—",
|
||||
"recommendation_records.id", "recommendation_records.action_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "recommendation_records"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.total", "Match 总数",
|
||||
"全部来源的 Match 记录数(去重键为 matches.id,同一对用户只有一行,重新牵手递增 episode 而不新增行)。",
|
||||
"COUNT(matches.id)", "—", "matches.id", "matches.matched_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.source.user_like", "用户互相 Like 形成的 Match 数",
|
||||
"source='user_like' 的 Match 数。与 Match 总数分开统计,"
|
||||
+ "避免把红娘促成、活动互选的量算进「用户自主匹配」的成果。",
|
||||
"COUNT(matches.id) WHERE source='user_like'", "—", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.source.activity_mutual", "活动互选形成的 Match 数",
|
||||
"source='activity_mutual' 的 Match 数。注意:它 ≤ 活动互选对数,"
|
||||
+ "且**不等于**活动互选的成功数(见 activity.mutual.successful)。",
|
||||
"COUNT(matches.id) WHERE source='activity_mutual'", "—", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "活动管理员", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.source.matchmaker", "红娘促成的 Match 数",
|
||||
"source='matchmaker' 的 Match 数。",
|
||||
"COUNT(matches.id) WHERE source='matchmaker'", "—", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "红娘主管", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.successful", "成功牵手数",
|
||||
"status='successful' 的 Match 数。仅表示双方在平台上确认了关系结果,"
|
||||
+ "不涉及线下真实性核验。",
|
||||
"COUNT(matches.id) WHERE status='successful'", "—", "matches.id",
|
||||
"matches.successful_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"match.effective", "有效交友匹配数",
|
||||
"**口径未确认(provisional)**。当前近似定义为:已建立 Match 且双方在会话中"
|
||||
+ "产生过真人消息(排除 system/risk_notice)的配对数。"
|
||||
+ "「有效」到哪一步算数(是否要求见面、是否要求持续 N 天)业务方尚未裁决,"
|
||||
+ "**禁止作为正式成功率口径,禁止对外宣传**。",
|
||||
"COUNT(matches.id) WHERE status IN ('active','getting_to_know','successful') "
|
||||
+ "AND EXISTS(真人消息)", "match.total", "matches.id", "matches.matched_at",
|
||||
TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.PROVISIONAL, "matches"),
|
||||
|
||||
// ==================== 聊天域 ====================
|
||||
new MetricDefinition(
|
||||
"chat.started", "开启聊天会话数",
|
||||
"**至少存在一条真人消息**(message_type 不在 system/risk_notice 内)的会话数。"
|
||||
+ "系统通知(如「你们已互相喜欢」)不作为开启聊天的依据。",
|
||||
"COUNT(DISTINCT messages.conversation_id) WHERE message_type NOT IN ('system','risk_notice')",
|
||||
"—", "messages.conversation_id", "messages.created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "messages"),
|
||||
|
||||
new MetricDefinition(
|
||||
"chat.conversation.total", "会话总数",
|
||||
"已创建的会话数(含仅由系统消息开头的会话)。与 chat.started 的差额即"
|
||||
+ "「建了会话但双方从未真正开口」的数量。",
|
||||
"COUNT(conversations.id)", "—", "conversations.id", "conversations.created_at",
|
||||
TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "conversations"),
|
||||
|
||||
// ==================== 活动域 ====================
|
||||
new MetricDefinition(
|
||||
"activity.total", "活动总数",
|
||||
"全部已创建活动数(含草稿、已结束)。",
|
||||
"COUNT(activities.id)", "—", "activities.id", "activities.created_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "activities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.registration.total", "活动报名人次",
|
||||
"报名记录总数(含 pending/approved/rejected/cancelled)。"
|
||||
+ "同一用户报多个活动计多次 —— 这是**人次**而非人数。",
|
||||
"COUNT(activity_registrations.id)", "—", "activity_registrations.id",
|
||||
"activity_registrations.registered_at", TIMEZONE, VERSION, "活动管理员",
|
||||
MetricStatus.OFFICIAL, "activity_registrations"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.registration.approved", "活动报名通过人次",
|
||||
"status='approved' 的报名记录数。",
|
||||
"COUNT(activity_registrations.id) WHERE status='approved'", "—",
|
||||
"activity_registrations.id", "activity_registrations.reviewed_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "activity_registrations"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.registration.checked_in", "活动实际签到人次",
|
||||
"checkin_status='checked_in' 的报名记录数。与报名通过人次的差额即活动到场流失。",
|
||||
"COUNT(activity_registrations.id) WHERE checkin_status='checked_in'", "—",
|
||||
"activity_registrations.id", "activity_registrations.checkin_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "activity_registrations"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.mutual.both_confirmed", "活动互选成立对数",
|
||||
"两侧均 accepted 的互选记录数(严格互选,**不是**「有人点了同意」)。"
|
||||
+ "这是互选环节的产出。",
|
||||
"COUNT(activity_mutual_connection_consents.id) WHERE user_a_status='accepted' "
|
||||
+ "AND user_b_status='accepted'", "—", "activity_mutual_connection_consents.id",
|
||||
"activity_mutual_connection_consents.both_confirmed_at", TIMEZONE, VERSION, "活动管理员",
|
||||
MetricStatus.OFFICIAL, "activity_mutual_connection_consents"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.mutual.match", "互选后建立 Match 数",
|
||||
"source='activity_mutual' 的 Match 数。应当 ≤ 活动互选成立对数;"
|
||||
+ "若大于,说明存在未经双确认就建 Match 的路径。",
|
||||
"COUNT(matches.id) WHERE source='activity_mutual'", "activity.mutual.both_confirmed",
|
||||
"matches.id", "matches.matched_at", TIMEZONE, VERSION, "活动管理员",
|
||||
MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
new MetricDefinition(
|
||||
"activity.mutual.successful", "互选后成功牵手数",
|
||||
"source='activity_mutual' 且 status='successful' 的 Match 数。"
|
||||
+ "**明确区别于**活动互选成立对数:10 对互选成立远不等于 10 对成功,"
|
||||
+ "中间还要经过聊天、相处、确认关系。绝不可互相替代引用。",
|
||||
"COUNT(matches.id) WHERE source='activity_mutual' AND status='successful'",
|
||||
"activity.mutual.both_confirmed", "matches.id", "matches.successful_at", TIMEZONE, VERSION,
|
||||
"活动管理员", MetricStatus.OFFICIAL, "matches"),
|
||||
|
||||
// ==================== 公益服务域 ====================
|
||||
new MetricDefinition(
|
||||
"service.visit.total", "公益服务人次",
|
||||
"全部公益服务记录的条数之和(预约 + 咨询 + 专项项目参与 + 本地资格办理)。"
|
||||
+ "**不去重** —— 同一用户来 3 次就是 3 人次。这是服务承载量的口径。",
|
||||
"Σ(预约, 咨询, 项目参与, 资格办理) 记录数", "—", "各域记录 id(**不去重**)",
|
||||
"各域 created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "service_appointments + counseling_services + "
|
||||
+ "service_program_members + user_local_eligibilities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"service.user.unique", "服务用户数",
|
||||
"**跨全部公益服务域去重**后的用户数。同一用户既预约又咨询又参加项目,只算 1 人。"
|
||||
+ "这是服务覆盖面的口径,与 service.visit.total 是**两个不同的指标**,"
|
||||
+ "两者的比值即人均服务次数。",
|
||||
"COUNT(DISTINCT user_id) over (预约 ∪ 咨询 ∪ 项目参与 ∪ 资格办理)", "—",
|
||||
"user_id(**跨域去重**)", "各域 created_at", TIMEZONE, VERSION, "平台运营",
|
||||
MetricStatus.OFFICIAL, "service_appointments + counseling_services + "
|
||||
+ "service_program_members + user_local_eligibilities"),
|
||||
|
||||
new MetricDefinition(
|
||||
"service.appointment.total", "服务预约人次",
|
||||
"服务站预约记录数(不含已取消)。",
|
||||
"COUNT(service_appointments.id) WHERE status<>'cancelled'", "—", "service_appointments.id",
|
||||
"service_appointments.created_at", TIMEZONE, VERSION, "服务站运营",
|
||||
MetricStatus.OFFICIAL, "service_appointments"),
|
||||
|
||||
new MetricDefinition(
|
||||
"counseling.total", "婚恋咨询受理量",
|
||||
"婚恋咨询记录数(含婚前辅导)。",
|
||||
"COUNT(counseling_services.id)", "—", "counseling_services.id",
|
||||
"counseling_services.created_at", TIMEZONE, VERSION, "咨询管理员",
|
||||
MetricStatus.OFFICIAL, "counseling_services"),
|
||||
|
||||
new MetricDefinition(
|
||||
"service.program.member.approved", "专项服务项目在册成员数",
|
||||
"status='approved' 的项目成员记录数。成员身份**不得**被用作对外的用户价值标签。",
|
||||
"COUNT(service_program_members.id) WHERE status='approved'", "—",
|
||||
"service_program_members.id", "service_program_members.created_at", TIMEZONE, VERSION,
|
||||
"平台运营", MetricStatus.OFFICIAL, "service_program_members"),
|
||||
|
||||
new MetricDefinition(
|
||||
"content.published.total", "已发布内容数",
|
||||
"status='published' 的内容文章数(婚恋课堂 / 反诈 / 公益信息)。",
|
||||
"COUNT(content_articles.id) WHERE status='published'", "—", "content_articles.id",
|
||||
"content_articles.published_at", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "content_articles"),
|
||||
|
||||
new MetricDefinition(
|
||||
"content.views.total", "内容累计阅读量",
|
||||
"已发布文章的 view_count 之和。",
|
||||
"SUM(content_articles.view_count) WHERE status='published'", "—", "content_articles.id",
|
||||
"content_articles.published_at", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "content_articles"),
|
||||
|
||||
// ==================== Banner 域 ====================
|
||||
new MetricDefinition(
|
||||
"banner.total", "Banner 总数",
|
||||
"全部 Banner 数(含草稿与已下架,不含软删)。",
|
||||
"COUNT(home_banners.id) WHERE deleted_at IS NULL", "—", "home_banners.id",
|
||||
"home_banners.created_at", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "home_banners"),
|
||||
|
||||
new MetricDefinition(
|
||||
"banner.active", "生效中的 Banner 数",
|
||||
"status='active' 且当前时间落在 [start_at, end_at] 区间内的 Banner 数。",
|
||||
"COUNT(home_banners.id) WHERE status='active' AND NOW() BETWEEN start_at AND end_at", "—",
|
||||
"home_banners.id", "NOW()", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.OFFICIAL, "home_banners"),
|
||||
|
||||
new MetricDefinition(
|
||||
"banner.impression", "Banner 曝光量",
|
||||
"Banner 在小程序首页被渲染展示的次数。"
|
||||
+ "**当前不可用**:项目内不存在任何曝光埋点表或上报端点,"
|
||||
+ "修复前该指标被硬编码返回 0 —— 会被误读成「真的没人看到」,"
|
||||
+ "故改为显式标记为未埋点,绝不返回 0 冒充真实值。",
|
||||
"—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"),
|
||||
|
||||
new MetricDefinition(
|
||||
"banner.click", "Banner 点击量",
|
||||
"Banner 被点击进入落地页的次数。**当前不可用**,原因同 banner.impression。",
|
||||
"—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑",
|
||||
MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"),
|
||||
|
||||
// ==================== 安全域 ====================
|
||||
new MetricDefinition(
|
||||
"risk.report.pending", "待处理举报数",
|
||||
"状态为待处理的举报记录数。",
|
||||
"COUNT(reports.id) WHERE status='pending'", "—", "reports.id", "reports.created_at",
|
||||
TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"),
|
||||
|
||||
new MetricDefinition(
|
||||
"risk.report.resolved", "已处理举报数",
|
||||
"状态为已处理的举报记录数。",
|
||||
"COUNT(reports.id) WHERE status='resolved'", "—", "reports.id", "reports.resolved_at",
|
||||
TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"),
|
||||
|
||||
new MetricDefinition(
|
||||
"risk.user.high_risk", "高风险用户数",
|
||||
"存在生效中封禁或临时停用处置的用户数。",
|
||||
"COUNT(risk_actions.id) WHERE action_type IN ('ban','temporary_suspend') AND status='active'",
|
||||
"—", "risk_actions.id", "risk_actions.created_at", TIMEZONE, VERSION, "风控人员",
|
||||
MetricStatus.OFFICIAL, "risk_actions")
|
||||
);
|
||||
|
||||
private static final Map<String, MetricDefinition> BY_CODE = ALL.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(MetricDefinition::code, Function.identity()));
|
||||
|
||||
/** 全部指标定义(顺序稳定,供文档生成与守卫遍历)。 */
|
||||
public static List<MetricDefinition> all() {
|
||||
return ALL;
|
||||
}
|
||||
|
||||
/** 按编码取定义;编码不存在时抛异常(调用处写错编码必须立刻暴露,而不是静默拿到 null)。 */
|
||||
public static MetricDefinition of(String code) {
|
||||
MetricDefinition definition = BY_CODE.get(code);
|
||||
if (definition == null) {
|
||||
throw new IllegalArgumentException("未登记的指标编码: " + code
|
||||
+ "(新指标必须先加入 MetricCatalog 并同步 docs/ANALYTICS_METRICS.md)");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/** 口径未确认、禁止对外宣传的指标。 */
|
||||
public static List<MetricDefinition> provisional() {
|
||||
return ALL.stream().filter(MetricDefinition::provisional).toList();
|
||||
}
|
||||
|
||||
/** 因缺少埋点而当前无法计算的指标。 */
|
||||
public static List<MetricDefinition> notInstrumented() {
|
||||
return ALL.stream().filter(MetricDefinition::notInstrumented).toList();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
/**
|
||||
* 单个指标的完整定义(Phase 20 指标字典的代码化载体)。
|
||||
*
|
||||
* <p>Phase 20 计划原文要求:「为每个指标登记名称、定义、分子分母、去重键、时间字段、
|
||||
* 时区、版本和负责人」。这些字段此前散落在各统计方法里(大多数情况下**根本不存在**,
|
||||
* 口径只存在于写代码人的脑子里),导致:
|
||||
* <ul>
|
||||
* <li>同一个概念在不同接口里各算一遍,结果不一致(Dashboard 与专项页对不上);</li>
|
||||
* <li>「实名人数」被误算成「资料没被退回的人数」,没人能在评审时发现;</li>
|
||||
* <li>「服务用户数」与「服务人次」混用,去重与否无从判断。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>把定义变成结构化数据后,{@code MetricCatalogGuardTest} 才能把
|
||||
* 「每个对外指标都必须有定义」「文档必须覆盖全部指标」「provisional 状态不得缺失」
|
||||
* 变成可执行断言,而不是靠人肉 review。
|
||||
*
|
||||
* @param code 指标唯一编码(如 {@code user.identity.verified}),命名空间用点分层
|
||||
* @param name 中文名(后台与文档展示用)
|
||||
* @param definition 口径的自然语言定义(必须说明"算什么"以及"不算什么")
|
||||
* @param numerator 分子
|
||||
* @param denominator 分母(非比率指标填 {@code "—"})
|
||||
* @param dedupeKey 去重键(说明该指标的"1 个单位"是什么,防止口径混淆)
|
||||
* @param timeField 时间字段(决定"属于哪一天")
|
||||
* @param timezone 时区(本项目统一 {@code Asia/Shanghai})
|
||||
* @param version 口径版本(口径变更必须递增,否则历史数据不可比)
|
||||
* @param owner 口径负责人(业务侧角色,口径争议时的裁决人)
|
||||
* @param status 口径状态,见 {@link MetricStatus}
|
||||
* @param sourceTable 数据来源表(便于追溯与索引优化)
|
||||
*/
|
||||
public record MetricDefinition(
|
||||
String code,
|
||||
String name,
|
||||
String definition,
|
||||
String numerator,
|
||||
String denominator,
|
||||
String dedupeKey,
|
||||
String timeField,
|
||||
String timezone,
|
||||
String version,
|
||||
String owner,
|
||||
MetricStatus status,
|
||||
String sourceTable
|
||||
) {
|
||||
|
||||
/** 是否为口径未确认的临时指标(禁止对外宣传)。 */
|
||||
public boolean provisional() {
|
||||
return status == MetricStatus.PROVISIONAL;
|
||||
}
|
||||
|
||||
/** 是否因缺少埋点而当前无法计算。 */
|
||||
public boolean notInstrumented() {
|
||||
return status == MetricStatus.NOT_INSTRUMENTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
/**
|
||||
* 指标口径状态(Phase 20 指标字典的一部分)。
|
||||
*
|
||||
* <p>存在的理由:统计接口最大的风险不是"算错",而是**算出来的数字被当成别的意思用了**。
|
||||
* 「有效交友匹配」在口径确认前如果被后台当成"成功数"展示、被运营写进宣传稿,
|
||||
* 错误就从代码扩散到了对外承诺。把状态显式建模,可让守卫测试把
|
||||
* 「provisional 指标不得标记为正式」变成可执行的不变量。
|
||||
*/
|
||||
public enum MetricStatus {
|
||||
|
||||
/**
|
||||
* 口径已确认,可作为正式统计口径使用。
|
||||
*/
|
||||
OFFICIAL,
|
||||
|
||||
/**
|
||||
* 口径尚未确认,禁止当作正式成功口径,禁止对外宣传。
|
||||
*
|
||||
* <p>Phase 20 计划:「有效交友匹配在口径未确认前标为 provisional,
|
||||
* 不能当正式成功或对外宣传」。
|
||||
*/
|
||||
PROVISIONAL,
|
||||
|
||||
/**
|
||||
* 数据源尚未埋点,当前**无法**计算。
|
||||
*
|
||||
* <p>此状态的意义在于**诚实**:宁可明确返回「不可用」,
|
||||
* 也不能像修复前那样硬编码返回 0 —— 0 会被读成"真的没人点击",
|
||||
* 而实际是"根本没有记录点击的地方",两者对决策的含义完全相反。
|
||||
*/
|
||||
NOT_INSTRUMENTED
|
||||
}
|
||||
@@ -32,4 +32,22 @@ public interface MessageMapper extends BaseMapper<Message> {
|
||||
Optional<Message> findByClientMessageId(@Param("convId") String convId,
|
||||
@Param("senderId") String senderId,
|
||||
@Param("clientMsgId") String clientMsgId);
|
||||
|
||||
/**
|
||||
* **Phase 20 D3(Gate「System Message 不算 Chat Started」)**:真正开启过聊天的会话数。
|
||||
*
|
||||
* <p>口径:**至少存在一条真人消息**的会话数。{@code message_type} 的取值域由
|
||||
* {@code chk_msg_type} 限定为 {@code text / image / system / risk_notice},
|
||||
* 后两者是平台下发的通知(如「你们已互相喜欢,可以开始聊天了」),
|
||||
* 用户本人没有任何发言,因此**不能**算作 Chat Started。
|
||||
*
|
||||
* <p>此前 Analytics 用 {@code conversationMapper.selectCount(null)} 统计"开启聊天数",
|
||||
* 只看会话行数、**从不看消息类型** —— 一条系统通知就能让一个会话计入 Chat Started,
|
||||
* 导致该指标被系统性高估。
|
||||
*
|
||||
* <p>去重键:{@code conversation_id}(同一会话内多条真人消息只算一次)。
|
||||
*/
|
||||
@Select("SELECT COUNT(DISTINCT conversation_id) FROM messages "
|
||||
+ "WHERE message_type NOT IN ('system', 'risk_notice') AND deleted_at IS NULL")
|
||||
long countConversationsWithRealMessages();
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import lombok.EqualsAndHashCode;
|
||||
public class CounselingServiceRecord extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String serviceType; // counseling / premarital
|
||||
private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance(见 V022+V039 的 chk_counseling_service_type)
|
||||
private String requestSource; // user_self / matchmaker_referral
|
||||
private String userRequestSummary; // INV-013: Matchmaker 不可读
|
||||
private String organizationId;
|
||||
|
||||
@@ -69,4 +69,29 @@ public interface MatchMapper extends BaseMapper<Match> {
|
||||
"matched_at = NOW(), last_activity_at = NOW(), unmatched_at = NULL, updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND status = 'unmatched' AND deleted_at IS NULL")
|
||||
int reopenEpisode(@Param("id") String id, @Param("episode") int episode, @Param("source") String source);
|
||||
|
||||
// ==================== Phase 20:按来源分离的口径统计 ====================
|
||||
// 生产 chk_match_source 限定 source ∈ (user_like, activity_mutual, matchmaker)。
|
||||
// 统计必须按来源分离:Gate 明确要求「活动10对互选 ≠ 10 对 Success」,
|
||||
// 把三种来源混成一个总数会让"活动互选数"与"成功牵手数"被错误地互相比较。
|
||||
//
|
||||
// 说明:这里刻意用「单值 count」而非 `GROUP BY source` 返回 Map —— H2 与 PostgreSQL
|
||||
// 对结果集列名的大小写处理不同(H2 默认大写、PG 默认小写),返回 Map 会导致
|
||||
// 测试库取键失败而生产正常(或相反)的隐蔽不一致。显式参数化可彻底绕开该差异。
|
||||
|
||||
/** 全量 Match 数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE deleted_at IS NULL")
|
||||
long countAllMatches();
|
||||
|
||||
/** 按来源统计 Match 数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE source = #{source} AND deleted_at IS NULL")
|
||||
long countBySource(@Param("source") String source);
|
||||
|
||||
/** 按来源 + 状态统计 Match 数。 */
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE source = #{source} AND status = #{status} AND deleted_at IS NULL")
|
||||
long countBySourceAndStatus(@Param("source") String source, @Param("status") String status);
|
||||
|
||||
/** 按状态统计 Match 数。 */
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE status = #{status} AND deleted_at IS NULL")
|
||||
long countByStatus(@Param("status") String status);
|
||||
}
|
||||
|
||||
+26
@@ -20,4 +20,30 @@ public interface RecommendationRecordMapper extends BaseMapper<RecommendationRec
|
||||
|
||||
@Select("SELECT * FROM recommendation_records WHERE user_id = #{userId} AND target_user_id = #{targetUserId} AND action IS NOT NULL ORDER BY shown_at DESC LIMIT 1")
|
||||
RecommendationRecord findLatestAction(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
// ==================== Phase 20:分开「看过」与「发出」两种口径 ====================
|
||||
// 生产 chk_rec_source 限定 source ∈ (daily, discover, matchmaker, activity);
|
||||
// chk_rec_action 限定 action ∈ (liked, passed, blocked, matched, NULL)。
|
||||
//
|
||||
// Gate「红娘看20候选、发3个 → Recommendation=3」的准确含义:
|
||||
// * 「看20候选」= 红娘浏览过 20 条候选(红娘工作台的浏览行为,不是推荐记录行数);
|
||||
// * 「发3个」 = 红娘实际向用户**发出** 3 条推荐 → source='matchmaker' 的推荐记录 = 3。
|
||||
// 因此 Recommendation 指标必须按 source 过滤,否则会把用户端「每日推荐」
|
||||
// (source='daily') 与「发现页」(source='discover') 的量混进红娘业绩。
|
||||
|
||||
/** 已展示的推荐总数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE shown_at IS NOT NULL AND deleted_at IS NULL")
|
||||
long countShown();
|
||||
|
||||
/** 按来源统计已展示的推荐数。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE source = #{source} AND shown_at IS NOT NULL AND deleted_at IS NULL")
|
||||
long countShownBySource(@Param("source") String source);
|
||||
|
||||
/** 按来源统计用户已表态(liked / passed / blocked / matched)的推荐数。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE source = #{source} AND action = #{action} AND deleted_at IS NULL")
|
||||
long countBySourceAndAction(@Param("source") String source, @Param("action") String action);
|
||||
|
||||
/** 全量按行为统计(如全站 liked 数)。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE action = #{action} AND deleted_at IS NULL")
|
||||
long countByAction(@Param("action") String action);
|
||||
}
|
||||
|
||||
+15
@@ -19,4 +19,19 @@ public interface UserIdentityMapper extends BaseMapper<UserIdentity> {
|
||||
|
||||
@Select("SELECT * FROM user_identities WHERE id_number_hash = #{hash} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserIdentity> findByIdNumberHash(@Param("hash") String hash);
|
||||
|
||||
/**
|
||||
* **Phase 20 D1(口径造假修复)**:实名认证通过的用户数。
|
||||
*
|
||||
* <p>统计口径的唯一合法来源是 {@code user_identities.verification_status = 'verified'}。
|
||||
* 此前 Analytics 把 {@code user_profiles.profile_status != 'rejected'} 当作「实名人数」,
|
||||
* 而该列的取值域是 {@code in_progress / completed / pending_review / approved / need_edit / rejected}
|
||||
* ——「资料还在填」(in_progress) 与「被退回待改」(need_edit) 都被算进了"已实名",
|
||||
* 与 Gate「注册10 / 实名7 / 审核通过4」中的实名口径完全不是一回事。
|
||||
*
|
||||
* <p>去重键:{@code user_id}(同一用户多次提交实名只算一人)。
|
||||
*/
|
||||
@Select("SELECT COUNT(DISTINCT user_id) FROM user_identities "
|
||||
+ "WHERE verification_status = 'verified' AND deleted_at IS NULL")
|
||||
long countVerifiedUsers();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user