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:
diguaandCursor committed 2026-06-14 23:54:12 +08:00
1 parent 8192a43a41
commit 99ed14f8a5
9 files changed
+1887 -11

No files matched your search

+35
View File
@@ -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)
})
})
+33
View File
@@ -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
+125
View File
@@ -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),
}
}
+35
View File
@@ -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)
})
})
+124 -11
View File
@@ -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))
}
)
}