@kne/fastify-message 1.0.6 → 1.0.7

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,9 +27,8 @@ 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) => {
29
- fastify.sequelize.syncPromise.then(() => {
30
- return fastify[options.name].services.includeTemplate(options.templateDir);
31
- });
30
+ fastify.register(fp(async (fastify, options) => {
31
+ await fastify.sequelize.syncPromise;
32
+ await fastify[options.name].services.includeTemplate(options.templateDir);
32
33
  }), options);
33
34
  });
@@ -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,131 @@ 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
+ }
172
+ },
173
+ response: {
174
+ 200: {
175
+ type: 'object',
176
+ properties: {
177
+ range: { type: 'string', description: '当前时间范围' },
178
+ rangeLabel: { type: 'string', description: '时间范围描述' },
179
+ totalRecords: { type: 'integer', description: '时间范围内发送记录数' },
180
+ byType: {
181
+ type: 'object',
182
+ description: '按消息类型统计(0=邮件,1=短信)',
183
+ additionalProperties: { type: 'integer' }
184
+ },
185
+ byCode: {
186
+ type: 'object',
187
+ description: '按模板编码统计',
188
+ additionalProperties: { type: 'integer' }
189
+ },
190
+ templateStats: {
191
+ type: 'object',
192
+ properties: {
193
+ total: { type: 'integer', description: '模板总数' },
194
+ byStatus: {
195
+ type: 'object',
196
+ description: '按模板状态统计(0=启用,1=禁用)',
197
+ additionalProperties: { type: 'integer' }
198
+ },
199
+ byType: {
200
+ type: 'object',
201
+ description: '按模板类型统计(0=邮件,1=短信)',
202
+ additionalProperties: { type: 'integer' }
203
+ }
204
+ }
205
+ },
206
+ recentTrend: {
207
+ type: 'array',
208
+ description: '时间范围内发送趋势',
209
+ items: {
210
+ type: 'object',
211
+ properties: {
212
+ date: { type: 'string', description: '日期' },
213
+ count: { type: 'integer', description: '发送数量' }
214
+ }
215
+ }
216
+ },
217
+ recentTrendByType: {
218
+ type: 'array',
219
+ description: '时间范围内按类型发送趋势',
220
+ items: {
221
+ type: 'object',
222
+ properties: {
223
+ date: { type: 'string', description: '日期' },
224
+ type: { type: 'integer', description: '消息类型' },
225
+ count: { type: 'integer', description: '发送数量' }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }
233
+ },
234
+ async request => {
235
+ const { range = '7d' } = request.query;
236
+ return await services.statistics.getOverview({ range });
237
+ }
238
+ );
239
+
240
+ // SSE 实时统计数据推送(当天实时数据)
241
+ fastify.get(
242
+ `${options.prefix}/statistics/sse`,
243
+ {
244
+ sse: true,
245
+ onRequest: options.getAuthenticate('statistics'),
246
+ schema: {
247
+ description: 'SSE实时推送当天消息统计数据',
248
+ summary: '实时统计数据SSE',
249
+ querystring: {
250
+ type: 'object',
251
+ properties: {
252
+ interval: { type: 'integer', minimum: 1, default: 5, description: '推送间隔时间(秒),最小1秒,默认5秒' }
253
+ }
254
+ }
255
+ }
256
+ },
257
+ async function (request, reply) {
258
+ const intervalSeconds = request.query.interval;
259
+ reply.sse.keepAlive();
260
+
261
+ async function* eventStream() {
262
+ while (reply.sse.isConnected) {
263
+ try {
264
+ yield { data: JSON.stringify(await services.statistics.getRealtime()) };
265
+ } catch (err) {
266
+ yield { event: 'error', data: JSON.stringify({ message: err.message }) };
267
+ }
268
+ await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
269
+ }
270
+ }
271
+
272
+ await reply.sse.send(eventStream());
273
+ }
274
+ );
275
+
153
276
  // 查看消息模版列表
154
277
  fastify.get(
155
278
  `${options.prefix}/templates`,
@@ -213,7 +336,7 @@ module.exports = fp(async (fastify, options) => {
213
336
  if (level !== undefined) where.level = level;
214
337
  if (status !== undefined) where.status = status;
215
338
 
216
- return await services.template.list({ filter: where, perPage, currentPage });
339
+ return await services.template.list({ filter: where, perPage: Math.min(perPage, 100), currentPage });
217
340
  }
218
341
  );
219
342
 
@@ -254,7 +377,10 @@ module.exports = fp(async (fastify, options) => {
254
377
  try {
255
378
  return await services.template.detail({ id });
256
379
  } catch (error) {
257
- throw fastify.httpErrors.notFound(error.message);
380
+ if (error.message === '模版不存在') {
381
+ throw fastify.httpErrors.notFound(error.message);
382
+ }
383
+ throw error;
258
384
  }
259
385
  }
260
386
  );
@@ -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,7 +5,6 @@ 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');
9
8
 
10
9
  module.exports = fp(async (fastify, options) => {
11
10
  const emailConfig = Object.assign({}, {
@@ -15,17 +14,19 @@ module.exports = fp(async (fastify, options) => {
15
14
  const { models, services } = fastify[options.name];
16
15
  const includeTemplate = async dir => {
17
16
  if (!(await fs.exists(dir))) {
18
- console.log('template dir not exists');
17
+ fastify.log.info('template dir not exists');
19
18
  return;
20
19
  }
21
- console.log('------start include template------');
20
+ fastify.log.info('------start include template------');
22
21
  const list = await fs.readdir(dir);
23
22
  for (const file of list) {
24
23
  const filePath = path.join(dir, file);
24
+ const stat = await fs.stat(filePath);
25
+ if (!stat.isFile()) continue;
25
26
  const content = await fs.readFile(filePath, 'utf8');
26
27
  const filename = file.replace(path.extname(file), '');
27
28
  const tempArray = filename.split('_');
28
- const code = tempArray[0], type = tempArray[1] || 0, name = tempArray[2] || code;
29
+ const code = tempArray[0], type = Number(tempArray[1]) || 0, name = tempArray[2] || code;
29
30
  const codeTemplate = await models.template.findOne({
30
31
  where: {
31
32
  code, type, level: 0
@@ -36,15 +37,15 @@ module.exports = fp(async (fastify, options) => {
36
37
  codeTemplate.content = content;
37
38
  codeTemplate.name = name;
38
39
  await codeTemplate.save();
39
- console.log(`update template: ${code}`);
40
+ fastify.log.info(`update template: ${code}`);
40
41
  continue;
41
42
  }
42
43
  await models.template.create({
43
44
  code, type, name, content, level: 0
44
45
  });
45
- console.log(`create template: ${code}`);
46
+ fastify.log.info(`create template: ${code}`);
46
47
  }
47
- console.log('------end include template------');
48
+ fastify.log.info('------end include template------');
48
49
  };
49
50
 
50
51
  const parseTemplate = text => {
@@ -70,7 +71,7 @@ module.exports = fp(async (fastify, options) => {
70
71
  throw new Error('template not found');
71
72
  }
72
73
 
73
- const content = parseTemplate(template(codeTemplate.content)(props).split(/<!--(\s*)-->/g));
74
+ const content = parseTemplate(template(codeTemplate.content)(props));
74
75
 
75
76
  return {
76
77
  content, props, code, type, templateId: codeTemplate.id
@@ -103,19 +104,22 @@ module.exports = fp(async (fastify, options) => {
103
104
  }
104
105
  }, client);
105
106
  const smtp = nodemailer.createTransport(currentClient);
106
- await pify(smtp.sendMail.bind(smtp))(mailOptions);
107
+ try {
108
+ await smtp.sendMail(mailOptions);
109
+ } finally {
110
+ smtp.close();
111
+ }
107
112
  }
108
113
  }
109
114
  return mailOptions;
110
115
  }
111
116
  if (typeof currentSender === 'function') {
112
- const { content, templateId } = await messageTemplate({ code, type, level, props });
113
117
  if (!isTest) {
114
118
  return await currentSender({ code, templateId, content, props, name, type, level, options: targetOptions });
115
119
  }
116
-
117
120
  return { content, props, level, options: targetOptions };
118
121
  }
122
+ throw new Error(`未配置类型 ${type} 的消息发送器`);
119
123
  })(type);
120
124
  await models.record.create({ type, code, templateId, props, name, content: sendOptions });
121
125
  };
@@ -150,6 +154,164 @@ module.exports = fp(async (fastify, options) => {
150
154
  }
151
155
  },
152
156
 
157
+ // 统计相关服务
158
+ statistics: {
159
+ getOverview: async ({ range = '7d' } = {}) => {
160
+ const { Sequelize } = models.record.sequelize;
161
+ const recordModel = models.record;
162
+ const templateModel = models.template;
163
+ const createdAtCol = recordModel.rawAttributes.createdAt.field;
164
+
165
+ // 根据range计算起始时间
166
+ const rangeMap = {
167
+ '7d': { days: 7, label: '近7天' },
168
+ '1m': { days: 30, label: '近1个月' },
169
+ '1y': { days: 365, label: '近1年' }
170
+ };
171
+ const normalizedRange = rangeMap[range] ? range : '7d';
172
+ const rangeConfig = rangeMap[normalizedRange];
173
+ const startDate = new Date();
174
+ startDate.setDate(startDate.getDate() - rangeConfig.days);
175
+
176
+ const whereRange = { createdAt: { [Sequelize.Op.gte]: startDate } };
177
+ const dateFn = col => Sequelize.fn('DATE', Sequelize.col(col));
178
+
179
+ // 并行执行所有查询
180
+ const [totalRecords, byType, byCode, totalTemplates, templatesByStatus, templatesByType, recentTrend, recentTrendByType] = await Promise.all([
181
+ recordModel.count({ where: whereRange }),
182
+ recordModel.findAll({
183
+ attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
184
+ where: whereRange,
185
+ group: ['type'],
186
+ raw: true
187
+ }),
188
+ recordModel.findAll({
189
+ attributes: ['code', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
190
+ where: whereRange,
191
+ group: ['code'],
192
+ raw: true
193
+ }),
194
+ templateModel.count(),
195
+ templateModel.findAll({
196
+ attributes: ['status', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
197
+ group: ['status'],
198
+ raw: true
199
+ }),
200
+ templateModel.findAll({
201
+ attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
202
+ group: ['type'],
203
+ raw: true
204
+ }),
205
+ recordModel.findAll({
206
+ attributes: [
207
+ [dateFn(createdAtCol), 'date'],
208
+ [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
209
+ ],
210
+ where: whereRange,
211
+ group: [dateFn(createdAtCol)],
212
+ order: [[dateFn(createdAtCol), 'ASC']],
213
+ raw: true
214
+ }),
215
+ recordModel.findAll({
216
+ attributes: [
217
+ [dateFn(createdAtCol), 'date'],
218
+ 'type',
219
+ [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
220
+ ],
221
+ where: whereRange,
222
+ group: [dateFn(createdAtCol), 'type'],
223
+ order: [[dateFn(createdAtCol), 'ASC']],
224
+ raw: true
225
+ })
226
+ ]);
227
+
228
+ return {
229
+ range: normalizedRange,
230
+ rangeLabel: rangeConfig.label,
231
+ 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; }, {}),
234
+ templateStats: {
235
+ 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; }, {})
238
+ },
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) }))
241
+ };
242
+ },
243
+
244
+ getRealtime: async () => {
245
+ const { Sequelize } = models.record.sequelize;
246
+ const recordModel = models.record;
247
+ const createdAtCol = recordModel.rawAttributes.createdAt.field;
248
+ const dialect = recordModel.sequelize.getDialect();
249
+
250
+ // 当天起始时间
251
+ const todayStart = new Date();
252
+ todayStart.setHours(0, 0, 0, 0);
253
+
254
+ 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
+
259
+ // 并行执行所有查询
260
+ const [totalRecords, byType, byCode, hourlyTrend, hourlyTrendByType] = await Promise.all([
261
+ recordModel.count({ where: whereToday }),
262
+ recordModel.findAll({
263
+ attributes: ['type', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
264
+ where: whereToday,
265
+ group: ['type'],
266
+ raw: true
267
+ }),
268
+ recordModel.findAll({
269
+ attributes: ['code', [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']],
270
+ where: whereToday,
271
+ group: ['code'],
272
+ raw: true
273
+ }),
274
+ recordModel.findAll({
275
+ attributes: [
276
+ [hourExtract, 'hour'],
277
+ [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
278
+ ],
279
+ where: whereToday,
280
+ group: [hourExtract],
281
+ order: [[hourExtract, 'ASC']],
282
+ raw: true
283
+ }),
284
+ recordModel.findAll({
285
+ attributes: [
286
+ [hourExtract, 'hour'],
287
+ 'type',
288
+ [Sequelize.fn('COUNT', Sequelize.col('id')), 'count']
289
+ ],
290
+ where: whereToday,
291
+ group: [hourExtract, 'type'],
292
+ order: [[hourExtract, 'ASC'], ['type', 'ASC']],
293
+ raw: true
294
+ })
295
+ ]);
296
+
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}`;
302
+ };
303
+
304
+ return {
305
+ date: formatDate(todayStart),
306
+ 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) }))
311
+ };
312
+ }
313
+ },
314
+
153
315
  // 消息模版相关服务
154
316
  template: {
155
317
  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.7",
4
4
  "description": "管理消息发送,支持sms消息,email消息等.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -59,6 +59,7 @@
59
59
  "sqlite3": "^6.0.1"
60
60
  },
61
61
  "dependencies": {
62
+ "@fastify/sse": "^0.4.0",
62
63
  "fs-extra": "^11.3.0",
63
64
  "html-to-text": "^9.0.5",
64
65
  "lodash": "^4.17.21",