@kne/fastify-message 1.0.9 → 1.0.11

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/index.js CHANGED
@@ -10,14 +10,18 @@ module.exports = fp(async function(fastify, options) {
10
10
  return fastify.account.models.user;
11
11
  }, senders: {}, getAuthenticate: (type) => {
12
12
  if (!fastify.account) {
13
- throw new Error('fastify-account plugin must be registered before fastify-message,or set options.getAuthenticate');
13
+ return [() => {
14
+ throw new Error('fastify-account plugin must be registered before fastify-message,or set options.getAuthenticate');
15
+ }];
14
16
  }
15
17
  const { authenticate } = fastify.account;
16
18
  return [authenticate.user, authenticate.admin];
17
19
  }
18
20
  }, options);
19
21
 
20
- fastify.register(require('@fastify/sse'));
22
+ fastify.register(require('@kne/fastify-statistics'), {
23
+ dbTableNamePrefix: options.dbTableNamePrefix, name: `${options.name}Statistics`
24
+ });
21
25
 
22
26
  fastify.register(require('@kne/fastify-namespace'), {
23
27
  name: options.name,
@@ -156,125 +156,6 @@ module.exports = fp(async (fastify, options) => {
156
156
  }
157
157
  );
158
158
 
159
- // 获取统计数据概览
160
- fastify.get(
161
- `${options.prefix}/statistics`,
162
- {
163
- onRequest: options.getAuthenticate('statistics'),
164
- schema: {
165
- description: '获取消息统计数据概览',
166
- summary: '统计数据概览',
167
- querystring: {
168
- type: 'object',
169
- properties: {
170
- range: { type: 'string', default: '7d', description: '时间范围: 7d=近7天, 1m=近1个月, 1y=近1年' },
171
- timezone: { type: 'string', description: '时区,如 Asia/Shanghai,默认服务器时区' }
172
- }
173
- },
174
- response: {
175
- 200: {
176
- type: 'object',
177
- properties: {
178
- range: { type: 'string', description: '当前时间范围' },
179
- rangeLabel: { type: 'string', description: '时间范围描述' },
180
- totalRecords: { type: 'integer', description: '时间范围内发送记录数' },
181
- byType: {
182
- type: 'object',
183
- description: '按消息类型统计(0=邮件,1=短信)',
184
- additionalProperties: { type: 'integer' }
185
- },
186
- byCode: {
187
- type: 'object',
188
- description: '按模板编码统计',
189
- additionalProperties: { type: 'integer' }
190
- },
191
- templateStats: {
192
- type: 'object',
193
- properties: {
194
- total: { type: 'integer', description: '模板总数' },
195
- byStatus: {
196
- type: 'object',
197
- description: '按模板状态统计(0=启用,1=禁用)',
198
- additionalProperties: { type: 'integer' }
199
- },
200
- byType: {
201
- type: 'object',
202
- description: '按模板类型统计(0=邮件,1=短信)',
203
- additionalProperties: { type: 'integer' }
204
- }
205
- }
206
- },
207
- recentTrend: {
208
- type: 'array',
209
- description: '时间范围内发送趋势',
210
- items: {
211
- type: 'object',
212
- properties: {
213
- date: { type: 'string', description: '日期' },
214
- count: { type: 'integer', description: '发送数量' }
215
- }
216
- }
217
- },
218
- recentTrendByType: {
219
- type: 'array',
220
- description: '时间范围内按类型发送趋势',
221
- items: {
222
- type: 'object',
223
- properties: {
224
- date: { type: 'string', description: '日期' },
225
- type: { type: 'integer', description: '消息类型' },
226
- count: { type: 'integer', description: '发送数量' }
227
- }
228
- }
229
- }
230
- }
231
- }
232
- }
233
- }
234
- },
235
- async request => {
236
- const { range = '7d', timezone } = request.query;
237
- return await services.statistics.getOverview({ range, timezone });
238
- }
239
- );
240
-
241
- // SSE 实时统计数据推送(当天实时数据)
242
- fastify.get(
243
- `${options.prefix}/statistics/sse`,
244
- {
245
- sse: true,
246
- onRequest: options.getAuthenticate('statistics'),
247
- schema: {
248
- description: 'SSE实时推送当天消息统计数据',
249
- summary: '实时统计数据SSE',
250
- querystring: {
251
- type: 'object',
252
- properties: {
253
- interval: { type: 'integer', minimum: 1, default: 5, description: '推送间隔时间(秒),最小1秒,默认5秒' },
254
- timezone: { type: 'string', description: '时区,如 Asia/Shanghai,默认服务器时区' }
255
- }
256
- }
257
- }
258
- },
259
- async function (request, reply) {
260
- const intervalSeconds = request.query.interval ?? 5;
261
- const { timezone } = request.query;
262
-
263
- async function* eventStream() {
264
- while (reply.sse.isConnected) {
265
- try {
266
- yield { data: JSON.stringify(await services.statistics.getRealtime({ timezone })) };
267
- } catch (err) {
268
- yield { event: 'error', data: JSON.stringify({ message: err.message }) };
269
- }
270
- await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
271
- }
272
- }
273
-
274
- await reply.sse.send(eventStream());
275
- }
276
- );
277
-
278
159
  // 查看消息模版列表
279
160
  fastify.get(
280
161
  `${options.prefix}/templates`,
@@ -0,0 +1,53 @@
1
+ const fp = require('fastify-plugin');
2
+
3
+ module.exports = fp(async (fastify, options) => {
4
+ const { services } = fastify[options.name];
5
+
6
+ fastify.get(
7
+ `${options.prefix}/statistics`,
8
+ {
9
+ onRequest: options.getAuthenticate('statistics'),
10
+ schema: {
11
+ summary: '获取消息统计概览',
12
+ query: {
13
+ type: 'object',
14
+ properties: {
15
+ range: { type: 'string', default: '7d', description: '时间范围: 7d=近7天, 1m=近1个月, 3m=近3个月, 1y=近1年' },
16
+ timezone: { type: 'string', description: '时区,如 Asia/Shanghai,默认服务器时区' },
17
+ type: { type: 'integer', description: '按消息类型筛选: 0=邮件, 1=短信' },
18
+ code: { type: 'string', description: '按模板编码筛选' }
19
+ }
20
+ }
21
+ }
22
+ },
23
+ async request => {
24
+ const { range = '7d', timezone, type, code } = request.query;
25
+ return await services.queryStatistics({ range, timezone, type, code });
26
+ }
27
+ );
28
+
29
+ // SSE 实时统计数据推送
30
+ fastify.get(
31
+ `${options.prefix}/statistics/sse`,
32
+ {
33
+ onRequest: options.getAuthenticate('statistics'),
34
+ schema: {
35
+ summary: 'SSE实时推送消息统计数据',
36
+ query: {
37
+ type: 'object',
38
+ properties: {
39
+ range: { type: 'string', default: '7d', description: '时间范围: 7d=近7天, 1m=近1个月, 3m=近3个月, 1y=近1年' },
40
+ timezone: { type: 'string', description: '时区,如 Asia/Shanghai,默认服务器时区' },
41
+ type: { type: 'integer', description: '按消息类型筛选: 0=邮件, 1=短信' },
42
+ code: { type: 'string', description: '按模板编码筛选' },
43
+ interval: { type: 'integer', minimum: 1, default: 5, description: '推送间隔时间(秒),最小1秒,默认5秒' }
44
+ }
45
+ }
46
+ }
47
+ },
48
+ async (request, reply) => {
49
+ const { range = '7d', timezone, type, code, interval } = request.query;
50
+ await services.sseStatistics({ range, timezone, type, code, interval }, reply);
51
+ }
52
+ );
53
+ });
@@ -5,38 +5,36 @@ const template = require('lodash/template');
5
5
  const merge = require('lodash/merge');
6
6
  const nodemailer = require('nodemailer');
7
7
  const { convert } = require('html-to-text');
8
- const dayjs = require('dayjs');
9
- const utc = require('dayjs/plugin/utc');
10
- const timezone = require('dayjs/plugin/timezone');
11
-
12
- dayjs.extend(utc);
13
- dayjs.extend(timezone);
14
8
 
15
9
  module.exports = fp(async (fastify, options) => {
16
- // 按指定时区获取"今天0点"的UTC Date对象
17
- const getTodayStart = tz => {
18
- if (!tz) {
19
- return dayjs().startOf('day').toDate();
20
- }
21
- // 在目标时区下取今天0点,再转回UTC
22
- return dayjs().tz(tz).startOf('day').toDate();
23
- };
24
-
25
- // 按指定时区格式化日期
26
- const formatDate = (date, tz) => {
27
- if (!tz) {
28
- return dayjs(date).format('YYYY-MM-DD');
29
- }
30
- return dayjs(date).tz(tz).format('YYYY-MM-DD');
31
- };
32
-
33
- const resolveTimezone = tz => tz || dayjs.tz.guess();
34
-
35
10
  const emailConfig = Object.assign({}, {
36
11
  port: 465, secure: true
37
12
  }, options.emailConfig);
38
13
  const isTest = options.isTest;
39
14
  const { models, services } = fastify[options.name];
15
+
16
+ // 采集消息统计数据
17
+ const collectMessageStatistics = async ({ code, type, success = true }) => {
18
+ try {
19
+ const statisticsServices = fastify[`${options.name}Statistics`]?.services;
20
+ if (!statisticsServices) return;
21
+
22
+ const data = { total: 1, success: success ? 1 : 0, failed: success ? 0 : 1 };
23
+ const unit = { total: 'count', success: 'count', failed: 'count' };
24
+ const time = new Date();
25
+
26
+ // 1-segment channel: code
27
+ await statisticsServices.collect({
28
+ channel: code, data, unit, time
29
+ });
30
+ // 2-segment channel: code:type
31
+ await statisticsServices.collect({
32
+ channel: `${code}:${type}`, data, unit, time
33
+ });
34
+ } catch (e) {
35
+ fastify.log.error(`采集消息统计数据失败: ${e.message}`);
36
+ }
37
+ };
40
38
  const includeTemplate = async dir => {
41
39
  if (!(await fs.exists(dir))) {
42
40
  fastify.log.info('template dir not exists');
@@ -147,6 +145,7 @@ module.exports = fp(async (fastify, options) => {
147
145
  throw new Error(`未配置类型 ${type} 的消息发送器`);
148
146
  })(type);
149
147
  await models.record.create({ type, code, templateId, props, name, content: sendOptions });
148
+ await collectMessageStatistics({ code, type, success: true });
150
149
  };
151
150
 
152
151
  Object.assign(fastify[options.name].services, {
@@ -178,253 +177,6 @@ module.exports = fp(async (fastify, options) => {
178
177
  return record;
179
178
  }
180
179
  },
181
-
182
- // 统计相关服务
183
- statistics: {
184
- getOverview: async ({ range = '7d', timezone } = {}) => {
185
- const { Sequelize } = models.record.sequelize;
186
- const recordModel = models.record;
187
- const templateModel = models.template;
188
- const createdAtCol = recordModel.rawAttributes.createdAt.field;
189
- const dialect = recordModel.sequelize.getDialect();
190
- const effectiveTimezone = resolveTimezone(timezone);
191
-
192
- // 根据range计算起始时间
193
- const rangeMap = {
194
- '7d': { days: 7, label: '近7天' },
195
- '1m': { days: 30, label: '近1个月' },
196
- '1y': { days: 365, label: '近1年' }
197
- };
198
- const normalizedRange = rangeMap[range] ? range : '7d';
199
- const rangeConfig = rangeMap[normalizedRange];
200
- // 按时区计算起始日期:先获取目标时区"今天0点"的UTC时间,再往前推N天
201
- const todayStart = getTodayStart(effectiveTimezone);
202
- const startDate = new Date(todayStart);
203
- startDate.setDate(startDate.getDate() - rangeConfig.days);
204
-
205
- const whereRange = { createdAt: { [Sequelize.Op.gte]: startDate } };
206
-
207
- // 按时区格式化日期的SQL:SQLite用strftime+utc偏移,MySQL用DATE_FORMAT+CONVERT_TZ,PostgreSQL用TO_CHAR+AT TIME ZONE
208
- const dateFn = (() => {
209
- if (!effectiveTimezone || dialect === 'sqlite') {
210
- return col => Sequelize.fn('DATE', Sequelize.col(col));
211
- }
212
- if (dialect === 'postgres') {
213
- return col => Sequelize.fn('TO_CHAR', Sequelize.literal(`"${col}" AT TIME ZONE '${effectiveTimezone}'`), 'YYYY-MM-DD');
214
- }
215
- return col => Sequelize.fn('DATE_FORMAT', Sequelize.fn('CONVERT_TZ', Sequelize.col(col), '+00:00', Sequelize.literal(`'${effectiveTimezone}'`)), '%Y-%m-%d');
216
- })();
217
-
218
- // 并行执行所有查询
219
- const [totalRecords, byType, byCode, totalTemplates, templatesByStatus, templatesByType, recentTrend, recentTrendByType] = await Promise.all([
220
- recordModel.count({ where: whereRange }),
221
- recordModel.findAll({
222
- attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
223
- where: whereRange,
224
- group: ['type'],
225
- raw: true
226
- }),
227
- recordModel.findAll({
228
- attributes: ['code', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
229
- where: whereRange,
230
- group: ['code'],
231
- raw: true
232
- }),
233
- templateModel.count(),
234
- templateModel.findAll({
235
- attributes: ['status', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
236
- group: ['status'],
237
- raw: true
238
- }),
239
- templateModel.findAll({
240
- attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
241
- group: ['type'],
242
- raw: true
243
- }),
244
- recordModel.findAll({
245
- attributes: [
246
- [dateFn(createdAtCol), 'date'],
247
- [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
248
- ],
249
- where: whereRange,
250
- group: [dateFn(createdAtCol)],
251
- order: [[dateFn(createdAtCol), 'ASC']],
252
- raw: true
253
- }),
254
- recordModel.findAll({
255
- attributes: [
256
- [dateFn(createdAtCol), 'date'],
257
- 'type',
258
- [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
259
- ],
260
- where: whereRange,
261
- group: [dateFn(createdAtCol), 'type'],
262
- order: [[dateFn(createdAtCol), 'ASC']],
263
- raw: true
264
- })
265
- ]);
266
-
267
- // SQLite 需要在JS层做时区偏移修正DATE结果
268
- const adjustDate = dateStr => {
269
- if (!effectiveTimezone || dialect !== 'sqlite') return dateStr;
270
- // SQLite DATE返回UTC日期,用dayjs转到目标时区取日期
271
- return dayjs.utc(dateStr).tz(effectiveTimezone).format('YYYY-MM-DD');
272
- };
273
-
274
- return {
275
- range: normalizedRange,
276
- rangeLabel: rangeConfig.label,
277
- totalRecords,
278
- byType: byType.reduce((acc, item) => {
279
- acc[String(item.type)] = Number(item.count);
280
- return acc;
281
- }, {}),
282
- byCode: byCode.reduce((acc, item) => {
283
- acc[item.code] = Number(item.count);
284
- return acc;
285
- }, {}),
286
- templateStats: {
287
- total: totalTemplates,
288
- byStatus: templatesByStatus.reduce((acc, item) => {
289
- acc[String(item.status)] = Number(item.count);
290
- return acc;
291
- }, {}),
292
- byType: templatesByType.reduce((acc, item) => {
293
- acc[String(item.type)] = Number(item.count);
294
- return acc;
295
- }, {})
296
- },
297
- recentTrend: recentTrend.map(item => ({ date: adjustDate(item.date), count: Number(item.count) })),
298
- recentTrendByType: recentTrendByType.map(item => ({ date: adjustDate(item.date), type: item.type, count: Number(item.count) }))
299
- };
300
- },
301
-
302
- getRealtime: async ({ timezone } = {}) => {
303
- const { Sequelize } = models.record.sequelize;
304
- const recordModel = models.record;
305
- const createdAtCol = recordModel.rawAttributes.createdAt.field;
306
- const dialect = recordModel.sequelize.getDialect();
307
- const effectiveTimezone = resolveTimezone(timezone);
308
-
309
- // 当天起始时间(按指定时区)
310
- const todayStart = getTodayStart(effectiveTimezone);
311
-
312
- const whereToday = { createdAt: { [Sequelize.Op.gte]: todayStart } };
313
-
314
- // SQLite: strftime取UTC时间后JS层偏移;MySQL: CONVERT_TZ转时区后提取;PostgreSQL: AT TIME ZONE转时区后提取
315
- const hourExtract = (() => {
316
- if (!effectiveTimezone || dialect === 'sqlite') {
317
- return dialect === 'sqlite'
318
- ? Sequelize.fn('strftime', '%H', Sequelize.col(createdAtCol))
319
- : Sequelize.fn('EXTRACT', Sequelize.literal(`HOUR FROM "${createdAtCol}"`));
320
- }
321
- if (dialect === 'postgres') {
322
- return Sequelize.fn('EXTRACT', Sequelize.literal(`HOUR FROM "${createdAtCol}" AT TIME ZONE '${effectiveTimezone}'`));
323
- }
324
- return Sequelize.fn('EXTRACT', Sequelize.literal(`HOUR FROM CONVERT_TZ("${createdAtCol}", '+00:00', '${effectiveTimezone}')`));
325
- })();
326
-
327
- // 15分钟间隔提取:SQLite用strftime,MySQL用DATE_FORMAT+EXTRACT,PostgreSQL用TO_CHAR+EXTRACT
328
- const intervalExtract = (() => {
329
- if (dialect === 'sqlite') {
330
- return Sequelize.literal(`strftime('%H', "${createdAtCol}") || ':' || printf('%02d', CAST(strftime('%M', "${createdAtCol}") AS INTEGER) / 15 * 15)`);
331
- }
332
- if (!effectiveTimezone) {
333
- if (dialect === 'postgres') {
334
- return Sequelize.literal(`TO_CHAR("${createdAtCol}", 'HH24:') || LPAD((FLOOR(EXTRACT(MINUTE FROM "${createdAtCol}") / 15) * 15)::TEXT, 2, '0')`);
335
- }
336
- return Sequelize.literal(`CONCAT(DATE_FORMAT("${createdAtCol}", '%H:'), LPAD(FLOOR(EXTRACT(MINUTE FROM "${createdAtCol}") / 15) * 15, 2, '0'))`);
337
- }
338
- if (dialect === 'postgres') {
339
- return Sequelize.literal(`TO_CHAR("${createdAtCol}" AT TIME ZONE '${effectiveTimezone}', 'HH24:') || LPAD((FLOOR(EXTRACT(MINUTE FROM "${createdAtCol}" AT TIME ZONE '${effectiveTimezone}') / 15) * 15)::TEXT, 2, '0')`);
340
- }
341
- return Sequelize.literal(`CONCAT(DATE_FORMAT(CONVERT_TZ("${createdAtCol}", '+00:00', '${effectiveTimezone}'), '%H:'), LPAD(FLOOR(EXTRACT(MINUTE FROM CONVERT_TZ("${createdAtCol}", '+00:00', '${effectiveTimezone}')) / 15) * 15, 2, '0'))`);
342
- })();
343
-
344
- const [totalRecords, byType, byCode, hourlyTrend, hourlyTrendByType, intervalTrend] = await Promise.all([
345
- recordModel.count({ where: whereToday }),
346
- recordModel.findAll({
347
- attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
348
- where: whereToday,
349
- group: ['type'],
350
- raw: true
351
- }),
352
- recordModel.findAll({
353
- attributes: ['code', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
354
- where: whereToday,
355
- group: ['code'],
356
- raw: true
357
- }),
358
- recordModel.findAll({
359
- attributes: [
360
- [hourExtract, 'hour'],
361
- [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
362
- ],
363
- where: whereToday,
364
- group: [hourExtract],
365
- order: [[hourExtract, 'ASC']],
366
- raw: true
367
- }),
368
- recordModel.findAll({
369
- attributes: [
370
- [hourExtract, 'hour'],
371
- 'type',
372
- [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
373
- ],
374
- where: whereToday,
375
- group: [hourExtract, 'type'],
376
- order: [[hourExtract, 'ASC'], ['type', 'ASC']],
377
- raw: true
378
- }),
379
- recordModel.findAll({
380
- attributes: [
381
- [intervalExtract, 'interval'],
382
- [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
383
- ],
384
- where: whereToday,
385
- group: [intervalExtract],
386
- order: [[intervalExtract, 'ASC']],
387
- raw: true
388
- })
389
- ]);
390
-
391
- // SQLite的hour/interval是UTC,需按timezone偏移修正
392
- const adjustHour = hour => {
393
- if (!effectiveTimezone || dialect !== 'sqlite') return Number(hour);
394
- // 构建一个UTC时间点(今天+hour:00),转目标时区后取hour
395
- const utcTime = dayjs.utc().startOf('day').hour(Number(hour));
396
- return utcTime.tz(effectiveTimezone).hour();
397
- };
398
-
399
- const adjustInterval = intervalStr => {
400
- if (!effectiveTimezone || dialect !== 'sqlite') return intervalStr;
401
- const [h, m] = intervalStr.split(':').map(Number);
402
- // 构建一个UTC时间点(今天+hour:minute),转目标时区后格式化
403
- const utcTime = dayjs.utc().startOf('day').hour(h).minute(m);
404
- return utcTime.tz(effectiveTimezone).format('HH:mm');
405
- };
406
-
407
- return {
408
- date: formatDate(todayStart, effectiveTimezone),
409
- totalRecords,
410
- byType: byType.reduce((acc, item) => {
411
- acc[String(item.type)] = Number(item.count);
412
- return acc;
413
- }, {}),
414
- byCode: byCode.reduce((acc, item) => {
415
- acc[item.code] = Number(item.count);
416
- return acc;
417
- }, {}),
418
- hourlyTrend: hourlyTrend.map(item => ({ hour: adjustHour(item.hour), count: Number(item.count) })),
419
- hourlyTrendByType: hourlyTrendByType.map(item => ({
420
- hour: adjustHour(item.hour),
421
- type: Number(item.type),
422
- count: Number(item.count)
423
- })),
424
- intervalTrend: intervalTrend.map(item => ({ interval: adjustInterval(item.interval), count: Number(item.count) }))
425
- };
426
- }
427
- },
428
180
 
429
181
  // 消息模版相关服务
430
182
  template: {
@@ -0,0 +1,328 @@
1
+ const fp = require('fastify-plugin');
2
+ const dayjs = require('dayjs');
3
+ const utc = require('dayjs/plugin/utc');
4
+ const timezone = require('dayjs/plugin/timezone');
5
+ const {
6
+ CONFIG_CONSTANTS
7
+ } = require('../utils/config');
8
+
9
+ dayjs.extend(utc);
10
+ dayjs.extend(timezone);
11
+
12
+ // 查询用客户端时间:parseRange 使用客户端时区计算日期范围
13
+ const parseRange = (range = '7d', tz) => {
14
+ const config = CONFIG_CONSTANTS.TIME_RANGES[range];
15
+ if (!config) {
16
+ throw new Error(`不支持的时间范围: ${range}, 支持: ${Object.keys(CONFIG_CONSTANTS.TIME_RANGES).join(',')}`);
17
+ }
18
+ // 写入数据全使用服务器时间,查询用客户端时间
19
+ const now = tz ? dayjs().tz(tz) : dayjs();
20
+ const unitStartMap = { day: 'day', month: 'month', year: 'year' };
21
+ const startUnit = unitStartMap[config.unit];
22
+ const startTime = startUnit
23
+ ? now.subtract(config.value, startUnit).startOf(startUnit)
24
+ : now.subtract(config.value, config.unit);
25
+ return { startTime: startTime.toDate(), endTime: now.toDate(), label: config.label, range };
26
+ };
27
+
28
+ // 查询用客户端时间:formatDate 使用客户端时区格式化日期
29
+ const formatDate = (date, tz) => {
30
+ const d = tz ? dayjs(date).tz(tz) : dayjs(date);
31
+ return d.format('YYYY-MM-DD');
32
+ };
33
+
34
+ // 查询用客户端时间:提取客户端时区下的小时
35
+ const getHour = (date, tz) => {
36
+ const d = tz ? dayjs(date).tz(tz) : dayjs(date);
37
+ return d.hour();
38
+ };
39
+
40
+ const safeNum = v => {
41
+ const n = Number(v);
42
+ return (typeof n === 'number' && !isNaN(n) ? n : 0);
43
+ };
44
+
45
+ const getSumData = item => (item.data || {}).sum || item.data || {};
46
+
47
+ // channel 格式: "code" (1段) 或 "code:type" (2段)
48
+ // code = 模板编码, type = 消息类型 (0=邮件, 1=短信)
49
+ const parseChannel = channel => {
50
+ const parts = channel.split(':');
51
+ return parts.length >= 2
52
+ ? { code: parts[0], type: parts[1] }
53
+ : { code: channel, type: null };
54
+ };
55
+
56
+ const buildChannels = async (statisticsServices, code, type) => {
57
+ const metaResult = await statisticsServices.channelMeta.list();
58
+ const list = Array.isArray(metaResult) ? metaResult : metaResult.list || [];
59
+ const rootChannels = list.map(meta => meta.channel).filter(Boolean);
60
+ const codes = code ? rootChannels.filter(ch => ch === code) : rootChannels;
61
+ const messageTypes = type !== undefined && type !== null ? [String(type)] : ['0', '1'];
62
+ const channels = [];
63
+ for (const c of codes) {
64
+ channels.push(c);
65
+ for (const mt of messageTypes) channels.push(`${c}:${mt}`);
66
+ }
67
+ return channels;
68
+ };
69
+
70
+ // 创建空的 dailyMap 条目
71
+ const createDailyMapEntry = () => ({
72
+ total: 0,
73
+ byType: {}
74
+ });
75
+
76
+ // 查询 statistics 并解析结果
77
+ const queryAndParse = async (statisticsServices, { channels, startTime, endTime, timezone: tz, type: typeFilter }) => {
78
+ if (channels.length === 0) return null;
79
+
80
+ const statResult = await statisticsServices.query({
81
+ channels, startTime, endTime,
82
+ attributeNames: CONFIG_CONSTANTS.STATISTICS_ATTRIBUTES,
83
+ aggregates: ['sum'],
84
+ timezone: tz || undefined
85
+ });
86
+
87
+ const result = {
88
+ totalRecords: 0,
89
+ byType: {},
90
+ byCode: {},
91
+ dailyMap: {},
92
+ hourlyMap: {},
93
+ hourlyTypeMap: {}
94
+ };
95
+
96
+ for (const item of statResult.list || []) {
97
+ const { code: codeName, type: typeName } = parseChannel(item.channel);
98
+ if (typeFilter !== undefined && typeFilter !== null && typeName && typeName !== String(typeFilter)) continue;
99
+ if (typeFilter !== undefined && typeFilter !== null && !typeName) continue;
100
+
101
+ const sum = getSumData(item);
102
+ const count = safeNum(sum.total);
103
+ const isHourly = item.period === 'h';
104
+ const date = formatDate(item.time, tz);
105
+ const hour = isHourly ? getHour(item.time, tz) : null;
106
+
107
+ // ─── 1-segment channel: 全局 + 按日 聚合 ───
108
+ if (!typeName) {
109
+ result.totalRecords += count;
110
+ result.byCode[codeName] = (result.byCode[codeName] || 0) + count;
111
+
112
+ // 按日汇总
113
+ if (!result.dailyMap[date]) result.dailyMap[date] = createDailyMapEntry();
114
+ result.dailyMap[date].total += count;
115
+
116
+ // 小时级趋势(仅 period='h')
117
+ if (isHourly) {
118
+ if (!result.hourlyMap[date]) result.hourlyMap[date] = {};
119
+ result.hourlyMap[date][hour] = (result.hourlyMap[date][hour] || 0) + count;
120
+ }
121
+ }
122
+
123
+ // ─── 2-segment channel: byType 聚合 ───
124
+ if (typeName) {
125
+ result.byType[typeName] = (result.byType[typeName] || 0) + count;
126
+
127
+ if (result.dailyMap[date]) {
128
+ result.dailyMap[date].byType[typeName] = (result.dailyMap[date].byType[typeName] || 0) + count;
129
+ }
130
+
131
+ if (isHourly) {
132
+ if (!result.hourlyTypeMap[date]) result.hourlyTypeMap[date] = {};
133
+ if (!result.hourlyTypeMap[date][hour]) result.hourlyTypeMap[date][hour] = {};
134
+ result.hourlyTypeMap[date][hour][typeName] = (result.hourlyTypeMap[date][hour][typeName] || 0) + count;
135
+ }
136
+ }
137
+ }
138
+
139
+ return result;
140
+ };
141
+
142
+ // 构建历史日趋势数组
143
+ const buildDailyTrendArrays = (parsed) => {
144
+ const sorted = Object.entries(parsed.dailyMap).sort(([a], [b]) => a.localeCompare(b));
145
+ const recentTrend = sorted.map(([date, d]) => ({ date, count: d.total }));
146
+ const recentTrendByType = [];
147
+ sorted.forEach(([date, d]) => {
148
+ Object.entries(d.byType).forEach(([type, count]) => recentTrendByType.push({ date, type: Number(type), count }));
149
+ });
150
+ return { recentTrend, recentTrendByType };
151
+ };
152
+
153
+ // 构建小时趋势数组
154
+ const buildHourlyTrendArrays = (parsed, todayStr) => {
155
+ const hourlyTrend = [];
156
+ const hourlyTrendByType = [];
157
+
158
+ const dates = Object.keys(parsed.hourlyMap).sort();
159
+ for (const date of dates) {
160
+ const hoursMap = parsed.hourlyMap[date] || {};
161
+ const typeMap = parsed.hourlyTypeMap[date] || {};
162
+
163
+ // 只处理当天的数据(SSE 场景),或所有数据(历史场景)
164
+ const allHours = [...new Set([
165
+ ...Object.keys(hoursMap).map(Number),
166
+ ...Object.keys(typeMap).reduce((acc, h) => {
167
+ acc.push(Number(h));
168
+ return acc;
169
+ }, [])
170
+ ])].sort((a, b) => a - b);
171
+
172
+ for (const hour of allHours) {
173
+ if (todayStr && date !== todayStr) continue;
174
+ hourlyTrend.push({
175
+ date, hour,
176
+ count: hoursMap[hour] || 0
177
+ });
178
+
179
+ const types = typeMap[hour] || {};
180
+ for (const [typeName, count] of Object.entries(types)) {
181
+ if (count > 0) hourlyTrendByType.push({ date, hour, type: Number(typeName), count });
182
+ }
183
+ }
184
+ }
185
+
186
+ return { hourlyTrend, hourlyTrendByType };
187
+ };
188
+
189
+ module.exports = fp(async (fastify, options) => {
190
+ const statisticsServices = fastify[`${options.name}Statistics`].services;
191
+ const { template: TemplateModel } = fastify[options.name].models;
192
+ const { Sequelize } = TemplateModel.sequelize;
193
+
194
+ // ─── queryStatistics (history 接口) ───
195
+ const queryStatistics = async ({ range = '7d', timezone, type, code }) => {
196
+ const { startTime, endTime, label, range: rangeKey } = parseRange(range, timezone);
197
+ const emptyResult = {
198
+ range: rangeKey, rangeLabel: label,
199
+ totalRecords: 0, byType: {}, byCode: {},
200
+ recentTrend: [], recentTrendByType: [],
201
+ hourlyTrend: [], hourlyTrendByType: [],
202
+ templateStats: { total: 0, byStatus: {}, byType: {} }
203
+ };
204
+ try {
205
+ const channels = await buildChannels(statisticsServices, code, type);
206
+ const parsed = await queryAndParse(statisticsServices, { channels, startTime, endTime, timezone, type });
207
+ if (!parsed) return emptyResult;
208
+
209
+ const { recentTrend, recentTrendByType } = buildDailyTrendArrays(parsed);
210
+ const { hourlyTrend, hourlyTrendByType } = buildHourlyTrendArrays(parsed);
211
+
212
+ // 查询模板统计(独立于 statistics 服务)
213
+ const [totalTemplates, templatesByStatus, templatesByType] = await Promise.all([
214
+ TemplateModel.count(),
215
+ TemplateModel.findAll({
216
+ attributes: ['status', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
217
+ group: ['status'],
218
+ raw: true
219
+ }),
220
+ TemplateModel.findAll({
221
+ attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
222
+ group: ['type'],
223
+ raw: true
224
+ })
225
+ ]);
226
+
227
+ return {
228
+ range: rangeKey, rangeLabel: label,
229
+ totalRecords: parsed.totalRecords,
230
+ byType: parsed.byType,
231
+ byCode: parsed.byCode,
232
+ recentTrend, recentTrendByType,
233
+ hourlyTrend, hourlyTrendByType,
234
+ templateStats: {
235
+ total: totalTemplates,
236
+ byStatus: templatesByStatus.reduce((acc, item) => {
237
+ acc[String(item.status)] = Number(item.count);
238
+ return acc;
239
+ }, {}),
240
+ byType: templatesByType.reduce((acc, item) => {
241
+ acc[String(item.type)] = Number(item.count);
242
+ return acc;
243
+ }, {})
244
+ }
245
+ };
246
+ } catch (e) {
247
+ fastify.log.error(`查询统计数据失败: ${e.message}`);
248
+ return emptyResult;
249
+ }
250
+ };
251
+
252
+ // ─── buildSseData (SSE 接口) ───
253
+ const buildSseData = async ({ timezone: tz, type, code }) => {
254
+ // 查询用客户端时间:"今天"的边界使用客户端时区
255
+ const now = tz ? dayjs().tz(tz) : dayjs();
256
+ const todayStart = now.startOf('day').toDate();
257
+ const todayEnd = now.endOf('day').toDate();
258
+ const todayStr = now.format('YYYY-MM-DD');
259
+
260
+ // 默认值
261
+ let totalRecords = 0;
262
+ let byType = {};
263
+ let byCode = {};
264
+ let hourlyTrend = [];
265
+ let hourlyTrendByType = [];
266
+ let intervalTrend = [];
267
+
268
+ try {
269
+ const channels = await buildChannels(statisticsServices, code, type);
270
+ const parsed = await queryAndParse(statisticsServices, { channels, startTime: todayStart, endTime: todayEnd, timezone: tz, type });
271
+ if (parsed) {
272
+ totalRecords = parsed.totalRecords;
273
+ byType = parsed.byType;
274
+ byCode = parsed.byCode;
275
+
276
+ const hourlyArrays = buildHourlyTrendArrays(parsed, todayStr);
277
+
278
+ // SSE 格式:简化为 {hour, count}
279
+ hourlyTrend = hourlyArrays.hourlyTrend
280
+ .filter(h => h.date === todayStr)
281
+ .map(({ hour, count }) => ({ hour, count }));
282
+
283
+ hourlyTrendByType = hourlyArrays.hourlyTrendByType
284
+ .filter(h => h.date === todayStr)
285
+ .map(({ hour, type, count }) => ({ hour, type, count }));
286
+
287
+ intervalTrend = hourlyTrend.map(({ hour, count }) => ({
288
+ interval: `${String(hour).padStart(2, '0')}:00`,
289
+ count
290
+ }));
291
+ }
292
+ } catch (e) {
293
+ fastify.log.error(`SSE统计查询失败: ${e.message}`);
294
+ }
295
+
296
+ return {
297
+ date: todayStr,
298
+ totalRecords,
299
+ byType,
300
+ byCode,
301
+ hourlyTrend,
302
+ hourlyTrendByType,
303
+ intervalTrend
304
+ };
305
+ };
306
+
307
+ // ─── sseStatistics ───
308
+ const sseStatistics = async ({ range = '7d', timezone, type, code, interval }, reply) => {
309
+ await statisticsServices.sseStream.send(reply, {
310
+ name: `${options.name}Statistics`,
311
+ params: { type, code, timezone },
312
+ fetchData: async ({ type, code, timezone } = {}) => {
313
+ try {
314
+ return await buildSseData({ timezone, type, code });
315
+ } catch (e) {
316
+ fastify.log.error(`SSE获取数据失败: ${e.message}`);
317
+ return {};
318
+ }
319
+ },
320
+ interval: interval || 5
321
+ });
322
+ };
323
+
324
+ Object.assign(fastify[options.name].services, {
325
+ queryStatistics,
326
+ sseStatistics
327
+ });
328
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 配置常量定义
3
+ */
4
+ const CONFIG_CONSTANTS = {
5
+ // 时间范围
6
+ TIME_RANGES: {
7
+ '7d': { value: 7, unit: 'day', label: '近7天' },
8
+ '1m': { value: 1, unit: 'month', label: '近1个月' },
9
+ '3m': { value: 3, unit: 'month', label: '近3个月' },
10
+ '1y': { value: 1, unit: 'year', label: '近1年' }
11
+ },
12
+
13
+ // 统计相关
14
+ STATISTICS_ATTRIBUTES: ['total', 'success', 'failed'],
15
+ STATISTICS_PERIODS: ['h', 'd']
16
+ };
17
+
18
+ module.exports = { CONFIG_CONSTANTS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-message",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "管理消息发送,支持sms消息,email消息等.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -49,6 +49,7 @@
49
49
  "@kne/fastify-sequelize": "^2.0.1",
50
50
  "chai": "^6.2.2",
51
51
  "fastify": "^5.3.2",
52
+ "fastify-cron": "^1.4.0",
52
53
  "fastify-plugin": "^5.0.1",
53
54
  "husky": "^9.0.11",
54
55
  "mocha": "^11.7.5",
@@ -59,7 +60,7 @@
59
60
  "sqlite3": "^6.0.1"
60
61
  },
61
62
  "dependencies": {
62
- "@fastify/sse": "^0.4.0",
63
+ "@kne/fastify-statistics": "^0.1.0-alpha.10",
63
64
  "dayjs": "^1.11.20",
64
65
  "fs-extra": "^11.3.0",
65
66
  "html-to-text": "^9.0.5",