@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 +3 -2
- package/libs/controllers/main.js +11 -9
- package/libs/services/main.js +142 -28
- package/package.json +2 -1
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
|
-
|
|
32
|
-
|
|
31
|
+
fastify.sequelize.syncPromise.then(() => {
|
|
32
|
+
return fastify[options.name].services.includeTemplate(options.templateDir);
|
|
33
|
+
});
|
|
33
34
|
}), options);
|
|
34
35
|
});
|
package/libs/controllers/main.js
CHANGED
|
@@ -164,10 +164,11 @@ module.exports = fp(async (fastify, options) => {
|
|
|
164
164
|
schema: {
|
|
165
165
|
description: '获取消息统计数据概览',
|
|
166
166
|
summary: '统计数据概览',
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/libs/services/main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) => {
|
|
233
|
-
|
|
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) => {
|
|
237
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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) => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
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",
|