@kne/fastify-message 1.0.7 → 1.0.9

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
@@ -28,7 +28,8 @@ module.exports = fp(async function(fastify, options) {
28
28
  });
29
29
 
30
30
  fastify.register(fp(async (fastify, options) => {
31
- await fastify.sequelize.syncPromise;
32
- await fastify[options.name].services.includeTemplate(options.templateDir);
31
+ fastify.sequelize.syncPromise.then(() => {
32
+ return fastify[options.name].services.includeTemplate(options.templateDir);
33
+ });
33
34
  }), options);
34
35
  });
@@ -164,10 +164,11 @@ module.exports = fp(async (fastify, options) => {
164
164
  schema: {
165
165
  description: '获取消息统计数据概览',
166
166
  summary: '统计数据概览',
167
- querystring: {
167
+ querystring: {
168
168
  type: 'object',
169
169
  properties: {
170
- range: { type: 'string', default: '7d', description: '时间范围: 7d=近7天, 1m=近1个月, 1y=近1年' }
170
+ range: { type: 'string', default: '7d', description: '时间范围: 7d=近7天, 1m=近1个月, 1y=近1年' },
171
+ timezone: { type: 'string', description: '时区,如 Asia/Shanghai,默认服务器时区' }
171
172
  }
172
173
  },
173
174
  response: {
@@ -232,8 +233,8 @@ module.exports = fp(async (fastify, options) => {
232
233
  }
233
234
  },
234
235
  async request => {
235
- const { range = '7d' } = request.query;
236
- return await services.statistics.getOverview({ range });
236
+ const { range = '7d', timezone } = request.query;
237
+ return await services.statistics.getOverview({ range, timezone });
237
238
  }
238
239
  );
239
240
 
@@ -246,22 +247,23 @@ module.exports = fp(async (fastify, options) => {
246
247
  schema: {
247
248
  description: 'SSE实时推送当天消息统计数据',
248
249
  summary: '实时统计数据SSE',
249
- querystring: {
250
+ querystring: {
250
251
  type: 'object',
251
252
  properties: {
252
- interval: { type: 'integer', minimum: 1, default: 5, description: '推送间隔时间(秒),最小1秒,默认5秒' }
253
+ interval: { type: 'integer', minimum: 1, default: 5, description: '推送间隔时间(秒),最小1秒,默认5秒' },
254
+ timezone: { type: 'string', description: '时区,如 Asia/Shanghai,默认服务器时区' }
253
255
  }
254
256
  }
255
257
  }
256
258
  },
257
259
  async function (request, reply) {
258
- const intervalSeconds = request.query.interval;
259
- reply.sse.keepAlive();
260
+ const intervalSeconds = request.query.interval ?? 5;
261
+ const { timezone } = request.query;
260
262
 
261
263
  async function* eventStream() {
262
264
  while (reply.sse.isConnected) {
263
265
  try {
264
- yield { data: JSON.stringify(await services.statistics.getRealtime()) };
266
+ yield { data: JSON.stringify(await services.statistics.getRealtime({ timezone })) };
265
267
  } catch (err) {
266
268
  yield { event: 'error', data: JSON.stringify({ message: err.message }) };
267
269
  }
@@ -5,8 +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 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);
8
14
 
9
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
+
10
35
  const emailConfig = Object.assign({}, {
11
36
  port: 465, secure: true
12
37
  }, options.emailConfig);
@@ -156,11 +181,13 @@ module.exports = fp(async (fastify, options) => {
156
181
 
157
182
  // 统计相关服务
158
183
  statistics: {
159
- getOverview: async ({ range = '7d' } = {}) => {
184
+ getOverview: async ({ range = '7d', timezone } = {}) => {
160
185
  const { Sequelize } = models.record.sequelize;
161
186
  const recordModel = models.record;
162
187
  const templateModel = models.template;
163
188
  const createdAtCol = recordModel.rawAttributes.createdAt.field;
189
+ const dialect = recordModel.sequelize.getDialect();
190
+ const effectiveTimezone = resolveTimezone(timezone);
164
191
 
165
192
  // 根据range计算起始时间
166
193
  const rangeMap = {
@@ -170,11 +197,23 @@ module.exports = fp(async (fastify, options) => {
170
197
  };
171
198
  const normalizedRange = rangeMap[range] ? range : '7d';
172
199
  const rangeConfig = rangeMap[normalizedRange];
173
- const startDate = new Date();
200
+ // 按时区计算起始日期:先获取目标时区"今天0点"的UTC时间,再往前推N天
201
+ const todayStart = getTodayStart(effectiveTimezone);
202
+ const startDate = new Date(todayStart);
174
203
  startDate.setDate(startDate.getDate() - rangeConfig.days);
175
204
 
176
205
  const whereRange = { createdAt: { [Sequelize.Op.gte]: startDate } };
177
- const dateFn = col => Sequelize.fn('DATE', Sequelize.col(col));
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
+ })();
178
217
 
179
218
  // 并行执行所有查询
180
219
  const [totalRecords, byType, byCode, totalTemplates, templatesByStatus, templatesByType, recentTrend, recentTrendByType] = await Promise.all([
@@ -225,39 +264,84 @@ module.exports = fp(async (fastify, options) => {
225
264
  })
226
265
  ]);
227
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
+
228
274
  return {
229
275
  range: normalizedRange,
230
276
  rangeLabel: rangeConfig.label,
231
277
  totalRecords,
232
- byType: byType.reduce((acc, item) => { acc[item.type] = Number(item.count); return acc; }, {}),
233
- byCode: byCode.reduce((acc, item) => { acc[item.code] = Number(item.count); return acc; }, {}),
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
+ }, {}),
234
286
  templateStats: {
235
287
  total: totalTemplates,
236
- byStatus: templatesByStatus.reduce((acc, item) => { acc[item.status] = Number(item.count); return acc; }, {}),
237
- byType: templatesByType.reduce((acc, item) => { acc[item.type] = Number(item.count); return acc; }, {})
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
+ }, {})
238
296
  },
239
- recentTrend: recentTrend.map(item => ({ date: item.date, count: Number(item.count) })),
240
- recentTrendByType: recentTrendByType.map(item => ({ date: item.date, type: item.type, count: Number(item.count) }))
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) }))
241
299
  };
242
300
  },
243
301
 
244
- getRealtime: async () => {
302
+ getRealtime: async ({ timezone } = {}) => {
245
303
  const { Sequelize } = models.record.sequelize;
246
304
  const recordModel = models.record;
247
305
  const createdAtCol = recordModel.rawAttributes.createdAt.field;
248
306
  const dialect = recordModel.sequelize.getDialect();
307
+ const effectiveTimezone = resolveTimezone(timezone);
249
308
 
250
- // 当天起始时间
251
- const todayStart = new Date();
252
- todayStart.setHours(0, 0, 0, 0);
309
+ // 当天起始时间(按指定时区)
310
+ const todayStart = getTodayStart(effectiveTimezone);
253
311
 
254
312
  const whereToday = { createdAt: { [Sequelize.Op.gte]: todayStart } };
255
- const hourExtract = dialect === 'sqlite'
256
- ? Sequelize.fn('strftime', '%H', Sequelize.col(createdAtCol))
257
- : Sequelize.fn('EXTRACT', Sequelize.literal(`HOUR FROM "${createdAtCol}"`));
258
313
 
259
- // 并行执行所有查询
260
- const [totalRecords, byType, byCode, hourlyTrend, hourlyTrendByType] = await Promise.all([
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([
261
345
  recordModel.count({ where: whereToday }),
262
346
  recordModel.findAll({
263
347
  attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
@@ -291,23 +375,53 @@ module.exports = fp(async (fastify, options) => {
291
375
  group: [hourExtract, 'type'],
292
376
  order: [[hourExtract, 'ASC'], ['type', 'ASC']],
293
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
294
388
  })
295
389
  ]);
296
390
 
297
- const formatDate = date => {
298
- const y = date.getFullYear();
299
- const m = String(date.getMonth() + 1).padStart(2, '0');
300
- const d = String(date.getDate()).padStart(2, '0');
301
- return `${y}-${m}-${d}`;
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');
302
405
  };
303
406
 
304
407
  return {
305
- date: formatDate(todayStart),
408
+ date: formatDate(todayStart, effectiveTimezone),
306
409
  totalRecords,
307
- byType: byType.reduce((acc, item) => { acc[item.type] = Number(item.count); return acc; }, {}),
308
- byCode: byCode.reduce((acc, item) => { acc[item.code] = Number(item.count); return acc; }, {}),
309
- hourlyTrend: hourlyTrend.map(item => ({ hour: Number(item.hour), count: Number(item.count) })),
310
- hourlyTrendByType: hourlyTrendByType.map(item => ({ hour: Number(item.hour), type: item.type, count: Number(item.count) }))
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) }))
311
425
  };
312
426
  }
313
427
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-message",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "管理消息发送,支持sms消息,email消息等.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -60,6 +60,7 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@fastify/sse": "^0.4.0",
63
+ "dayjs": "^1.11.20",
63
64
  "fs-extra": "^11.3.0",
64
65
  "html-to-text": "^9.0.5",
65
66
  "lodash": "^4.17.21",