@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/dist/index.css ADDED
@@ -0,0 +1,2 @@
1
+ .analytic-chart header{justify-content:space-between;display:flex}.analytic-chart header .right{gap:1rem;font-size:1rem;font-weight:400;display:flex}.analytic-chart header .tab-item{cursor:pointer;padding:0 4px}.analytic-chart header .tab-item.active{color:var(--primary);border-bottom:2px solid var(--primary)}.k-analytic-number .k-card-body{justify-content:space-between;align-items:center;margin:1rem 0;display:flex}.k-analytic-number .k-card-body .k-icon{width:3rem;height:2.5rem;color:var(--k-text-normal);transition:color .3s}@media screen and (width<=768px){.k-analytic-number .k-card-body .k-icon{height:2.25rem}}.k-analytic-number .k-card-body .content{float:right;text-align:right;flex-direction:column;justify-content:space-around;height:3rem;display:flex}.k-analytic-number .k-card-body p{margin:0;font-size:.9375rem}.k-analytic-number .k-card-body .value{font-size:1rem}@media (width<=768px){.k-analytic-number .k-card-body{margin:.5rem 0;padding:0 1rem}}.k-analytic-number footer{border-top:1px solid var(--k-card-border);justify-content:space-between;margin-bottom:.75rem;padding-top:.75rem;font-size:.9em;display:flex}@media (width<=768px){.k-analytic-number footer{margin:.5rem 0;padding:.5rem 1rem 0}}.numeric-grid[data-v-c5f44e22]{grid-template-columns:repeat(4,1fr)}@media screen and (width<=1280px){.numeric-grid[data-v-c5f44e22]{grid-template-columns:repeat(2,1fr)}}.card-grid{margin:var(--card-margin);grid-gap:var(--card-margin);display:grid}.chart-grid .echarts{max-width:100%;margin:0 auto}@media (width>=1280px){.chart-grid{grid-template-columns:repeat(2,1fr)}}@media (width>=1280px) and (width>=1600px){.chart-grid .echarts{width:600px;max-width:100%;height:400px;margin:0 auto}}@media (width>=1280px) and (width<=1600px){.chart-grid .echarts{width:480px;height:360px}}@media (width<=1280px){.chart-grid{grid-template-columns:1fr}}@media (width<=1280px) and (width>=768px){.chart-grid .echarts{width:800px;height:400px}}@media (width<=1280px) and (width<=768px) and (width>=480px){.chart-grid .echarts{width:600px;height:400px}}@media (width<=1280px) and (width<=480px){.chart-grid .echarts{width:100%;height:300px}}.grid{display:grid}.flex{display:flex}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease{transition-timing-function:cubic-bezier(.4,0,.2,1)}
2
+ /*$vite$:1*/
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{icons as e,store as t}from"@koishi-ce/client";import{Fragment as n,computed as r,createBlock as i,createCommentVNode as a,createElementBlock as o,createElementVNode as s,createSlots as ee,createTextVNode as c,createVNode as l,defineAsyncComponent as u,defineComponent as d,h as f,openBlock as p,provide as m,ref as h,renderSlot as g,resolveComponent as _,toDisplayString as v,unref as y,withCtx as b}from"vue";var x=u(()=>import(`./echarts-8St0gdH6.js`)),S=h(`send`);function C({title:e,fields:n=[],showTab:r,options:i}){return d({render:()=>{if(!n.every(e=>t[e]))return null;let a=i(t,S.value);if(a)return f(_(`k-card`),{class:`frameless analytic-chart`},{header:()=>[f(`span`,{class:`left`},[e]),...r?[f(`span`,{class:`right`},[f(`span`,{class:`tab-item`+(S.value===`send`?` active`:``),onClick:()=>S.value=`send`},[`发送`]),f(`span`,{class:`tab-item`+(S.value===`receive`?` active`:``),onClick:()=>S.value=`receive`},[`接收`])])]:[]],default:()=>f(x,{option:a,autoresize:!0})})}})}var w={item(e){return{trigger:`item`,formatter:e}},axis(e){return{trigger:`axis`,axisPointer:{type:`cross`},formatter:e}}},T=e=>{e.slot({type:`analytic-chart`,component:C({title:`各平台消息占比`,fields:[`analytics`],showTab:!0,options({analytics:e},t){if(!e)return;let n=Object.entries(e.messageByBot).map(([e,n])=>({name:e,children:Object.entries(n).map(([e,n])=>({name:n.name||e,value:n[t]}))}));if(n.reduce((e,{children:t})=>e+t.reduce((e,{value:t})=>e+t,0),0))return{tooltip:w.item(({data:e})=>`${e.children?`平台`:`昵称`}:${e.name}<br>日均消息数量:${+e.value.toFixed(1)}`),series:[{type:`sunburst`,data:n,radius:[`0`,`65%`],nodeClick:!1,emphasis:{focus:`ancestor`},levels:[{},{label:{rotate:`tangential`}},{label:{position:`outside`,padding:3,silent:!1}}]}]}}})})},E=e=>{e.slot({type:`analytic-chart`,component:C({title:`指令调用频率`,fields:[`analytics`],options({analytics:e}){if(!e)return;let t=Object.entries(e.commandRate).sort((e,t)=>t[1]-e[1]).map(([e,t])=>({name:e,value:t}));if(t.length)return{tooltip:w.item(({data:e})=>{let t=[e.name];return t.push(`日均调用:${+e.value.toFixed(1)}`),t.join(`<br>`)}),series:[{type:`pie`,data:t,radius:[`35%`,`65%`],minShowLabelAngle:3}]}}})})},D=`日一二三四五六`,O=e=>{e.slot({type:`analytic-chart`,component:C({title:`历史消息数量`,fields:[`analytics`],showTab:!0,options({analytics:e},t){if(!e||!e.messageByDate.length)return;let n=e.messageByDate.slice(1);return{tooltip:w.axis(([e])=>{if(!e)return``;let{name:t,value:n}=e;return`${t} 星期${D[new Date(t).getDay()]}<br>消息数量:${n}`}),xAxis:{type:`category`,data:n.map((e,t)=>new Date(Date.now()-(t+1)*864e5).toLocaleDateString(`zh-CN`)).reverse()},yAxis:{type:`value`},series:{type:`line`,smooth:!0,data:n.map(e=>e[t]).reverse()}}}})})},k=e=>`${(e-.5).toFixed()}:00-${(e+.5).toFixed()}:00`,A=e=>{e.slot({type:`analytic-chart`,component:C({title:`每小时消息数量`,fields:[`analytics`],showTab:!0,options({analytics:e},t){if(e&&!e.messageByHour.every(e=>!e[t]))return{tooltip:w.axis(([n])=>{if(!n)return``;let[r=0]=n.data,{dataIndex:i}=n,a=e.messageByHour[i];return[`${k(r)}`,`日均消息数量:${+(a?.[t]||0).toFixed(1)}`].join(`<br>`)}),xAxis:{type:`value`,min:0,max:24,minInterval:1,maxInterval:4,axisLabel:{formatter:e=>`${e}:00`},axisPointer:{label:{formatter:({value:e})=>k(e)}}},yAxis:{type:`value`},series:[{data:e.messageByHour.map((e,n)=>[n+.5,e[t]||0]),type:`bar`,stack:`1`}]}}})})},j=e=>{e.plugin(O),e.plugin(A),e.plugin(T),e.plugin(E)},M={class:`content`},N={class:`title`},P={class:`value`},F={class:`left`},I={class:`right`},L=d({__name:`numeric`,props:{title:{},icon:{}},setup(e){return(t,n)=>{let r=_(`k-icon`),a=_(`k-card`);return p(),i(a,{class:`k-analytic-number`},ee({default:b(()=>[l(r,{name:e.icon},null,8,[`name`]),s(`div`,M,[s(`p`,N,v(e.title),1),s(`p`,P,[g(t.$slots,`default`,{},()=>[n[0]||=c(`-`,-1)])])])]),_:2},[t.$slots[`footer-left`]?{name:`footer`,fn:b(()=>[s(`span`,F,[g(t.$slots,`footer-left`)]),s(`span`,I,[g(t.$slots,`footer-right`,{},()=>[n[1]||=c(`-`,-1)])])]),key:`0`}:void 0]),1024)}}}),R={key:0,class:`card-grid numeric-grid`},z=d({__name:`index`,setup(e){m(`component:analytic-number`,L);let n=r(()=>{let e=t.analytics.dauHistory.slice(1),n=t.analytics.dauHistory.length-1;return n?e.reduce((e,t)=>e+t,0)/Math.min(e.length,n):0});return(e,r)=>{let i=_(`k-slot-item`),s=_(`k-slot`);return y(t).analytics?(p(),o(`div`,R,[l(s,{name:`analytic-number`},{default:b(()=>[l(i,null,{default:b(()=>[l(L,{icon:`analytic:user`,title:`用户数量`},{default:b(()=>[c(v(y(t).analytics.userCount),1)]),"footer-left":b(()=>[...r[0]||=[c(`昨日新增用户`,-1)]]),"footer-right":b(()=>[c(v(y(t).analytics.userIncrement),1)]),_:1}),l(L,{icon:`analytic:guild`,title:`群组数量`},{default:b(()=>[c(v(y(t).analytics.guildCount),1)]),"footer-left":b(()=>[...r[1]||=[c(`昨日新增群组`,-1)]]),"footer-right":b(()=>[c(v(y(t).analytics.guildIncrement),1)]),_:1}),l(L,{icon:`analytic:heart`,title:`今日 DAU`},{default:b(()=>[c(v(y(t).analytics.dauHistory[0]),1)]),"footer-left":b(()=>[...r[2]||=[c(`近期 DAU`,-1)]]),"footer-right":b(()=>[c(v(+n.value.toFixed(1)),1)]),_:1})]),_:1})]),_:1})])):a(`v-if`,!0)}}}),B=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},V=B(z,[[`__scopeId`,`data-v-c5f44e22`]]),H={class:`card-grid chart-grid`},U=d({__name:`home`,setup(e){return(e,t)=>{let r=_(`k-slot`);return p(),o(n,null,[l(V),s(`div`,H,[l(r,{name:`analytic-chart`})])],64)}}}),W={},G={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 640 512`};function K(e,t){return p(),o(`svg`,G,[...t[0]||=[s(`path`,{fill:`currentColor`,d:`M274.7 304H173.3c-95.73 0-173.3 77.6-173.3 173.3C0 496.5 15.52 512 34.66 512H413.3C432.5 512 448 496.5 448 477.3C448 381.6 370.4 304 274.7 304zM413.3 480H34.66C33.2 480 32 478.8 32 477.3C32 399.4 95.4 336 173.3 336H274.7C352.6 336 416 399.4 416 477.3C416 478.8 414.8 480 413.3 480zM224 256c70.7 0 128-57.31 128-128S294.7 0 224 0C153.3 0 96 57.31 96 128S153.3 256 224 256zM224 32c52.94 0 96 43.06 96 96c0 52.93-43.06 96-96 96S128 180.9 128 128C128 75.06 171.1 32 224 32zM375.1 241C392.9 250.8 412.3 256 432 256C493.8 256 544 205.8 544 144S493.8 32 432 32c-12.83 0-25.39 2.156-37.34 6.391c-8.328 2.953-12.69 12.09-9.734 20.42c2.953 8.344 12.12 12.66 20.42 9.734C413.9 65.53 422.8 64 432 64C476.1 64 512 99.89 512 144S476.1 224 432 224c-14.08 0-27.91-3.703-39.98-10.69c-7.656-4.453-17.44-1.828-21.86 5.828C365.7 226.8 368.3 236.6 375.1 241zM490.7 320H448c-8.844 0-16 7.156-16 16S439.2 352 448 352h42.67C555.4 352 608 404.6 608 469.3C608 475.2 603.2 480 597.3 480H496c-8.844 0-16 7.156-16 16s7.156 16 16 16h101.3C620.9 512 640 492.9 640 469.3C640 386.1 573 320 490.7 320z`},null,-1)]])}var q=B(W,[[`render`,K]]),J={},Y={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function X(e,t){return p(),o(`svg`,Y,[...t[0]||=[s(`path`,{fill:`currentColor`,d:`M255.1 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 0 232.4 0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L255.1 96zM255.1 141.3L221.4 106.6C196.1 81.31 160 69.77 124.7 75.66C71.21 84.58 31.1 130.9 31.1 185.1V190.9C31.1 223.6 45.55 254.7 69.42 277L250.1 445.7C251.7 447.2 253.8 448 255.1 448C258.2 448 260.3 447.2 261.9 445.7L442.6 277C466.4 254.7 480 223.6 480 190.9V185.1C480 130.9 440.8 84.58 387.3 75.66C351.1 69.77 315.9 81.31 290.6 106.6L255.1 141.3z`},null,-1)]])}var Z=B(J,[[`render`,X]]),Q={},$={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function te(e,t){return p(),o(`svg`,$,[...t[0]||=[s(`path`,{fill:`currentColor`,d:`M256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C172.2 512 97.87 471.8 51.19 409.6C45.88 402.5 47.31 392.5 54.37 387.2C61.44 381.9 71.47 383.3 76.78 390.4C117.7 444.8 182.7 480 256 480C379.7 480 480 379.7 480 256C480 132.3 379.7 32 256 32C166.7 32 89.51 84.3 53.55 160H144C152.8 160 160 167.2 160 176C160 184.8 152.8 192 144 192H16C7.164 192 0 184.8 0 176V48C0 39.16 7.164 32 16 32C24.84 32 32 39.16 32 48V131.1C75.66 53.29 159.6 0 256 0zM256 128C264.8 128 272 135.2 272 144V249.4L347.3 324.7C353.6 330.9 353.6 341.1 347.3 347.3C341.1 353.6 330.9 353.6 324.7 347.3L244.7 267.3C241.7 264.3 239.1 260.2 239.1 256V144C239.1 135.2 247.2 128 255.1 128H256z`},null,-1)]])}var ne=B(Q,[[`render`,te]]),re={},ie={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function ae(e,t){return p(),o(`svg`,ie,[...t[0]||=[s(`path`,{fill:`currentColor`,d:`M224 256c70.7 0 128-57.31 128-128s-57.3-128-128-128C153.3 0 96 57.31 96 128S153.3 256 224 256zM224 32c52.94 0 96 43.06 96 96c0 52.93-43.06 96-96 96S128 180.9 128 128C128 75.06 171.1 32 224 32zM274.7 304H173.3C77.61 304 0 381.6 0 477.3c0 19.14 15.52 34.67 34.66 34.67h378.7C432.5 512 448 496.5 448 477.3C448 381.6 370.4 304 274.7 304zM413.3 480H34.66C33.2 480 32 478.8 32 477.3C32 399.4 95.4 336 173.3 336h101.3C352.6 336 416 399.4 416 477.3C416 478.8 414.8 480 413.3 480z`},null,-1)]])}var oe=B(re,[[`render`,ae]]);e.register(`analytic:guild`,q),e.register(`analytic:heart`,Z),e.register(`analytic:history`,ne),e.register(`analytic:user`,oe);var se=e=>{e.plugin(j),e.slot({type:`home`,component:U,order:0})};export{se as default};
package/lib/index.d.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { DataService } from "@koishi-ce/console";
2
+ import { Context, Dict, Schema, Universal } from "@koishi-ce/koishi";
3
+ //#region src/index.d.ts
4
+ declare module "@koishi-ce/koishi" {
5
+ interface Tables {
6
+ "analytics.message": Analytics.Message;
7
+ "analytics.command": Analytics.Command;
8
+ }
9
+ }
10
+ declare module "@koishi-ce/console" {
11
+ namespace Console {
12
+ interface Services {
13
+ analytics: Analytics;
14
+ }
15
+ }
16
+ }
17
+ /** 收发消息计数对:send 为发出条数,receive 为收到条数。 */
18
+ interface MessageStats {
19
+ send: number;
20
+ receive: number;
21
+ }
22
+ declare class Analytics extends DataService<Analytics.Payload> {
23
+ static inject: string[];
24
+ static Config: Schema<Analytics.Config>;
25
+ config: Required<Analytics.Config>;
26
+ /** 上次落库时间,用于判断距下次上传是否超过 statsInternal。 */
27
+ lastUpdate: Date;
28
+ /** 上次落库的小时数,跨小时(整点)时强制上传一次。 */
29
+ updateHour: number;
30
+ /** 当前缓存的日期号(yyyymmdd),日期变化时缓存失效需重新聚合。 */
31
+ cachedDate?: number;
32
+ /** 当日的聚合结果缓存(Payload 的 Promise),同一天内复用。 */
33
+ cachedData: Promise<Analytics.Payload>;
34
+ /** 消息计数内存缓冲(未落库部分)。 */
35
+ private messages;
36
+ /** 指令计数内存缓冲(未落库部分)。 */
37
+ private commands;
38
+ constructor(ctx: Context, config?: Analytics.Config);
39
+ private createIndex;
40
+ /**
41
+ * 向内存缓冲追加一次计数:缓冲中已有全部维度字段都相同的记录则 count + 1,
42
+ * 否则以 count = 1 追加新条目(deepEqual 逐字段比对,保证聚合键唯一)。
43
+ */
44
+ private addAudit;
45
+ /**
46
+ * 把内存缓冲整表 upsert 落库:命中联合主键的行在其 count 基础上累加,
47
+ * 未命中则插入新行;完成后清空缓冲。
48
+ */
49
+ private uploadAudit;
50
+ /**
51
+ * 落库入口(事件回调高频调用):仅在距上次上传超过 statsInternal、
52
+ * 跨小时或 forced 时才真正执行上传,其余调用静默跳过。
53
+ *
54
+ * @param forced 是否无视时间间隔强制上传(退出 / 卸载时使用)
55
+ */
56
+ upload(forced?: boolean): Promise<void>;
57
+ /** 最近 N 天(不含今天)的日期号查询区间,供各聚合查询复用。 */
58
+ private queryRecent;
59
+ /**
60
+ * 指令调用频率:近 N 天各指令的总调用次数 ÷ 天数,得到"日均调用次数",
61
+ * 以指令名为键的字典返回(供饼图使用)。
62
+ *
63
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
64
+ */
65
+ private getCommandRate;
66
+ /**
67
+ * DAU 历史:按天统计触发过指令的去重用户数(userId > 0 过滤未登录调用)。
68
+ * 返回数组下标为"距今天数"(0 = 今天),长度 recentDayCount + 1,
69
+ * 无数据的日期补 0。
70
+ */
71
+ private getDauHistory;
72
+ /**
73
+ * 各机器人消息频率:近 N 天按 (平台, 机器人) 分组求和后除以天数,
74
+ * 得到每个机器人的日均收发消息数;结构为 { 平台: { selfId: 统计+机器人资料 } },
75
+ * 并尽量合并当前运行中的 bot.user 资料(昵称 / 头像等)供旭日图展示。
76
+ *
77
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
78
+ */
79
+ private getMessageByBot;
80
+ /**
81
+ * 按日历史消息量:不设日期下限地按天汇总全部历史(不含今天),
82
+ * 返回数组下标为"距今天数"(0 = 今天,恒为 0 值占位),
83
+ * 无记录的日期补 0。注意 result.length 由最久远记录决定。
84
+ */
85
+ private getMessageByDate;
86
+ /**
87
+ * 按小时消息分布:近 N 天按小时汇总后除以天数,得到每个时段的日均消息量。
88
+ * 返回固定 24 个元素的数组(下标即小时),越界小时数据直接丢弃。
89
+ *
90
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
91
+ */
92
+ private getMessageByHour;
93
+ /**
94
+ * 执行一次全量聚合,产出推送前端的完整 Payload:
95
+ * 数值指标(用户 / 群组总数与昨日增量)+ 各图表数据
96
+ * (指令频率、DAU 历史、机器人 / 按日 / 按小时消息量)。
97
+ *
98
+ * 一次 download 会并发发起十余个数据库查询;lengthTask 先行启动,
99
+ * 其结果(有效天数,介于 1 与 recentDayCount 之间)供各"日均"类指标除算。
100
+ */
101
+ download(): Promise<Analytics.Payload>;
102
+ /**
103
+ * DataService 读取入口:按自然日缓存聚合结果——
104
+ * 当天内的重复拉取复用同一 Promise,跨天后才重新 download
105
+ * (全部统计都以天为最小粒度,日内无变化)。
106
+ */
107
+ get(): Promise<Analytics.Payload>;
108
+ }
109
+ declare namespace Analytics {
110
+ /** 统计记录的公共维度:日期号(yyyymmdd)、小时、机器人 selfId 与平台。 */
111
+ interface Index {
112
+ id?: number;
113
+ date: number;
114
+ hour: number;
115
+ selfId: string;
116
+ platform: string;
117
+ }
118
+ /** 内存缓冲中的一条计数:公共维度 + 计数值。 */
119
+ interface Audit extends Index {
120
+ count: number;
121
+ }
122
+ /** analytics.message 表行:公共维度 + 消息方向(send / receive)。 */
123
+ interface Message extends Index {
124
+ type: string;
125
+ count: number;
126
+ }
127
+ /** analytics.command 表行:公共维度 + 指令名、用户与频道。 */
128
+ interface Command extends Index {
129
+ name: string;
130
+ userId: number;
131
+ channelId: string;
132
+ count: number;
133
+ }
134
+ /** 推送给前端的聚合结果:数值指标与各图表数据。 */
135
+ interface Payload {
136
+ userCount: number;
137
+ userIncrement: number;
138
+ guildCount: number;
139
+ guildIncrement: number;
140
+ dauHistory: number[];
141
+ commandRate: Dict<number>;
142
+ messageByBot: Dict<Dict<MessageStats & Partial<Universal.User>>>;
143
+ messageByDate: MessageStats[];
144
+ messageByHour: MessageStats[];
145
+ }
146
+ interface Config {
147
+ statsInternal?: number;
148
+ recentDayCount?: number;
149
+ }
150
+ }
151
+ //#endregion
152
+ export { MessageStats, Analytics as default };
package/lib/index.mjs ADDED
@@ -0,0 +1,317 @@
1
+ import { resolve } from "node:path";
2
+ import { DataService } from "@koishi-ce/console";
3
+ import { $, Logger, Schema, Time, deepEqual, pick } from "@koishi-ce/koishi";
4
+ //#region src/index.ts
5
+ /**
6
+ * analytics 插件(node 侧):消息与指令的统计分析服务。
7
+ *
8
+ * 工作方式:
9
+ * 1. 监听消息收发与指令执行事件,在内存中按 (日期, 小时, 维度…) 增量计数;
10
+ * 2. 每隔 statsInternal(或跨小时)把内存缓冲 upsert 到数据库的
11
+ * analytics.message / analytics.command 两张表(列存计数,主键联合维度);
12
+ * 3. 前端拉取时(get → download)对近 recentDayCount 天的数据做分组聚合,
13
+ * 产出数值指标与各图表所需的 Payload 推送给控制台。
14
+ */
15
+ const logger = new Logger("analytics");
16
+ var Analytics = class extends DataService {
17
+ static inject = ["database", "console"];
18
+ static Config = Schema.object({
19
+ statsInternal: Schema.natural().role("ms").description("统计数据推送的时间间隔。").default(Time.minute * 10),
20
+ recentDayCount: Schema.natural().description("统计最近几天的数据。").default(7)
21
+ });
22
+ config;
23
+ /** 上次落库时间,用于判断距下次上传是否超过 statsInternal。 */
24
+ lastUpdate = /* @__PURE__ */ new Date();
25
+ /** 上次落库的小时数,跨小时(整点)时强制上传一次。 */
26
+ updateHour = this.lastUpdate.getHours();
27
+ /** 当前缓存的日期号(yyyymmdd),日期变化时缓存失效需重新聚合。 */
28
+ cachedDate;
29
+ /** 当日的聚合结果缓存(Payload 的 Promise),同一天内复用。 */
30
+ cachedData;
31
+ /** 消息计数内存缓冲(未落库部分)。 */
32
+ messages = [];
33
+ /** 指令计数内存缓冲(未落库部分)。 */
34
+ commands = [];
35
+ constructor(ctx, config = {}) {
36
+ super(ctx, "analytics");
37
+ this.config = {
38
+ statsInternal: config.statsInternal ?? Time.minute * 10,
39
+ recentDayCount: config.recentDayCount ?? 7
40
+ };
41
+ ctx.model.extend("analytics.message", {
42
+ date: "integer",
43
+ hour: "integer",
44
+ type: "string(63)",
45
+ selfId: "string(63)",
46
+ platform: "string(63)",
47
+ count: "integer"
48
+ }, { primary: [
49
+ "date",
50
+ "hour",
51
+ "type",
52
+ "selfId",
53
+ "platform"
54
+ ] });
55
+ ctx.model.extend("analytics.command", {
56
+ date: "integer",
57
+ hour: "integer",
58
+ name: "string(63)",
59
+ selfId: "string(63)",
60
+ userId: "integer",
61
+ channelId: "string(63)",
62
+ platform: "string(63)",
63
+ count: "integer"
64
+ }, { primary: [
65
+ "date",
66
+ "hour",
67
+ "name",
68
+ "selfId",
69
+ "userId",
70
+ "channelId",
71
+ "platform"
72
+ ] });
73
+ ctx.on("exit", () => this.upload(true));
74
+ ctx.on("dispose", async () => {
75
+ await this.upload(true);
76
+ });
77
+ ctx.on("message", (session) => {
78
+ this.addAudit(this.messages, {
79
+ ...this.createIndex(session),
80
+ type: "receive"
81
+ });
82
+ this.upload();
83
+ });
84
+ ctx.on("send", (session) => {
85
+ this.addAudit(this.messages, {
86
+ ...this.createIndex(session),
87
+ type: "send"
88
+ });
89
+ this.upload();
90
+ });
91
+ ctx.any().before("command/execute", ({ command, session }) => {
92
+ if (!command || !session) return;
93
+ const user = session.user;
94
+ this.addAudit(this.commands, {
95
+ ...this.createIndex(session),
96
+ name: command.name,
97
+ userId: +(user?.["id"] || 0),
98
+ channelId: session.channelId ?? ""
99
+ });
100
+ this.upload();
101
+ });
102
+ ctx.console.addEntry({
103
+ dev: resolve(__dirname, "../client/index.ts"),
104
+ prod: resolve(__dirname, "../dist")
105
+ });
106
+ }
107
+ createIndex(session) {
108
+ return {
109
+ selfId: session.selfId,
110
+ platform: session.platform,
111
+ date: Time.getDateNumber(),
112
+ hour: (/* @__PURE__ */ new Date()).getHours()
113
+ };
114
+ }
115
+ /**
116
+ * 向内存缓冲追加一次计数:缓冲中已有全部维度字段都相同的记录则 count + 1,
117
+ * 否则以 count = 1 追加新条目(deepEqual 逐字段比对,保证聚合键唯一)。
118
+ */
119
+ addAudit(buffer, index) {
120
+ const audit = buffer.find((data) => deepEqual(pick(data, Object.keys(index)), index));
121
+ if (audit) audit.count += 1;
122
+ else buffer.push({
123
+ ...index,
124
+ count: 1
125
+ });
126
+ }
127
+ /**
128
+ * 把内存缓冲整表 upsert 落库:命中联合主键的行在其 count 基础上累加,
129
+ * 未命中则插入新行;完成后清空缓冲。
130
+ */
131
+ async uploadAudit(table, buffer) {
132
+ if (!buffer.length) return;
133
+ await this.ctx.database.upsert(table, (row) => buffer.map((audit) => ({
134
+ ...audit,
135
+ count: $.add($.ifNull(row.count, 0), audit.count)
136
+ })));
137
+ buffer.splice(0);
138
+ }
139
+ /**
140
+ * 落库入口(事件回调高频调用):仅在距上次上传超过 statsInternal、
141
+ * 跨小时或 forced 时才真正执行上传,其余调用静默跳过。
142
+ *
143
+ * @param forced 是否无视时间间隔强制上传(退出 / 卸载时使用)
144
+ */
145
+ async upload(forced = false) {
146
+ const date = /* @__PURE__ */ new Date();
147
+ const dateHour = date.getHours();
148
+ if (forced || +date - +this.lastUpdate > this.config.statsInternal || dateHour !== this.updateHour) {
149
+ this.lastUpdate = date;
150
+ this.updateHour = dateHour;
151
+ await Promise.all([this.uploadAudit("analytics.message", this.messages), this.uploadAudit("analytics.command", this.commands)]);
152
+ logger.debug("analytics updated");
153
+ }
154
+ }
155
+ /** 最近 N 天(不含今天)的日期号查询区间,供各聚合查询复用。 */
156
+ queryRecent() {
157
+ return {
158
+ $gte: Time.getDateNumber() - this.config.recentDayCount,
159
+ $lt: Time.getDateNumber()
160
+ };
161
+ }
162
+ /**
163
+ * 指令调用频率:近 N 天各指令的总调用次数 ÷ 天数,得到"日均调用次数",
164
+ * 以指令名为键的字典返回(供饼图使用)。
165
+ *
166
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
167
+ */
168
+ async getCommandRate(lengthTask) {
169
+ const data = await this.ctx.database.select("analytics.command", { date: this.queryRecent() }).groupBy(["name"], { count: (row) => $.sum(row.count) }).execute();
170
+ const length = await lengthTask;
171
+ const result = {};
172
+ data.forEach((stat) => {
173
+ result[stat.name] = stat.count / length;
174
+ });
175
+ return result;
176
+ }
177
+ /**
178
+ * DAU 历史:按天统计触发过指令的去重用户数(userId > 0 过滤未登录调用)。
179
+ * 返回数组下标为"距今天数"(0 = 今天),长度 recentDayCount + 1,
180
+ * 无数据的日期补 0。
181
+ */
182
+ async getDauHistory() {
183
+ const data = await this.ctx.database.select("analytics.command", {
184
+ date: { $gte: Time.getDateNumber() - this.config.recentDayCount },
185
+ userId: { $gt: 0 }
186
+ }).groupBy(["date"], { count: (row) => $.count(row.userId) }).execute();
187
+ const result = new Array(this.config.recentDayCount + 1).fill(0);
188
+ const today = Time.getDateNumber();
189
+ data.forEach((stat) => {
190
+ result[today - stat.date] = stat.count;
191
+ });
192
+ return result;
193
+ }
194
+ /**
195
+ * 各机器人消息频率:近 N 天按 (平台, 机器人) 分组求和后除以天数,
196
+ * 得到每个机器人的日均收发消息数;结构为 { 平台: { selfId: 统计+机器人资料 } },
197
+ * 并尽量合并当前运行中的 bot.user 资料(昵称 / 头像等)供旭日图展示。
198
+ *
199
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
200
+ */
201
+ async getMessageByBot(lengthTask) {
202
+ const data = await this.ctx.database.select("analytics.message", { date: this.queryRecent() }).groupBy([
203
+ "type",
204
+ "platform",
205
+ "selfId"
206
+ ], { count: (row) => $.sum(row.count) }).execute();
207
+ const length = await lengthTask;
208
+ const result = {};
209
+ data.forEach((stat) => {
210
+ const bot = this.ctx.bots[`${stat.platform}:${stat.selfId}`];
211
+ const entry = (result[stat.platform] ||= {})[stat.selfId] ||= {
212
+ ...bot?.user ?? {},
213
+ send: 0,
214
+ receive: 0
215
+ };
216
+ entry[stat.type] = stat.count / length;
217
+ });
218
+ return result;
219
+ }
220
+ /**
221
+ * 按日历史消息量:不设日期下限地按天汇总全部历史(不含今天),
222
+ * 返回数组下标为"距今天数"(0 = 今天,恒为 0 值占位),
223
+ * 无记录的日期补 0。注意 result.length 由最久远记录决定。
224
+ */
225
+ async getMessageByDate() {
226
+ const data = await this.ctx.database.select("analytics.message", { date: { $lt: Time.getDateNumber() } }).groupBy(["type", "date"], { count: (row) => $.sum(row.count) }).orderBy("date", "desc").execute();
227
+ const today = Time.getDateNumber();
228
+ const result = [];
229
+ data.forEach((stat) => {
230
+ const entry = result[today - stat.date] ||= {
231
+ send: 0,
232
+ receive: 0
233
+ };
234
+ entry[stat.type] = stat.count;
235
+ });
236
+ for (let i = 0; i < result.length; i++) result[i] ||= {
237
+ send: 0,
238
+ receive: 0
239
+ };
240
+ return result;
241
+ }
242
+ /**
243
+ * 按小时消息分布:近 N 天按小时汇总后除以天数,得到每个时段的日均消息量。
244
+ * 返回固定 24 个元素的数组(下标即小时),越界小时数据直接丢弃。
245
+ *
246
+ * @param lengthTask 参与平均的天数(见 download 中的计算)
247
+ */
248
+ async getMessageByHour(lengthTask) {
249
+ const data = await this.ctx.database.select("analytics.message", { date: this.queryRecent() }).groupBy(["type", "hour"], { count: (row) => $.sum(row.count) }).execute();
250
+ const length = await lengthTask;
251
+ const result = new Array(24).fill(null).map(() => ({
252
+ send: 0,
253
+ receive: 0
254
+ }));
255
+ data.forEach((stat) => {
256
+ const entry = result[stat.hour];
257
+ if (!entry) return;
258
+ entry[stat.type] = stat.count / length;
259
+ });
260
+ return result;
261
+ }
262
+ /**
263
+ * 执行一次全量聚合,产出推送前端的完整 Payload:
264
+ * 数值指标(用户 / 群组总数与昨日增量)+ 各图表数据
265
+ * (指令频率、DAU 历史、机器人 / 按日 / 按小时消息量)。
266
+ *
267
+ * 一次 download 会并发发起十余个数据库查询;lengthTask 先行启动,
268
+ * 其结果(有效天数,介于 1 与 recentDayCount 之间)供各"日均"类指标除算。
269
+ */
270
+ async download() {
271
+ const messageByDateTask = this.getMessageByDate();
272
+ const lengthTask = messageByDateTask.then((data) => {
273
+ return Math.min(Math.max(data.length - 1, 1), this.config.recentDayCount);
274
+ });
275
+ const [userCount, userIncrement, guildCount, guildIncrement, commandRate, dauHistory, messageByBot, messageByDate, messageByHour] = await Promise.all([
276
+ this.ctx.database.eval("user", (row) => $.count(row.id)),
277
+ this.ctx.database.eval("user", (row) => $.count(row.id), { createdAt: {
278
+ $gte: Time.fromDateNumber(Time.getDateNumber() - 1),
279
+ $lt: Time.fromDateNumber(Time.getDateNumber())
280
+ } }),
281
+ this.ctx.database.eval("channel", () => $.sum(1), (row) => $.eq(row.id, row.guildId)),
282
+ this.ctx.database.eval("channel", () => $.sum(1), (row) => $.and($.eq(row.id, row.guildId), $.gte(row.createdAt, Time.fromDateNumber(Time.getDateNumber() - 1)), $.lt(row.createdAt, Time.fromDateNumber(Time.getDateNumber())))),
283
+ this.getCommandRate(lengthTask),
284
+ this.getDauHistory(),
285
+ this.getMessageByBot(lengthTask),
286
+ messageByDateTask,
287
+ this.getMessageByHour(lengthTask)
288
+ ]);
289
+ return {
290
+ userCount,
291
+ userIncrement,
292
+ guildCount,
293
+ guildIncrement,
294
+ commandRate,
295
+ dauHistory,
296
+ messageByBot,
297
+ messageByDate,
298
+ messageByHour
299
+ };
300
+ }
301
+ /**
302
+ * DataService 读取入口:按自然日缓存聚合结果——
303
+ * 当天内的重复拉取复用同一 Promise,跨天后才重新 download
304
+ * (全部统计都以天为最小粒度,日内无变化)。
305
+ */
306
+ async get() {
307
+ const date = /* @__PURE__ */ new Date();
308
+ const dateNumber = Time.getDateNumber(date, date.getTimezoneOffset());
309
+ if (dateNumber !== this.cachedDate) {
310
+ this.cachedData = this.download();
311
+ this.cachedDate = dateNumber;
312
+ }
313
+ return this.cachedData;
314
+ }
315
+ };
316
+ //#endregion
317
+ export { Analytics as default };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@koishi-ce/plugin-analytics",
3
+ "description": "Show statistics for Koishi application",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "main": "lib/index.mjs",
7
+ "typings": "lib/index.d.ts",
8
+ "files": [
9
+ "lib",
10
+ "dist",
11
+ "src"
12
+ ],
13
+ "contributors": [
14
+ "Shigma <shigma10826@gmail.com>",
15
+ "Oppenheymu <oppenheymu@gmail.com>"
16
+ ],
17
+ "license": "AGPL-3.0",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Koishi-CE/koishi.git",
21
+ "directory": "plugins/webui/analytics"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/Koishi-CE/koishi/issues"
25
+ },
26
+ "homepage": "https://koishi.chat/plugins/console/analytics.html",
27
+ "keywords": [
28
+ "bot",
29
+ "chatbot",
30
+ "koishi",
31
+ "plugin",
32
+ "chart",
33
+ "console",
34
+ "statistics",
35
+ "analytics",
36
+ "status"
37
+ ],
38
+ "koishi": {
39
+ "description": {
40
+ "en": "Show statistics for Koishi application",
41
+ "zh": "在控制台中显示统计数据"
42
+ },
43
+ "service": {
44
+ "required": [
45
+ "console"
46
+ ],
47
+ "optional": [
48
+ "database"
49
+ ]
50
+ }
51
+ },
52
+ "peerDependencies": {
53
+ "@koishijs/plugin-console": "^5.30.11",
54
+ "koishi": "^4.18.11"
55
+ },
56
+ "devDependencies": {
57
+ "@koishijs/assets": "^1.1.2",
58
+ "@koishi-ce/client": "^1.0.0",
59
+ "echarts": "^6.1.0",
60
+ "@koishi-ce/koishi": "^1.0.0",
61
+ "vue-echarts": "^8.1.0"
62
+ },
63
+ "dependencies": {
64
+ "@koishi-ce/console": "^1.0.0"
65
+ },
66
+ "exports": {
67
+ ".": {
68
+ "source": "./src/index.ts",
69
+ "types": "./lib/index.d.ts",
70
+ "import": "./lib/index.mjs",
71
+ "default": "./lib/index.mjs"
72
+ }
73
+ }
74
+ }