Murasame52 commited on
Commit
00b9b23
·
verified ·
1 Parent(s): 7830a57

Upload 5 files

Browse files
services/AISummary.js ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // AISummary.js - AI总结模块
2
+ const cron = require('node-cron');
3
+ const {config} = require("dotenv");
4
+
5
+ class AISummary {
6
+ /**
7
+ * 获取用户时区的执行时间点列表(格式化字符串)
8
+ * @returns {string[]} 例如: ['4:00', '8:00', '12:00', '16:00', '20:00']
9
+ */
10
+ getScheduleStrings() {
11
+ try {
12
+ const scheduleHours = this.generateScheduleHours();
13
+ return scheduleHours.map(hour => {
14
+ const h = hour.toString().padStart(2, '0');
15
+ return `${h}:00`;
16
+ });
17
+ } catch (error) {
18
+ console.error('[AISummary] 获取schedules失败:', error.message);
19
+ return [];
20
+ }
21
+ }
22
+
23
+ constructor(statsRecorder, statsQuery, config = {}) {
24
+ this.recorder = statsRecorder;
25
+ this.query = statsQuery;
26
+
27
+ // AI配置
28
+ this.aiConfig = {
29
+ apiUrl: config.aiApiUrl || process.env.AI_API_URL || 'https://api.openai.com/v1/chat/completions',
30
+ apiKey: config.aiApiKey || process.env.AI_API_KEY || '',
31
+ model: config.aiModel || process.env.AI_MODEL || 'gpt-4',
32
+ maxTokens: config.aiMaxTokens || 1000,
33
+ timezoneOffset: config.timezoneOffset || parseInt(process.env.DEFAULT_TIMEZONE_OFFSET) || 8,
34
+ aiPrompt: config.aiPrompt || '无',
35
+ };
36
+
37
+ // 发布配置
38
+ this.publishConfig = {
39
+ publishEnabled: config.publishEnabled || process.env.PUBLISH_ENABLED || false,
40
+ apiUrl: config.publishApiUrl || process.env.PUBLISH_API_URL || '',
41
+ apiKey: config.publishApiKey || process.env.PUBLISH_API_KEY || '',
42
+ };
43
+
44
+ // 定时任务配置
45
+ this.scheduleConfig = {
46
+ // 时间间隔(小时),默认4小时
47
+ intervalHours: config.intervalHours || parseInt(process.env.SCHEDULE_INTERVAL_HOURS) || 4,
48
+
49
+ // 起始时间(小时),默认0点(但不触发)
50
+ startHour: config.startHour !== undefined ? config.startHour : 0,
51
+
52
+ // 结束时间(小时),默认24点
53
+ endHour: config.endHour !== undefined ? config.endHour : 24,
54
+
55
+ // 是否跳过起始时间的触发,默认true
56
+ skipStartHour: config.skipStartHour !== false
57
+ };
58
+
59
+ // 定时任务实例
60
+ this.cronJobs = [];
61
+
62
+ // 是否启用定时任务
63
+ this.enabled = config.enabled !== false;
64
+
65
+ // 存储最近的总结结果(内存缓存)
66
+ this.recentSummaries = new Map();
67
+ }
68
+
69
+ // 生成执行时间点
70
+ generateScheduleHours() {
71
+ const { intervalHours, startHour, endHour, skipStartHour } = this.scheduleConfig;
72
+
73
+ // 参数验证
74
+ if (intervalHours <= 0 || intervalHours > 24) {
75
+ throw new Error('intervalHours must be between 1 and 24');
76
+ }
77
+
78
+ if (startHour < 0 || startHour >= 24) {
79
+ throw new Error('startHour must be between 0 and 23');
80
+ }
81
+
82
+ if (endHour <= startHour || endHour > 24) {
83
+ throw new Error('endHour must be greater than startHour and not exceed 24');
84
+ }
85
+
86
+ const scheduleHours = [];
87
+ let currentHour = startHour + intervalHours; // 从起始时间后的第一个间隔开始
88
+
89
+ // 如果不跳过起始时间,则添加起始时间
90
+ if (!skipStartHour) {
91
+ scheduleHours.push(startHour);
92
+ }
93
+
94
+ // 生成后续时间点
95
+ while (currentHour < endHour) {
96
+ scheduleHours.push(currentHour);
97
+ currentHour += intervalHours;
98
+ }
99
+
100
+ return scheduleHours;
101
+ }
102
+
103
+ // 启动定时任务
104
+ start() {
105
+ if (!this.enabled) {
106
+ console.log('[AISummary] 定时任务未启用');
107
+ return;
108
+ }
109
+
110
+ if (!this.aiConfig.apiKey) {
111
+ console.error('[AISummary] AI API Key未配置,无法启动');
112
+ return;
113
+ }
114
+
115
+ try {
116
+ const scheduleHours = this.generateScheduleHours();
117
+ const offset = this.aiConfig.timezoneOffset;
118
+
119
+ console.log('[AISummary] 定时任务配置:');
120
+ console.log(` - 用户时区: UTC${offset >= 0 ? '+' : ''}${offset}`);
121
+ console.log(` - 时间间隔: 每${this.scheduleConfig.intervalHours}小时`);
122
+ console.log(` - 起始时间: ${this.scheduleConfig.startHour}:00 (${this.scheduleConfig.skipStartHour ? '不触发' : '触发'})`);
123
+ console.log(` - 结束时间: ${this.scheduleConfig.endHour}:00`);
124
+ console.log(` - 执行时间点: ${scheduleHours.map(h => `${h}:00`).join('、')}`);
125
+
126
+ // 为每个时间点创建定时任务
127
+ scheduleHours.forEach(userHour => {
128
+ // 将用户时区时间转换为UTC时间
129
+ const utcHour = (userHour - offset + 24) % 24;
130
+ const cronTime = `0 ${utcHour} * * *`;
131
+ const triggerType = `cron-${userHour}`;
132
+
133
+ const job = cron.schedule(cronTime, async () => {
134
+ console.log(`[AISummary] 定时任务触发 (用户时区 ${userHour}:00 = UTC ${utcHour}:00)`);
135
+ await this.runDailySummaryForAllDevices(triggerType);
136
+ });
137
+
138
+ this.cronJobs.push(job);
139
+ console.log(` ✓ 已创建定时任务: 用户时区 ${userHour}:00 (UTC ${utcHour}:00) - cron: ${cronTime}`);
140
+ });
141
+
142
+ console.log(`[AISummary] 定时任务已启动,共 ${this.cronJobs.length} 个任务`);
143
+
144
+ } catch (error) {
145
+ console.error('[AISummary] 定时任务启动失败:', error.message);
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ // 停止定时任务
151
+ stop() {
152
+ this.cronJobs.forEach(job => job.stop());
153
+ this.cronJobs = [];
154
+ console.log('[AISummary] 定时任务已停止');
155
+ }
156
+
157
+ // 为所有设备运行每日总结
158
+ async runDailySummaryForAllDevices(trigger = 'cron') {
159
+ try {
160
+ const devices = await this.query.getDevices();
161
+ console.log(`[AISummary] 开始为 ${devices.length} 个设备生成总结`);
162
+
163
+ for (const device of devices) {
164
+ try {
165
+ await this.generateDailySummary(device.device, null, null, trigger);
166
+ } catch (error) {
167
+ console.error(`[AISummary] 设备 ${device.device} 总结失败:`, error.message);
168
+ }
169
+ }
170
+
171
+ console.log('[AISummary] 所有设备总结完成');
172
+ } catch (error) {
173
+ console.error('[AISummary] 运行总结任务失败:', error);
174
+ }
175
+ }
176
+
177
+ // 生成每日总结 (对外接口)
178
+ async generateDailySummary(deviceId, date = null, timezoneOffset = null, trigger = 'manual') {
179
+ const tz = timezoneOffset !== null ? timezoneOffset : this.aiConfig.timezoneOffset;
180
+
181
+ // 如果没有指定日期,使用当天的数据(考虑时区)
182
+ let targetDate;
183
+ if (date) {
184
+ targetDate = new Date(date);
185
+ } else {
186
+ // 获取用户时区的当前时间
187
+ const now = new Date();
188
+ const userNow = new Date(now.getTime() + tz * 60 * 60 * 1000);
189
+ targetDate = new Date(userNow);
190
+ }
191
+ // 归零到当天0点
192
+ targetDate.setHours(0, 0, 0, 0);
193
+
194
+ console.log(`[AISummary] 开始为设备 ${deviceId} 生成 ${targetDate.toISOString().split('T')[0]} 的总结 (触发方式: ${trigger})`);
195
+
196
+ // 1. 获取统计数据
197
+ const statsData = await this.collectDailyData(deviceId, targetDate, tz);
198
+
199
+ if (statsData.totalUsage === 0) {
200
+ console.log(`[AISummary] 设备 ${deviceId} 在 ${targetDate.toISOString().split('T')[0]} 无使用数据`);
201
+ return {
202
+ success: false,
203
+ message: 'No usage data for this day'
204
+ };
205
+ }
206
+
207
+ // 2. 调用AI生成总结
208
+ const aiSummary = await this.callAI(statsData, deviceId);
209
+
210
+ // 3. 发布总结
211
+ const publishResult = await this.publishSummary(deviceId, targetDate, aiSummary, statsData);
212
+
213
+ // 4. 保存到内存缓存
214
+ const summaryRecord = {
215
+ summary: aiSummary,
216
+ date: targetDate.toISOString().split('T')[0],
217
+ timestamp: new Date().toISOString(),
218
+ trigger: trigger, // 'manual', 'cron-x'
219
+ publishResult
220
+ };
221
+
222
+ this.recentSummaries.set(deviceId, summaryRecord);
223
+
224
+ console.log(`[AISummary] 设备 ${deviceId} 总结完成并已保存`);
225
+
226
+ return {
227
+ success: true,
228
+ deviceId,
229
+ ...summaryRecord
230
+ };
231
+ }
232
+
233
+ // 收集每日数据
234
+ async collectDailyData(deviceId, date, timezoneOffset) {
235
+ // 获取当天统计
236
+ const dailyStats = await this.query.getDailyStats(deviceId, date, timezoneOffset);
237
+
238
+ // 获取最近200条切换记录
239
+ let recentSwitches = [];
240
+ if (this.recorder.recentAppSwitches.has(deviceId)) {
241
+ const switches = this.recorder.recentAppSwitches.get(deviceId);
242
+ recentSwitches = switches
243
+ .filter(entry => {
244
+ const entryDate = new Date(entry.timestamp);
245
+ const entryDateOnly = new Date(entryDate);
246
+ entryDateOnly.setHours(0, 0, 0, 0);
247
+ return entryDateOnly.getTime() === date.getTime();
248
+ })
249
+ .slice(0, 200)
250
+ .map(entry => {
251
+ // 将UTC时间转换为用户本地时间
252
+ const userTime = new Date(entry.timestamp);
253
+ userTime.setMinutes(userTime.getMinutes() + timezoneOffset * 60);
254
+
255
+ return {
256
+ appName: entry.appName,
257
+ timestamp: userTime.toISOString(), // 保持ISO格式
258
+ localTime: userTime.toLocaleTimeString('zh-CN', { hour12: false }), // 添加本地时间字符串
259
+ running: entry.running !== false
260
+ };
261
+ });
262
+ }
263
+
264
+ return {
265
+ deviceId,
266
+ date: date.toISOString().split('T')[0],
267
+ totalUsage: dailyStats.totalUsage,
268
+ appStats: dailyStats.appStats,
269
+ hourlyStats: dailyStats.hourlyStats,
270
+ recentSwitches,
271
+ timezoneOffset
272
+ };
273
+ }
274
+
275
+
276
+ // 调用AI API
277
+ async callAI(statsData, deviceId) {
278
+ const prompt = this.buildPrompt(statsData, deviceId);
279
+
280
+ try {
281
+ const response = await fetch(this.aiConfig.apiUrl, {
282
+ method: 'POST',
283
+ headers: {
284
+ 'Content-Type': 'application/json',
285
+ 'Authorization': `Bearer ${this.aiConfig.apiKey}`
286
+ },
287
+ body: JSON.stringify({
288
+ model: this.aiConfig.model,
289
+ messages: [
290
+ {
291
+ role: 'system',
292
+ content: '你是一个时间分析师'
293
+ },
294
+ {
295
+ role: 'user',
296
+ content: prompt
297
+ }
298
+ ],
299
+ max_tokens: this.aiConfig.maxTokens,
300
+ temperature: 0.7
301
+ })
302
+ });
303
+
304
+ if (!response.ok) {
305
+ const errorText = await response.text();
306
+ throw new Error(`AI API 请求失败: ${response.status} ${errorText}`);
307
+ }
308
+
309
+ const result = await response.json();
310
+ return result.choices[0].message.content;
311
+
312
+ } catch (error) {
313
+ console.error('[AISummary] AI调用失败:', error);
314
+ throw error;
315
+ }
316
+ }
317
+
318
+ // 构建AI提示词
319
+ buildPrompt(statsData, deviceId) {
320
+ const { date, totalUsage, appStats, hourlyStats, recentSwitches } = statsData;
321
+
322
+ // 计算应用使用占比
323
+ const appUsageList = Object.entries(appStats)
324
+ .map(([app, minutes]) => ({
325
+ app,
326
+ minutes,
327
+ percentage: ((minutes / totalUsage) * 100).toFixed(1)
328
+ }))
329
+ .sort((a, b) => b.minutes - a.minutes);
330
+
331
+ // 构建提示词
332
+ let prompt = `总结以下设备的应用使用情况,控制在300字以内\n\n`;
333
+
334
+ prompt += `- 设备ID: ${deviceId}\n`;
335
+ prompt += `- 统计日期: ${date}\n\n`;
336
+
337
+ prompt += `## 总体使用情况\n`;
338
+ prompt += `- 总使用时长: ${Math.floor(totalUsage / 60)}小时${totalUsage % 60}分钟\n`;
339
+ prompt += `- 使用应用数量: ${Object.keys(appStats).length}个\n\n`;
340
+
341
+ prompt += `## 应用使用占比(TOP 20)\n`;
342
+ appUsageList.slice(0, 20).forEach(({ app, minutes, percentage }) => {
343
+ prompt += `- ${app}: ${Math.floor(minutes / 60)}小时${minutes % 60}分钟 (${percentage}%)\n`;
344
+ });
345
+
346
+
347
+ prompt += `\n## 最近应用切换记录 (最新${Math.min(recentSwitches.length, 100)}条)\n`;
348
+ recentSwitches.slice(0, 10).forEach(({ appName, timestamp, running }) => {
349
+ const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false });
350
+ const status = running ? '打开' : '关闭';
351
+ prompt += `- ${time} ${status} ${appName}\n`;
352
+ });
353
+ prompt += this.aiConfig.aiPrompt;
354
+ prompt += `注意:控制在300字以内,不要返回md格式,只能换行`;
355
+
356
+ return prompt;
357
+ }
358
+
359
+ // 发布总结到指定API
360
+ async publishSummary(deviceId, date, summary, statsData) {
361
+ if (!this.publishConfig.apiUrl || this.publishConfig.publishEnabled){
362
+ console.log('[AISummary] 未配置发布功能,跳过发布');
363
+ return { published: false, reason: 'No publish API configured' };
364
+ }
365
+
366
+ try {
367
+ const payload = {
368
+ deviceId,
369
+ date: date.toISOString().split('T')[0],
370
+ timestamp: new Date().toISOString(),
371
+ summary
372
+ };
373
+
374
+ const headers = {
375
+ 'Content-Type': 'application/json'
376
+ };
377
+
378
+ if (this.publishConfig.apiKey) {
379
+ headers['Authorization'] = `Bearer ${this.publishConfig.apiKey}`;
380
+ }
381
+
382
+ const response = await fetch(this.publishConfig.apiUrl, {
383
+ method: 'POST',
384
+ headers,
385
+ body: JSON.stringify(payload)
386
+ });
387
+
388
+ if (!response.ok) {
389
+ const errorText = await response.text();
390
+ throw new Error(`发布API请求失败: ${response.status} ${errorText}`);
391
+ }
392
+
393
+ const result = await response.json();
394
+ console.log('[AISummary] 总结已成功发布');
395
+
396
+ return {
397
+ published: true,
398
+ response: result
399
+ };
400
+
401
+ } catch (error) {
402
+ console.error('[AISummary] 发布失败:', error);
403
+ return {
404
+ published: false,
405
+ error: error.message
406
+ };
407
+ }
408
+ }
409
+
410
+ // 手动触发总结 (用于测试或按需生成)
411
+ async triggerSummary(deviceId, options = {}) {
412
+ const {
413
+ date = null,
414
+ timezoneOffset = null
415
+ } = options;
416
+
417
+ return await this.generateDailySummary(deviceId, date, timezoneOffset, 'manual');
418
+ }
419
+
420
+ // 获取最近一次总结
421
+ getRecentSummary(deviceId) {
422
+ if (!this.recentSummaries.has(deviceId)) {
423
+ return null;
424
+ }
425
+ return this.recentSummaries.get(deviceId);
426
+ }
427
+
428
+ // 获取所有设备的最近总结
429
+ getAllRecentSummaries() {
430
+ const summaries = {};
431
+ this.recentSummaries.forEach((summary, deviceId) => {
432
+ summaries[deviceId] = summary;
433
+ });
434
+ return summaries;
435
+ }
436
+
437
+ // 预留:周总结功能
438
+ async generateWeeklySummary(deviceId, weekOffset = 0, timezoneOffset = null) {
439
+ // TODO: 实现周总结
440
+ console.log('[AISummary] 周总结功能待实现');
441
+ throw new Error('Weekly summary not implemented yet');
442
+ }
443
+
444
+ // 预留:月总结功能
445
+ async generateMonthlySummary(deviceId, monthOffset = 0, timezoneOffset = null) {
446
+ // TODO: 实现月总结
447
+ console.log('[AISummary] 月总结功能待实现');
448
+ throw new Error('Monthly summary not implemented yet');
449
+ }
450
+ }
451
+
452
+ module.exports = AISummary;
services/EyeTimeQuery.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // EyeTimeQuery.js - 仅统计用眼分钟数(日/周/月), 支持时区
2
+ const { mongoose } = require('../index');
3
+ const DateRangeHelper = require('../utils/DateRange');
4
+ const TimezoneUtils = require('../utils/timezone');
5
+
6
+ const DailyEyeTime = mongoose.model('DailyEyeTime');
7
+
8
+ class EyeTimeQuery {
9
+ constructor(timezoneOffset = 8) {
10
+ // 初始化时区工具与日期范围助手
11
+ this.timezoneOffset = timezoneOffset;
12
+ this.timezoneUtils = new TimezoneUtils(timezoneOffset);
13
+ this.dateHelper = new DateRangeHelper(timezoneOffset);
14
+ }
15
+
16
+ /**
17
+ * 获取某日的用眼分钟数
18
+ * @param {Date} date - 查询目标日期(UTC 或本地时间均可)
19
+ * @param {number} timezoneOffset - 时区偏移(小时)
20
+ */
21
+ async getDailyMinutes(date, timezoneOffset = this.timezoneOffset) {
22
+ const tzUtils = new TimezoneUtils(timezoneOffset);
23
+
24
+ // 解析输入日期为本地日期
25
+ const localDate = tzUtils.parseDate(date);
26
+
27
+ // 构建数据库查询的日期(本地零点对应的 UTC 时间)
28
+ const localDayStart = new Date(Date.UTC(
29
+ localDate.getUTCFullYear(),
30
+ localDate.getUTCMonth(),
31
+ localDate.getUTCDate(),
32
+ 0, 0, 0, 0
33
+ ));
34
+ const dbDate = tzUtils.localToUtc(localDayStart);
35
+
36
+ // 查询记录
37
+ const dayRecords = await DailyEyeTime.find({
38
+ date: dbDate
39
+ }).lean();
40
+
41
+ // 初始化小时分布
42
+ const hourlyStats = Array(24).fill(0);
43
+
44
+ for (const rec of dayRecords) {
45
+ if (rec.hourlyUsage && Array.isArray(rec.hourlyUsage)) {
46
+ for (let i = 0; i < 24; i++) {
47
+ hourlyStats[i] += rec.hourlyUsage[i] || 0;
48
+ }
49
+ }
50
+ }
51
+
52
+ // 总分钟数直接用小时分布求和
53
+ const totalUsage = Math.round(hourlyStats.reduce((a, b) => a + b, 0) * 100) / 100;
54
+
55
+ return {
56
+ date: localDate.toISOString().split('T')[0],
57
+ totalUsage,
58
+ hourlyStats: hourlyStats.map(v => Math.round(v * 100) / 100),
59
+ timezoneOffset
60
+ };
61
+ }
62
+
63
+
64
+ /**
65
+ * 获取周统计结果
66
+ */
67
+ async getWeeklyMinutes(weekOffset = 0, timezoneOffset = this.timezoneOffset) {
68
+ const { startDate, endDate } = this.dateHelper.getWeekRange(weekOffset);
69
+ const result = {};
70
+
71
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
72
+ const stats = await this.getDailyMinutes(new Date(d), timezoneOffset);
73
+ result[stats.date] = stats.totalUsage;
74
+ }
75
+
76
+ return {
77
+ weekOffset,
78
+ weekRange: {
79
+ start: startDate.toISOString().split('T')[0],
80
+ end: endDate.toISOString().split('T')[0]
81
+ },
82
+ dailyTotals: result,
83
+ timezoneOffset
84
+ };
85
+ }
86
+
87
+ /**
88
+ * 获取月统计结果
89
+ */
90
+ async getMonthlyMinutes(monthOffset = 0, timezoneOffset = this.timezoneOffset) {
91
+ const { startDate, endDate } = this.dateHelper.getMonthRange(monthOffset, timezoneOffset);
92
+ const result = {};
93
+
94
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
95
+ const stats = await this.getDailyMinutes(new Date(d), timezoneOffset);
96
+ result[stats.date] = stats.totalUsage;
97
+ }
98
+
99
+ return {
100
+ monthOffset,
101
+ monthRange: {
102
+ start: startDate.toISOString().split('T')[0],
103
+ end: endDate.toISOString().split('T')[0]
104
+ },
105
+ dailyTotals: result,
106
+ timezoneOffset
107
+ };
108
+ }
109
+ }
110
+
111
+ module.exports = EyeTimeQuery;
services/EyeTimeRecorder.js ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // EyeTimeRecorder.js
2
+ const { mongoose } = require('../index');
3
+ const TimezoneUtils = require('../utils/timezone');
4
+
5
+ const DailyEyeTime = mongoose.model('DailyEyeTime', {
6
+ date: Date, // 本地时区日期零点对应的 UTC 时间戳
7
+ hourlyUsage: [Number] // 24小时数组,每项代表分钟数
8
+ });
9
+
10
+ class EyeTimeRecorder {
11
+ constructor(timezoneConfig) {
12
+ // 初始化时区工具
13
+ this.timezoneUtils = new TimezoneUtils(timezoneConfig.timezoneOffset || 'Asia/Shanghai');
14
+
15
+ // 存储所有设备的状态
16
+ this.deviceStates = new Map(); // { deviceId: { isActive: boolean, lastUpdateTime: timestamp } }
17
+
18
+ // 全局状态追踪
19
+ this.globalActive = false;
20
+ this.lastRecordTime = null;
21
+ }
22
+
23
+ /**
24
+ * 记录设备活动状态
25
+ * @param {string} deviceId - 设备ID
26
+ * @param {boolean} isActive - 是否活跃(用眼中)
27
+ */
28
+ async recordActivity(deviceId, isActive = true) {
29
+ const now = Date.now();
30
+ const wasGlobalActive = this.globalActive;
31
+
32
+ // 更新设备状态
33
+ this.deviceStates.set(deviceId, {
34
+ isActive: isActive,
35
+ lastUpdateTime: now
36
+ });
37
+
38
+ // 重新计算全局状态(只要有一个设备活跃,全局就活跃)
39
+ this.globalActive = false;
40
+ for (const [, state] of this.deviceStates) {
41
+ if (state.isActive) {
42
+ this.globalActive = true;
43
+ break;
44
+ }
45
+ }
46
+
47
+ // 如果全局状态从活跃变为非活跃,需要记录最后一段时间
48
+ if (wasGlobalActive && !this.globalActive && this.lastRecordTime) {
49
+ await this._saveUsageTime(this.lastRecordTime, now);
50
+ this.lastRecordTime = null;
51
+ }
52
+
53
+ // 如果全局状态从非活跃变为活跃,开始新的记录
54
+ if (!wasGlobalActive && this.globalActive) {
55
+ this.lastRecordTime = now;
56
+ }
57
+
58
+ // 如果全局状态持续活跃,记录这段时间并更新起始时间
59
+ if (wasGlobalActive && this.globalActive && this.lastRecordTime) {
60
+ await this._saveUsageTime(this.lastRecordTime, now);
61
+ this.lastRecordTime = now;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 保存使用时间到数据库
67
+ * @private
68
+ */
69
+ async _saveUsageTime(startTime, endTime) {
70
+ const durationMs = endTime - startTime;
71
+ if (durationMs <= 0) return;
72
+
73
+ // 将时间段按本地时区的小时和日期拆分
74
+ const segments = this._splitTimeSegments(startTime, endTime);
75
+
76
+ // 为每个时间段更新数据库
77
+ for (const segment of segments) {
78
+ await this._updateDailyRecord(segment.date, segment.hour, segment.minutes);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 将时间段按本地时区的日期和小时拆分
84
+ * @private
85
+ */
86
+ _splitTimeSegments(startTime, endTime) {
87
+ const segments = [];
88
+ let currentTime = startTime;
89
+
90
+ while (currentTime < endTime) {
91
+ // 转换为本地时间
92
+ const localDate = this.timezoneUtils.utcToLocal(new Date(currentTime));
93
+ const currentHour = localDate.getHours();
94
+
95
+ // 计算当前小时结束时间(本地时间)
96
+ const hourEndLocal = new Date(localDate);
97
+ hourEndLocal.setHours(currentHour, 59, 59, 999);
98
+
99
+ // 转换回UTC
100
+ const hourEndUtc = this.timezoneUtils.localToUtc(hourEndLocal).getTime();
101
+
102
+ // 确定这个时间段的结束时间
103
+ const segmentEnd = Math.min(endTime, hourEndUtc + 1); // +1ms 进入下一小时
104
+
105
+ // 计算分钟数
106
+ const segmentDurationMs = segmentEnd - currentTime;
107
+ const minutes = segmentDurationMs / (60 * 1000);
108
+
109
+ // 获取本地时区零点的正确 UTC 时间
110
+ // 例如:北京时间 2025-11-24 -> UTC 2025-11-23T16:00Z
111
+ const localDayStartUtc = this.timezoneUtils.getLocalDayStart(new Date(currentTime));
112
+
113
+ segments.push({
114
+ date: localDayStartUtc, // 存储为本地时区零点对应的 UTC 时间
115
+ hour: currentHour,
116
+ minutes: minutes
117
+ });
118
+
119
+ currentTime = segmentEnd;
120
+ }
121
+
122
+ return segments;
123
+ }
124
+
125
+ /**
126
+ * 更新数据库中的每日记录
127
+ * @private
128
+ */
129
+ async _updateDailyRecord(date, hour, minutes) {
130
+ try {
131
+ // 查找或创建当天的记录
132
+ let record = await DailyEyeTime.findOne({ date: date });
133
+
134
+ if (!record) {
135
+ // 创建新记录,初始化24小时数组为0
136
+ record = new DailyEyeTime({
137
+ date: date, // 存储为本地时区零点的 UTC 时间
138
+ hourlyUsage: Array(24).fill(0)
139
+ });
140
+ }
141
+
142
+ // 累加该小时的使用时间
143
+ record.hourlyUsage[hour] += minutes;
144
+
145
+ // 保存到数据库
146
+ await record.save();
147
+
148
+ } catch (error) {
149
+ console.error('保存用眼时间记录失败:', error);
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 获取设备当前状态(可选方法,用于调试)
156
+ */
157
+ getDeviceStatus(deviceId) {
158
+ return this.deviceStates.get(deviceId) || null;
159
+ }
160
+
161
+ /**
162
+ * 获取全局活跃状态(可选方法,用于调试)
163
+ */
164
+ isGlobalActive() {
165
+ return this.globalActive;
166
+ }
167
+
168
+ /**
169
+ * 获取所有活跃设备列表(可选方法,用于调试)
170
+ */
171
+ getActiveDevices() {
172
+ const activeDevices = [];
173
+ for (const [deviceId, state] of this.deviceStates) {
174
+ if (state.isActive) {
175
+ activeDevices.push(deviceId);
176
+ }
177
+ }
178
+ return activeDevices;
179
+ }
180
+ }
181
+
182
+ module.exports = EyeTimeRecorder;
services/StatsQuery.js ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // StatsQuery.js - 简化版(适配新的存储逻辑)
2
+ const { mongoose } = require('../index');
3
+ const DateRangeHelper = require('../utils/DateRange');
4
+ const TimezoneUtils = require('../utils/timezone');
5
+
6
+ const DailyStat = mongoose.model('DailyStat');
7
+
8
+ class StatsQuery {
9
+ constructor(recorder, config = {}) {
10
+ this.recorder = recorder;
11
+ this.timezoneOffset = config.timezoneOffset || 8;
12
+ this.dateRangeHelper = new DateRangeHelper(this.timezoneOffset);
13
+ this.tzUtils = new TimezoneUtils(this.timezoneOffset);
14
+ }
15
+
16
+ // 查询统计数据(直接查询本地日期)
17
+ async _queryStats(query, startDate, endDate) {
18
+ // 构建查询日期数组(本地时区日期)
19
+ const queryDates = [];
20
+ const current = new Date(startDate);
21
+
22
+ while (current <= endDate) {
23
+ // 转换为数据库存储格式(本地零点对应的 UTC 时间)
24
+ const localDayStart = new Date(Date.UTC(
25
+ current.getUTCFullYear(),
26
+ current.getUTCMonth(),
27
+ current.getUTCDate(),
28
+ 0, 0, 0, 0
29
+ ));
30
+ const dbDate = this.tzUtils.localToUtc(localDayStart);
31
+ queryDates.push(dbDate);
32
+
33
+ // 移动到下一天
34
+ current.setUTCDate(current.getUTCDate() + 1);
35
+ }
36
+
37
+ query.date = { $in: queryDates };
38
+ return DailyStat.find(query);
39
+ }
40
+
41
+ // 处理统计数据
42
+ _processStats(allStats, startDate, endDate, isSingleDay = false) {
43
+ const dailyStats = {};
44
+ const appDailyStats = {};
45
+ const hourlyStats = Array(24).fill(0);
46
+ const appHourlyStats = {};
47
+ let totalUsage = 0;
48
+
49
+ allStats.forEach(stat => {
50
+ const appName = stat.appName;
51
+
52
+ // 将数据库的 date 转换回本地日期
53
+ const dbDate = new Date(stat.date);
54
+ const localDayStart = this.tzUtils.utcToLocal(dbDate);
55
+ const localDateOnly = new Date(Date.UTC(
56
+ localDayStart.getUTCFullYear(),
57
+ localDayStart.getUTCMonth(),
58
+ localDayStart.getUTCDate()
59
+ ));
60
+
61
+ const dateKey = localDateOnly.toISOString().split('T')[0];
62
+
63
+ // 初始化
64
+ if (!dailyStats[dateKey]) dailyStats[dateKey] = 0;
65
+ if (!appDailyStats[appName]) appDailyStats[appName] = {};
66
+ if (!appDailyStats[appName][dateKey]) appDailyStats[appName][dateKey] = 0;
67
+ if (!appHourlyStats[appName]) {
68
+ appHourlyStats[appName] = Array(24).fill(0);
69
+ }
70
+
71
+ // 累加每小时数据
72
+ stat.hourlyUsage.forEach((minutes, hour) => {
73
+ if (minutes > 0) {
74
+ dailyStats[dateKey] += minutes;
75
+ appDailyStats[appName][dateKey] += minutes;
76
+ hourlyStats[hour] += minutes;
77
+ appHourlyStats[appName][hour] += minutes;
78
+ totalUsage += minutes;
79
+ }
80
+ });
81
+ });
82
+
83
+ return { dailyStats, appDailyStats, hourlyStats, appHourlyStats, totalUsage };
84
+ }
85
+
86
+ // ============ 对外接口 ============
87
+
88
+ // 获取某天的统计数据
89
+ async getDailyStats(deviceId, date) {
90
+ const localDate = this.tzUtils.parseDate(date);
91
+
92
+ const allStats = await this._queryStats(
93
+ { deviceId },
94
+ localDate,
95
+ localDate
96
+ );
97
+
98
+ const { hourlyStats, appHourlyStats, totalUsage } = this._processStats(
99
+ allStats,
100
+ localDate,
101
+ localDate,
102
+ true
103
+ );
104
+
105
+ // 只保留有数据的应用
106
+ const appStats = {};
107
+ Object.keys(appHourlyStats).forEach(appName => {
108
+ const total = appHourlyStats[appName].reduce((sum, val) => sum + val, 0);
109
+ if (total > 0) appStats[appName] = total;
110
+ });
111
+
112
+ return {
113
+ totalUsage,
114
+ appStats,
115
+ hourlyStats,
116
+ appHourlyStats: Object.keys(appStats).length > 0 ? appHourlyStats : {}
117
+ };
118
+ }
119
+
120
+ // 获取周统计
121
+ async getWeeklyAppStats(deviceId, appName = null, weekOffset = 0) {
122
+ const { startDate, endDate } = this.dateRangeHelper.getWeekRange(weekOffset);
123
+
124
+ const query = { deviceId };
125
+ if (appName) query.appName = appName;
126
+
127
+ const allStats = await this._queryStats(query, startDate, endDate);
128
+ const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
129
+
130
+ return {
131
+ weekOffset,
132
+ weekRange: {
133
+ start: startDate.toISOString().split('T')[0],
134
+ end: endDate.toISOString().split('T')[0]
135
+ },
136
+ dailyTotals: dailyStats,
137
+ appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
138
+ };
139
+ }
140
+
141
+ // 获取月统计
142
+ async getMonthlyAppStats(deviceId, appName = null, monthOffset = 0) {
143
+ const { startDate, endDate } = this.dateRangeHelper.getMonthRange(monthOffset);
144
+
145
+ const query = { deviceId };
146
+ if (appName) query.appName = appName;
147
+
148
+ const allStats = await this._queryStats(query, startDate, endDate);
149
+ const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
150
+
151
+ return {
152
+ monthOffset,
153
+ monthRange: {
154
+ start: startDate.toISOString().split('T')[0],
155
+ end: endDate.toISOString().split('T')[0]
156
+ },
157
+ dailyTotals: dailyStats,
158
+ appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
159
+ };
160
+ }
161
+
162
+ // 获取某天所有设备统计
163
+ async getDailyStatsForAllDevices(date) {
164
+ const localDate = this.tzUtils.parseDate(date);
165
+
166
+ const allStats = await this._queryStats({}, localDate, localDate);
167
+ const { hourlyStats, appHourlyStats, totalUsage } = this._processStats(
168
+ allStats,
169
+ localDate,
170
+ localDate,
171
+ true
172
+ );
173
+
174
+ const appStats = {};
175
+ Object.keys(appHourlyStats).forEach(appName => {
176
+ const total = appHourlyStats[appName].reduce((sum, val) => sum + val, 0);
177
+ if (total > 0) appStats[appName] = total;
178
+ });
179
+
180
+ return { totalUsage, appStats, hourlyStats, appHourlyStats };
181
+ }
182
+
183
+ // 获取周统计所有设备
184
+ async getWeeklyAppStatsForAllDevices(appName = null, weekOffset = 0) {
185
+ const { startDate, endDate } = this.dateRangeHelper.getWeekRange(weekOffset);
186
+
187
+ const query = {};
188
+ if (appName) query.appName = appName;
189
+
190
+ const allStats = await this._queryStats(query, startDate, endDate);
191
+ const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
192
+
193
+ return {
194
+ weekOffset,
195
+ weekRange: {
196
+ start: startDate.toISOString().split('T')[0],
197
+ end: endDate.toISOString().split('T')[0]
198
+ },
199
+ dailyTotals: dailyStats,
200
+ appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
201
+ };
202
+ }
203
+
204
+ // 获取月统计所有设备
205
+ async getMonthlyAppStatsForAllDevices(appName = null, monthOffset = 0) {
206
+ const { startDate, endDate } = this.dateRangeHelper.getMonthRange(monthOffset);
207
+
208
+ const query = {};
209
+ if (appName) query.appName = appName;
210
+
211
+ const allStats = await this._queryStats(query, startDate, endDate);
212
+ const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
213
+
214
+ return {
215
+ monthOffset,
216
+ monthRange: {
217
+ start: startDate.toISOString().split('T')[0],
218
+ end: endDate.toISOString().split('T')[0]
219
+ },
220
+ dailyTotals: dailyStats,
221
+ appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
222
+ };
223
+ }
224
+
225
+ // 获取设备列表
226
+ async getDevices() {
227
+ return Array.from(this.recorder.recentAppSwitches.keys()).map(deviceId => {
228
+ let currentApp = "Unknown";
229
+ let runningSince = new Date();
230
+ let isRunning = true;
231
+ const batteryInfo = this.recorder.getLatestBatteryInfo(deviceId);
232
+
233
+ if (this.recorder.recentAppSwitches.has(deviceId) && this.recorder.recentAppSwitches.get(deviceId).length > 0) {
234
+ const lastSwitch = this.recorder.recentAppSwitches.get(deviceId)[0];
235
+ currentApp = lastSwitch.appName;
236
+ runningSince = lastSwitch.timestamp;
237
+ isRunning = lastSwitch.running !== false;
238
+ }
239
+
240
+ return {
241
+ device: deviceId,
242
+ currentApp,
243
+ running: isRunning,
244
+ runningSince,
245
+ batteryLevel: batteryInfo.level,
246
+ isCharging: batteryInfo.isCharging,
247
+ batteryTimestamp: batteryInfo.timestamp
248
+ };
249
+ });
250
+ }
251
+ }
252
+
253
+ module.exports = StatsQuery;
services/StatsRecorder.js ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // StatsRecorder.js - 记录信息模块
2
+ const { mongoose } = require('../index');
3
+ const EyeTimeRecorder = require('./EyeTimeRecorder');
4
+ const TimezoneUtils = require('../utils/timezone'); // 引入时区工具
5
+
6
+ let eyeTimeRecorder;
7
+ let timezoneUtils;
8
+
9
+ // 定义新的数据模型 - 按天/小时/应用存储
10
+ const DailyStat = mongoose.model('DailyStat', {
11
+ deviceId: String,
12
+ date: Date, // 本地时区日期零点 (存储为该时区零点的 UTC 时间戳)
13
+ appName: String,
14
+ hourlyUsage: [Number] // 24小时数组,每项代表分钟数
15
+ });
16
+
17
+ class StatsRecorder {
18
+ constructor(timezoneConfig = 8) {
19
+ // 初始化时区工具(接收时区偏移或时区名称)
20
+ timezoneUtils = new TimezoneUtils(timezoneConfig);
21
+ eyeTimeRecorder = new EyeTimeRecorder({timezoneOffset: timezoneConfig});
22
+ // 设备应用切换记录
23
+ this.recentAppSwitches = new Map(); // {deviceId: [{appName, timestamp}]}
24
+ // 电池信息存储
25
+ this.batteryInfo = new Map(); // {deviceId: {level, isCharging, timestamp}}
26
+ }
27
+
28
+ // 记录电池信息和充电状态
29
+ recordBattery(deviceId, level, isCharging = false) {
30
+ const now = new Date();
31
+
32
+ this.batteryInfo.set(deviceId, {
33
+ level: level,
34
+ isCharging: isCharging,
35
+ timestamp: now
36
+ });
37
+ }
38
+
39
+ // 获取最新电池信息
40
+ getLatestBatteryInfo(deviceId) {
41
+ const info = this.batteryInfo.get(deviceId);
42
+ if (!info) {
43
+ return {
44
+ level: 0,
45
+ isCharging: false,
46
+ timestamp: null
47
+ };
48
+ }
49
+ return info;
50
+ }
51
+
52
+ // 记录应用使用时间
53
+ async recordUsage(deviceId, appName, running) {
54
+ const now = new Date();
55
+
56
+ await eyeTimeRecorder.recordActivity(deviceId, running) // 记录公共使用时间(用眼时长)
57
+
58
+ if (!this.recentAppSwitches.has(deviceId)) {
59
+ this.recentAppSwitches.set(deviceId, []);
60
+ }
61
+
62
+ const deviceSwitches = this.recentAppSwitches.get(deviceId);
63
+
64
+ // 处理停止运行的情况
65
+ if (running === false) {
66
+ if (deviceSwitches.length > 0) {
67
+ const lastSwitch = deviceSwitches[0];
68
+ if (lastSwitch.running !== false) {
69
+ const minutesSinceLastSwitch = this.calculatePreciseMinutes(lastSwitch.timestamp, now);
70
+ // 更新应用分时段时间统计,传递完整的开始时间戳
71
+ await this.updateDailyStat(deviceId, lastSwitch.appName, lastSwitch.timestamp, minutesSinceLastSwitch);
72
+ }
73
+ deviceSwitches[0].running = false;
74
+ deviceSwitches.unshift({
75
+ appName: "设备待机",
76
+ timestamp: now,
77
+ running: false
78
+ });
79
+ }
80
+ return;
81
+ }
82
+
83
+ // 使用时间计算
84
+ let minutesSinceLastSwitch = 0;
85
+ if (deviceSwitches.length > 0) {
86
+ const lastSwitch = deviceSwitches[0];
87
+ if (lastSwitch.running !== false) {
88
+ minutesSinceLastSwitch = this.calculatePreciseMinutes(lastSwitch.timestamp, now);
89
+ // 关键修改:传递完整的开始时间戳
90
+ await this.updateDailyStat(deviceId, lastSwitch.appName, lastSwitch.timestamp, minutesSinceLastSwitch);
91
+ }
92
+ }
93
+
94
+ // 添加新记录
95
+ deviceSwitches.unshift({
96
+ appName: appName,
97
+ timestamp: now,
98
+ running: true
99
+ });
100
+
101
+ if (deviceSwitches.length > 20) {
102
+ deviceSwitches.pop();
103
+ }
104
+ }
105
+
106
+ // 精确计算时间差,返回小数分钟(精确到2位)
107
+ calculatePreciseMinutes(startTime, endTime) {
108
+ const milliseconds = endTime - startTime;
109
+ const minutes = milliseconds / (60 * 1000);
110
+ // 保留2位小数
111
+ return Math.round(minutes * 100) / 100;
112
+ }
113
+
114
+ // 获取本地时区的日期零点(返回该零点对应的 UTC Date 对象)
115
+ getLocalDayStart(timestamp) {
116
+ // 将 UTC 时间转换为本地时间
117
+ const localTime = timezoneUtils.utcToLocal(new Date(timestamp));
118
+
119
+ // 获取本地时间的日期部分
120
+ const year = localTime.getUTCFullYear();
121
+ const month = localTime.getUTCMonth();
122
+ const day = localTime.getUTCDate();
123
+
124
+ // 创建本地零点时间
125
+ const localDayStart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
126
+
127
+ // 转换回 UTC(这样存储的就是本地零点对应的 UTC 时间)
128
+ return timezoneUtils.localToUtc(localDayStart);
129
+ }
130
+
131
+ // 更新每日统计
132
+ async updateDailyStat(deviceId, appName, startTimestamp, durationMinutes) {
133
+ // 使用本地时区的日期零点
134
+ const dayStart = this.getLocalDayStart(startTimestamp);
135
+
136
+ let stat = await DailyStat.findOne({
137
+ deviceId,
138
+ date: dayStart,
139
+ appName
140
+ });
141
+
142
+ if (!stat) {
143
+ stat = new DailyStat({
144
+ deviceId,
145
+ date: dayStart,
146
+ appName,
147
+ hourlyUsage: Array(24).fill(0)
148
+ });
149
+ }
150
+
151
+ await this.distributePreciseMinutes(stat, startTimestamp, durationMinutes);
152
+ await stat.save();
153
+ }
154
+
155
+ async distributePreciseMinutes(stat, startTimestamp, totalMinutes) {
156
+ let remainingMinutes = totalMinutes;
157
+ let currentTimestamp = new Date(startTimestamp);
158
+
159
+ while (remainingMinutes > 0) {
160
+ // 获取当前时间戳对应的本地日期零点
161
+ const currentDayStart = this.getLocalDayStart(currentTimestamp);
162
+
163
+ // 获取本地时区的小时、分钟、秒
164
+ const localTime = timezoneUtils.utcToLocal(currentTimestamp);
165
+ const currentHour = localTime.getUTCHours();
166
+ const currentMinute = localTime.getUTCMinutes();
167
+ const currentSecond = localTime.getUTCSeconds();
168
+
169
+ // 如果跨日期了,需要获取新的统计记录
170
+ let currentStat = stat;
171
+ if (currentDayStart.getTime() !== stat.date.getTime()) {
172
+ currentStat = await DailyStat.findOne({
173
+ deviceId: stat.deviceId,
174
+ date: currentDayStart,
175
+ appName: stat.appName
176
+ });
177
+
178
+ if (!currentStat) {
179
+ currentStat = new DailyStat({
180
+ deviceId: stat.deviceId,
181
+ date: currentDayStart,
182
+ appName: stat.appName,
183
+ hourlyUsage: Array(24).fill(0)
184
+ });
185
+ }
186
+ }
187
+
188
+ // 计算当前小时内已使用的分钟数
189
+ const usedInCurrentHour = currentStat.hourlyUsage[currentHour];
190
+
191
+ // 计算当前时间点到下一个小时开始还有多少分钟
192
+ const minutesToNextHour = 60 - currentMinute - (currentSecond > 0 ? (currentSecond / 60) : 0);
193
+
194
+ // 当前小时的剩余容量
195
+ const availableSpace = Math.max(0, 60 - usedInCurrentHour);
196
+
197
+ // 实际能在当前小时分配的时间
198
+ const minutesToAdd = Math.min(remainingMinutes, minutesToNextHour, availableSpace);
199
+
200
+ if (minutesToAdd > 0) {
201
+ const preciseMinutesToAdd = Math.round(minutesToAdd * 100) / 100;
202
+ currentStat.hourlyUsage[currentHour] = Math.round((currentStat.hourlyUsage[currentHour] + preciseMinutesToAdd) * 100) / 100;
203
+
204
+ // 如果是跨日期的新统计记录,需要保存
205
+ if (currentStat !== stat) {
206
+ await currentStat.save();
207
+ }
208
+
209
+ remainingMinutes = Math.round((remainingMinutes - preciseMinutesToAdd) * 100) / 100;
210
+ }
211
+
212
+ // 移动到下一个时间点
213
+ if (minutesToAdd >= minutesToNextHour) {
214
+ // 移动到下一个小时的开始
215
+ currentTimestamp = new Date(currentTimestamp.getTime() + minutesToNextHour * 60 * 1000);
216
+ } else {
217
+ // 在当前小时内完成了分配
218
+ break;
219
+ }
220
+
221
+ // 避免浮点数精度问题
222
+ if (remainingMinutes < 0.01) {
223
+ remainingMinutes = 0;
224
+ }
225
+
226
+ // 防止无限循环(最多处理30天)
227
+ const daysDifference = Math.floor((currentTimestamp - startTimestamp) / (24 * 60 * 60 * 1000));
228
+ if (daysDifference > 30) {
229
+ console.warn(`超过30天限制,剩余 ${remainingMinutes} 分钟无法分配,设备: ${stat.deviceId}, 应用: ${stat.appName}`);
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ module.exports = StatsRecorder;