/** * HealthKit 数据同步服务 * * 实现 HealthKit 与服务端之间的双向数据同步 * - 从 HealthKit 同步身高、体重、出生日期到服务端 * - 从服务端同步身高、体重、出生日期到 HealthKit * - 防止循环同步的机制 */ import { fetchActiveEnergyBurned, fetchBasalEnergyBurned, fetchCompleteSleepData, fetchHourlyExerciseMinutesForDate, fetchHourlyStandHoursForDate, fetchOxygenSaturation, fetchPersonalHealthData, fetchSmartHRVData, fetchStepCount, saveHeight, saveWeight } from '@/utils/health'; import AsyncStorage from '@/utils/kvStore'; import { logger } from '@/utils/logger'; import { convertHrvToStressIndex } from '@/utils/stress'; import dayjs from 'dayjs'; import { DailyHealthDataDto, updateDailyHealthData } from './users'; // 同步状态存储键 const SYNC_STATUS_KEY = '@healthkit_sync_status'; const SYNC_LOCK_KEY = '@healthkit_sync_lock'; const DAILY_HEALTH_SYNC_KEY = '@daily_health_sync_status'; // 同步状态类型 interface SyncStatus { lastSyncTime: number; lastSyncDirection: 'healthkit_to_server' | 'server_to_healthkit' | null; lastSyncData: { height?: number; weight?: number; birthDate?: string; }; } // 每日健康数据同步状态 interface DailyHealthSyncStatus { lastSyncTime: number; lastSyncDate: string; // YYYY-MM-DD data: DailyHealthDataDto; } // 同步锁(防止并发同步) let syncLock = false; /** * 获取同步锁 */ async function acquireSyncLock(): Promise { if (syncLock) { console.log('同步操作正在进行中,跳过'); return false; } // 检查持久化的锁(防止应用重启后的并发) const persistedLock = await AsyncStorage.getItem(SYNC_LOCK_KEY); if (persistedLock) { const lockTime = parseInt(persistedLock); const now = Date.now(); // 如果锁超过5分钟,认为是过期锁,可以清除 if (now - lockTime < 5 * 60 * 1000) { console.log('检测到持久化同步锁,跳过'); return false; } else { console.log('清除过期的同步锁'); await AsyncStorage.removeItem(SYNC_LOCK_KEY); } } syncLock = true; await AsyncStorage.setItem(SYNC_LOCK_KEY, Date.now().toString()); return true; } /** * 释放同步锁 */ async function releaseSyncLock(): Promise { syncLock = false; await AsyncStorage.removeItem(SYNC_LOCK_KEY); } /** * 获取上次同步状态 */ async function getLastSyncStatus(): Promise { try { const status = await AsyncStorage.getItem(SYNC_STATUS_KEY); if (status) { return JSON.parse(status); } return null; } catch (error) { console.error('获取同步状态失败:', error); return null; } } /** * 保存同步状态 */ async function saveSyncStatus(status: SyncStatus): Promise { try { await AsyncStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status)); } catch (error) { console.error('保存同步状态失败:', error); } } /** * 判断数据是否需要同步(比较数据是否有变化) */ function shouldSyncData( currentData: { height?: number; weight?: number; birthDate?: string }, lastSyncData: { height?: number; weight?: number; birthDate?: string } ): boolean { // 身高变化(允许1cm误差) if (currentData.height && lastSyncData.height) { if (Math.abs(currentData.height - lastSyncData.height) > 1) { return true; } } else if (currentData.height !== lastSyncData.height) { return true; } // 体重变化(允许0.1kg误差) if (currentData.weight && lastSyncData.weight) { if (Math.abs(currentData.weight - lastSyncData.weight) > 0.1) { return true; } } else if (currentData.weight !== lastSyncData.weight) { return true; } // 出生日期变化 if (currentData.birthDate !== lastSyncData.birthDate) { // 出生日期通常不会变,但仍然检查 if (currentData.birthDate && lastSyncData.birthDate) { const date1 = dayjs(currentData.birthDate).format('YYYY-MM-DD'); const date2 = dayjs(lastSyncData.birthDate).format('YYYY-MM-DD'); if (date1 !== date2) { return true; } } else if (currentData.birthDate || lastSyncData.birthDate) { return true; } } return false; } /** * 从 HealthKit 同步数据到服务端 * * @param updateUserProfile - 更新用户资料的函数(从 Redux userSlice) * @param currentUserProfile - 当前用户资料(用于 diff 比较) * @returns 是否成功同步 */ export async function syncHealthKitToServer( updateUserProfile: (data: { height?: number; weight?: number; birthDate?: number; // Unix timestamp in seconds }) => Promise, currentUserProfile?: { height?: string | number; weight?: string | number; birthDate?: string | number; } ): Promise { console.log('=== 开始从 HealthKit 同步数据到服务端 ==='); // 获取同步锁 const acquired = await acquireSyncLock(); if (!acquired) { return false; } try { // 获取上次同步状态 const lastSync = await getLastSyncStatus(); // 检查是否刚刚从服务端同步过(防止循环) if (lastSync?.lastSyncDirection === 'server_to_healthkit') { const timeSinceLastSync = Date.now() - lastSync.lastSyncTime; // 如果2分钟内刚从服务端同步过,跳过本次同步 if (timeSinceLastSync < 2 * 60 * 1000) { console.log('刚从服务端同步过数据,跳过 HealthKit -> 服务端同步'); return false; } } // 从 HealthKit 获取数据 const healthData = await fetchPersonalHealthData(); console.log('从 HealthKit 获取的数据:', healthData); // 检查是否有数据需要同步 if (!healthData.height && !healthData.weight && !healthData.dateOfBirth) { console.log('HealthKit 中没有个人健康数据,跳过同步'); return false; } // 准备当前 HealthKit 数据 const currentHealthKitData = { height: healthData.height || undefined, weight: healthData.weight || undefined, birthDate: healthData.dateOfBirth || undefined }; // 1. 首先与服务端当前数据进行比较(如果提供了) if (currentUserProfile) { // 转换服务端数据格式以便比较 const serverData = { height: currentUserProfile.height ? (typeof currentUserProfile.height === 'string' ? parseFloat(currentUserProfile.height) : currentUserProfile.height) : undefined, weight: currentUserProfile.weight ? (typeof currentUserProfile.weight === 'string' ? parseFloat(currentUserProfile.weight) : currentUserProfile.weight) : undefined, birthDate: currentUserProfile.birthDate ? (typeof currentUserProfile.birthDate === 'number' ? new Date(currentUserProfile.birthDate * 1000).toISOString() : currentUserProfile.birthDate) : undefined }; const needsSyncWithServer = shouldSyncData(currentHealthKitData, serverData); if (!needsSyncWithServer) { console.log('HealthKit 数据与服务端数据一致,跳过同步'); // 更新同步状态(即使没有同步,也记录检查时间) await saveSyncStatus({ lastSyncTime: Date.now(), lastSyncDirection: 'healthkit_to_server', lastSyncData: currentHealthKitData }); return false; } console.log('检测到 HealthKit 数据与服务端数据存在差异,需要同步'); } // 2. 然后与上次同步的数据进行比较 if (lastSync?.lastSyncData) { const needsSync = shouldSyncData(currentHealthKitData, lastSync.lastSyncData); if (!needsSync) { console.log('数据与上次同步时一致,跳过同步'); return false; } } // 准备同步到服务端的数据 const serverData: { height?: number; weight?: number; birthDate?: number; } = {}; if (healthData.height) { serverData.height = healthData.height; } if (healthData.weight) { serverData.weight = healthData.weight; } if (healthData.dateOfBirth) { // 将 ISO 字符串转换为 Unix 时间戳(秒) const timestamp = new Date(healthData.dateOfBirth).getTime() / 1000; serverData.birthDate = Math.floor(timestamp); } // 同步到服务端 console.log('准备同步到服务端的数据:', serverData); await updateUserProfile(serverData); console.log('成功同步数据到服务端'); // 保存同步状态 await saveSyncStatus({ lastSyncTime: Date.now(), lastSyncDirection: 'healthkit_to_server', lastSyncData: { height: healthData.height || undefined, weight: healthData.weight || undefined, birthDate: healthData.dateOfBirth || undefined } }); return true; } catch (error) { console.error('从 HealthKit 同步数据到服务端失败:', error); return false; } finally { // 释放同步锁 await releaseSyncLock(); } } /** * 从服务端同步数据到 HealthKit * * @param serverData - 服务端的用户数据 * @returns 是否成功同步 */ export async function syncServerToHealthKit( serverData: { height?: number; weight?: number; birthDate?: number | string; // Unix timestamp (seconds) or ISO string } ): Promise { console.log('=== 开始从服务端同步数据到 HealthKit ==='); // 获取同步锁 const acquired = await acquireSyncLock(); if (!acquired) { return false; } try { // 获取上次同步状态 const lastSync = await getLastSyncStatus(); // 检查是否刚刚从 HealthKit 同步过(防止循环) if (lastSync?.lastSyncDirection === 'healthkit_to_server') { const timeSinceLastSync = Date.now() - lastSync.lastSyncTime; // 如果2分钟内刚从 HealthKit 同步过,跳过本次同步 if (timeSinceLastSync < 2 * 60 * 1000) { console.log('刚从 HealthKit 同步过数据,跳过 服务端 -> HealthKit 同步'); return false; } } // 准备要同步的数据 const syncData: { height?: number; weight?: number; birthDate?: string; } = {}; if (serverData.height) { // 确保身高是数字类型 syncData.height = typeof serverData.height === 'string' ? parseFloat(serverData.height) : serverData.height; } if (serverData.weight) { // 确保体重是数字类型 syncData.weight = typeof serverData.weight === 'string' ? parseFloat(serverData.weight) : serverData.weight; } if (serverData.birthDate) { // 如果是时间戳,转换为 ISO 字符串 if (typeof serverData.birthDate === 'number') { syncData.birthDate = new Date(serverData.birthDate * 1000).toISOString(); } else { syncData.birthDate = serverData.birthDate; } } console.log('准备同步到 HealthKit 的数据:', syncData); // 检查数据是否有变化 if (lastSync?.lastSyncData) { const needsSync = shouldSyncData(syncData, lastSync.lastSyncData); if (!needsSync) { console.log('数据未变化,跳过同步'); return false; } } // 同步到 HealthKit const results: boolean[] = []; if (syncData.height) { console.log('同步身高到 HealthKit:', syncData.height, 'cm'); const success = await saveHeight(syncData.height); results.push(success); if (success) { console.log('身高同步成功'); } else { console.error('身高同步失败'); } } if (syncData.weight) { // 确保体重值是数字类型 const weightValue = typeof syncData.weight === 'string' ? parseFloat(syncData.weight) : syncData.weight; console.log('同步体重到 HealthKit:', weightValue, 'kg'); const success = await saveWeight(weightValue); results.push(success); if (success) { console.log('体重同步成功'); } else { console.error('体重同步失败'); } } // 注意:出生日期在 HealthKit 中是只读的,无法写入 if (syncData.birthDate) { console.log('注意:出生日期在 HealthKit 中是只读的,无法同步'); } // 如果至少有一项同步成功,则认为同步成功 const success = results.length > 0 && results.some(r => r); if (success) { // 保存同步状态 await saveSyncStatus({ lastSyncTime: Date.now(), lastSyncDirection: 'server_to_healthkit', lastSyncData: syncData }); console.log('成功从服务端同步数据到 HealthKit'); } return success; } catch (error) { console.error('从服务端同步数据到 HealthKit 失败:', error); return false; } finally { // 释放同步锁 await releaseSyncLock(); } } /** * 清除同步状态(用于测试或重置) */ export async function clearSyncStatus(): Promise { try { await AsyncStorage.removeItem(SYNC_STATUS_KEY); await AsyncStorage.removeItem(SYNC_LOCK_KEY); syncLock = false; console.log('已清除同步状态'); } catch (error) { console.error('清除同步状态失败:', error); } } /** * 获取同步状态信息(用于调试) */ export async function getSyncStatusInfo(): Promise { return getLastSyncStatus(); } /** * 同步每日健康数据报表到服务端 * @param waterIntake - 当日饮水量(从应用内部获取,因为 HealthKit 可能不包含应用内记录) */ export async function syncDailyHealthReport(waterIntake?: number): Promise { try { const today = new Date(); const dateStr = dayjs(today).format('YYYY-MM-DD'); // 1. 获取各项健康数据 // 并行获取以提高性能 const [ activeEnergy, basalEnergy, sleepData, exerciseMinutesData, standHoursData, oxygenSaturation, hrvData, stepCount ] = await Promise.all([ // 卡路里 fetchActiveEnergyBurned({ startDate: dayjs(today).startOf('day').toISOString(), endDate: dayjs(today).endOf('day').toISOString() }), // 基础代谢 fetchBasalEnergyBurned({ startDate: dayjs(today).startOf('day').toISOString(), endDate: dayjs(today).endOf('day').toISOString() }), // 睡眠数据 (需要完整数据来获取分钟数) fetchCompleteSleepData(today), // 锻炼分钟数 (按小时聚合) fetchHourlyExerciseMinutesForDate(today), // 站立小时 (按小时聚合) fetchHourlyStandHoursForDate(today), // 血氧 fetchOxygenSaturation({ startDate: dayjs(today).startOf('day').toISOString(), endDate: dayjs(today).endOf('day').toISOString() }), // HRV (用于计算压力) fetchSmartHRVData(today), // 步数 fetchStepCount(today) ]); // 2. 数据处理与计算 // 计算总锻炼分钟数 const totalExerciseMinutes = exerciseMinutesData.reduce((sum, item) => sum + item.minutes, 0); // 计算总站立时间 (分钟) - 注意 HealthKit 返回的是小时是否有站立,我们这里估算每小时站立1分钟或者直接用小时数 // 根据 API 要求 "standingMinutes",HealthKit 的 standHours 是指有多少个小时有站立活动 // 通常 Apple Watch 判定一小时内站立至少1分钟即计为1个站立小时 // 为了符合 API 语义,我们这里统计有多少个小时达标,转换成分钟可能不太准确,但 API 字段叫 minutes // 策略:如果有 HealthKit 数据,我们用 达标小时数 * 60 作为估算,或者直接传小时数? // 文档说 "standingMinutes: 站立时间(分钟)"。 // HealthKit 的 appleStandHours 是 count,比如 12。 // 如果我们传 12 分钟显然不对。如果我们传 12 * 60 = 720 分钟也不太对,因为并不是站了这么久。 // 实际上 Apple 的 Stand Hours 是 "Hours with >1 min standing". // 这里我们统计有多少小时是有站立的,并乘以一个系数?或者直接传小时数让后端理解? // 鉴于字段名是 minutes,我们统计所有有站立的小时数。 // 实际上,我们应该直接使用 HealthKit 的 appleStandTime (如果可用) 或者通过 appleStandHours 估算。 // 这里的 fetchHourlyStandHoursForDate 返回的是每小时是否有站立(0或1)。 const standHoursCount = standHoursData.filter(h => h.hasStood > 0).length; // 暂时策略:将小时数转换为分钟数传递,虽然这代表的是跨度 const standingMinutes = standHoursCount * 60; // 计算睡眠分钟数 const sleepMinutes = sleepData ? Math.round(sleepData.totalSleepTime) : 0; // 计算压力值 let stressLevel = 0; if (hrvData && hrvData.value > 0) { const stressIndex = convertHrvToStressIndex(hrvData.value); if (stressIndex !== null) { stressLevel = stressIndex; } } // 3. 构建 DTO const healthData: DailyHealthDataDto = { date: dateStr, // 只有当数据有效时才包含字段 ...(waterIntake !== undefined && { waterIntake }), ...(totalExerciseMinutes > 0 && { exerciseMinutes: Math.round(totalExerciseMinutes) }), ...(activeEnergy > 0 && { caloriesBurned: Math.round(activeEnergy) }), ...(standingMinutes > 0 && { standingMinutes }), ...(basalEnergy > 0 && { basalMetabolism: Math.round(basalEnergy) }), ...(sleepMinutes > 0 && { sleepMinutes }), ...(oxygenSaturation !== null && oxygenSaturation > 0 && { bloodOxygen: oxygenSaturation }), ...(stressLevel > 0 && { stressLevel }), ...(stepCount > 0 && { steps: Math.round(stepCount) }) }; logger.info('准备同步每日健康数据:', healthData); // 4. 检查是否需要同步 (与上次同步的数据比较) const lastSyncStatusStr = await AsyncStorage.getItem(DAILY_HEALTH_SYNC_KEY); if (lastSyncStatusStr) { const lastSyncStatus: DailyHealthSyncStatus = JSON.parse(lastSyncStatusStr); // 如果是同一天,检查数据差异 if (lastSyncStatus.lastSyncDate === dateStr) { const lastData = lastSyncStatus.data; const isDifferent = healthData.waterIntake !== lastData.waterIntake || healthData.exerciseMinutes !== lastData.exerciseMinutes || healthData.caloriesBurned !== lastData.caloriesBurned || healthData.standingMinutes !== lastData.standingMinutes || healthData.basalMetabolism !== lastData.basalMetabolism || healthData.sleepMinutes !== lastData.sleepMinutes || healthData.bloodOxygen !== lastData.bloodOxygen || healthData.stressLevel !== lastData.stressLevel || healthData.steps !== lastData.steps; if (!isDifferent) { console.log('每日健康数据无变化,跳过同步'); return true; } } } // 5. 调用 API if (Object.keys(healthData).length > 1) { // 至少包含 date 以外的一个字段 await updateDailyHealthData(healthData); console.log('每日健康数据同步成功'); // 6. 保存同步状态 const newSyncStatus: DailyHealthSyncStatus = { lastSyncTime: Date.now(), lastSyncDate: dateStr, data: healthData }; await AsyncStorage.setItem(DAILY_HEALTH_SYNC_KEY, JSON.stringify(newSyncStatus)); return true; } else { console.log('没有有效的健康数据需要同步'); return false; } } catch (error) { console.error('同步每日健康报表失败:', error); return false; } }