183 lines
6.6 KiB
Go
183 lines
6.6 KiB
Go
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
|
||
}
|