@kne/fastify-message 1.0.6 → 1.0.8

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
@@ -17,6 +17,8 @@ module.exports = fp(async function(fastify, options) {
17
17
  }
18
18
  }, options);
19
19
 
20
+ fastify.register(require('@fastify/sse'));
21
+
20
22
  fastify.register(require('@kne/fastify-namespace'), {
21
23
  name: options.name,
22
24
  options,
@@ -25,7 +27,7 @@ module.exports = fp(async function(fastify, options) {
25
27
  })], ['services', path.resolve(__dirname, './libs/services')], ['controllers', path.resolve(__dirname, './libs/controllers')]]
26
28
  });
27
29
 
28
- fastify.register(fp((fastify, options) => {
30
+ fastify.register(fp(async (fastify, options) => {
29
31
  fastify.sequelize.syncPromise.then(() => {
30
32
  return fastify[options.name].services.includeTemplate(options.templateDir);
31
33
  });
@@ -64,7 +64,7 @@ module.exports = fp(async (fastify, options) => {
64
64
  if (code) where.code = code;
65
65
  if (name) where.name = name;
66
66
 
67
- return await services.record.list({ filter: where, perPage, currentPage });
67
+ return await services.record.list({ filter: where, perPage: Math.min(perPage, 100), currentPage });
68
68
  }
69
69
  );
70
70
 
@@ -103,7 +103,10 @@ module.exports = fp(async (fastify, options) => {
103
103
  if (error.message === '模版已禁用,无法发送消息') {
104
104
  throw fastify.httpErrors.badRequest(error.message);
105
105
  }
106
- throw fastify.httpErrors.notFound(error.message);
106
+ if (error.message === '模版不存在' || error.message === 'template not found') {
107
+ throw fastify.httpErrors.notFound(error.message);
108
+ }
109
+ throw error;
107
110
  }
108
111
  }
109
112
  );
@@ -145,11 +148,134 @@ module.exports = fp(async (fastify, options) => {
145
148
  try {
146
149
  return await services.record.detail({ id });
147
150
  } catch (error) {
148
- throw fastify.httpErrors.notFound(error.message);
151
+ if (error.message === '记录不存在') {
152
+ throw fastify.httpErrors.notFound(error.message);
153
+ }
154
+ throw error;
149
155
  }
150
156
  }
151
157
  );
152
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;
261
+ const { timezone } = request.query;
262
+ reply.sse.keepAlive();
263
+
264
+ async function* eventStream() {
265
+ while (reply.sse.isConnected) {
266
+ try {
267
+ yield { data: JSON.stringify(await services.statistics.getRealtime({ timezone })) };
268
+ } catch (err) {
269
+ yield { event: 'error', data: JSON.stringify({ message: err.message }) };
270
+ }
271
+ await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
272
+ }
273
+ }
274
+
275
+ await reply.sse.send(eventStream());
276
+ }
277
+ );
278
+
153
279
  // 查看消息模版列表
154
280
  fastify.get(
155
281
  `${options.prefix}/templates`,
@@ -213,7 +339,7 @@ module.exports = fp(async (fastify, options) => {
213
339
  if (level !== undefined) where.level = level;
214
340
  if (status !== undefined) where.status = status;
215
341
 
216
- return await services.template.list({ filter: where, perPage, currentPage });
342
+ return await services.template.list({ filter: where, perPage: Math.min(perPage, 100), currentPage });
217
343
  }
218
344
  );
219
345
 
@@ -254,7 +380,10 @@ module.exports = fp(async (fastify, options) => {
254
380
  try {
255
381
  return await services.template.detail({ id });
256
382
  } catch (error) {
257
- throw fastify.httpErrors.notFound(error.message);
383
+ if (error.message === '模版不存在') {
384
+ throw fastify.httpErrors.notFound(error.message);
385
+ }
386
+ throw error;
258
387
  }
259
388
  }
260
389
  );
@@ -31,7 +31,13 @@ module.exports = ({ DataTypes, options }) => {
31
31
  record.belongsTo(template);
32
32
  },
33
33
  options: {
34
- comment: '系统消息记录'
34
+ comment: '系统消息记录',
35
+ indexes: [
36
+ { fields: ['created_at'] },
37
+ { fields: ['type'] },
38
+ { fields: ['code'] },
39
+ { fields: ['type', 'code'] }
40
+ ]
35
41
  }
36
42
  };
37
43
  };
@@ -37,7 +37,10 @@ module.exports = ({ DataTypes, options }) => {
37
37
  });
38
38
  },
