@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 +3 -1
- package/libs/controllers/main.js +134 -5
- package/libs/models/record.js +7 -1
- package/libs/models/template.js +4 -1
- package/libs/services/main.js +287 -11
- package/package.json +3 -1
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
|
});
|
package/libs/controllers/main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
+
if (error.message === '模版不存在') {
|
|
384
|
+
throw fastify.httpErrors.notFound(error.message);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
258
387
|
}
|
|
259
388
|
}
|
|
260
389
|
);
|
package/libs/models/record.js
CHANGED
|
@@ -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
|
};
|
package/libs/models/template.js
CHANGED
package/libs/services/main.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
42
|
+
fastify.log.info('template dir not exists');
|
|
19
43
|
return;
|
|
20
44
|
}
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
fastify.log.info(`create template: ${code}`);
|
|
46
72
|
}
|
|
47
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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",
|