Files
ChatLab/electron/main/worker/query/advanced/activity.ts
2025-12-02 22:37:40 +08:00

617 lines
18 KiB
TypeScript
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.
/**
* 活跃度分析模块
* 包含:夜猫分析、龙王分析、潜水分析、打卡分析
*/
import { openDatabase, buildTimeFilter, buildSystemMessageFilter, type TimeFilter } from '../../core'
// ==================== 夜猫分析 ====================
/**
* 根据深夜发言数获取称号
*/
function getNightOwlTitleByCount(count: number): string {
if (count === 0) return '养生达人'
if (count <= 20) return '偶尔失眠'
if (count <= 50) return '经常失眠'
if (count <= 100) return '夜猫子'
if (count <= 200) return '秃头预备役'
if (count <= 500) return '修仙练习生'
return '守夜冠军'
}
/**
* 将时间戳转换为"调整后的日期"以凌晨5点为界
*/
function getAdjustedDate(ts: number): string {
const date = new Date(ts * 1000)
const hour = date.getHours()
if (hour < 5) {
date.setDate(date.getDate() - 1)
}
return date.toISOString().split('T')[0]
}
/**
* 格式化分钟数为 HH:MM
*/
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`
}
/**
* 获取夜猫分析数据
*/
export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabase(sessionId)
const emptyResult = {
nightOwlRank: [],
lastSpeakerRank: [],
firstSpeakerRank: [],
consecutiveRecords: [],
champions: [],
totalDays: 0,
}
if (!db) return emptyResult
const { clause, params } = buildTimeFilter(filter)
const clauseWithSystem = buildSystemMessageFilter(clause)
const messages = db
.prepare(
`
SELECT
msg.id,
msg.sender_id as senderId,
msg.ts,
m.platform_id as platformId,
m.name
FROM message msg
JOIN member m ON msg.sender_id = m.id
${clauseWithSystem}
ORDER BY msg.ts ASC
`
)
.all(...params) as Array<{
id: number
senderId: number
ts: number
platformId: string
name: string
}>
if (messages.length === 0) return emptyResult
const memberInfo = new Map<number, { platformId: string; name: string }>()
const nightStats = new Map<
number,
{
total: number
h23: number
h0: number
h1: number
h2: number
h3to4: number
totalMessages: number
}
>()
const dailyMessages = new Map<string, Array<{ senderId: number; ts: number; hour: number; minute: number }>>()
const memberNightDays = new Map<number, Set<string>>()
for (const msg of messages) {
if (!memberInfo.has(msg.senderId)) {
memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name })
}
const date = new Date(msg.ts * 1000)
const hour = date.getHours()
const minute = date.getMinutes()
const adjustedDate = getAdjustedDate(msg.ts)
if (!nightStats.has(msg.senderId)) {
nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 })
}
const stats = nightStats.get(msg.senderId)!
stats.totalMessages++
if (hour === 23) {
stats.h23++
stats.total++
} else if (hour === 0) {
stats.h0++
stats.total++
} else if (hour === 1) {
stats.h1++
stats.total++
} else if (hour === 2) {
stats.h2++
stats.total++
} else if (hour >= 3 && hour < 5) {
stats.h3to4++
stats.total++
}
if (hour >= 23 || hour < 5) {
if (!memberNightDays.has(msg.senderId)) {
memberNightDays.set(msg.senderId, new Set())
}
memberNightDays.get(msg.senderId)!.add(adjustedDate)
}
if (!dailyMessages.has(adjustedDate)) {
dailyMessages.set(adjustedDate, [])
}
dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute })
}
const totalDays = dailyMessages.size
// 构建修仙排行榜
const nightOwlRank: any[] = []
for (const [memberId, stats] of nightStats.entries()) {
if (stats.total === 0) continue
const info = memberInfo.get(memberId)!
nightOwlRank.push({
memberId,
platformId: info.platformId,
name: info.name,
totalNightMessages: stats.total,
title: getNightOwlTitleByCount(stats.total),
hourlyBreakdown: {
h23: stats.h23,
h0: stats.h0,
h1: stats.h1,
h2: stats.h2,
h3to4: stats.h3to4,
},
percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0,
})
}
nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages)
// 最晚/最早发言
const lastSpeakerStats = new Map<number, { count: number; times: number[] }>()
const firstSpeakerStats = new Map<number, { count: number; times: number[] }>()
for (const [, dayMessages] of dailyMessages.entries()) {
if (dayMessages.length === 0) continue
const lastMsg = dayMessages[dayMessages.length - 1]
if (!lastSpeakerStats.has(lastMsg.senderId)) {
lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] })
}
const lastStats = lastSpeakerStats.get(lastMsg.senderId)!
lastStats.count++
lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute)
const firstMsg = dayMessages[0]
if (!firstSpeakerStats.has(firstMsg.senderId)) {
firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] })
}
const firstStats = firstSpeakerStats.get(firstMsg.senderId)!
firstStats.count++
firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute)
}
// 构建排行
const lastSpeakerRank: any[] = []
for (const [memberId, stats] of lastSpeakerStats.entries()) {
const info = memberInfo.get(memberId)!
const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length
const maxMinutes = Math.max(...stats.times)
lastSpeakerRank.push({
memberId,
platformId: info.platformId,
name: info.name,
count: stats.count,
avgTime: formatMinutes(avgMinutes),
extremeTime: formatMinutes(maxMinutes),
percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0,
})
}
lastSpeakerRank.sort((a, b) => b.count - a.count)
const firstSpeakerRank: any[] = []
for (const [memberId, stats] of firstSpeakerStats.entries()) {
const info = memberInfo.get(memberId)!
const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length
const minMinutes = Math.min(...stats.times)
firstSpeakerRank.push({
memberId,
platformId: info.platformId,
name: info.name,
count: stats.count,
avgTime: formatMinutes(avgMinutes),
extremeTime: formatMinutes(minMinutes),
percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0,
})
}
firstSpeakerRank.sort((a, b) => b.count - a.count)
// 连续修仙天数
const consecutiveRecords: any[] = []
for (const [memberId, nightDaysSet] of memberNightDays.entries()) {
if (nightDaysSet.size === 0) continue
const info = memberInfo.get(memberId)!
const sortedDays = Array.from(nightDaysSet).sort()
let maxStreak = 1
let currentStreak = 1
for (let i = 1; i < sortedDays.length; i++) {
const prevDate = new Date(sortedDays[i - 1])
const currDate = new Date(sortedDays[i])
const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays === 1) {
currentStreak++
maxStreak = Math.max(maxStreak, currentStreak)
} else {
currentStreak = 1
}
}
const lastDay = sortedDays[sortedDays.length - 1]
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]
const isCurrentStreak = lastDay === today || lastDay === yesterday
consecutiveRecords.push({
memberId,
platformId: info.platformId,
name: info.name,
maxConsecutiveDays: maxStreak,
currentStreak: isCurrentStreak ? currentStreak : 0,
})
}
consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays)
// 综合排名
const championScores = new Map<number, { nightMessages: number; lastSpeakerCount: number; consecutiveDays: number }>()
for (const item of nightOwlRank) {
if (!championScores.has(item.memberId)) {
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
}
championScores.get(item.memberId)!.nightMessages = item.totalNightMessages
}
for (const item of lastSpeakerRank) {
if (!championScores.has(item.memberId)) {
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
}
championScores.get(item.memberId)!.lastSpeakerCount = item.count
}
for (const item of consecutiveRecords) {
if (!championScores.has(item.memberId)) {
championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 })
}
championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays
}
const champions: any[] = []
for (const [memberId, scores] of championScores.entries()) {
const info = memberInfo.get(memberId)!
const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20
if (score > 0) {
champions.push({
memberId,
platformId: info.platformId,
name: info.name,
score,
nightMessages: scores.nightMessages,
lastSpeakerCount: scores.lastSpeakerCount,
consecutiveDays: scores.consecutiveDays,
})
}
}
champions.sort((a, b) => b.score - a.score)
return {
nightOwlRank,
lastSpeakerRank,
firstSpeakerRank,
consecutiveRecords,
champions,
totalDays,
}
}
// ==================== 龙王分析 ====================
/**
* 获取龙王排名
*/
export function getDragonKingAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabase(sessionId)
const emptyResult = { rank: [], totalDays: 0 }
if (!db) return emptyResult
const { clause, params } = buildTimeFilter(filter)
const clauseWithSystem = buildSystemMessageFilter(clause)
const dailyTopSpeakers = db
.prepare(
`
WITH daily_counts AS (
SELECT
strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date,
msg.sender_id,
m.platform_id,
m.name,
COUNT(*) as msg_count
FROM message msg
JOIN member m ON msg.sender_id = m.id
${clauseWithSystem}
GROUP BY date, msg.sender_id
),
daily_max AS (
SELECT date, MAX(msg_count) as max_count
FROM daily_counts
GROUP BY date
)
SELECT dc.sender_id, dc.platform_id, dc.name, COUNT(*) as dragon_days
FROM daily_counts dc
JOIN daily_max dm ON dc.date = dm.date AND dc.msg_count = dm.max_count
GROUP BY dc.sender_id
ORDER BY dragon_days DESC
`
)
.all(...params) as Array<{
sender_id: number
platform_id: string
name: string
dragon_days: number
}>
const totalDaysRow = db
.prepare(
`
SELECT COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as total
FROM message msg
JOIN member m ON msg.sender_id = m.id
${clauseWithSystem}
`
)
.get(...params) as { total: number }
const totalDays = totalDaysRow.total
const rank = dailyTopSpeakers.map((item) => ({
memberId: item.sender_id,
platformId: item.platform_id,
name: item.name,
count: item.dragon_days,
percentage: totalDays > 0 ? Math.round((item.dragon_days / totalDays) * 10000) / 100 : 0,
}))
return { rank, totalDays }
}
// ==================== 潜水分析 ====================
/**
* 获取潜水排名
*/
export function getDivingAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabase(sessionId)
const emptyResult = { rank: [] }
if (!db) return emptyResult
const { clause, params } = buildTimeFilter(filter)
const clauseWithSystem = buildSystemMessageFilter(clause)
const lastMessages = db
.prepare(
`
SELECT
m.id as member_id,
m.platform_id,
m.name,
MAX(msg.ts) as last_ts
FROM member m
JOIN message msg ON m.id = msg.sender_id
${clauseWithSystem.replace('msg.', 'msg.')}
GROUP BY m.id
ORDER BY last_ts ASC
`
)
.all(...params) as Array<{
member_id: number
platform_id: string
name: string
last_ts: number
}>
const now = Math.floor(Date.now() / 1000)
const rank = lastMessages.map((item) => ({
memberId: item.member_id,
platformId: item.platform_id,
name: item.name,
lastMessageTs: item.last_ts,
daysSinceLastMessage: Math.floor((now - item.last_ts) / 86400),
}))
return { rank }
}
// ==================== 打卡分析 ====================
/**
* 获取打卡分析数据(火花榜 + 忠臣榜)
*/
export function getCheckInAnalysis(sessionId: string, filter?: TimeFilter): any {
const db = openDatabase(sessionId)
const emptyResult = {
streakRank: [],
loyaltyRank: [],
totalDays: 0,
}
if (!db) return emptyResult
const { clause, params } = buildTimeFilter(filter)
const whereClause = buildSystemMessageFilter(clause)
// 1. 获取每个成员每天是否发言的数据
// 检查时间戳格式:如果 ts > 1e12 则是毫秒,否则是秒
const sampleTs = db.prepare(`SELECT ts FROM message LIMIT 1`).get() as { ts: number } | undefined
const tsIsMillis = sampleTs?.ts && sampleTs.ts > 1e12
const tsExpr = tsIsMillis ? 'msg.ts / 1000' : 'msg.ts'
const dailyActivity = db
.prepare(
`
SELECT
msg.sender_id as senderId,
m.name,
DATE(${tsExpr}, 'unixepoch', 'localtime') as day
FROM message msg
JOIN member m ON msg.sender_id = m.id
${whereClause}
GROUP BY msg.sender_id, day
ORDER BY msg.sender_id, day
`
)
.all(...params) as Array<{
senderId: number
name: string
day: string
}>
if (dailyActivity.length === 0) return emptyResult
// 2. 获取群聊总天数
const allDays = new Set(dailyActivity.map((r) => r.day))
const totalDays = allDays.size
// 获取最后一天(用于判断当前连续)
const sortedDays = Array.from(allDays).sort()
const lastDay = sortedDays[sortedDays.length - 1]
// 3. 按成员分组
const memberDays = new Map<number, { name: string; days: Set<string> }>()
for (const record of dailyActivity) {
if (!memberDays.has(record.senderId)) {
memberDays.set(record.senderId, { name: record.name, days: new Set() })
}
memberDays.get(record.senderId)!.days.add(record.day)
}
// 4. 计算每个成员的连续发言和累计发言
const streakData: Array<{
memberId: number
name: string
maxStreak: number
maxStreakStart: string
maxStreakEnd: string
currentStreak: number
}> = []
const loyaltyData: Array<{
memberId: number
name: string
totalDays: number
}> = []
for (const [memberId, data] of memberDays) {
const sortedMemberDays = Array.from(data.days).sort()
const totalMemberDays = sortedMemberDays.length
// 计算最长连续
let maxStreak = 1
let maxStreakStart = sortedMemberDays[0]
let maxStreakEnd = sortedMemberDays[0]
let currentStreakCount = 1
let currentStreakStart = sortedMemberDays[0]
for (let i = 1; i < sortedMemberDays.length; i++) {
const prevDate = new Date(sortedMemberDays[i - 1])
const currDate = new Date(sortedMemberDays[i])
const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
// 连续
currentStreakCount++
} else {
// 中断,检查是否更新最大值
if (currentStreakCount > maxStreak) {
maxStreak = currentStreakCount
maxStreakStart = currentStreakStart
maxStreakEnd = sortedMemberDays[i - 1]
}
currentStreakCount = 1
currentStreakStart = sortedMemberDays[i]
}
}
// 检查最后一段连续
if (currentStreakCount > maxStreak) {
maxStreak = currentStreakCount
maxStreakStart = currentStreakStart
maxStreakEnd = sortedMemberDays[sortedMemberDays.length - 1]
}
// 计算当前连续(是否以最后一天结束)
let finalCurrentStreak = 0
if (sortedMemberDays[sortedMemberDays.length - 1] === lastDay) {
// 从最后一天往前数
finalCurrentStreak = 1
for (let i = sortedMemberDays.length - 2; i >= 0; i--) {
const currDate = new Date(sortedMemberDays[i + 1])
const prevDate = new Date(sortedMemberDays[i])
const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
finalCurrentStreak++
} else {
break
}
}
}
streakData.push({
memberId,
name: data.name,
maxStreak,
maxStreakStart,
maxStreakEnd,
currentStreak: finalCurrentStreak,
})
loyaltyData.push({
memberId,
name: data.name,
totalDays: totalMemberDays,
})
}
// 5. 排序
const streakRank = streakData.sort((a, b) => b.maxStreak - a.maxStreak)
const sortedLoyalty = loyaltyData.sort((a, b) => b.totalDays - a.totalDays)
const maxLoyaltyDays = sortedLoyalty.length > 0 ? sortedLoyalty[0].totalDays : 1
const loyaltyRank = sortedLoyalty.map((item) => ({
...item,
percentage: Math.round((item.totalDays / maxLoyaltyDays) * 100),
}))
return {
streakRank,
loyaltyRank,
totalDays,
}
}