39
39
  options: {
40
- comment: '消息模版'
40
+ comment: '消息模版',
41
+ indexes: [
42
+ { fields: ['code', 'type', 'level'] }
43
+ ]
41
44
  }
42
45
  };
43
46
  };
@@ -5,9 +5,33 @@ 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 pify = require('pify');
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);
9
14
 
10
15
  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
+
11
35
  const emailConfig = Object.assign({}, {
12
36
  port: 465, secure: true
13
37
  }, options.emailConfig);
@@ -15,17 +39,19 @@ module.exports = fp(async (fastify, options) => {
15
39
  const { models, services } = fastify[options.name];
16
40
  const includeTemplate = async dir => {
17
41
  if (!(await fs.exists(dir))) {
18
- console.log('template dir not exists');
42
+ fastify.log.info('template dir not exists');
19
43
  return;
20
44
  }
21
- console.log('------start include template------');
45
+ fastify.log.info('------start include template------');
22
46
  const list = await fs.readdir(dir);
23
47
  for (const file of list) {
24
48
  const filePath = path.join(dir, file);
49
+ const stat = await fs.stat(filePath);
50
+ if (!stat.isFile()) continue;
25
51
  const content = await fs.readFile(filePath, 'utf8');
26
52
  const filename = file.replace(path.extname(file), '');
27
53
  const tempArray = filename.split('_');
28
- const code = tempArray[0], type = tempArray[1] || 0, name = tempArray[2] || code;
54
+ const code = tempArray[0], type = Number(tempArray[1]) || 0, name = tempArray[2] || code;
29
55
  const codeTemplate = await models.template.findOne({
30
56
  where: {
31
57
  code, type, level: 0
@@ -36,15 +62,15 @@ module.exports = fp(async (fastify, options) => {
36
62
  codeTemplate.content = content;
37
63
  codeTemplate.name = name;
38
64
  await codeTemplate.save();
39
- console.log(`update template: ${code}`);
65
+ fastify.log.info(`update template: ${code}`);
40
66
  continue;
41
67
  }
42
68
  await models.template.create({
43
69
  code, type, name, content, level: 0
44
70
  });
45
- console.log(`create template: ${code}`);
71
+ fastify.log.info(`create template: ${code}`);
46
72
  }
47
- console.log('------end include template------');
73
+ fastify.log.info('------end include template------');
48
74
  };
49
75
 
50
76
  const parseTemplate = text => {
@@ -70,7 +96,7 @@ module.exports = fp(async (fastify, options) => {
70
96
  throw new Error('template not found');
71
97
  }
72
98
 
73
- const content = parseTemplate(template(codeTemplate.content)(props).split(/<!--(\s*)-->/g));
99
+ const content = parseTemplate(template(codeTemplate.content)(props));
74
100
 
75
101
  return {
76
102
  content, props, code, type, templateId: codeTemplate.id
@@ -103,19 +129,22 @@ module.exports = fp(async (fastify, options) => {
103
129
  }
104
130
  }, client);
105
131
  const smtp = nodemailer.createTransport(currentClient);
106
- await pify(smtp.sendMail.bind(smtp))(mailOptions);
132
+ try {
133
+ await smtp.sendMail(mailOptions);
134
+ } finally {
135
+ smtp.close();
136
+ }
107
137
  }
108
138
  }
109
139
  return mailOptions;
110
140
  }
111
141
  if (typeof currentSender === 'function') {
112
- const { content, templateId } = await messageTemplate({ code, type, level, props });
113
142
  if (!isTest) {
114
143
  return await currentSender({ code, templateId, content, props, name, type, level, options: targetOptions });
115
144
  }
116
-
117
145
  return { content, props, level, options: targetOptions };
118
146
  }
147
+ throw new Error(`未配置类型 ${type} 的消息发送器`);
119
148
  })(type);
120
149
  await models.record.create({ type, code, templateId, props, name, content: sendOptions });
121
150
  };
@@ -150,6 +179,253 @@ module.exports = fp(async (fastify, options) => {
150
179
  }
151
180
  },
152
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
+
153
429
  // 消息模版相关服务
154
430
  template: {
155
431
  list: async ({ filter = {}, perPage = 20, currentPage = 1 }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-message",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "管理消息发送,支持sms消息,email消息等.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -59,6 +59,8 @@
59
59
  "sqlite3": "^6.0.1"
60
60
  },
61
61
  "dependencies": {
62
+ "@fastify/sse": "^0.4.0",
63
+ "dayjs": "^1.11.20",
62
64
  "fs-extra": "^11.3.0",
63
65
  "html-to-text": "^9.0.5",
64
66
  "lodash": "^4.17.21",