mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-24 04:21:01 +08:00
617 lines
18 KiB
TypeScript
617 lines
18 KiB
TypeScript
/**
|
||
* 活跃度分析模块
|
||
* 包含:夜猫分析、龙王分析、潜水分析、打卡分析
|
||
*/
|
||
|
||
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,
|
||
}
|
||
}
|
||
|