Files
digital-pilates/services/healthKitSync.ts

615 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<boolean> {
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<void> {
syncLock = false;
await AsyncStorage.removeItem(SYNC_LOCK_KEY);
}
/**
* 获取上次同步状态
*/
async function getLastSyncStatus(): Promise<SyncStatus | null> {
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<void> {
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<any>,
currentUserProfile?: {
height?: string | number;
weight?: string | number;
birthDate?: string | number;
}
): Promise<boolean> {
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<boolean> {
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<void> {
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<SyncStatus | null> {
return getLastSyncStatus();
}
/**
* 同步每日健康数据报表到服务端
* @param waterIntake - 当日饮水量(从应用内部获取,因为 HealthKit 可能不包含应用内记录)
*/
export async function syncDailyHealthReport(waterIntake?: number): Promise<boolean> {
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;
}
}