feat(fasting): 重构断食通知系统并增强可靠性

- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑
- 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时
- 添加通知验证机制,确保通知正确设置和避免重复
- 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项
- 实现断食计划持久化存储,应用重启后自动恢复
- 添加开发者测试面板用于验证通知系统可靠性
- 优化通知同步策略,支持选择性更新减少不必要的操作
- 修复个人页面编辑按钮样式问题
- 更新应用版本号至 1.0.18
This commit is contained in:
richarjiang
2025-10-14 15:05:11 +08:00
parent e03b2b3032
commit cf069f3537
18 changed files with 1565 additions and 242 deletions

View File

@@ -1,9 +1,10 @@
import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
import type { FastingSchedule } from '@/store/fastingSlice';
import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import AsyncStorage from '@/utils/kvStore';
import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
dayjs.extend(duration);
dayjs.extend(isSameOrAfter);
@@ -100,6 +101,47 @@ export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => {
};
};
export const loadActiveFastingSchedule = async (): Promise<FastingSchedule | null> => {
try {
const stored = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.activeSchedule);
if (!stored) return null;
const parsed = JSON.parse(stored) as Partial<FastingSchedule>;
if (
!parsed ||
typeof parsed.planId !== 'string' ||
typeof parsed.startISO !== 'string' ||
typeof parsed.endISO !== 'string'
) {
return null;
}
return {
planId: parsed.planId,
startISO: parsed.startISO,
endISO: parsed.endISO,
createdAtISO: parsed.createdAtISO ?? parsed.startISO,
updatedAtISO: parsed.updatedAtISO ?? parsed.endISO,
origin: parsed.origin ?? 'manual',
};
} catch (error) {
console.warn('读取断食计划失败', error);
return null;
}
};
export const persistActiveFastingSchedule = async (schedule: FastingSchedule | null) => {
try {
if (schedule) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.activeSchedule, JSON.stringify(schedule));
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.activeSchedule);
}
} catch (error) {
console.warn('保存断食计划失败', error);
}
};
export const loadPreferredPlanId = async (): Promise<string | null> => {
try {
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
@@ -118,8 +160,10 @@ export const savePreferredPlanId = async (planId: string) => {
};
export type FastingNotificationIds = {
startId?: string | null;
endId?: string | null;
preStartId?: string | null; // 开始前30分钟
startId?: string | null; // 开始时
preEndId?: string | null; // 结束前30分钟
endId?: string | null; // 结束时
};
export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
@@ -146,13 +190,17 @@ export const setFastingNotificationsRegistered = async (registered: boolean) =>
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
try {
const [startId, endId] = await Promise.all([
const [preStartId, startId, preEndId, endId] = await Promise.all([
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preStartNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preEndNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
]);
return {
preStartId: preStartId ?? undefined,
startId: startId ?? undefined,
preEndId: preEndId ?? undefined,
endId: endId ?? undefined,
};
} catch (error) {
@@ -163,12 +211,28 @@ export const loadStoredFastingNotificationIds = async (): Promise<FastingNotific
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
try {
// 保存开始前30分钟通知ID
if (ids.preStartId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preStartNotificationId, ids.preStartId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId);
}
// 保存开始时通知ID
if (ids.startId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId);
}
// 保存结束前30分钟通知ID
if (ids.preEndId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preEndNotificationId, ids.preEndId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId);
}
// 保存结束时通知ID
if (ids.endId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
} else {
@@ -182,7 +246,9 @@ export const saveFastingNotificationIds = async (ids: FastingNotificationIds) =>
export const clearFastingNotificationIds = async () => {
try {
await Promise.all([
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
]);
} catch (error) {

View File

@@ -0,0 +1,341 @@
import { FastingPlan } from '@/constants/Fasting';
import {
ensureFastingNotificationsReady,
resyncFastingNotifications,
verifyFastingNotifications
} from '@/services/fastingNotifications';
import { notificationService } from '@/services/notifications';
import { FastingSchedule } from '@/store/fastingSlice';
import { FastingNotificationIds } from '@/utils/fasting';
import dayjs from 'dayjs';
/**
* 断食通知系统测试工具
* 用于验证通知系统在各种场景下的可靠性
*/
export class FastingNotificationTester {
private testResults: Array<{
testName: string;
passed: boolean;
message: string;
timestamp: Date;
}> = [];
private logResult(testName: string, passed: boolean, message: string) {
const result = {
testName,
passed,
message,
timestamp: new Date(),
};
this.testResults.push(result);
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${testName}: ${message}`);
}
/**
* 获取测试结果
*/
getTestResults() {
return this.testResults;
}
/**
* 清除测试结果
*/
clearResults() {
this.testResults = [];
}
/**
* 测试通知权限检查
*/
async testNotificationPermissions() {
try {
const ready = await ensureFastingNotificationsReady();
this.logResult(
'通知权限检查',
ready,
ready ? '通知权限已授予' : '通知权限未授予'
);
return ready;
} catch (error) {
this.logResult(
'通知权限检查',
false,
`权限检查失败: ${error instanceof Error ? error.message : '未知错误'}`
);
return false;
}
}
/**
* 测试通知安排
*/
async testNotificationScheduling(plan: FastingPlan) {
const now = dayjs();
const start = now.add(1, 'hour');
const end = start.add(plan.fastingHours, 'hour');
const schedule: FastingSchedule = {
planId: plan.id,
startISO: start.toISOString(),
endISO: end.toISOString(),
createdAtISO: now.toISOString(),
updatedAtISO: now.toISOString(),
origin: 'manual',
};
try {
const notificationIds = await resyncFastingNotifications({
schedule,
plan,
previousIds: {},
enabled: true,
});
const hasAllIds = !!(notificationIds.preStartId && notificationIds.startId &&
notificationIds.preEndId && notificationIds.endId);
this.logResult(
'通知安排测试',
hasAllIds,
hasAllIds
? '成功安排所有四个通知点'
: `缺少通知ID: ${JSON.stringify(notificationIds)}`
);
return { success: hasAllIds, notificationIds };
} catch (error) {
this.logResult(
'通知安排测试',
false,
`通知安排失败: ${error instanceof Error ? error.message : '未知错误'}`
);
return { success: false, notificationIds: {} };
}
}
/**
* 测试通知验证
*/
async testNotificationVerification(
schedule: FastingSchedule,
plan: FastingPlan,
notificationIds: FastingNotificationIds
) {
try {
const { isValid, updatedIds } = await verifyFastingNotifications({
schedule,
plan,
storedIds: notificationIds,
});
this.logResult(
'通知验证测试',
isValid,
isValid ? '通知验证通过' : '通知验证失败,已重新同步'
);
return { isValid, updatedIds };
} catch (error) {
this.logResult(
'通知验证测试',
false,
`通知验证失败: ${error instanceof Error ? error.message : '未知错误'}`
);
return { isValid: false, updatedIds: {} };
}
}
/**
* 测试通知取消
*/
async testNotificationCancellation(notificationIds: FastingNotificationIds) {
try {
// 取消所有通知
const cancelPromises = [];
if (notificationIds.preStartId) {
cancelPromises.push(notificationService.cancelNotification(notificationIds.preStartId));
}
if (notificationIds.startId) {
cancelPromises.push(notificationService.cancelNotification(notificationIds.startId));
}
if (notificationIds.preEndId) {
cancelPromises.push(notificationService.cancelNotification(notificationIds.preEndId));
}
if (notificationIds.endId) {
cancelPromises.push(notificationService.cancelNotification(notificationIds.endId));
}
await Promise.all(cancelPromises);
// 验证通知是否已取消
const scheduledNotifications = await notificationService.getAllScheduledNotifications();
const remainingIds = scheduledNotifications.map(n => n.identifier);
const cancelledIds = Object.values(notificationIds).filter(id =>
id && !remainingIds.includes(id)
);
const allCancelled = cancelledIds.length === Object.values(notificationIds).filter(id => id).length;
this.logResult(
'通知取消测试',
allCancelled,
allCancelled
? '所有通知已成功取消'
: `部分通知未取消: ${JSON.stringify(cancelledIds)}`
);
return allCancelled;
} catch (error) {
this.logResult(
'通知取消测试',
false,
`通知取消失败: ${error instanceof Error ? error.message : '未知错误'}`
);
return false;
}
}
/**
* 测试边界情况
*/
async testEdgeCases(plan: FastingPlan) {
const now = dayjs();
// 测试1: 已经过期的断食期
const pastStart = now.subtract(2, 'hour');
const pastEnd = pastStart.add(plan.fastingHours, 'hour');
const pastSchedule: FastingSchedule = {
planId: plan.id,
startISO: pastStart.toISOString(),
endISO: pastEnd.toISOString(),
createdAtISO: now.toISOString(),
updatedAtISO: now.toISOString(),
origin: 'manual',
};
try {
const pastNotificationIds = await resyncFastingNotifications({
schedule: pastSchedule,
plan,
previousIds: {},
enabled: true,
});
const hasNoIds = Object.values(pastNotificationIds).every(id => !id);
this.logResult(
'过期断食期测试',
hasNoIds,
hasNoIds ? '正确处理过期断食期,未安排通知' : '错误地为过期断食期安排了通知'
);
} catch (error) {
this.logResult(
'过期断食期测试',
false,
`过期断食期测试失败: ${error instanceof Error ? error.message : '未知错误'}`
);
}
// 测试2: 即将开始的断食期少于30分钟
const imminentStart = now.add(15, 'minute');
const imminentEnd = imminentStart.add(plan.fastingHours, 'hour');
const imminentSchedule: FastingSchedule = {
planId: plan.id,
startISO: imminentStart.toISOString(),
endISO: imminentEnd.toISOString(),
createdAtISO: now.toISOString(),
updatedAtISO: now.toISOString(),
origin: 'manual',
};
try {
const imminentNotificationIds = await resyncFastingNotifications({
schedule: imminentSchedule,
plan,
previousIds: {},
enabled: true,
});
// 应该只有开始时、结束前30分钟和结束时的通知
const hasCorrectIds = !imminentNotificationIds.preStartId &&
!!imminentNotificationIds.startId &&
!!imminentNotificationIds.preEndId &&
!!imminentNotificationIds.endId;
this.logResult(
'即将开始断食期测试',
hasCorrectIds,
hasCorrectIds
? '正确处理即将开始的断食期,只安排了必要的通知'
: '通知安排不正确'
);
} catch (error) {
this.logResult(
'即将开始断食期测试',
false,
`即将开始断食期测试失败: ${error instanceof Error ? error.message : '未知错误'}`
);
}
}
/**
* 运行完整的测试套件
*/
async runFullTestSuite(plan: FastingPlan) {
console.log('开始运行断食通知系统测试套件...');
this.clearResults();
// 1. 测试通知权限
const hasPermission = await this.testNotificationPermissions();
if (!hasPermission) {
console.log('通知权限未授予,跳过其他测试');
return this.getTestResults();
}
// 2. 测试通知安排
const { success: schedulingSuccess, notificationIds } = await this.testNotificationScheduling(plan);
if (!schedulingSuccess) {
console.log('通知安排失败,跳过后续测试');
return this.getTestResults();
}
// 3. 创建测试用的断食计划
const now = dayjs();
const start = now.add(1, 'hour');
const end = start.add(plan.fastingHours, 'hour');
const schedule: FastingSchedule = {
planId: plan.id,
startISO: start.toISOString(),
endISO: end.toISOString(),
createdAtISO: now.toISOString(),
updatedAtISO: now.toISOString(),
origin: 'manual',
};
// 4. 测试通知验证
await this.testNotificationVerification(schedule, plan, notificationIds);
// 5. 测试边界情况
await this.testEdgeCases(plan);
// 6. 测试通知取消
await this.testNotificationCancellation(notificationIds);
const results = this.getTestResults();
const passedCount = results.filter(r => r.passed).length;
const totalCount = results.length;
console.log(`测试完成: ${passedCount}/${totalCount} 通过`);
return results;
}
}
// 导出测试实例
export const fastingNotificationTester = new FastingNotificationTester();