mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
refactor(analysis): add cached analytics endpoints for message & ranking charts
将 charts-message 的文字统计/长消息/月度趋势/文本长度百分位,以及 charts-ranking 的龙王/潜水/打卡/斗图/夜猫/复读六项分析从前端 pluginQuery+pluginCompute 移植到 @openchatlab/core,并通过 analytics 缓存路由暴露,消除前端拉全量数据再计算的开销。 - core: 新增 basic-queries 文本统计函数与 advanced/ranking.ts 六项分析及类型,含单测 - http-routes: 新增对应缓存路由,long-message-count 的 minLength 进缓存 key Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8192a43a41
commit
99ed14f8a5
9 files changed
+1887
-11
No files matched your search
@@ -58,6 +58,10 @@ export {
|
||||
getMonthlyActivity,
|
||||
getYearlyActivity,
|
||||
getMessageLengthDistribution,
|
||||
getTextStats,
|
||||
getLongMessageCount,
|
||||
getMemberMonthlyTrend,
|
||||
getTextLengthPercentiles,
|
||||
queryMessages,
|
||||
searchMessagesLike,
|
||||
getRecentMessages,
|
||||
@@ -80,6 +84,12 @@ export {
|
||||
getClusterGraph,
|
||||
getRelationshipStats,
|
||||
getLanguagePreferenceAnalysis,
|
||||
getDragonKingAnalysis,
|
||||
getDivingAnalysis,
|
||||
getCheckInAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getNightOwlAnalysis,
|
||||
getRepeatAnalysis,
|
||||
FULL_MSG_COLUMNS,
|
||||
FULL_MSG_FROM,
|
||||
FULL_MSG_SELECT,
|
||||
@@ -128,6 +138,9 @@ export type {
|
||||
MonthlyActivity,
|
||||
YearlyActivity,
|
||||
MessageLengthDistribution,
|
||||
TextStats,
|
||||
TextLengthPercentiles,
|
||||
MemberMonthlyTrend,
|
||||
QueryMessagesOptions,
|
||||
QueryMessagesResult,
|
||||
MessageResult,
|
||||
@@ -160,6 +173,28 @@ export type {
|
||||
NlpProvider,
|
||||
PosTagResult,
|
||||
LanguagePreferenceParams,
|
||||
NightOwlTitle,
|
||||
NightOwlRankItem,
|
||||
TimeRankItem,
|
||||
ConsecutiveNightRecord,
|
||||
NightOwlChampion,
|
||||
NightOwlAnalysis,
|
||||
DragonKingRankItem,
|
||||
DragonKingAnalysis,
|
||||
DivingRankItem,
|
||||
DivingAnalysis,
|
||||
RepeatStatItem,
|
||||
RepeatRateItem,
|
||||
ChainLengthDistribution,
|
||||
HotRepeatContent,
|
||||
FastestRepeaterItem,
|
||||
RepeatAnalysis,
|
||||
MemeBattleRankItem,
|
||||
MemeBattleRecord,
|
||||
MemeBattleAnalysis,
|
||||
StreakRankItem,
|
||||
LoyaltyRankItem,
|
||||
CheckInAnalysis,
|
||||
FullMessageRow,
|
||||
MappedMessage,
|
||||
MsgQueryConditions,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Tests for the text/length statistics queries migrated from the plugin charts:
|
||||
* getTextStats, getLongMessageCount, getMemberMonthlyTrend, getTextLengthPercentiles.
|
||||
*
|
||||
* Uses a real in-memory SQLite DB to lock the SQL + post-processing behavior,
|
||||
* including system-message exclusion and member/time filtering.
|
||||
*
|
||||
* Run: npx tsx --test packages/core/src/query/__tests__/basic-queries.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import Database from 'better-sqlite3'
|
||||
import { getTextStats, getLongMessageCount, getMemberMonthlyTrend, getTextLengthPercentiles } from '../basic-queries'
|
||||
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces'
|
||||
|
||||
class Stmt implements PreparedStatement {
|
||||
readonly?: boolean
|
||||
constructor(private stmt: Database.Statement) {
|
||||
this.readonly = stmt.readonly
|
||||
}
|
||||
get(...p: unknown[]) {
|
||||
return this.stmt.get(...p) as Record<string, unknown> | undefined
|
||||
}
|
||||
all(...p: unknown[]) {
|
||||
return this.stmt.all(...p) as Record<string, unknown>[]
|
||||
}
|
||||
run(...p: unknown[]): RunResult {
|
||||
const r = this.stmt.run(...p)
|
||||
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid }
|
||||
}
|
||||
}
|
||||
|
||||
class Adapter implements DatabaseAdapter {
|
||||
constructor(private db: Database.Database) {}
|
||||
exec(sql: string) {
|
||||
this.db.exec(sql)
|
||||
}
|
||||
prepare(sql: string) {
|
||||
return new Stmt(this.db.prepare(sql))
|
||||
}
|
||||
transaction<T>(fn: () => T): T {
|
||||
return this.db.transaction(fn)()
|
||||
}
|
||||
pragma(p: string) {
|
||||
return this.db.pragma(p)
|
||||
}
|
||||
close() {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 2024-01-15 12:00:00 UTC, 时区偏移不会跨出 2024-01
|
||||
const TS = 1705320000
|
||||
|
||||
describe('basic-queries text/length stats', () => {
|
||||
let raw: Database.Database
|
||||
let db: Adapter
|
||||
|
||||
beforeEach(() => {
|
||||
raw = new Database(':memory:')
|
||||
raw.exec(`
|
||||
CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, group_nickname TEXT, avatar TEXT);
|
||||
CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT);
|
||||
INSERT INTO member (id, platform_id, account_name) VALUES (1, 'u1', 'Alice'), (2, 'u2', 'Bob'), (99, 'sys', '系统消息');
|
||||
`)
|
||||
const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)')
|
||||
// Alice 文字消息 长度 3 / 6 / 40
|
||||
insert.run(1, 1, TS, 0, 'a'.repeat(3))
|
||||
insert.run(2, 1, TS, 0, 'a'.repeat(6))
|
||||
insert.run(3, 1, TS, 0, 'a'.repeat(40))
|
||||
// Bob 文字消息 长度 2 / 10
|
||||
insert.run(4, 2, TS, 0, 'b'.repeat(2))
|
||||
insert.run(5, 2, TS, 0, 'b'.repeat(10))
|
||||
// Alice 非文字消息(type!=0)应忽略
|
||||
insert.run(6, 1, TS, 1, 'image-url')
|
||||
// 系统消息成员的长文字消息应被系统过滤排除
|
||||
insert.run(7, 99, TS, 0, 's'.repeat(50))
|
||||
db = new Adapter(raw)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
raw.close()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
})
|
||||
|
||||
it('getTextStats aggregates text-only, excludes system messages', () => {
|
||||
// 长度 [3,6,40,2,10] -> count5, avg=12.2, max40, short(<=5)=2
|
||||
assert.deepEqual(getTextStats(db), { textCount: 5, avgLength: 12.2, maxLength: 40, shortCount: 2 })
|
||||
})
|
||||
|
||||
it('getTextStats honors memberId filter', () => {
|
||||
// Alice 长度 [3,6,40] -> count3, avg=16.3, max40, short=1
|
||||
assert.deepEqual(getTextStats(db, { memberId: 1 }), {
|
||||
textCount: 3,
|
||||
avgLength: 16.3,
|
||||
maxLength: 40,
|
||||
shortCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('getTextStats returns zeros when no text messages match', () => {
|
||||
// 系统成员被过滤后无文字消息
|
||||
assert.deepEqual(getTextStats(db, { memberId: 99 }), {
|
||||
textCount: 0,
|
||||
avgLength: 0,
|
||||
maxLength: 0,
|
||||
shortCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('getTextLengthPercentiles computes percentiles over sorted lengths', () => {
|
||||
// sorted [2,3,6,10,40] -> p25=3, p50=6, p75=10, p90=40
|
||||
assert.deepEqual(getTextLengthPercentiles(db), { p25: 3, p50: 6, p75: 10, p90: 40 })
|
||||
})
|
||||
|
||||
it('getTextLengthPercentiles returns zeros on empty set', () => {
|
||||
assert.deepEqual(getTextLengthPercentiles(db, { memberId: 99 }), { p25: 0, p50: 0, p75: 0, p90: 0 })
|
||||
})
|
||||
|
||||
it('getLongMessageCount counts text >= minLength (default 30)', () => {
|
||||
assert.equal(getLongMessageCount(db), 1) // 仅长度 40
|
||||
assert.equal(getLongMessageCount(db, undefined, 10), 2) // 长度 40 与 10
|
||||
assert.equal(getLongMessageCount(db, { memberId: 2 }, 10), 1) // Bob 仅长度 10
|
||||
})
|
||||
|
||||
it('getMemberMonthlyTrend groups by month and sender, excludes system', () => {
|
||||
const rows = getMemberMonthlyTrend(db)
|
||||
// Alice 3 文字+1 非文字=4 条;Bob 2 条;系统成员排除
|
||||
assert.equal(rows.length, 2)
|
||||
const alice = rows.find((r) => r.memberId === 1)
|
||||
const bob = rows.find((r) => r.memberId === 2)
|
||||
assert.deepEqual(alice, { month: '2024-01', memberId: 1, memberName: 'Alice', count: 4 })
|
||||
assert.deepEqual(bob, { month: '2024-01', memberId: 2, memberName: 'Bob', count: 2 })
|
||||
assert.equal(
|
||||
rows.find((r) => r.memberId === 99),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Tests for ranking analytics migrated from frontend pluginCompute:
|
||||
* getDragonKingAnalysis, getDivingAnalysis, getCheckInAnalysis,
|
||||
* getMemeBattleAnalysis, getNightOwlAnalysis, getRepeatAnalysis.
|
||||
*
|
||||
* Locks the ported algorithms against the previous behavior using a real
|
||||
* in-memory SQLite DB. TZ is pinned to UTC so strftime('localtime') and JS
|
||||
* Date hour/date math are deterministic across machines.
|
||||
*
|
||||
* Run: npx tsx --test packages/core/src/query/advanced/__tests__/ranking.test.ts
|
||||
*/
|
||||
|
||||
process.env.TZ = 'UTC'
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import Database from 'better-sqlite3'
|
||||
import {
|
||||
getDragonKingAnalysis,
|
||||
getDivingAnalysis,
|
||||
getCheckInAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getNightOwlAnalysis,
|
||||
getRepeatAnalysis,
|
||||
} from '../ranking'
|
||||
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../../interfaces'
|
||||
|
||||
class Stmt implements PreparedStatement {
|
||||
readonly?: boolean
|
||||
constructor(private stmt: Database.Statement) {
|
||||
this.readonly = stmt.readonly
|
||||
}
|
||||
get(...p: unknown[]) {
|
||||
return this.stmt.get(...p) as Record<string, unknown> | undefined
|
||||
}
|
||||
all(...p: unknown[]) {
|
||||
return this.stmt.all(...p) as Record<string, unknown>[]
|
||||
}
|
||||
run(...p: unknown[]): RunResult {
|
||||
const r = this.stmt.run(...p)
|
||||
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid }
|
||||
}
|
||||
}
|
||||
|
||||
class Adapter implements DatabaseAdapter {
|
||||
constructor(private db: Database.Database) {}
|
||||
exec(sql: string) {
|
||||
this.db.exec(sql)
|
||||
}
|
||||
prepare(sql: string) {
|
||||
return new Stmt(this.db.prepare(sql))
|
||||
}
|
||||
transaction<T>(fn: () => T): T {
|
||||
return this.db.transaction(fn)()
|
||||
}
|
||||
pragma(p: string) {
|
||||
return this.db.pragma(p)
|
||||
}
|
||||
close() {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: number
|
||||
name: string
|
||||
system?: boolean
|
||||
}
|
||||
interface Msg {
|
||||
id: number
|
||||
senderId: number
|
||||
ts: number
|
||||
type?: number
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
function makeDb(members: Member[], messages: Msg[]): Adapter {
|
||||
const raw = new Database(':memory:')
|
||||
raw.exec(`
|
||||
CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, group_nickname TEXT, avatar TEXT);
|
||||
CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT);
|
||||
`)
|
||||
const mIns = raw.prepare('INSERT INTO member (id, platform_id, account_name, group_nickname) VALUES (?, ?, ?, ?)')
|
||||
for (const m of members) {
|
||||
mIns.run(m.id, `u${m.id}`, m.system ? '系统消息' : m.name, null)
|
||||
}
|
||||
const ins = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)')
|
||||
for (const msg of messages) {
|
||||
ins.run(msg.id, msg.senderId, msg.ts, msg.type ?? 0, msg.content ?? 'x')
|
||||
}
|
||||
return new Adapter(raw)
|
||||
}
|
||||
|
||||
const DAY1 = 1704067200 // 2024-01-01 00:00:00 UTC
|
||||
const DAY2 = DAY1 + 86400 // 2024-01-02
|
||||
const DAY3 = DAY2 + 86400 // 2024-01-03
|
||||
|
||||
describe('ranking analytics (ported algorithms)', () => {
|
||||
it('getDragonKingAnalysis ranks daily top sender', () => {
|
||||
// A 两天都是发言最多 -> dragon_days=2;B 从未夺冠 -> 不进榜
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY1 + 10 },
|
||||
{ id: 2, senderId: 1, ts: DAY1 + 20 },
|
||||
{ id: 3, senderId: 1, ts: DAY1 + 30 },
|
||||
{ id: 4, senderId: 2, ts: DAY1 + 40 },
|
||||
{ id: 5, senderId: 1, ts: DAY2 + 10 },
|
||||
{ id: 6, senderId: 1, ts: DAY2 + 20 },
|
||||
{ id: 7, senderId: 2, ts: DAY2 + 30 },
|
||||
]
|
||||
)
|
||||
const res = getDragonKingAnalysis(db)
|
||||
assert.equal(res.totalDays, 2)
|
||||
assert.equal(res.rank.length, 1)
|
||||
assert.equal(res.rank[0].memberId, 1)
|
||||
assert.equal(res.rank[0].count, 2)
|
||||
assert.equal(res.rank[0].percentage, 100)
|
||||
})
|
||||
|
||||
it('getDivingAnalysis orders by last message ascending', () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY3 + 5 },
|
||||
{ id: 2, senderId: 2, ts: DAY1 + 5 },
|
||||
]
|
||||
)
|
||||
const res = getDivingAnalysis(db)
|
||||
assert.equal(res.rank.length, 2)
|
||||
assert.equal(res.rank[0].memberId, 2) // B 最久没发言,排最前
|
||||
assert.equal(res.rank[0].lastMessageTs, DAY1 + 5)
|
||||
assert.equal(res.rank[1].memberId, 1)
|
||||
})
|
||||
|
||||
it('getCheckInAnalysis computes max/current streak and loyalty', () => {
|
||||
// A: 01-01,02,03 连续 streak3;B: 01-01,03 有断层 streak1
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY1 + 5 },
|
||||
{ id: 2, senderId: 1, ts: DAY2 + 5 },
|
||||
{ id: 3, senderId: 1, ts: DAY3 + 5 },
|
||||
{ id: 4, senderId: 2, ts: DAY1 + 6 },
|
||||
{ id: 5, senderId: 2, ts: DAY3 + 6 },
|
||||
]
|
||||
)
|
||||
const res = getCheckInAnalysis(db)
|
||||
assert.equal(res.totalDays, 3)
|
||||
const a = res.streakRank.find((r) => r.memberId === 1)!
|
||||
const b = res.streakRank.find((r) => r.memberId === 2)!
|
||||
assert.equal(a.maxStreak, 3)
|
||||
assert.equal(a.currentStreak, 3)
|
||||
assert.equal(b.maxStreak, 1)
|
||||
assert.equal(b.currentStreak, 1)
|
||||
const la = res.loyaltyRank.find((r) => r.memberId === 1)!
|
||||
const lb = res.loyaltyRank.find((r) => r.memberId === 2)!
|
||||
assert.equal(la.totalDays, 3)
|
||||
assert.equal(la.percentage, 100)
|
||||
assert.equal(lb.totalDays, 2)
|
||||
assert.equal(lb.percentage, 67)
|
||||
})
|
||||
|
||||
it('getMemeBattleAnalysis detects image chains with >=3 msgs and >=2 senders', () => {
|
||||
// A,B,A 连续图片 (type 1) -> 一场斗图;C 文字打断
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
{ id: 3, name: 'C' },
|
||||
],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY1 + 1, type: 1 },
|
||||
{ id: 2, senderId: 2, ts: DAY1 + 2, type: 1 },
|
||||
{ id: 3, senderId: 1, ts: DAY1 + 3, type: 5 },
|
||||
{ id: 4, senderId: 3, ts: DAY1 + 4, type: 0 },
|
||||
]
|
||||
)
|
||||
const res = getMemeBattleAnalysis(db)
|
||||
assert.equal(res.totalBattles, 1)
|
||||
assert.equal(res.topBattles[0].totalImages, 3)
|
||||
assert.equal(res.topBattles[0].participantCount, 2)
|
||||
// A 发 2 张图,B 发 1 张
|
||||
assert.equal(res.rankByImageCount[0].memberId, 1)
|
||||
assert.equal(res.rankByImageCount[0].count, 2)
|
||||
const aCount = res.rankByCount.find((r) => r.memberId === 1)!
|
||||
assert.equal(aCount.count, 1) // 参与 1 场
|
||||
})
|
||||
|
||||
it('getNightOwlAnalysis counts late-night messages per member', () => {
|
||||
// 同一成员 23 点 / 次日 0 点 / 次日 1 点 各一条,调整后同属一天
|
||||
const db = makeDb(
|
||||
[{ id: 1, name: 'A' }],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY1 + 23 * 3600 }, // 23:00
|
||||
{ id: 2, senderId: 1, ts: DAY2 + 0 * 3600 + 60 }, // 次日 00:01
|
||||
{ id: 3, senderId: 1, ts: DAY2 + 1 * 3600 }, // 次日 01:00
|
||||
]
|
||||
)
|
||||
const res = getNightOwlAnalysis(db)
|
||||
assert.equal(res.totalDays, 1)
|
||||
assert.equal(res.nightOwlRank.length, 1)
|
||||
const owl = res.nightOwlRank[0]
|
||||
assert.equal(owl.totalNightMessages, 3)
|
||||
assert.equal(owl.title, '偶尔失眠')
|
||||
assert.deepEqual(owl.hourlyBreakdown, { h23: 1, h0: 1, h1: 1, h2: 0, h3to4: 0 })
|
||||
})
|
||||
|
||||
it('getRepeatAnalysis detects repeat chains with originator/initiator/breaker', () => {
|
||||
// A,B,C 复读 "haha" 形成长度 3 的链,D 用 "bye" 打断
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
{ id: 3, name: 'C' },
|
||||
{ id: 4, name: 'D' },
|
||||
],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY1 + 1, content: 'haha' },
|
||||
{ id: 2, senderId: 2, ts: DAY1 + 2, content: 'haha' },
|
||||
{ id: 3, senderId: 3, ts: DAY1 + 3, content: 'haha' },
|
||||
{ id: 4, senderId: 4, ts: DAY1 + 4, content: 'bye' },
|
||||
]
|
||||
)
|
||||
const res = getRepeatAnalysis(db)
|
||||
assert.equal(res.totalRepeatChains, 1)
|
||||
assert.equal(res.avgChainLength, 3)
|
||||
assert.deepEqual(res.chainLengthDistribution, [{ length: 3, count: 1 }])
|
||||
assert.equal(res.originators[0].memberId, 1)
|
||||
assert.equal(res.initiators[0].memberId, 2)
|
||||
assert.equal(res.breakers[0].memberId, 4)
|
||||
assert.equal(res.hotContents[0].content, 'haha')
|
||||
assert.equal(res.hotContents[0].maxChainLength, 3)
|
||||
})
|
||||
|
||||
it('excludes system messages for system-filtered analyses', () => {
|
||||
// 系统消息成员不应进入龙王榜
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 99, name: 'sys', system: true },
|
||||
],
|
||||
[
|
||||
{ id: 1, senderId: 1, ts: DAY1 + 1 },
|
||||
{ id: 2, senderId: 99, ts: DAY1 + 2 },
|
||||
{ id: 3, senderId: 99, ts: DAY1 + 3 },
|
||||
]
|
||||
)
|
||||
const res = getDragonKingAnalysis(db)
|
||||
assert.equal(res.rank.length, 1)
|
||||
assert.equal(res.rank[0].memberId, 1)
|
||||
})
|
||||
})
|
||||
@@ -32,3 +32,36 @@ export type {
|
||||
|
||||
export { getLanguagePreferenceAnalysis } from './languagePreference'
|
||||
export type { NlpProvider, PosTagResult, LanguagePreferenceParams } from './languagePreference'
|
||||
|
||||
export {
|
||||
getDragonKingAnalysis,
|
||||
getDivingAnalysis,
|
||||
getCheckInAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getNightOwlAnalysis,
|
||||
getRepeatAnalysis,
|
||||
} from './ranking'
|
||||
export type {
|
||||
NightOwlTitle,
|
||||
NightOwlRankItem,
|
||||
TimeRankItem,
|
||||
ConsecutiveNightRecord,
|
||||
NightOwlChampion,
|
||||
NightOwlAnalysis,
|
||||
DragonKingRankItem,
|
||||
DragonKingAnalysis,
|
||||
DivingRankItem,
|
||||
DivingAnalysis,
|
||||
RepeatStatItem,
|
||||
RepeatRateItem,
|
||||
ChainLengthDistribution,
|
||||
HotRepeatContent,
|
||||
FastestRepeaterItem,
|
||||
RepeatAnalysis,
|
||||
MemeBattleRankItem,
|
||||
MemeBattleRecord,
|
||||
MemeBattleAnalysis,
|
||||
StreakRankItem,
|
||||
LoyaltyRankItem,
|
||||
CheckInAnalysis,
|
||||
} from './ranking'
|
||||
File diff suppressed because it is too large.
Load diff
@@ -53,6 +53,27 @@ export interface MessageTypeStats {
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface TextStats {
|
||||
textCount: number
|
||||
avgLength: number
|
||||
maxLength: number
|
||||
shortCount: number
|
||||
}
|
||||
|
||||
export interface TextLengthPercentiles {
|
||||
p25: number
|
||||
p50: number
|
||||
p75: number
|
||||
p90: number
|
||||
}
|
||||
|
||||
export interface MemberMonthlyTrend {
|
||||
month: string
|
||||
memberId: number
|
||||
memberName: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息时间范围
|
||||
*/
|
||||
@@ -330,3 +351,107 @@ export function getMessageLengthDistribution(db: DatabaseAdapter, filter?: TimeF
|
||||
|
||||
return { detail, grouped }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文字消息统计(仅 type=0):数量、平均长度、最大长度、短消息(≤5 字)数
|
||||
*/
|
||||
export function getTextStats(db: DatabaseAdapter, filter?: TimeFilter): TextStats {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const typeCondition =
|
||||
buildSystemMessageFilter(clause) + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0'
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as textCount,
|
||||
ROUND(AVG(LENGTH(msg.content)), 1) as avgLength,
|
||||
MAX(LENGTH(msg.content)) as maxLength,
|
||||
SUM(CASE WHEN LENGTH(msg.content) <= 5 THEN 1 ELSE 0 END) as shortCount
|
||||
FROM message msg JOIN member m ON msg.sender_id = m.id
|
||||
${typeCondition}`
|
||||
)
|
||||
.get(...params) as
|
||||
| { textCount: number; avgLength: number | null; maxLength: number | null; shortCount: number | null }
|
||||
| undefined
|
||||
|
||||
return {
|
||||
textCount: row?.textCount ?? 0,
|
||||
avgLength: row?.avgLength ?? 0,
|
||||
maxLength: row?.maxLength ?? 0,
|
||||
shortCount: row?.shortCount ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取长消息(小作文)数量,minLength 为字符阈值(默认 30),仅统计文字消息
|
||||
*/
|
||||
export function getLongMessageCount(db: DatabaseAdapter, filter?: TimeFilter, minLength = 30): number {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const typeCondition =
|
||||
buildSystemMessageFilter(clause) + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) >= ?'
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as cnt
|
||||
FROM message msg JOIN member m ON msg.sender_id = m.id
|
||||
${typeCondition}`
|
||||
)
|
||||
.get(...params, minLength) as { cnt: number } | undefined
|
||||
|
||||
return row?.cnt ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成员月度消息趋势(按月 × 发送者)
|
||||
*/
|
||||
export function getMemberMonthlyTrend(db: DatabaseAdapter, filter?: TimeFilter): MemberMonthlyTrend[] {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT
|
||||
strftime('%Y-%m', msg.ts, 'unixepoch', 'localtime') as month,
|
||||
msg.sender_id as memberId,
|
||||
m.account_name as memberName,
|
||||
COUNT(*) as count
|
||||
FROM message msg JOIN member m ON msg.sender_id = m.id
|
||||
${clauseWithSystem}
|
||||
GROUP BY month, msg.sender_id
|
||||
ORDER BY month`
|
||||
)
|
||||
.all(...params) as unknown as MemberMonthlyTrend[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文字消息长度百分位(P25/P50/P75/P90),仅统计文字消息
|
||||
*/
|
||||
export function getTextLengthPercentiles(db: DatabaseAdapter, filter?: TimeFilter): TextLengthPercentiles {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const typeCondition =
|
||||
buildSystemMessageFilter(clause) + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0'
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT LENGTH(msg.content) as len
|
||||
FROM message msg JOIN member m ON msg.sender_id = m.id
|
||||
${typeCondition}
|
||||
ORDER BY len`
|
||||
)
|
||||
.all(...params) as Array<{ len: number }>
|
||||
|
||||
if (rows.length === 0) return { p25: 0, p50: 0, p75: 0, p90: 0 }
|
||||
|
||||
const lengths = rows.map((r) => r.len)
|
||||
const getPercentile = (arr: number[], p: number) => {
|
||||
const idx = Math.ceil((p / 100) * arr.length) - 1
|
||||
return arr[Math.max(0, idx)]
|
||||
}
|
||||
|
||||
return {
|
||||
p25: getPercentile(lengths, 25),
|
||||
p50: getPercentile(lengths, 50),
|
||||
p75: getPercentile(lengths, 75),
|
||||
p90: getPercentile(lengths, 90),
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ export {
|
||||
getMonthlyActivity,
|
||||
getYearlyActivity,
|
||||
getMessageLengthDistribution,
|
||||
getTextStats,
|
||||
getLongMessageCount,
|
||||
getMemberMonthlyTrend,
|
||||
getTextLengthPercentiles,
|
||||
} from './basic-queries'
|
||||
export type {
|
||||
MemberActivity,
|
||||
@@ -69,6 +73,9 @@ export type {
|
||||
MonthlyActivity,
|
||||
YearlyActivity,
|
||||
MessageLengthDistribution,
|
||||
TextStats,
|
||||
TextLengthPercentiles,
|
||||
MemberMonthlyTrend,
|
||||
} from './basic-queries'
|
||||
|
||||
export {
|
||||
@@ -147,6 +154,12 @@ export {
|
||||
getClusterGraph,
|
||||
getRelationshipStats,
|
||||
getLanguagePreferenceAnalysis,
|
||||
getDragonKingAnalysis,
|
||||
getDivingAnalysis,
|
||||
getCheckInAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getNightOwlAnalysis,
|
||||
getRepeatAnalysis,
|
||||
} from './advanced'
|
||||
export type {
|
||||
CatchphraseAnalysis,
|
||||
@@ -170,4 +183,26 @@ export type {
|
||||
NlpProvider,
|
||||
PosTagResult,
|
||||
LanguagePreferenceParams,
|
||||
NightOwlTitle,
|
||||
NightOwlRankItem,
|
||||
TimeRankItem,
|
||||
ConsecutiveNightRecord,
|
||||
NightOwlChampion,
|
||||
NightOwlAnalysis,
|
||||
DragonKingRankItem,
|
||||
DragonKingAnalysis,
|
||||
DivingRankItem,
|
||||
DivingAnalysis,
|
||||
RepeatStatItem,
|
||||
RepeatRateItem,
|
||||
ChainLengthDistribution,
|
||||
HotRepeatContent,
|
||||
FastestRepeaterItem,
|
||||
RepeatAnalysis,
|
||||
MemeBattleRankItem,
|
||||
MemeBattleRecord,
|
||||
MemeBattleAnalysis,
|
||||
StreakRankItem,
|
||||
LoyaltyRankItem,
|
||||
CheckInAnalysis,
|
||||
} from './advanced'
|
||||
@@ -136,4 +136,39 @@ describe('analytics routes caching', () => {
|
||||
const second = await app.inject({ method: 'GET', url: MEMBER_ACTIVITY_URL })
|
||||
assert.equal(second.statusCode, 500)
|
||||
})
|
||||
|
||||
it('keys long-message-count cache by minLength', async () => {
|
||||
const base = `/_web/sessions/${SESSION_ID}/analytics/long-message-count`
|
||||
const first = await app.inject({ method: 'GET', url: `${base}?minLength=1` })
|
||||
assert.equal(first.statusCode, 200)
|
||||
|
||||
raw.close()
|
||||
|
||||
// 相同 minLength => 命中缓存
|
||||
const same = await app.inject({ method: 'GET', url: `${base}?minLength=1` })
|
||||
assert.equal(same.statusCode, 200)
|
||||
assert.deepEqual(same.json(), first.json())
|
||||
|
||||
// 不同 minLength => 缓存未命中、重算因 db 关闭而失败,证明 minLength 进入缓存键
|
||||
const diff = await app.inject({ method: 'GET', url: `${base}?minLength=999` })
|
||||
assert.equal(diff.statusCode, 500)
|
||||
})
|
||||
|
||||
it('keys cluster cache by options so different params recompute', async () => {
|
||||
const base = `/_web/sessions/${SESSION_ID}/analytics/cluster`
|
||||
const first = await app.inject({ method: 'GET', url: `${base}?lookAhead=3&decaySeconds=120&topEdges=150` })
|
||||
assert.equal(first.statusCode, 200)
|
||||
|
||||
// 关闭底层数据库:命中缓存才能成功,重算必然抛错。
|
||||
raw.close()
|
||||
|
||||
// 相同参数 => 命中缓存
|
||||
const same = await app.inject({ method: 'GET', url: `${base}?lookAhead=3&decaySeconds=120&topEdges=150` })
|
||||
assert.equal(same.statusCode, 200)
|
||||
assert.deepEqual(same.json(), first.json())
|
||||
|
||||
// 不同参数 => 缓存未命中、重算因 db 关闭而失败,证明 lookAhead 已进入缓存键
|
||||
const diff = await app.inject({ method: 'GET', url: `${base}?lookAhead=10&decaySeconds=120&topEdges=150` })
|
||||
assert.equal(diff.statusCode, 500)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
getMonthlyActivity,
|
||||
getYearlyActivity,
|
||||
getMessageLengthDistribution,
|
||||
getTextStats,
|
||||
getLongMessageCount,
|
||||
getMemberMonthlyTrend,
|
||||
getTextLengthPercentiles,
|
||||
getRelationshipStats,
|
||||
getCatchphraseAnalysis,
|
||||
getMentionAnalysis,
|
||||
@@ -19,7 +23,14 @@ import {
|
||||
getLaughAnalysis,
|
||||
getClusterGraph,
|
||||
getLanguagePreferenceAnalysis,
|
||||
getDragonKingAnalysis,
|
||||
getDivingAnalysis,
|
||||
getCheckInAnalysis,
|
||||
getMemeBattleAnalysis,
|
||||
getNightOwlAnalysis,
|
||||
getRepeatAnalysis,
|
||||
} from '@openchatlab/core'
|
||||
import type { ClusterGraphOptions } from '@openchatlab/core'
|
||||
import { parseTimeFilter } from '../../helpers'
|
||||
import { withAnalyticsCache } from '../../analytics-cache'
|
||||
|
||||
@@ -132,17 +143,22 @@ export function registerAnalyticsRoutes(server: FastifyInstance, ctx: HttpRouteC
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery & { topEdges?: string } }>(
|
||||
'/_web/sessions/:id/analytics/cluster',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
const topEdges = request.query.topEdges ? parseInt(request.query.topEdges, 10) : undefined
|
||||
return cached('cluster', id, { ...filter, topEdges }, () =>
|
||||
getClusterGraph(adapter.ensureReadonly(id), filter, topEdges ? { topEdges } : undefined)
|
||||
)
|
||||
}
|
||||
)
|
||||
server.get<{
|
||||
Params: { id: string }
|
||||
Querystring: FilteredQuery & { topEdges?: string; lookAhead?: string; decaySeconds?: string }
|
||||
}>('/_web/sessions/:id/analytics/cluster', async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
// 仅收集显式传入的参数,避免 undefined 覆盖核心算法默认值
|
||||
const options: ClusterGraphOptions = {}
|
||||
if (request.query.topEdges) options.topEdges = parseInt(request.query.topEdges, 10)
|
||||
if (request.query.lookAhead) options.lookAhead = parseInt(request.query.lookAhead, 10)
|
||||
if (request.query.decaySeconds) options.decaySeconds = parseInt(request.query.decaySeconds, 10)
|
||||
const hasOptions = Object.keys(options).length > 0
|
||||
return cached('cluster', id, { ...filter, ...options }, () =>
|
||||
getClusterGraph(adapter.ensureReadonly(id), filter, hasOptions ? options : undefined)
|
||||
)
|
||||
})
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery & { locale?: string } }>(
|
||||
'/_web/sessions/:id/analytics/language-preference',
|
||||
@@ -188,4 +204,101 @@ export function registerAnalyticsRoutes(server: FastifyInstance, ctx: HttpRouteC
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/text-stats',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('text-stats', id, { ...filter }, () => getTextStats(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery & { minLength?: string } }>(
|
||||
'/_web/sessions/:id/analytics/long-message-count',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
const minLength = request.query.minLength ? parseInt(request.query.minLength, 10) : undefined
|
||||
return cached('long-message-count', id, { ...filter, minLength }, () =>
|
||||
getLongMessageCount(adapter.ensureReadonly(id), filter, minLength)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/member-monthly-trend',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('member-monthly-trend', id, { ...filter }, () =>
|
||||
getMemberMonthlyTrend(adapter.ensureReadonly(id), filter)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/text-length-percentiles',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('text-length-percentiles', id, { ...filter }, () =>
|
||||
getTextLengthPercentiles(adapter.ensureReadonly(id), filter)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/dragon-king',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('dragon-king', id, { ...filter }, () => getDragonKingAnalysis(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/diving',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('diving', id, { ...filter }, () => getDivingAnalysis(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/check-in',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('check-in', id, { ...filter }, () => getCheckInAnalysis(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/meme-battle',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('meme-battle', id, { ...filter }, () => getMemeBattleAnalysis(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/night-owl',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('night-owl', id, { ...filter }, () => getNightOwlAnalysis(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
|
||||
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
|
||||
'/_web/sessions/:id/analytics/repeat',
|
||||
async (request) => {
|
||||
const id = request.params.id
|
||||
const filter = parseTimeFilter(request.query)
|
||||
return cached('repeat', id, { ...filter }, () => getRepeatAnalysis(adapter.ensureReadonly(id), filter))
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user