2026-07-21 18:08:55 +08:00

183 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}