mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-21 11:26:47 +08:00
1343 lines
38 KiB
TypeScript
1343 lines
38 KiB
TypeScript
/**
|
||
* 数据库分析模块
|
||
* 负责各种数据分析查询
|
||
*/
|
||
|
||
import type { MessageType } from '../../../src/types/base'
|
||
import type {
|
||
MemberActivity,
|
||
HourlyActivity,
|
||
DailyActivity,
|
||
WeekdayActivity,
|
||
RepeatAnalysis,
|
||
RepeatStatItem,
|
||
RepeatRateItem,
|
||
ChainLengthDistribution,
|
||
HotRepeatContent,
|
||
CatchphraseAnalysis,
|
||
NightOwlAnalysis,
|
||
NightOwlRankItem,
|
||
NightOwlTitle,
|
||
TimeRankItem,
|
||
ConsecutiveNightRecord,
|
||
NightOwlChampion,
|
||
DragonKingAnalysis,
|
||
DragonKingRankItem,
|
||
DivingAnalysis,
|
||
DivingRankItem,
|
||
MonologueAnalysis,
|
||
MonologueRankItem,
|
||
MaxComboRecord,
|
||
} from '../../../src/types/analysis'
|
||
import { openDatabase } from './core'
|
||
|
||
/**
|
||
* 时间过滤参数
|
||
*/
|
||
export interface TimeFilter {
|
||
startTs?: number
|
||
endTs?: number
|
||
}
|
||
|
||
/**
|
||
* 构建时间过滤 WHERE 子句
|
||
*/
|
||
function buildTimeFilter(filter?: TimeFilter): { clause: string; params: number[] } {
|
||
const conditions: string[] = []
|
||
const params: number[] = []
|
||
|
||
if (filter?.startTs !== undefined) {
|
||
conditions.push('ts >= ?')
|
||
params.push(filter.startTs)
|
||
}
|
||
if (filter?.endTs !== undefined) {
|
||
conditions.push('ts <= ?')
|
||
params.push(filter.endTs)
|
||
}
|
||
|
||
return {
|
||
clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '',
|
||
params,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构建排除系统消息的过滤条件
|
||
*/
|
||
function buildSystemMessageFilter(existingClause: string): string {
|
||
const systemFilter = "COALESCE(m.account_name, '') != '系统消息'"
|
||
|
||
if (existingClause.includes('WHERE')) {
|
||
return existingClause + ' AND ' + systemFilter
|
||
} else {
|
||
return ' WHERE ' + systemFilter
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取可用的年份列表
|
||
*/
|
||
export function getAvailableYears(sessionId: string): number[] {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year
|
||
FROM message
|
||
ORDER BY year DESC
|
||
`
|
||
)
|
||
.all() as Array<{ year: number }>
|
||
|
||
return rows.map((r) => r.year)
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取成员活跃度排行
|
||
*/
|
||
export function getMemberActivity(sessionId: string, filter?: TimeFilter): MemberActivity[] {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
|
||
const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : ''
|
||
const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'"
|
||
|
||
const totalClauseWithSystem = buildSystemMessageFilter(clause)
|
||
const totalMessages = (
|
||
db
|
||
.prepare(
|
||
`SELECT COUNT(*) as count
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${totalClauseWithSystem}`
|
||
)
|
||
.get(...params) as { count: number }
|
||
).count
|
||
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
m.id as memberId,
|
||
m.platform_id as platformId,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name,
|
||
COUNT(msg.id) as messageCount
|
||
FROM member m
|
||
LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem}
|
||
WHERE COALESCE(m.account_name, '') != '系统消息'
|
||
GROUP BY m.id
|
||
HAVING messageCount > 0
|
||
ORDER BY messageCount DESC
|
||
`
|
||
)
|
||
.all(...params) as Array<{
|
||
memberId: number
|
||
platformId: string
|
||
name: string
|
||
messageCount: number
|
||
}>
|
||
|
||
return rows.map((row) => ({
|
||
memberId: row.memberId,
|
||
platformId: row.platformId,
|
||
name: row.name,
|
||
messageCount: row.messageCount,
|
||
percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0,
|
||
}))
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取每小时活跃度分布
|
||
*/
|
||
export function getHourlyActivity(sessionId: string, filter?: TimeFilter): HourlyActivity[] {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
||
COUNT(*) as messageCount
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${clauseWithSystem}
|
||
GROUP BY hour
|
||
ORDER BY hour
|
||
`
|
||
)
|
||
.all(...params) as Array<{ hour: number; messageCount: number }>
|
||
|
||
const result: HourlyActivity[] = []
|
||
for (let h = 0; h < 24; h++) {
|
||
const found = rows.find((r) => r.hour === h)
|
||
result.push({
|
||
hour: h,
|
||
messageCount: found ? found.messageCount : 0,
|
||
})
|
||
}
|
||
|
||
return result
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取每日活跃度趋势
|
||
*/
|
||
export function getDailyActivity(sessionId: string, filter?: TimeFilter): DailyActivity[] {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date,
|
||
COUNT(*) as messageCount
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${clauseWithSystem}
|
||
GROUP BY date
|
||
ORDER BY date
|
||
`
|
||
)
|
||
.all(...params) as Array<{ date: string; messageCount: number }>
|
||
|
||
return rows
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取消息类型分布
|
||
*/
|
||
export function getMessageTypeDistribution(
|
||
sessionId: string,
|
||
filter?: TimeFilter
|
||
): Array<{ type: MessageType; count: number }> {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT msg.type, COUNT(*) as count
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${clauseWithSystem}
|
||
GROUP BY msg.type
|
||
ORDER BY count DESC
|
||
`
|
||
)
|
||
.all(...params) as Array<{ type: number; count: number }>
|
||
|
||
return rows.map((r) => ({
|
||
type: r.type as MessageType,
|
||
count: r.count,
|
||
}))
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取时间范围
|
||
*/
|
||
export function getTimeRange(sessionId: string): { start: number; end: number } | null {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return null
|
||
|
||
try {
|
||
const row = db
|
||
.prepare(
|
||
`
|
||
SELECT MIN(ts) as start, MAX(ts) as end FROM message
|
||
`
|
||
)
|
||
.get() as { start: number | null; end: number | null }
|
||
|
||
if (row.start === null || row.end === null) return null
|
||
|
||
return { start: row.start, end: row.end }
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取成员的历史昵称记录
|
||
*/
|
||
export function getMemberNameHistory(
|
||
sessionId: string,
|
||
memberId: number
|
||
): Array<{ name: string; startTs: number; endTs: number | null }> {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT name, start_ts as startTs, end_ts as endTs
|
||
FROM member_name_history
|
||
WHERE member_id = ?
|
||
ORDER BY start_ts DESC
|
||
`
|
||
)
|
||
.all(memberId) as Array<{ name: string; startTs: number; endTs: number | null }>
|
||
|
||
return rows
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取复读分析数据
|
||
* 使用滑动窗口算法检测复读链:
|
||
* - 复读成立条件:至少 3 条连续的相同内容消息,且发送者不同
|
||
* - 排除:系统消息、空消息、图片消息
|
||
*/
|
||
export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): RepeatAnalysis {
|
||
const db = openDatabase(sessionId)
|
||
const emptyResult: RepeatAnalysis = {
|
||
originators: [],
|
||
initiators: [],
|
||
breakers: [],
|
||
originatorRates: [],
|
||
initiatorRates: [],
|
||
breakerRates: [],
|
||
chainLengthDistribution: [],
|
||
hotContents: [],
|
||
avgChainLength: 0,
|
||
totalRepeatChains: 0,
|
||
}
|
||
|
||
if (!db) {
|
||
return emptyResult
|
||
}
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
|
||
let whereClause = clause
|
||
if (whereClause.includes('WHERE')) {
|
||
whereClause +=
|
||
" AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''"
|
||
} else {
|
||
whereClause =
|
||
" WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''"
|
||
}
|
||
|
||
const messages = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
msg.id,
|
||
msg.sender_id as senderId,
|
||
msg.content,
|
||
msg.ts,
|
||
m.platform_id as platformId,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${whereClause}
|
||
ORDER BY msg.ts ASC, msg.id ASC
|
||
`
|
||
)
|
||
.all(...params) as Array<{
|
||
id: number
|
||
senderId: number
|
||
content: string
|
||
ts: number
|
||
platformId: string
|
||
name: string
|
||
}>
|
||
|
||
const originatorCount = new Map<number, number>()
|
||
const initiatorCount = new Map<number, number>()
|
||
const breakerCount = new Map<number, number>()
|
||
const memberMessageCount = new Map<number, number>()
|
||
|
||
const memberInfo = new Map<number, { platformId: string; name: string }>()
|
||
|
||
const chainLengthCount = new Map<number, number>()
|
||
|
||
const contentStats = new Map<
|
||
string,
|
||
{ count: number; maxChainLength: number; originatorId: number; lastTs: number }
|
||
>()
|
||
|
||
let currentContent: string | null = null
|
||
let repeatChain: Array<{ senderId: number; content: string; ts: number }> = []
|
||
let totalRepeatChains = 0
|
||
let totalChainLength = 0
|
||
|
||
const processRepeatChain = (
|
||
chain: Array<{ senderId: number; content: string; ts: number }>,
|
||
breakerId?: number
|
||
) => {
|
||
if (chain.length < 3) return
|
||
|
||
totalRepeatChains++
|
||
const chainLength = chain.length
|
||
totalChainLength += chainLength
|
||
|
||
const originatorId = chain[0].senderId
|
||
originatorCount.set(originatorId, (originatorCount.get(originatorId) || 0) + 1)
|
||
|
||
const initiatorId = chain[1].senderId
|
||
initiatorCount.set(initiatorId, (initiatorCount.get(initiatorId) || 0) + 1)
|
||
|
||
if (breakerId !== undefined) {
|
||
breakerCount.set(breakerId, (breakerCount.get(breakerId) || 0) + 1)
|
||
}
|
||
|
||
chainLengthCount.set(chainLength, (chainLengthCount.get(chainLength) || 0) + 1)
|
||
|
||
const content = chain[0].content
|
||
const chainTs = chain[0].ts
|
||
const existing = contentStats.get(content)
|
||
if (existing) {
|
||
existing.count++
|
||
existing.lastTs = Math.max(existing.lastTs, chainTs)
|
||
if (chainLength > existing.maxChainLength) {
|
||
existing.maxChainLength = chainLength
|
||
existing.originatorId = originatorId
|
||
}
|
||
} else {
|
||
contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs })
|
||
}
|
||
}
|
||
|
||
for (const msg of messages) {
|
||
if (!memberInfo.has(msg.senderId)) {
|
||
memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name })
|
||
}
|
||
|
||
memberMessageCount.set(msg.senderId, (memberMessageCount.get(msg.senderId) || 0) + 1)
|
||
|
||
const content = msg.content.trim()
|
||
|
||
if (content === currentContent) {
|
||
const lastSender = repeatChain[repeatChain.length - 1]?.senderId
|
||
if (lastSender !== msg.senderId) {
|
||
repeatChain.push({ senderId: msg.senderId, content, ts: msg.ts })
|
||
}
|
||
} else {
|
||
processRepeatChain(repeatChain, msg.senderId)
|
||
|
||
currentContent = content
|
||
repeatChain = [{ senderId: msg.senderId, content, ts: msg.ts }]
|
||
}
|
||
}
|
||
|
||
processRepeatChain(repeatChain)
|
||
|
||
const buildRankList = (countMap: Map<number, number>, total: number): RepeatStatItem[] => {
|
||
const items: RepeatStatItem[] = []
|
||
for (const [memberId, count] of countMap.entries()) {
|
||
const info = memberInfo.get(memberId)
|
||
if (info) {
|
||
items.push({
|
||
memberId,
|
||
platformId: info.platformId,
|
||
name: info.name,
|
||
count,
|
||
percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0,
|
||
})
|
||
}
|
||
}
|
||
return items.sort((a, b) => b.count - a.count)
|
||
}
|
||
|
||
const buildRateList = (countMap: Map<number, number>): RepeatRateItem[] => {
|
||
const items: RepeatRateItem[] = []
|
||
for (const [memberId, count] of countMap.entries()) {
|
||
const info = memberInfo.get(memberId)
|
||
const totalMessages = memberMessageCount.get(memberId) || 0
|
||
if (info && totalMessages > 0) {
|
||
items.push({
|
||
memberId,
|
||
platformId: info.platformId,
|
||
name: info.name,
|
||
count,
|
||
totalMessages,
|
||
rate: Math.round((count / totalMessages) * 10000) / 100,
|
||
})
|
||
}
|
||
}
|
||
return items.sort((a, b) => b.rate - a.rate)
|
||
}
|
||
|
||
const chainLengthDistribution: ChainLengthDistribution[] = []
|
||
for (const [length, count] of chainLengthCount.entries()) {
|
||
chainLengthDistribution.push({ length, count })
|
||
}
|
||
chainLengthDistribution.sort((a, b) => a.length - b.length)
|
||
|
||
const hotContents: HotRepeatContent[] = []
|
||
for (const [content, stats] of contentStats.entries()) {
|
||
const originatorInfo = memberInfo.get(stats.originatorId)
|
||
hotContents.push({
|
||
content,
|
||
count: stats.count,
|
||
maxChainLength: stats.maxChainLength,
|
||
originatorName: originatorInfo?.name || '未知',
|
||
lastTs: stats.lastTs,
|
||
})
|
||
}
|
||
hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength)
|
||
const top10HotContents = hotContents.slice(0, 10)
|
||
|
||
return {
|
||
originators: buildRankList(originatorCount, totalRepeatChains),
|
||
initiators: buildRankList(initiatorCount, totalRepeatChains),
|
||
breakers: buildRankList(breakerCount, totalRepeatChains),
|
||
originatorRates: buildRateList(originatorCount),
|
||
initiatorRates: buildRateList(initiatorCount),
|
||
breakerRates: buildRateList(breakerCount),
|
||
chainLengthDistribution,
|
||
hotContents: top10HotContents,
|
||
avgChainLength: totalRepeatChains > 0 ? Math.round((totalChainLength / totalRepeatChains) * 100) / 100 : 0,
|
||
totalRepeatChains,
|
||
}
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取星期活跃度分布
|
||
* 返回周一到周日的消息统计
|
||
*/
|
||
export function getWeekdayActivity(sessionId: string, filter?: TimeFilter): WeekdayActivity[] {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) return []
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||
|
||
// SQLite strftime('%w') 返回 0-6,0=周日
|
||
// 我们需要转换为 1-7,1=周一,7=周日
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
CASE
|
||
WHEN CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) = 0 THEN 7
|
||
ELSE CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER)
|
||
END as weekday,
|
||
COUNT(*) as messageCount
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${clauseWithSystem}
|
||
GROUP BY weekday
|
||
ORDER BY weekday
|
||
`
|
||
)
|
||
.all(...params) as Array<{ weekday: number; messageCount: number }>
|
||
|
||
// 补全所有星期(1-7)
|
||
const result: WeekdayActivity[] = []
|
||
for (let w = 1; w <= 7; w++) {
|
||
const found = rows.find((r) => r.weekday === w)
|
||
result.push({
|
||
weekday: w,
|
||
messageCount: found ? found.messageCount : 0,
|
||
})
|
||
}
|
||
|
||
return result
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取口头禅分析数据
|
||
* 统计每个成员最常说的内容(前5个)
|
||
* - 排除:系统消息、空消息、图片消息
|
||
* - 排除:过短的内容(少于2个字符)
|
||
*/
|
||
export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter): CatchphraseAnalysis {
|
||
const db = openDatabase(sessionId)
|
||
if (!db) {
|
||
return { members: [] }
|
||
}
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
|
||
let whereClause = clause
|
||
if (whereClause.includes('WHERE')) {
|
||
whereClause +=
|
||
" AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2"
|
||
} else {
|
||
whereClause =
|
||
" WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2"
|
||
}
|
||
|
||
const rows = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
m.id as memberId,
|
||
m.platform_id as platformId,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name,
|
||
TRIM(msg.content) as content,
|
||
COUNT(*) as count
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${whereClause}
|
||
GROUP BY m.id, TRIM(msg.content)
|
||
ORDER BY m.id, count DESC
|
||
`
|
||
)
|
||
.all(...params) as Array<{
|
||
memberId: number
|
||
platformId: string
|
||
name: string
|
||
content: string
|
||
count: number
|
||
}>
|
||
|
||
const memberMap = new Map<
|
||
number,
|
||
{
|
||
memberId: number
|
||
platformId: string
|
||
name: string
|
||
catchphrases: Array<{ content: string; count: number }>
|
||
}
|
||
>()
|
||
|
||
for (const row of rows) {
|
||
if (!memberMap.has(row.memberId)) {
|
||
memberMap.set(row.memberId, {
|
||
memberId: row.memberId,
|
||
platformId: row.platformId,
|
||
name: row.name,
|
||
catchphrases: [],
|
||
})
|
||
}
|
||
|
||
const member = memberMap.get(row.memberId)!
|
||
if (member.catchphrases.length < 5) {
|
||
member.catchphrases.push({
|
||
content: row.content,
|
||
count: row.count,
|
||
})
|
||
}
|
||
}
|
||
|
||
const members = Array.from(memberMap.values())
|
||
members.sort((a, b) => {
|
||
const aTotal = a.catchphrases.reduce((sum, c) => sum + c.count, 0)
|
||
const bTotal = b.catchphrases.reduce((sum, c) => sum + c.count, 0)
|
||
return bTotal - aTotal
|
||
})
|
||
|
||
return { members }
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据深夜发言数获取称号
|
||
*/
|
||
function getNightOwlTitleByCount(count: number): NightOwlTitle {
|
||
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点为界)
|
||
* 05:00 之前的消息算作前一天
|
||
*/
|
||
function getAdjustedDate(ts: number): string {
|
||
const date = new Date(ts * 1000)
|
||
const hour = date.getHours()
|
||
|
||
// 如果是凌晨 0-4 点,算作前一天
|
||
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')}`
|
||
}
|
||
|
||
/**
|
||
* 获取夜猫分析数据
|
||
* 深夜时段定义:23:00-05:00
|
||
* 一天定义:05:00 ~ 次日 04:59
|
||
*/
|
||
export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): NightOwlAnalysis {
|
||
const db = openDatabase(sessionId)
|
||
const emptyResult: NightOwlAnalysis = {
|
||
nightOwlRank: [],
|
||
lastSpeakerRank: [],
|
||
firstSpeakerRank: [],
|
||
consecutiveRecords: [],
|
||
champions: [],
|
||
totalDays: 0,
|
||
}
|
||
|
||
if (!db) return emptyResult
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||
|
||
// 1. 获取所有消息(用于多种分析)
|
||
const messages = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
msg.id,
|
||
msg.sender_id as senderId,
|
||
msg.ts,
|
||
m.platform_id as platformId,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as 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 }>()
|
||
|
||
// ========== 分析 1: 修仙排行榜 ==========
|
||
const nightStats = new Map<
|
||
number,
|
||
{
|
||
total: number
|
||
h23: number
|
||
h0: number
|
||
h1: number
|
||
h2: number
|
||
h3to4: number
|
||
totalMessages: number
|
||
}
|
||
>()
|
||
|
||
// ========== 分析 2 & 3: 最晚/最早发言 ==========
|
||
// 按调整后的日期分组消息
|
||
const dailyMessages = new Map<string, Array<{ senderId: number; ts: number; hour: number; minute: number }>>()
|
||
|
||
// ========== 分析 4: 连续修仙天数 ==========
|
||
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++
|
||
|
||
// 统计深夜发言 (23:00-05:00)
|
||
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: NightOwlRankItem[] = []
|
||
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: TimeRankItem[] = []
|
||
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: TimeRankItem[] = []
|
||
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: ConsecutiveNightRecord[] = []
|
||
|
||
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)
|
||
|
||
// ========== 构建修仙王者(综合排名) ==========
|
||
// 综合得分 = 深夜发言数 × 1 + 最晚下班次数 × 10 + 连续修仙天数 × 20
|
||
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: NightOwlChampion[] = []
|
||
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,
|
||
}
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取龙王排名
|
||
* 每天发言最多的人+1,统计所有天数
|
||
*/
|
||
export function getDragonKingAnalysis(sessionId: string, filter?: TimeFilter): DragonKingAnalysis {
|
||
const db = openDatabase(sessionId)
|
||
const emptyResult: DragonKingAnalysis = {
|
||
rank: [],
|
||
totalDays: 0,
|
||
}
|
||
|
||
if (!db) return emptyResult
|
||
|
||
try {
|
||
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,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as 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: DragonKingRankItem[] = 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 }
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取潜水排名
|
||
* 所有人的最后一次发言记录,按时间倒序(最久没发言的在前面)
|
||
*/
|
||
export function getDivingAnalysis(sessionId: string, filter?: TimeFilter): DivingAnalysis {
|
||
const db = openDatabase(sessionId)
|
||
const emptyResult: DivingAnalysis = {
|
||
rank: [],
|
||
}
|
||
|
||
if (!db) return emptyResult
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||
|
||
// 查询每个成员的最后发言时间
|
||
const lastMessages = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
m.id as member_id,
|
||
m.platform_id,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as 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: DivingRankItem[] = 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 }
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取自言自语分析
|
||
* 定义:一个人连续发言超过三次(间隔<=5分钟)
|
||
* 分类:3-4句(加特林)、5-9句(小作文)、10+句(无人区广播)
|
||
*/
|
||
export function getMonologueAnalysis(sessionId: string, filter?: TimeFilter): MonologueAnalysis {
|
||
const db = openDatabase(sessionId)
|
||
const emptyResult: MonologueAnalysis = {
|
||
rank: [],
|
||
maxComboRecord: null,
|
||
}
|
||
|
||
if (!db) return emptyResult
|
||
|
||
try {
|
||
const { clause, params } = buildTimeFilter(filter)
|
||
|
||
// 构建 WHERE 子句:只统计文本消息
|
||
let whereClause = clause
|
||
if (whereClause.includes('WHERE')) {
|
||
whereClause += " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0"
|
||
} else {
|
||
whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0"
|
||
}
|
||
|
||
// 获取所有文本消息,按时间排序
|
||
const messages = db
|
||
.prepare(
|
||
`
|
||
SELECT
|
||
msg.id,
|
||
msg.sender_id as senderId,
|
||
msg.ts,
|
||
m.platform_id as platformId,
|
||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name
|
||
FROM message msg
|
||
JOIN member m ON msg.sender_id = m.id
|
||
${whereClause}
|
||
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 }>()
|
||
|
||
// 连击统计:memberId -> { totalStreaks, maxCombo, lowStreak, midStreak, highStreak }
|
||
const memberStats = new Map<
|
||
number,
|
||
{
|
||
totalStreaks: number
|
||
maxCombo: number
|
||
lowStreak: number
|
||
midStreak: number
|
||
highStreak: number
|
||
}
|
||
>()
|
||
|
||
// 全群最高纪录
|
||
let globalMaxCombo: { memberId: number; comboLength: number; startTs: number } | null = null
|
||
|
||
// 连击间隔限制:5分钟 = 300秒
|
||
const MAX_INTERVAL = 300
|
||
|
||
// 当前连击状态
|
||
let currentStreak = {
|
||
senderId: -1,
|
||
count: 0,
|
||
startTs: 0,
|
||
lastTs: 0,
|
||
}
|
||
|
||
// 处理一个连击结束
|
||
const finishStreak = () => {
|
||
if (currentStreak.count >= 3) {
|
||
const memberId = currentStreak.senderId
|
||
|
||
// 初始化成员统计
|
||
if (!memberStats.has(memberId)) {
|
||
memberStats.set(memberId, {
|
||
totalStreaks: 0,
|
||
maxCombo: 0,
|
||
lowStreak: 0,
|
||
midStreak: 0,
|
||
highStreak: 0,
|
||
})
|
||
}
|
||
|
||
const stats = memberStats.get(memberId)!
|
||
stats.totalStreaks++
|
||
stats.maxCombo = Math.max(stats.maxCombo, currentStreak.count)
|
||
|
||
// 分类统计:3-4句、5-9句、10+句
|
||
if (currentStreak.count >= 10) {
|
||
stats.highStreak++
|
||
} else if (currentStreak.count >= 5) {
|
||
stats.midStreak++
|
||
} else {
|
||
stats.lowStreak++ // 3-4句
|
||
}
|
||
|
||
// 更新全群最高纪录
|
||
if (!globalMaxCombo || currentStreak.count > globalMaxCombo.comboLength) {
|
||
globalMaxCombo = {
|
||
memberId,
|
||
comboLength: currentStreak.count,
|
||
startTs: currentStreak.startTs,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 遍历所有消息
|
||
for (const msg of messages) {
|
||
// 记录成员信息
|
||
if (!memberInfo.has(msg.senderId)) {
|
||
memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name })
|
||
}
|
||
|
||
// 检查是否延续当前连击
|
||
const isSameSender = msg.senderId === currentStreak.senderId
|
||
const isWithinInterval = msg.ts - currentStreak.lastTs <= MAX_INTERVAL
|
||
|
||
if (isSameSender && isWithinInterval) {
|
||
// 延续连击
|
||
currentStreak.count++
|
||
currentStreak.lastTs = msg.ts
|
||
} else {
|
||
// 结束当前连击,开始新的
|
||
finishStreak()
|
||
currentStreak = {
|
||
senderId: msg.senderId,
|
||
count: 1,
|
||
startTs: msg.ts,
|
||
lastTs: msg.ts,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理最后一个连击
|
||
finishStreak()
|
||
|
||
// 构建排行榜(按总连击次数排序)
|
||
const rank: MonologueRankItem[] = []
|
||
for (const [memberId, stats] of memberStats.entries()) {
|
||
const info = memberInfo.get(memberId)!
|
||
rank.push({
|
||
memberId,
|
||
platformId: info.platformId,
|
||
name: info.name,
|
||
totalStreaks: stats.totalStreaks,
|
||
maxCombo: stats.maxCombo,
|
||
lowStreak: stats.lowStreak,
|
||
midStreak: stats.midStreak,
|
||
highStreak: stats.highStreak,
|
||
})
|
||
}
|
||
rank.sort((a, b) => b.totalStreaks - a.totalStreaks)
|
||
|
||
// 构建全群最高纪录
|
||
let maxComboRecord: MaxComboRecord | null = null
|
||
if (globalMaxCombo) {
|
||
const info = memberInfo.get(globalMaxCombo.memberId)!
|
||
maxComboRecord = {
|
||
memberId: globalMaxCombo.memberId,
|
||
platformId: info.platformId,
|
||
memberName: info.name,
|
||
comboLength: globalMaxCombo.comboLength,
|
||
startTs: globalMaxCombo.startTs,
|
||
}
|
||
}
|
||
|
||
return { rank, maxComboRecord }
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|