核心要点
- OAuth 2.0 认证流程: Oura Ring API 使用标准的授权码流程,需要正确处理令牌刷新和过期管理
- React Query SSR 配置: 使用
hydrateDehydratedState和dehydrate实现服务端和客户端状态同步 - 数据缓存策略: 利用 React Query 的缓存机制减少 API 调用,提升应用性能和用户体验
- 错误处理与重试: 实现指数退避重试机制,处理 API 限流和网络错误
- 类型安全: 使用 TypeScript 定义 Oura Ring API 响应类型,确保数据完整性
Oura Ring 是目前最先进的智能戒指之一,提供详细的睡眠、活动和健康指标数据。将 Oura Ring API 集成到 Next.js 应用中,可以为用户提供个性化的健康洞察和数据可视化。本指南将详细介绍如何使用 React Query 实现 SSR 的 Oura Ring API 集成。
技术栈概述
项目依赖
npm install @tanstack/react-query @tanstack/react-query-devtools
npm install next-auth # 用于 OAuth 2.0 处理
npm install axios # HTTP 客户端
npm install zod # 数据验证
npm install date-fns # 日期处理
架构设计
graph TB
A[用户浏览器] -->|Next.js SSR| B[Next.js 服务器]
B --> C[React Query Hydration]
C --> D[服务端数据获取]
D --> E[Oura Ring API]
E -->|OAuth 2.0| F[令牌管理]
F --> G[Redis 缓存]
G --> H[数据库]
C --> I[客户端 React Query]
I --> J[自动重新验证]
J --> K[UI 更新]
style B fill:#4f46e5,stroke:#333,stroke-width:2px
style D fill:#06b6d4,stroke:#333,stroke-width:2px
style I fill:#10b981,stroke:#333,stroke-width:2pxOAuth 2.0 认证实现
1. 配置 Oura Ring API
首先在 Oura Ring 开发者平台 创建应用:
// .env.local
OURA_CLIENT_ID=your_client_id
OURA_CLIENT_SECRET=your_client_secret
OURA_REDIRECT_URI=http://localhost:3000/api/auth/callback/oura
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_nextauth_secret
2. NextAuth 配置
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { OuraProvider } from 'next-auth/providers/oura';
export const authOptions = {
providers: [
OuraProvider({
clientId: process.env.OURA_CLIENT_ID!,
clientSecret: process.env.OURA_CLIENT_SECRET!,
authorization: {
params: {
scope: 'email personal daily heartage workout session',
},
},
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
},
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
3. 令牌刷新机制
// lib/oura-auth.ts
import axios from 'axios';
export interface OuraTokens {
access_token: string;
refresh_token: string;
expires_at: number;
}
export async function refreshOuraToken(refreshToken: string): Promise<OuraTokens> {
const response = await axios.post('https://cloud.ouraring.com/oauth/token', {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.OURA_CLIENT_ID,
client_secret: process.env.OURA_CLIENT_SECRET,
});
return {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires_at: Date.now() + response.data.expires_in * 1000,
};
}
export function isTokenExpired(expiresAt: number): boolean {
return Date.now() >= expiresAt - 60000; // 提前 1 分钟刷新
}
数据类型定义
Oura Ring API 响应类型
// types/oura.ts
export interface OuraDailySleep {
timestamp: string;
score: number;
total: number;
duration: number;
efficiency: number;
latency: number;
bedtime_start: string;
bedtime_end: string;
rem: number;
deep: number;
light: number;
awake: number;
onset_latency: number;
midpoint_time: string;
hr_lowest: number;
hr_average: number;
rmssd: number;
breath_average: number;
temperature_deviation: number;
}
export interface OuraDailyActivity {
timestamp: string;
score: number;
active_calories: number;
average_met_minutes: number;
steps: number;
daily_movement: number;
non_wear_time: number;
rest_time: number;
sedentary_time: number;
low_activity_time: number;
medium_activity_time: number;
high_activity_time: number;
total_calories: number;
target_calories: number;
equivalent_walking_distance: number;
high_activity_met_minutes: number;
medium_activity_met_minutes: number;
low_activity_met_minutes: number;
}
export interface OuraHeartRate {
timestamp: string;
heart_rate: number;
source: 'watch';
}
export interface OuraSession {
start_datetime: string;
end_datetime: string;
activity_type: string;
score: number;
metrics: {
distance: number;
calories: number;
steps: number;
elevation: number;
heart_rate: {
max: number;
min: number;
average: number;
};
};
}
export interface OuraUserData {
sleep: OuraDailySleep[];
activity: OuraDailyActivity[];
heartRate: OuraHeartRate[];
sessions: OuraSession[];
}
React Query 配置
1. 客户端配置
// lib/react-query.ts
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 分钟
gcTime: 5 * 60 * 1000, // 5 分钟
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
2. 服务端查询函数
// lib/oura-api.ts
import axios from 'axios';
import type { OuraDailySleep, OuraDailyActivity, OuraHeartRate } from '@/types/oura';
const OURA_API_BASE = 'https://api.ouraring.com/v2';
export async function fetchOuraDailySleep(
accessToken: string,
startDate: string,
endDate: string
): Promise<OuraDailySleep[]> {
const response = await axios.get(`${OURA_API_BASE}/usercollection/daily_sleep`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
params: {
start_date: startDate,
end_date: endDate,
},
});
return response.data.data;
}
export async function fetchOuraDailyActivity(
accessToken: string,
startDate: string,
endDate: string
): Promise<OuraDailyActivity[]> {
const response = await axios.get(`${OURA_API_BASE}/usercollection/daily_activity`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
params: {
start_date: startDate,
end_date: endDate,
},
});
return response.data.data;
}
export async function fetchOuraHeartRate(
accessToken: string,
startDate: string,
endDate: string
): Promise<OuraHeartRate[]> {
const response = await axios.get(`${OURA_API_BASE}/usercollection/heartrate`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
params: {
start_datetime: `${startDate}T00:00:00Z`,
end_datetime: `${endDate}T23:59:59Z`,
},
});
return response.data.data;
}
3. 错误处理和重试
// lib/api-utils.ts
import { type QueryFunctionContext } from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
export async function fetchWithRetry<T>(
queryFn: () => Promise<T>,
retries = 3
): Promise<T> {
let lastError: Error;
for (let i = 0; i < retries; i++) {
try {
return await queryFn();
} catch (error) {
lastError = error as Error;
if (error instanceof AxiosError) {
// 处理 API 限流
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'];
const delay = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// 处理认证错误
if (error.response?.status === 401) {
throw new Error('认证失败,请重新登录');
}
}
// 指数退避
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
throw lastError!;
}
export function createQueryKey(type: string, params: Record<string, any>) {
return ['oura', type, params] as const;
}
SSR 数据预取
1. 服务端组件实现
// app/dashboard/page.tsx
import { dehydrate } from '@tanstack/react-query';
import { QueryHydrationBoundary } from './QueryHydrationBoundary';
import { fetchOuraDailySleep, fetchOuraDailyActivity } from '@/lib/oura-api';
import { getQueryClient } from '@/lib/get-query-client';
import { auth } from '@/auth';
export default async function DashboardPage() {
const session = await auth();
const queryClient = getQueryClient();
if (session?.accessToken) {
const today = new Date();
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
await queryClient.prefetchQuery({
queryKey: ['oura', 'sleep', {
startDate: sevenDaysAgo.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0]
}],
queryFn: () => fetchOuraDailySleep(
session.accessToken,
sevenDaysAgo.toISOString().split('T')[0],
today.toISOString().split('T')[0]
),
});
await queryClient.prefetchQuery({
queryKey: ['oura', 'activity', {
startDate: sevenDaysAgo.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0]
}],
queryFn: () => fetchOuraDailyActivity(
session.accessToken,
sevenDaysAgo.toISOString().split('T')[0],
today.toISOString().split('T')[0]
),
});
}
const dehydratedState = dehydrate(queryClient);
return <QueryHydrationBoundary state={dehydratedState}>;
}
2. 客户端组件
// app/dashboard/QueryHydrationBoundary.tsx
'use client';
import { HydrationBoundary, useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export function QueryHydrationBoundary({
state
}: {
state: unknown
}) {
return (
<HydrationBoundary state={state}>
<DashboardContent />
</HydrationBoundary>
);
}
function DashboardContent() {
const { data: session } = useSession();
const today = new Date();
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const { data: sleepData } = useQuery({
queryKey: ['oura', 'sleep', {
startDate: sevenDaysAgo.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0]
}],
queryFn: () => fetchOuraDailySleep(
session!.accessToken,
sevenDaysAgo.toISOString().split('T')[0],
today.toISOString().split('T')[0]
),
enabled: !!session?.accessToken,
refetchInterval: 5 * 60 * 1000, // 每 5 分钟刷新
});
const { data: activityData } = useQuery({
queryKey: ['oura', 'activity', {
startDate: sevenDaysAgo.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0]
}],
queryFn: () => fetchOuraDailyActivity(
session!.accessToken,
sevenDaysAgo.toISOString().split('T')[0],
today.toISOString().split('T')[0]
),
enabled: !!session?.accessToken,
refetchInterval: 5 * 60 * 1000,
});
return (
<div>
<SleepChart data={sleepData} />
<ActivityChart data={activityData} />
</div>
);
}
数据可视化组件
睡眠评分图表
// components/SleepChart.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { Line } from 'react-chartjs-2';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
export function SleepChart() {
const { data: sleepData, isLoading, error } = useQuery({
queryKey: ['oura', 'sleep'],
queryFn: fetchSleepData,
});
if (isLoading) return <div>加载中...</div>;
if (error) return <div>加载失败</div>;
const chartData = {
labels: sleepData?.map(d => new Date(d.timestamp).toLocaleDateString('zh-CN')) || [],
datasets: [{
label: '睡眠评分',
data: sleepData?.map(d => d.score) || [],
borderColor: 'rgb(99, 102, 241)',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
tension: 0.4,
}],
};
const options = {
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: '过去 7 天睡眠评分',
},
},
scales: {
y: {
min: 0,
max: 100,
},
},
};
return (
<div className="w-full h-80">
<Line data={chartData} options={options} />
</div>
);
}
活动评分图表
// components/ActivityChart.tsx
'use client';
import { Bar } from 'react-chartjs-2';
export function ActivityChart() {
const { data: activityData } = useQuery({
queryKey: ['oura', 'activity'],
queryFn: fetchActivityData,
});
const chartData = {
labels: activityData?.map(d => new Date(d.timestamp).toLocaleDateString('zh-CN')) || [],
datasets: [{
label: '活动评分',
data: activityData?.map(d => d.score) || [],
backgroundColor: 'rgba(16, 185, 129, 0.8)',
}],
};
return (
<div className="w-full h-80">
<Bar data={chartData} />
</div>
);
}
性能优化
1. Redis 缓存
// lib/cache.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function getCachedData<T>(key: string): Promise<T | null> {
const cached = await redis.get(key);
return cached ? JSON.parse(cached) : null;
}
export async function setCachedData<T>(
key: string,
data: T,
ttl = 300
): Promise<void> {
await redis.setex(key, ttl, JSON.stringify(data));
}
export function generateCacheKey(
type: string,
params: Record<string, any>
): string {
return `oura:${type}:${JSON.stringify(params)}`;
}
2. 带缓存的查询函数
// lib/oura-api-cached.ts
import { fetchOuraDailySleep } from './oura-api';
import { getCachedData, setCachedData, generateCacheKey } from './cache';
export async function fetchOuraDailySleepWithCache(
accessToken: string,
startDate: string,
endDate: string
) {
const cacheKey = generateCacheKey('sleep', { startDate, endDate });
// 尝试从缓存获取
const cached = await getCachedData(cacheKey);
if (cached) return cached;
// 从 API 获取
const data = await fetchOuraDailySleep(accessToken, startDate, endDate);
// 存入缓存(5 分钟)
await setCachedData(cacheKey, data, 300);
return data;
}
生产环境部署
环境变量配置
# .env.production
# Oura Ring API
OURA_CLIENT_ID=your_production_client_id
OURA_CLIENT_SECRET=your_production_client_secret
OURA_REDIRECT_URI=https://yourdomain.com/api/auth/callback/oura
# NextAuth
NEXTAUTH_URL=https://yourdomain.com
NEXTAUTH_SECRET=your_secure_secret
# Redis(用于缓存)
REDIS_URL=redis://your-redis-instance
# 数据库(用于令牌存储)
DATABASE_URL=postgresql://user:password@host/database
# 应用配置
NODE_ENV=production
Docker 配置
# Dockerfile
FROM node:20-alpine AS base
# 安装依赖
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 构建应用
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 生产环境
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
故障排查指南
常见问题
-
认证失败(401)
- 检查访问令牌是否过期
- 验证刷新令牌逻辑
- 确认 OAuth 配置正确
-
API 限流(429)
- 实现请求队列
- 增加缓存时间
- 使用指数退避重试
-
数据不更新
- 检查 React Query 配置
- 验证刷新间隔设置
- 确认 API 返回最新数据
-
SSR 水合不匹配
- 确保服务端和客户端使用相同查询键
- 检查时区处理
- 验证数据序列化
总结
本指南详细介绍了如何在 Next.js 中集成 Oura Ring API,使用 React Query 实现 SSR 数据获取。通过正确的 OAuth 认证、数据缓存和错误处理,可以构建高性能、可靠的健康数据仪表板。
关键要点:
- 使用 NextAuth 处理 OAuth 2.0 认证流程
- React Query 的 SSR 模式提供首屏快速加载
- 缓存策略减少 API 调用和成本
- 类型安全确保数据完整性
- 错误处理保证应用稳定性
参考资料
常见问题
Q: 如何处理多个 Oura Ring 设备?
A: Oura Ring 每个 OAuth 账户只能关联一个设备。如果需要支持多设备,可以让用户连接多个 Oura 账户,并在应用层面进行数据聚合和对比。
Q: API 调用频率限制是多少?
A: Oura Ring API v2 的默认限制是每小时 1200 次请求。建议实现缓存机制以减少不必要的 API 调用,并在应用层面监控请求频率。
Q: 如何实现实时数据更新?
A: Oura Ring 不提供实时数据推送。建议使用轮询机制(每 5-15 分钟),并在用户主动刷新时获取最新数据。对于关键指标,可以缩短轮询间隔但需要注意 API 限制。
Q: 能否离线访问历史数据?
A: 可以通过将历史数据存储在数据库中实现离线访问。建议定期同步数据到本地数据库,并提供离线模式的基本功能。
Q: 如何处理数据隐私和合规性?
A: 确保遵守 GDPR 和其他隐私法规。获得用户明确同意,提供数据删除功能,使用加密存储敏感信息,并在隐私政策中明确说明数据处理方式。