package host import ( "context" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" ) const validMicDayThresholdMS = int64(time.Hour / time.Millisecond) // GetHostEngagementStats 聚合 user-service 自己拥有的事实表,不从当前 Agency 关系反推历史归属。 func (r *Repository) GetHostEngagementStats(ctx context.Context, query hostdomain.EngagementStatsQuery) (hostdomain.EngagementStats, error) { if r == nil || r.db == nil { return hostdomain.EngagementStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } stats := hostdomain.EngagementStats{} appCode := appcode.FromContext(ctx) startDate := time.UnixMilli(query.StartAtMS).UTC().Format("2006-01-02") endDate := time.UnixMilli(query.EndAtMS - 1).UTC().Format("2006-01-02") if err := r.db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(mic_online_ms), 0), COALESCE(SUM(CASE WHEN mic_online_ms >= ? THEN 1 ELSE 0 END), 0) FROM user_mic_daily_stats WHERE app_code = ? AND user_id = ? AND stat_date BETWEEN ? AND ?`, validMicDayThresholdMS, appCode, query.HostUserID, startDate, endDate, ).Scan(&stats.ValidMicDurationMS, &stats.ValidMicDays); err != nil { return hostdomain.EngagementStats{}, err } // 旧版 C2C 回调缺 app_code 时会把 Fami 事实写到默认租户。读取仅扩展到这两个索引范围, // 并用 users 全局主键校验发送者真实属于当前 App,既恢复历史数据又不放大跨租户计数。 privateMessageSQL := ` SELECT COUNT(DISTINCT matched.sender_user_id) FROM ( SELECT sender_user_id FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) WHERE app_code = ? AND target_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ? ) matched INNER JOIN users sender ON sender.user_id = matched.sender_user_id AND sender.app_code = ?` privateMessageArgs := []any{appCode, query.HostUserID, query.StartAtMS, query.EndAtMS, appCode} if appCode != appcode.Default { // 分成两个精确 app_code 区间,避免 MySQL 对 IN 条件选择覆盖索引全扫; // UNION ALL 后在最终 COUNT(DISTINCT) 收敛同一发送者,历史/新记录重叠时也不会重复计数。 privateMessageSQL = ` SELECT COUNT(DISTINCT matched.sender_user_id) FROM ( SELECT sender_user_id FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) WHERE app_code = ? AND target_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ? UNION ALL SELECT sender_user_id FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) WHERE app_code = ? AND target_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ? ) matched INNER JOIN users sender ON sender.user_id = matched.sender_user_id AND sender.app_code = ?` privateMessageArgs = []any{ appCode, query.HostUserID, query.StartAtMS, query.EndAtMS, appcode.Default, query.HostUserID, query.StartAtMS, query.EndAtMS, appCode, } } if err := r.db.QueryRowContext(ctx, privateMessageSQL, privateMessageArgs...).Scan(&stats.PrivateMessageSenders); err != nil { return hostdomain.EngagementStats{}, err } if err := r.db.QueryRowContext(ctx, ` SELECT COALESCE(SUM(delta), 0) FROM user_follow_events WHERE app_code = ? AND followee_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`, appCode, query.HostUserID, query.StartAtMS, query.EndAtMS, ).Scan(&stats.NewFollowers); err != nil { return hostdomain.EngagementStats{}, err } onlineMS, err := r.hostOnlineDuration(ctx, query) if err != nil { return hostdomain.EngagementStats{}, err } stats.OnlineDurationMS = onlineMS return stats, nil } // hostOnlineDuration 按每天“最早会话开始到最后一次心跳”累计;多设备/多 session 不重复相加。 func (r *Repository) hostOnlineDuration(ctx context.Context, query hostdomain.EngagementStatsQuery) (int64, error) { rows, err := r.db.QueryContext(ctx, ` SELECT created_at_ms, last_heartbeat_at_ms FROM auth_sessions WHERE app_code = ? AND user_id = ? AND last_heartbeat_at_ms >= ? AND created_at_ms < ?`, appcode.FromContext(ctx), query.HostUserID, query.StartAtMS, query.EndAtMS, ) if err != nil { return 0, err } defer rows.Close() type daySpan struct{ first, last int64 } spans := map[string]daySpan{} for rows.Next() { var startedAtMS, heartbeatAtMS int64 if err := rows.Scan(&startedAtMS, &heartbeatAtMS); err != nil { return 0, err } start := maxInt64(startedAtMS, query.StartAtMS) end := minInt64(heartbeatAtMS, query.EndAtMS-1) if end < start { continue } for day := utcDayStart(start); day <= utcDayStart(end); day += int64(24 * time.Hour / time.Millisecond) { segmentStart := maxInt64(start, day) segmentEnd := minInt64(end, day+int64(24*time.Hour/time.Millisecond)-1) key := time.UnixMilli(day).UTC().Format("2006-01-02") span, exists := spans[key] if !exists || segmentStart < span.first { span.first = segmentStart } if !exists || segmentEnd > span.last { span.last = segmentEnd } spans[key] = span } } if err := rows.Err(); err != nil { return 0, err } var total int64 for _, span := range spans { if span.last >= span.first { total += span.last - span.first } } return total, nil } // RecordPrivateMessageEvent 由 users 主数据反查租户,不信任公网回调缺省的 app_code。 // users.user_id 是全局主键;两次主键查找同时确认发送者和接收者属于同一 App,避免 Fami 私信误落到默认租户。 func (r *Repository) RecordPrivateMessageEvent(ctx context.Context, event hostdomain.PrivateMessageEvent) (bool, error) { result, err := r.db.ExecContext(ctx, ` INSERT IGNORE INTO user_private_message_events ( app_code, event_id, sender_user_id, target_user_id, occurred_at_ms, created_at_ms ) SELECT target.app_code, ?, sender.user_id, target.user_id, ?, ? FROM users target INNER JOIN users sender ON sender.user_id = ? AND sender.app_code = target.app_code WHERE target.user_id = ?`, event.EventID, event.OccurredAtMS, time.Now().UTC().UnixMilli(), event.SenderUserID, event.TargetUserID, ) if err != nil { return false, err } rows, err := result.RowsAffected() return rows > 0, err } func utcDayStart(value int64) int64 { t := time.UnixMilli(value).UTC() return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() } func minInt64(left, right int64) int64 { if left < right { return left } return right } func maxInt64(left, right int64) int64 { if left > right { return left } return right }