@koishi-ce/plugin-analytics 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,521 @@
1
+ /**
2
+ * analytics 插件(node 侧):消息与指令的统计分析服务。
3
+ *
4
+ * 工作方式:
5
+ * 1. 监听消息收发与指令执行事件,在内存中按 (日期, 小时, 维度…) 增量计数;
6
+ * 2. 每隔 statsInternal(或跨小时)把内存缓冲 upsert 到数据库的
7
+ * analytics.message / analytics.command 两张表(列存计数,主键联合维度);
8
+ * 3. 前端拉取时(get → download)对近 recentDayCount 天的数据做分组聚合,
9
+ * 产出数值指标与各图表所需的 Payload 推送给控制台。
10
+ */
11
+
12
+ import { resolve } from "node:path";
13
+ import { DataService } from "@koishi-ce/console";
14
+ import {
15
+ $,
16
+ type Context,
17
+ type Dict,
18
+ deepEqual,
19
+ Logger,
20
+ pick,
21
+ type Query,
22
+ Schema,
23
+ type Session,
24
+ Time,
25
+ type Universal,
26
+ } from "@koishi-ce/koishi";
27
+
28
+ declare module "@koishi-ce/koishi" {
29
+ interface Tables {
30
+ "analytics.message": Analytics.Message;
31
+ "analytics.command": Analytics.Command;
32
+ }
33
+ }
34
+
35
+ declare module "@koishi-ce/console" {
36
+ namespace Console {
37
+ interface Services {
38
+ analytics: Analytics;
39
+ }
40
+ }
41
+ }
42
+
43
+ /** 收发消息计数对:send 为发出条数,receive 为收到条数。 */
44
+ export interface MessageStats {
45
+ send: number;
46
+ receive: number;
47
+ }
48
+
49
+ const logger = new Logger("analytics");
50
+
51
+ class Analytics extends DataService<Analytics.Payload> {
52
+ static override inject = ["database", "console"];
53
+
54
+ // 原本位于 namespace Analytics 的 export const Config;erasableSyntaxOnly
55
+ // 禁止携带运行时值的 namespace,迁为类静态成员(loader 从插件类上读取静态 Config)
56
+ static Config: Schema<Analytics.Config> = Schema.object({
57
+ statsInternal: Schema.natural()
58
+ .role("ms")
59
+ .description("统计数据推送的时间间隔。")
60
+ .default(Time.minute * 10),
61
+ recentDayCount: Schema.natural()
62
+ .description("统计最近几天的数据。")
63
+ .default(7),
64
+ });
65
+
66
+ // 基类 Service 声明了 config: unknown;此处收敛为必填并在构造时归一化
67
+ // (loader 经 Schema.default 注入,归一化仅兜底,不改变运行时取值)
68
+ override config: Required<Analytics.Config>;
69
+
70
+ /** 上次落库时间,用于判断距下次上传是否超过 statsInternal。 */
71
+ lastUpdate = new Date();
72
+ /** 上次落库的小时数,跨小时(整点)时强制上传一次。 */
73
+ updateHour = this.lastUpdate.getHours();
74
+ /** 当前缓存的日期号(yyyymmdd),日期变化时缓存失效需重新聚合。 */
75
+ cachedDate?: number;
76
+ /** 当日的聚合结果缓存(Payload 的 Promise),同一天内复用。 */
77
+ cachedData!: Promise<Analytics.Payload>;
78
+
79
+ /** 消息计数内存缓冲(未落库部分)。 */
80
+ private messages: Analytics.Message[] = [];
81
+ /** 指令计数内存缓冲(未落库部分)。 */
82
+ private commands: Analytics.Command[] = [];
83
+
84
+ constructor(ctx: Context, config: Analytics.Config = {}) {
85
+ super(ctx, "analytics");
86
+
87
+ this.config = {
88
+ statsInternal: config.statsInternal ?? Time.minute * 10,
89
+ recentDayCount: config.recentDayCount ?? 7,
90
+ };
91
+
92
+ // 两张统计表:按 (日期, 小时, 业务维度) 联合主键存增量计数 count
93
+ ctx.model.extend(
94
+ "analytics.message",
95
+ {
96
+ date: "integer",
97
+ hour: "integer",
98
+ type: "string(63)",
99
+ selfId: "string(63)",
100
+ platform: "string(63)",
101
+ count: "integer",
102
+ },
103
+ {
104
+ primary: ["date", "hour", "type", "selfId", "platform"],
105
+ },
106
+ );
107
+
108
+ ctx.model.extend(
109
+ "analytics.command",
110
+ {
111
+ date: "integer",
112
+ hour: "integer",
113
+ name: "string(63)",
114
+ selfId: "string(63)",
115
+ userId: "integer",
116
+ channelId: "string(63)",
117
+ platform: "string(63)",
118
+ count: "integer",
119
+ },
120
+ {
121
+ primary: [
122
+ "date",
123
+ "hour",
124
+ "name",
125
+ "selfId",
126
+ "userId",
127
+ "channelId",
128
+ "platform",
129
+ ],
130
+ },
131
+ );
132
+
133
+ // 进程退出 / 插件卸载时强制把内存缓冲落库,避免丢失末段计数
134
+ ctx.on("exit", () => this.upload(true));
135
+
136
+ ctx.on("dispose", async () => {
137
+ await this.upload(true);
138
+ });
139
+
140
+ ctx.on("message", (session) => {
141
+ this.addAudit(this.messages, {
142
+ ...this.createIndex(session),
143
+ type: "receive",
144
+ });
145
+ this.upload();
146
+ });
147
+
148
+ ctx.on("send", (session) => {
149
+ this.addAudit(this.messages, {
150
+ ...this.createIndex(session),
151
+ type: "send",
152
+ });
153
+ this.upload();
154
+ });
155
+
156
+ ctx.any().before("command/execute", ({ command, session }) => {
157
+ if (!command || !session) return;
158
+ // 观察字段在类型上不可索引,按宽泛视图读取(与 createIndex 同理)
159
+ // biome-ignore lint/suspicious/noExplicitAny: Session<never,...> 的 user
160
+ const user = (session as Session<any, any, any>).user;
161
+ this.addAudit(this.commands, {
162
+ ...this.createIndex(session),
163
+ name: command.name,
164
+ // 库表列声明为 integer,而 user.id 为字符串,这里按数值归一
165
+ userId: +(user?.["id"] || 0),
166
+ channelId: session.channelId ?? "",
167
+ });
168
+ this.upload();
169
+ });
170
+
171
+ ctx.console.addEntry({
172
+ dev: resolve(__dirname, "../client/index.ts"),
173
+ prod: resolve(__dirname, "../dist"),
174
+ });
175
+ }
176
+
177
+ // 不协变,各事件回调处的具体泛型互不相同,内部工具方法统一放宽
178
+ // biome-ignore lint/suspicious/noExplicitAny: Session 泛型在 user 观察字段上
179
+ private createIndex(session: Session<any, any, any>): Analytics.Index {
180
+ return {
181
+ selfId: session.selfId,
182
+ platform: session.platform,
183
+ date: Time.getDateNumber(),
184
+ hour: new Date().getHours(),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * 向内存缓冲追加一次计数:缓冲中已有全部维度字段都相同的记录则 count + 1,
190
+ * 否则以 count = 1 追加新条目(deepEqual 逐字段比对,保证聚合键唯一)。
191
+ */
192
+ private addAudit<T extends Analytics.Audit>(
193
+ buffer: T[],
194
+ index: Omit<T, "count">,
195
+ ) {
196
+ const audit = buffer.find((data) =>
197
+ deepEqual(pick(data, Object.keys(index) as (keyof T)[]), index),
198
+ );
199
+ if (audit) {
200
+ audit.count += 1;
201
+ } else {
202
+ buffer.push({ ...index, count: 1 } as T);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * 把内存缓冲整表 upsert 落库:命中联合主键的行在其 count 基础上累加,
208
+ * 未命中则插入新行;完成后清空缓冲。
209
+ */
210
+ private async uploadAudit(
211
+ table: "analytics.message" | "analytics.command",
212
+ buffer: (Analytics.Message | Analytics.Command)[],
213
+ ) {
214
+ if (!buffer.length) return;
215
+ await this.ctx.database.upsert(table, (row) =>
216
+ buffer.map((audit) => ({
217
+ ...audit,
218
+ count: $.add($.ifNull(row.count, 0), audit.count),
219
+ })),
220
+ );
221
+ buffer.splice(0);
222
+ }
223
+
224
+ /**
225
+ * 落库入口(事件回调高频调用):仅在距上次上传超过 statsInternal、
226
+ * 跨小时或 forced 时才真正执行上传,其余调用静默跳过。
227
+ *
228
+ * @param forced 是否无视时间间隔强制上传(退出 / 卸载时使用)
229
+ */
230
+ async upload(forced = false) {
231
+ const date = new Date();
232
+ const dateHour = date.getHours();
233
+ if (
234
+ forced ||
235
+ +date - +this.lastUpdate > this.config.statsInternal ||
236
+ dateHour !== this.updateHour
237
+ ) {
238
+ this.lastUpdate = date;
239
+ this.updateHour = dateHour;
240
+ await Promise.all([
241
+ this.uploadAudit("analytics.message", this.messages),
242
+ this.uploadAudit("analytics.command", this.commands),
243
+ ]);
244
+ logger.debug("analytics updated");
245
+ }
246
+ }
247
+
248
+ /** 最近 N 天(不含今天)的日期号查询区间,供各聚合查询复用。 */
249
+ private queryRecent(): Query.FieldExpr<number> {
250
+ return {
251
+ $gte: Time.getDateNumber() - this.config.recentDayCount,
252
+ $lt: Time.getDateNumber(),
253
+ };
254
+ }
255
+
256
+ /**
257
+ * 指令调用频率:近 N 天各指令的总调用次数 ÷ 天数,得到"日均调用次数",
258
+ * 以指令名为键的字典返回(供饼图使用)。
259
+ *
260
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
261
+ */
262
+ private async getCommandRate(lengthTask: Promise<number>) {
263
+ const data = await this.ctx.database
264
+ .select("analytics.command", {
265
+ date: this.queryRecent(),
266
+ })
267
+ .groupBy(["name"], {
268
+ count: (row) => $.sum(row.count),
269
+ })
270
+ .execute();
271
+ const length = await lengthTask;
272
+ const result = {} as Dict<number>;
273
+ data.forEach((stat) => {
274
+ result[stat.name] = stat.count / length;
275
+ });
276
+ return result;
277
+ }
278
+
279
+ /**
280
+ * DAU 历史:按天统计触发过指令的去重用户数(userId > 0 过滤未登录调用)。
281
+ * 返回数组下标为"距今天数"(0 = 今天),长度 recentDayCount + 1,
282
+ * 无数据的日期补 0。
283
+ */
284
+ private async getDauHistory() {
285
+ const data = await this.ctx.database
286
+ .select("analytics.command", {
287
+ date: { $gte: Time.getDateNumber() - this.config.recentDayCount },
288
+ userId: { $gt: 0 },
289
+ })
290
+ .groupBy(["date"], {
291
+ count: (row) => $.count(row.userId),
292
+ })
293
+ .execute();
294
+ const result: number[] = new Array(this.config.recentDayCount + 1).fill(0);
295
+ const today = Time.getDateNumber();
296
+ data.forEach((stat) => {
297
+ result[today - stat.date] = stat.count;
298
+ });
299
+ return result;
300
+ }
301
+
302
+ /**
303
+ * 各机器人消息频率:近 N 天按 (平台, 机器人) 分组求和后除以天数,
304
+ * 得到每个机器人的日均收发消息数;结构为 { 平台: { selfId: 统计+机器人资料 } },
305
+ * 并尽量合并当前运行中的 bot.user 资料(昵称 / 头像等)供旭日图展示。
306
+ *
307
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
308
+ */
309
+ private async getMessageByBot(lengthTask: Promise<number>) {
310
+ const data = await this.ctx.database
311
+ .select("analytics.message", {
312
+ date: this.queryRecent(),
313
+ })
314
+ .groupBy(["type", "platform", "selfId"], {
315
+ count: (row) => $.sum(row.count),
316
+ })
317
+ .execute();
318
+ const length = await lengthTask;
319
+ // 机器人资料(bot.user)运行时可能缺席,按 Partial 记录
320
+ const result = {} as Dict<Dict<MessageStats & Partial<Universal.User>>>;
321
+ data.forEach((stat) => {
322
+ const bot = this.ctx.bots[`${stat.platform}:${stat.selfId}`];
323
+ const entry = ((result[stat.platform] ||= {})[stat.selfId] ||= {
324
+ ...(bot?.user ?? {}),
325
+ send: 0,
326
+ receive: 0,
327
+ });
328
+ // type 列的取值集合由写入端约定为 send / receive 两种
329
+ entry[stat.type as "send" | "receive"] = stat.count / length;
330
+ });
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * 按日历史消息量:不设日期下限地按天汇总全部历史(不含今天),
336
+ * 返回数组下标为"距今天数"(0 = 今天,恒为 0 值占位),
337
+ * 无记录的日期补 0。注意 result.length 由最久远记录决定。
338
+ */
339
+ private async getMessageByDate() {
340
+ const data = await this.ctx.database
341
+ .select("analytics.message", {
342
+ date: { $lt: Time.getDateNumber() },
343
+ })
344
+ .groupBy(["type", "date"], {
345
+ count: (row) => $.sum(row.count),
346
+ })
347
+ .orderBy("date", "desc")
348
+ .execute();
349
+ const today = Time.getDateNumber();
350
+ const result: MessageStats[] = [];
351
+ data.forEach((stat) => {
352
+ const entry = (result[today - stat.date] ||= { send: 0, receive: 0 });
353
+ entry[stat.type as "send" | "receive"] = stat.count;
354
+ });
355
+ for (let i = 0; i < result.length; i++) {
356
+ result[i] ||= { send: 0, receive: 0 };
357
+ }
358
+ return result;
359
+ }
360
+
361
+ /**
362
+ * 按小时消息分布:近 N 天按小时汇总后除以天数,得到每个时段的日均消息量。
363
+ * 返回固定 24 个元素的数组(下标即小时),越界小时数据直接丢弃。
364
+ *
365
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
366
+ */
367
+ private async getMessageByHour(lengthTask: Promise<number>) {
368
+ const data = await this.ctx.database
369
+ .select("analytics.message", {
370
+ date: this.queryRecent(),
371
+ })
372
+ .groupBy(["type", "hour"], {
373
+ count: (row) => $.sum(row.count),
374
+ })
375
+ .execute();
376
+ const length = await lengthTask;
377
+ const result = new Array(24)
378
+ .fill(null)
379
+ .map(() => ({ send: 0, receive: 0 }));
380
+ data.forEach((stat) => {
381
+ const entry = result[stat.hour];
382
+ if (!entry) return;
383
+ entry[stat.type as "send" | "receive"] = stat.count / length;
384
+ });
385
+ return result;
386
+ }
387
+
388
+ /**
389
+ * 执行一次全量聚合,产出推送前端的完整 Payload:
390
+ * 数值指标(用户 / 群组总数与昨日增量)+ 各图表数据
391
+ * (指令频率、DAU 历史、机器人 / 按日 / 按小时消息量)。
392
+ *
393
+ * 一次 download 会并发发起十余个数据库查询;lengthTask 先行启动,
394
+ * 其结果(有效天数,介于 1 与 recentDayCount 之间)供各"日均"类指标除算。
395
+ */
396
+ async download(): Promise<Analytics.Payload> {
397
+ const messageByDateTask = this.getMessageByDate();
398
+ const lengthTask = messageByDateTask.then((data) => {
399
+ return Math.min(Math.max(data.length - 1, 1), this.config.recentDayCount);
400
+ });
401
+ const [
402
+ userCount,
403
+ userIncrement,
404
+ guildCount,
405
+ guildIncrement,
406
+ commandRate,
407
+ dauHistory,
408
+ messageByBot,
409
+ messageByDate,
410
+ messageByHour,
411
+ ] = await Promise.all([
412
+ // 用户总数
413
+ this.ctx.database.eval("user", (row) => $.count(row.id)),
414
+ // 昨日新增用户数(createdAt 落在昨天一整天)
415
+ this.ctx.database.eval("user", (row) => $.count(row.id), {
416
+ createdAt: {
417
+ $gte: Time.fromDateNumber(Time.getDateNumber() - 1),
418
+ $lt: Time.fromDateNumber(Time.getDateNumber()),
419
+ },
420
+ }),
421
+ // 群组总数:channel 表中 id === guildId 的行即群本身(而非普通子频道)
422
+ this.ctx.database.eval(
423
+ "channel",
424
+ () => $.sum(1),
425
+ (row) => $.eq(row.id, row.guildId),
426
+ ),
427
+ // 昨日新增群组数
428
+ this.ctx.database.eval(
429
+ "channel",
430
+ () => $.sum(1),
431
+ (row) =>
432
+ $.and(
433
+ $.eq(row.id, row.guildId),
434
+ $.gte(row.createdAt, Time.fromDateNumber(Time.getDateNumber() - 1)),
435
+ $.lt(row.createdAt, Time.fromDateNumber(Time.getDateNumber())),
436
+ ),
437
+ ),
438
+ this.getCommandRate(lengthTask),
439
+ this.getDauHistory(),
440
+ this.getMessageByBot(lengthTask),
441
+ messageByDateTask,
442
+ this.getMessageByHour(lengthTask),
443
+ ]);
444
+ return {
445
+ userCount,
446
+ userIncrement,
447
+ guildCount,
448
+ guildIncrement,
449
+ commandRate,
450
+ dauHistory,
451
+ messageByBot,
452
+ messageByDate,
453
+ messageByHour,
454
+ };
455
+ }
456
+
457
+ /**
458
+ * DataService 读取入口:按自然日缓存聚合结果——
459
+ * 当天内的重复拉取复用同一 Promise,跨天后才重新 download
460
+ * (全部统计都以天为最小粒度,日内无变化)。
461
+ */
462
+ override async get() {
463
+ const date = new Date();
464
+ const dateNumber = Time.getDateNumber(date, date.getTimezoneOffset());
465
+ if (dateNumber !== this.cachedDate) {
466
+ this.cachedData = this.download();
467
+ this.cachedDate = dateNumber;
468
+ }
469
+ return this.cachedData;
470
+ }
471
+ }
472
+
473
+ namespace Analytics {
474
+ /** 统计记录的公共维度:日期号(yyyymmdd)、小时、机器人 selfId 与平台。 */
475
+ export interface Index {
476
+ id?: number;
477
+ date: number;
478
+ hour: number;
479
+ selfId: string;
480
+ platform: string;
481
+ }
482
+
483
+ /** 内存缓冲中的一条计数:公共维度 + 计数值。 */
484
+ export interface Audit extends Index {
485
+ count: number;
486
+ }
487
+
488
+ /** analytics.message 表行:公共维度 + 消息方向(send / receive)。 */
489
+ export interface Message extends Index {
490
+ type: string;
491
+ count: number;
492
+ }
493
+
494
+ /** analytics.command 表行:公共维度 + 指令名、用户与频道。 */
495
+ export interface Command extends Index {
496
+ name: string;
497
+ userId: number;
498
+ channelId: string;
499
+ count: number;
500
+ }
501
+
502
+ /** 推送给前端的聚合结果:数值指标与各图表数据。 */
503
+ export interface Payload {
504
+ userCount: number;
505
+ userIncrement: number;
506
+ guildCount: number;
507
+ guildIncrement: number;
508
+ dauHistory: number[];
509
+ commandRate: Dict<number>;
510
+ messageByBot: Dict<Dict<MessageStats & Partial<Universal.User>>>;
511
+ messageByDate: MessageStats[];
512
+ messageByHour: MessageStats[];
513
+ }
514
+
515
+ export interface Config {
516
+ statsInternal?: number;
517
+ recentDayCount?: number;
518
+ }
519
+ }
520
+
521
+ export default Analytics;