关键要点
- BFF 模式简化前端数据获取:通过为前端应用量身定制的 API 层,减少客户端复杂度,统一处理多个第三方 API 的差异。
- 身份验证与授权分离:可穿戴设备 API 需要不同的认证流程(OAuth2、API Key、JWT),BFF 层统一管理这些凭证,避免在前端暴露敏感信息。
- 数据标准化至关重要:不同平台返回的健康数据格式各异(步数可能是 steps、count 或 value),建立统一的数据模型可提高代码可维护性。
- 缓存策略降低 API 限流风险:大多数可穿戴设备 API 有严格的速率限制,使用 Redis 或内存缓存可减少对外部 API 的调用次数。
- 错误处理需要细致设计:网络超时、API 限流、用户撤销授权等场景需要优雅处理,确保应用部分功能可用时不受影响。
在当今数字健康领域,用户经常使用多种可穿戴设备——Apple Watch 追踪步数、Oura Ring 监测睡眠、Fitbit 记录心率。对于开发者来说,这意味着需要整合来自不同厂商的 API,每个都有独特的认证方式、数据格式和速率限制。
本教程将指导你使用 Next.js 构建一个 Backend For Frontend (BFF) 服务,统一聚合这些数据源。我们将重点关注架构设计、安全性、数据标准化和错误处理。
前置条件:
- Node.js (LTS 版本) 和 npm/yarn 已安装
- 基本的 TypeScript 和 Next.js 知识
- 了解 RESTful API 和 OAuth2 概念
- 可选:拥有可穿戴设备开发者账号(Apple Developer、Google Cloud Console)
理解 BFF 架构模式
Backend For Frontend 是一种服务端模式,为特定的前端应用创建专用的 API 层。
为什么选择 BFF?
- 聚合多个数据源:可穿戴设备数据分散在不同平台,BFF 可以统一调用并组合结果
- 处理认证复杂性:将 OAuth 回调、令牌刷新等逻辑放在服务端,避免前端暴露敏感凭证
- 数据转换与标准化:将不同厂商的响应格式转换为统一的前端数据结构
- 性能优化:通过缓存、批量请求和并行处理减少延迟
- 安全性:API 密钥和刷新令牌安全存储在服务端环境变量中
架构概览
前端应用 (React/Next.js)
↓
BFF 层 (Next.js API Routes)
↓
┌─────────────┬──────────────┬──────────────┐
│Apple HealthKit│ Google Fit API│ Oura API │
└─────────────┴──────────────┴──────────────┘
项目初始化与设置
步骤 1:创建 Next.js 项目
npx create-next-app@latest wearable-aggregator --typescript --tailwind --app
cd wearable-aggregator
npm install axios redis ioredis zod
依赖说明:
axios:HTTP 客户端,用于调用外部 APIredis/ioredis:Redis 客户端,用于缓存zod:运行时类型验证,确保数据结构正确
步骤 2:配置环境变量
创建 .env.local 文件:
# Redis 配置
REDIS_URL=redis://localhost:6379
# Apple HealthKit 配置(需要后端服务,这里假设已有 HealthKit 数据同步服务)
APPLE_HEALTHKIT_BASE_URL=https://your-healthkit-service.com/api
APPLE_HEALTHKIT_API_KEY=your_api_key_here
# Google Fit API 配置
GOOGLE_FIT_CLIENT_ID=your_client_id
GOOGLE_FIT_CLIENT_SECRET=your_client_secret
GOOGLE_FIT_REDIRECT_URI=http://localhost:3000/api/auth/callback/google
# Oura Ring API 配置
OURA_CLIENT_ID=your_oura_client_id
OURA_CLIENT_SECRET=your_oura_client_secret
OURA_REDIRECT_URI=http://localhost:3000/api/auth/callback/oura
# JWT 密钥(用于生成前端访问令牌)
JWT_SECRET=your_super_secret_jwt_key
定义统一数据模型
创建类型定义文件 types/health-data.ts:
// types/health-data.ts
export interface UnifiedHealthData {
userId: string;
date: string; // ISO 8601 格式
steps?: number;
activeMinutes?: number;
heartRate?: {
resting?: number;
average?: number;
max?: number;
};
sleep?: {
duration: number; // 分钟
efficiency?: number;
stages?: {
deep?: number;
light?: number;
rem?: number;
awake?: number;
};
};
sources: string[]; // 数据来源:['apple-healthkit', 'google-fit', 'oura']
}
export interface WearableAuthConfig {
accessToken: string;
refreshToken?: string;
expiresAt?: number;
}
export interface AggregationOptions {
date: string;
userId: string;
sources?: ('apple-healthkit' | 'google-fit' | 'oura')[];
}
实现 Redis 缓存层
创建 lib/cache.ts:
// lib/cache.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
export const CACHE_TTL = {
SHORT: 300, // 5 分钟
MEDIUM: 1800, // 30 分钟
LONG: 3600, // 1 小时
VERY_LONG: 86400, // 24 小时
};
export async function getCache<T>(key: string): Promise<T | null> {
try {
const cached = await redis.get(key);
return cached ? JSON.parse(cached) : null;
} catch (error) {
console.error('Cache get error:', error);
return null;
}
}
export async function setCache<T>(
key: string,
value: T,
ttl: number = CACHE_TTL.MEDIUM
): Promise<void> {
try {
await redis.setex(key, ttl, JSON.stringify(value));
} catch (error) {
console.error('Cache set error:', error);
}
}
export async function deleteCache(key: string): Promise<void> {
try {
await redis.del(key);
} catch (error) {
console.error('Cache delete error:', error);
}
}
export function generateCacheKey(
prefix: string,
userId: string,
date: string,
source?: string
): string {
return source
? `${prefix}:${userId}:${date}:${source}`
: `${prefix}:${userId}:${date}`;
}
实现数据源适配器
Apple HealthKit 适配器
创建 lib/adapters/apple-healthkit.ts:
// lib/adapters/apple-healthkit.ts
import axios from 'axios';
import { UnifiedHealthData } from '@/types/health-data';
const APPLE_BASE_URL = process.env.APPLE_HEALTHKIT_BASE_URL;
const APPLE_API_KEY = process.env.APPLE_HEALTHKIT_API_KEY;
const api = axios.create({
baseURL: APPLE_BASE_URL,
headers: {
'Authorization': `Bearer ${APPLE_API_KEY}`,
'Content-Type': 'application/json',
},
});
export async function fetchAppleHealthData(
userId: string,
date: string
): Promise<Partial<UnifiedHealthData>> {
try {
const [stepsRes, heartRateRes, sleepRes] = await Promise.all([
api.get(`/users/${userId}/steps`, { params: { date } }),
api.get(`/users/${userId}/heart-rate`, { params: { date } }),
api.get(`/users/${userId}/sleep`, { params: { date } }),
]);
return {
steps: stepsRes.data.totalSteps || 0,
activeMinutes: stepsRes.data.activeMinutes || 0,
heartRate: {
resting: heartRateRes.data.resting,
average: heartRateRes.data.average,
max: heartRateRes.data.max,
},
sleep: sleepRes.data ? {
duration: sleepRes.data.durationMinutes || 0,
efficiency: sleepRes.data.efficiency,
} : undefined,
};
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
throw new Error('Apple HealthKit authentication failed');
}
if (error.response?.status === 429) {
throw new Error('Apple HealthKit rate limit exceeded');
}
}
console.error('Apple HealthKit fetch error:', error);
return {}; // 失败时返回空对象,让聚合器继续
}
}
Google Fit 适配器
创建 lib/adapters/google-fit.ts:
// lib/adapters/google-fit.ts
import axios from 'axios';
import { UnifiedHealthData } from '@/types/health-data';
const GOOGLE_FIT_BASE_URL = 'https://www.googleapis.com/fitness/v1/users/me';
// 从数据库或缓存获取用户的访问令牌
async function getGoogleAccessToken(userId: string): Promise<string> {
// 实际实现中,这里应该从数据库查询
// const user = await db.users.findById(userId);
// return user.googleFitAccessToken;
throw new Error('Token retrieval not implemented');
}
export async function fetchGoogleFitData(
userId: string,
date: string
): Promise<Partial<UnifiedHealthData>> {
try {
const accessToken = await getGoogleAccessToken(userId);
const startTime = new Date(date).toISOString();
const endTime = new Date(new Date(date).setHours(23, 59, 59)).toISOString();
// 获取步数数据
const stepsRes = await axios.post(
`${GOOGLE_FIT_BASE_URL}/dataset:aggregate`,
{
aggregateBy: [{
dataTypeName: 'com.google.step_count.delta',
dataSourceId: 'derived:com.google.step_count.delta:com.google.android.gms:estimated_steps',
}],
bucketByTime: { durationMillis: 86400000 }, // 1天
startTimeMillis: new Date(startTime).getTime(),
endTimeMillis: new Date(endTime).getTime(),
},
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
const steps = stepsRes.data.bucket?.[0]?.dataset?.[0]?.point?.[0]?.value?.[0]?.intVal || 0;
return {
steps,
activeMinutes: Math.round(steps / 100), // 估算
};
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
throw new Error('Google Fit token expired');
}
}
console.error('Google Fit fetch error:', error);
return {};
}
}
Oura Ring 适配器
创建 lib/adapters/oura.ts:
// lib/adapters/oura.ts
import axios from 'axios';
import { UnifiedHealthData } from '@/types/health-data';
const OURA_BASE_URL = 'https://api.ouraring.com/v2';
async function getOuraAccessToken(userId: string): Promise<string> {
// 从数据库获取用户令牌
throw new Error('Token retrieval not implemented');
}
export async function fetchOuraData(
userId: string,
date: string
): Promise<Partial<UnifiedHealthData>> {
try {
const accessToken = await getOuraAccessToken(userId);
// 获取每日活动数据
const [activityRes, sleepRes, heartRateRes] = await Promise.all([
axios.get(`${OURA_BASE_URL}/user/dailyactivity`, {
headers: { Authorization: `Bearer ${accessToken}` },
params: { start_date: date, end_date: date },
}),
axios.get(`${OURA_BASE_URL}/user/daily_sleep`, {
headers: { Authorization: `Bearer ${accessToken}` },
params: { start_date: date, end_date: date },
}),
axios.get(`${OURA_BASE_URL}/user/heartrate`, {
headers: { Authorization: `Bearer ${accessToken}` },
params: { start_date: date, end_date: date },
}),
]);
const activity = activityRes.data.data?.[0] || {};
const sleep = sleepRes.data.data?.[0] || {};
return {
steps: activity.steps,
activeMinutes: activity.active_calories ? Math.round(activity.active_calories / 7) : undefined,
heartRate: {
resting: activity.resting_hrv,
average: heartRateRes.data.data?.average_heart_rate,
},
sleep: sleep.total_sleep_duration ? {
duration: Math.round(sleep.total_sleep_duration / 60), // 转换为分钟
efficiency: sleep.sleep_efficiency,
stages: {
deep: sleep.rem_sleep_duration ? Math.round(sleep.deep_sleep_duration / 60) : undefined,
light: sleep.light_sleep_duration ? Math.round(sleep.light_sleep_duration / 60) : undefined,
rem: sleep.rem_sleep_duration ? Math.round(sleep.rem_sleep_duration / 60) : undefined,
awake: sleep.awake_time ? Math.round(sleep.awake_time / 60) : undefined,
},
} : undefined,
};
} catch (error) {
console.error('Oura API fetch error:', error);
return {};
}
}
实现 BFF 聚合 API
创建 app/api/health-data/route.ts:
// app/api/health-data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { fetchAppleHealthData } from '@/lib/adapters/apple-healthkit';
import { fetchGoogleFitData } from '@/lib/adapters/google-fit';
import { fetchOuraData } from '@/lib/adapters/oura';
import { getCache, setCache, generateCacheKey, CACHE_TTL } from '@/lib/cache';
import { UnifiedHealthData } from '@/types/health-data';
// 验证请求参数
const querySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
userId: z.string().min(1),
sources: z.string().optional(), // 逗号分隔:'apple-healthkit,google-fit'
});
export async function GET(request: NextRequest) {
try {
// 1. 解析和验证请求参数
const { searchParams } = new URL(request.url);
const query = querySchema.parse({
date: searchParams.get('date'),
userId: searchParams.get('userId'),
sources: searchParams.get('sources'),
});
const sources = query.sources
? (query.sources.split(',') as Array<'apple-healthkit' | 'google-fit' | 'oura'>)
: ['apple-healthkit', 'google-fit', 'oura'];
// 2. 检查缓存
const cacheKey = generateCacheKey('health-data', query.userId, query.date);
const cached = await getCache<UnifiedHealthData>(cacheKey);
if (cached) {
return NextResponse.json(cached);
}
// 3. 并行获取所有数据源
const fetchPromises = sources.map(async (source) => {
try {
switch (source) {
case 'apple-healthkit':
return fetchAppleHealthData(query.userId, query.date);
case 'google-fit':
return fetchGoogleFitData(query.userId, query.date);
case 'oura':
return fetchOuraData(query.userId, query.date);
default:
return {};
}
} catch (error) {
console.error(`Error fetching from ${source}:`, error);
return {}; // 失败的数据源返回空对象
}
});
const results = await Promise.all(fetchPromises);
// 4. 聚合数据(取最完整的值)
const aggregated: UnifiedHealthData = {
userId: query.userId,
date: query.date,
steps: Math.max(...results.map(r => r.steps || 0)),
activeMinutes: Math.max(...results.map(r => r.activeMinutes || 0)),
heartRate: results.reduce((best, current) => ({
resting: Math.max(best.resting || 0, current.heartRate?.resting || 0) || undefined,
average: Math.max(best.average || 0, current.heartRate?.average || 0) || undefined,
max: Math.max(best.max || 0, current.heartRate?.max || 0) || undefined,
}), {}).resting ? results.reduce((best, current) => ({
resting: Math.max(best.resting || 0, current.heartRate?.resting || 0) || undefined,
average: Math.max(best.average || 0, current.heartRate?.average || 0) || undefined,
max: Math.max(best.max || 0, current.heartRate?.max || 0) || undefined,
}), {}) : undefined,
sleep: results.reduce((best, current) => {
if (!current.sleep) return best;
if (!best) return current.sleep;
// 选择数据更完整的睡眠记录
return current.sleep.duration > best.duration ? current.sleep : best;
}, undefined as UnifiedHealthData['sleep']),
sources: results.filter(r => Object.keys(r).length > 0)
.map((_, i) => sources[i]),
};
// 5. 缓存结果
await setCache(cacheKey, aggregated, CACHE_TTL.MEDIUM);
return NextResponse.json(aggregated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request parameters', details: error.errors },
{ status: 400 }
);
}
console.error('Health data aggregation error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
实现 OAuth 回调处理
Google Fit OAuth 回调
创建 app/api/auth/callback/google/route.ts:
// app/api/auth/callback/google/route.ts
import { NextRequest, NextResponse } from 'next/server';
import axios from 'axios';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state'); // 用户ID
if (!code) {
return NextResponse.redirect(new URL('/auth/error?no_code', request.url));
}
try {
// 交换授权码获取访问令牌
const tokenRes = await axios.post('https://oauth2.googleapis.com/token', {
code,
client_id: process.env.GOOGLE_FIT_CLIENT_ID,
client_secret: process.env.GOOGLE_FIT_CLIENT_SECRET,
redirect_uri: process.env.GOOGLE_FIT_REDIRECT_URI,
grant_type: 'authorization_code',
});
const { access_token, refresh_token, expires_in } = tokenRes.data;
// 保存令牌到数据库(这里简化处理)
// await db.users.update(state, { googleFitTokens: { access_token, refresh_token, expiresAt: Date.now() + expires_in * 1000 } });
return NextResponse.redirect(new URL('/dashboard?connected=google-fit', request.url));
} catch (error) {
console.error('Google OAuth error:', error);
return NextResponse.redirect(new URL('/auth/error?google_failed', request.url));
}
}
前端集成示例
创建客户端组件 components/HealthDashboard.tsx:
// components/HealthDashboard.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
interface HealthDataProps {
userId: string;
date: string;
}
export function HealthDashboard({ userId, date }: HealthDataProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['health-data', userId, date],
queryFn: async () => {
const res = await fetch(`/api/health-data?userId=${userId}&date=${date}`);
if (!res.ok) throw new Error('Failed to fetch health data');
return res.json();
},
staleTime: 30 * 60 * 1000, // 30 分钟
});
if (isLoading) return <div>加载中...</div>;
if (error) return <div>加载失败: {error.message}</div>;
return (
<div className: "grid grid-cols-1 md:grid-cols-3 gap-6">
<MetricCard
title: "步数"
value={data?.steps || 0}
icon: "👣"
sources={data?.sources}
/>
<MetricCard
title: "活动时间"
value={`${data?.activeMinutes || 0} 分钟`}
icon: "⏱️"
/>
<MetricCard
title: "静息心率"
value={`${data?.heartRate?.resting || '-'} bpm`}
icon: "❤️"
/>
<div className: "md:col-span-3">
<SleepCard sleep={data?.sleep} />
</div>
</div>
);
}
function MetricCard({ title, value, icon, sources }: any) {
return (
<div className: "bg-white rounded-lg shadow p-6">
<div className: "flex items-center justify-between">
<div>
<p className: "text-gray-500 text-sm">{title}</p>
<p className: "text-3xl font-bold mt-2">{value}</p>
</div>
<span className: "text-4xl">{icon}</span>
</div>
{sources && (
<p className: "text-xs text-gray-400 mt-4">
来源: {sources.join(', ')}
</p>
)}
</div>
);
}
function SleepCard({ sleep }: any) {
if (!sleep) return <div className: "text-gray-500">暂无睡眠数据</div>;
return (
<div className: "bg-white rounded-lg shadow p-6">
<h3 className: "text-lg font-semibold mb-4">睡眠分析</h3>
<div className: "grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className: "text-sm text-gray-500">总时长</p>
<p className: "text-xl font-bold">{sleep.duration} 分钟</p>
</div>
<div>
<p className: "text-sm text-gray-500">深睡</p>
<p className: "text-xl font-bold">{sleep.stages?.deep || '-'} 分钟</p>
</div>
<div>
<p className: "text-sm text-gray-500">浅睡</p>
<p className: "text-xl font-bold">{sleep.stages?.light || '-'} 分钟</p>
</div>
<div>
<p className: "text-sm text-gray-500">REM</p>
<p className: "text-xl font-bold">{sleep.stages?.rem || '-'} 分钟</p>
</div>
</div>
</div>
);
}
安全性最佳实践
1. 令牌管理
// lib/token-manager.ts
import jwt from 'jsonwebtoken';
export function generateFrontendToken(userId: string): string {
return jwt.sign(
{ userId },
process.env.JWT_SECRET!,
{ expiresIn: '1h' }
);
}
export function verifyFrontendToken(token: string): { userId: string } | null {
try {
return jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
} catch {
return null;
}
}
2. 速率限制
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getCache, setCache } from './lib/cache';
const RATE_LIMIT = 100; // 每小时100次请求
export async function middleware(request: NextRequest) {
const userId = request.headers.get('x-user-id');
if (!userId) return NextResponse.next();
const key = `ratelimit:${userId}:${Math.floor(Date.now() / 3600000)}`;
const current = await getCache<number>(key) || 0;
if (current >= RATE_LIMIT) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
await setCache(key, current + 1, 3600);
return NextResponse.next();
}
测试策略
创建单元测试 __tests__/aggregation.test.ts:
import { describe, it, expect, vi } from 'vitest';
import { GET } from '../app/api/health-data/route';
import { fetchAppleHealthData } from '../lib/adapters/apple-healthkit';
vi.mock('../lib/adapters/apple-healthkit');
vi.mock('../lib/adapters/google-fit');
vi.mock('../lib/adapters/oura');
describe('Health Data Aggregation', () => {
it('should aggregate data from multiple sources', async () => {
vi.mocked(fetchAppleHealthData).mockResolvedValue({
steps: 8000,
activeMinutes: 45,
});
const request = new Request(
'http://localhost:3000/api/health-data?userId=test-user&date=2026-03-08'
);
const response = await GET(request as any);
const data = await response.json();
expect(data.steps).toBeGreaterThan(0);
expect(data.sources).toContain('apple-healthkit');
});
it('should handle partial failures gracefully', async () => {
vi.mocked(fetchAppleHealthData).mockRejectedValue(new Error('API error'));
const request = new Request(
'http://localhost:3000/api/health-data?userId=test-user&date=2026-03-08'
);
const response = await GET(request as any);
// 应该返回200,即使某个数据源失败
expect(response.status).toBe(200);
});
});
部署建议
Docker Compose 配置
# docker-compose.yml
version: '3.8'
services:
nextjs:
build: .
ports:
- "3000:3000"
environment:
- REDIS_URL=redis://redis:6379
depends_on:
- redis
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
redis_data:
总结
使用 Next.js 构建 BFF 层来聚合可穿戴设备数据,可以显著简化前端复杂度,同时提供更好的性能和安全性。通过本教程,你已经学会了:
- 设计统一的数据模型来抽象不同 API 的差异
- 使用缓存减少外部 API 调用
- 实现错误处理和部分失败容错
- 安全地管理 OAuth 令牌
- 使用 Zod 进行运行时验证
下一步
- 添加实时数据更新功能(WebSocket 或 Server-Sent Events)
- 实现数据导出功能(PDF、CSV)
- 添加健康数据趋势分析和可视化
- 集成更多可穿戴设备(Fitbit、Garmin 等)
参考资料
常见问题
Q: 如何处理用户撤销授权后的令牌刷新?
A: 实现一个令牌刷新服务,定期检查令牌过期时间并使用 refresh_token 获取新的 access_token。如果用户撤销授权,需要清除本地令牌并提示用户重新连接。
Q: 如何确保数据在传输过程中的安全性?
A: 始终使用 HTTPS 连接,对所有 API 端点实施身份验证,敏感数据(如健康记录)应考虑额外的加密层。
Q: 缓存策略如何选择?
A: 根据数据更新频率选择 TTL。历史数据可以使用较长的缓存时间(24小时),而今日数据应该使用较短的缓存(5-15分钟)。