康心伴Logo
康心伴WellAlly
技术

Oura Ring API 与 Next.js 集成指南:React Query SSR 实现

W
WellAlly 开发团队
2026-03-08
12 分钟阅读

核心要点

  • OAuth 2.0 认证流程: Oura Ring API 使用标准的授权码流程,需要正确处理令牌刷新和过期管理
  • React Query SSR 配置: 使用 hydrateDehydratedStatedehydrate 实现服务端和客户端状态同步
  • 数据缓存策略: 利用 React Query 的缓存机制减少 API 调用,提升应用性能和用户体验
  • 错误处理与重试: 实现指数退避重试机制,处理 API 限流和网络错误
  • 类型安全: 使用 TypeScript 定义 Oura Ring API 响应类型,确保数据完整性

Oura Ring 是目前最先进的智能戒指之一,提供详细的睡眠、活动和健康指标数据。将 Oura Ring API 集成到 Next.js 应用中,可以为用户提供个性化的健康洞察和数据可视化。本指南将详细介绍如何使用 React Query 实现 SSR 的 Oura Ring API 集成。


技术栈概述

项目依赖

code
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 # 日期处理
Code collapsed

架构设计

正在渲染图表...
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:2px

OAuth 2.0 认证实现

1. 配置 Oura Ring API

首先在 Oura Ring 开发者平台 创建应用:

code
// .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
Code collapsed

2. NextAuth 配置

code
// 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 };
Code collapsed

3. 令牌刷新机制

code
// 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 分钟刷新
}
Code collapsed

数据类型定义

Oura Ring API 响应类型

code
// 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[];
}
Code collapsed

React Query 配置

1. 客户端配置

code
// 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>
  );
}
Code collapsed

2. 服务端查询函数

code
// 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;
}
Code collapsed

3. 错误处理和重试

code
// 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;
}
Code collapsed

SSR 数据预取

1. 服务端组件实现

code
// 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}>;
}
Code collapsed

2. 客户端组件

code
// 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>
  );
}
Code collapsed

数据可视化组件

睡眠评分图表

code
// 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>
  );
}
Code collapsed

活动评分图表

code
// 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>
  );
}
Code collapsed

性能优化

1. Redis 缓存

code
// 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)}`;
}
Code collapsed

2. 带缓存的查询函数

code
// 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;
}
Code collapsed

生产环境部署

环境变量配置

code
# .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
Code collapsed

Docker 配置

code
# 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"]
Code collapsed

故障排查指南

常见问题

  1. 认证失败(401)

    • 检查访问令牌是否过期
    • 验证刷新令牌逻辑
    • 确认 OAuth 配置正确
  2. API 限流(429)

    • 实现请求队列
    • 增加缓存时间
    • 使用指数退避重试
  3. 数据不更新

    • 检查 React Query 配置
    • 验证刷新间隔设置
    • 确认 API 返回最新数据
  4. 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 和其他隐私法规。获得用户明确同意,提供数据删除功能,使用加密存储敏感信息,并在隐私政策中明确说明数据处理方式。

#

文章标签

ouraring
nextjs
react-query
api集成
健康科技
ssr

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