You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现可靠的SoundCloud令牌刷新流程?

SoundCloud令牌刷新异常:短期失效问题排查与解决方案

问题描述

我在平台实现SoundCloud用户令牌刷新时遇到问题:仅过短短几天,用户令牌就被标记为false(无效),尽管我严格遵循SoundCloud推荐的令牌刷新流程。

根据SoundCloud官方文档:

Access token约1小时后过期,需使用refresh_token定期刷新。
每个refresh_token仅可使用一次。
所有客户端均被视为保密客户端(confidential),即需要client_secret。

官方文档示例:

curl -X POST "https://secure.soundcloud.com/oauth/token" \
  -H "accept: application/json; charset=utf-8" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "client_id=YOUR_CLIENT_ID" \
  --data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
  --data-urlencode "refresh_token=YOUR_TOKEN"

尽管如此,我仍持续遇到刷新错误,最终用户/令牌状态变为false。我怀疑刷新逻辑或整体流程中存在疏漏(如令牌轮换、时机控制或新refresh_token的持久化),但无法准确定位问题。

请问如何实现可靠的SoundCloud令牌刷新流程?

当前使用的刷新函数

import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";

// 检查令牌是否仍有效的辅助函数
function isTokenStillValid(expiresAt: string, bufferSeconds: number = 300): boolean {
  const expiryTime = new Date(expiresAt).getTime();
  const now = Date.now();
  const bufferMs = bufferSeconds * 1000;
  
  const isValid = expiryTime - now > bufferMs;
  
  if (!isValid) {
    console.log(`Token expired or expiring soon. Expires at: ${expiresAt}, Now: ${new Date().toISOString()}`);
  }
  
  return isValid;
}

export async function refreshSoundCloudToken(refreshToken: string, userId: string) {
  const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
  const supabase = createClient(
    supabaseUrl,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  );

  // 记录后端标识用于调试
  const urlHost = new URL(supabaseUrl).host;
  console.log(`🔗 Backend: ${urlHost} | User: ${userId}`);

  // 🔒 锁定:尝试通过数据库获取锁,防止多个进程同时刷新
  const { data: lockAttempt, error: lockError } = await supabase
    .from('soundcloud_token_cache')
    .update({ 
      refreshing: true,
      updated_at: new Date().toISOString() 
    })
    .eq('user_id', userId)
    .eq('refreshing', false) // 仅当未在刷新时更新
    .select()
    .maybeSingle();

  // 如果lockAttempt为null且无错误,检查是否存在记录
  if (!lockAttempt && !lockError) {
    // 检查该用户是否存在记录
    const { data: existingRow } = await supabase
      .from('soundcloud_token_cache')
      .select('user_id, refreshing')
      .eq('user_id', userId)
      .maybeSingle();

    if (!existingRow) {
      // 无记录 = 该用户首次刷新,创建带锁的记录
      console.log(`🔒 No cache row exists for user ${userId}, creating with lock...`);
      
      const { error: insertError } = await supabase
        .from('soundcloud_token_cache')
        .insert({
          user_id: userId,
          access_token: 'pending_refresh',
          refresh_token: refreshToken,
          expires_at: new Date().toISOString(), // 已过期,将更新
          refreshing: true,
          updated_at: new Date().toISOString()
        });

      if (insertError) {
        console.error('Failed to create cache row with lock:', insertError);
        // 继续执行,尝试刷新
      }
    } else if (existingRow.refreshing) {
      // 存在记录但已被其他进程锁定
      console.log(`🔒 Another process is already refreshing token for user ${userId}, waiting...`);
      
      // 等待2秒后尝试重新读取缓存令牌
      await new Promise(resolve => setTimeout(resolve, 2000));
      
      const { data: cachedAfterWait } = await supabase
        .from('soundcloud_token_cache')
        .select('access_token, refresh_token, expires_at, refreshing')
        .eq('user_id', userId)
        .maybeSingle();
      
      if (cachedAfterWait && !cachedAfterWait.refreshing && isTokenStillValid(cachedAfterWait.expires_at, 60)) {
        console.log(`✅ Token was refreshed by another process, using that`);
        return {
          access_token: cachedAfterWait.access_token,
          refresh_token: cachedAfterWait.refresh_token,
          expires_at: cachedAfterWait.expires_at,
        };
      }
    }
  }

  // ✅ 检查1:获取缓存令牌(唯一可信源)
  const { data: cachedToken } = await supabase
    .from('soundcloud_token_cache')
    .select('access_token, refresh_token, expires_at, updated_at')
    .eq('user_id', userId)
    .maybeSingle();

  // 🔒 去重:如果令牌在30秒内刚刷新,使用缓存
  if (cachedToken?.updated_at) {
    const updatedAt = new Date(cachedToken.updated_at).getTime();
    const secondsSinceUpdate = (Date.now() - updatedAt) / 1000;
    
    if (secondsSinceUpdate < 30 && isTokenStillValid(cachedToken.expires_at, 60)) {
      console.log(`🔒 Token was refreshed ${Math.round(secondsSinceUpdate)}s ago, using cached (race condition prevention)`);
      
      // 释放锁
      await supabase
        .from('soundcloud_token_cache')
        .update({ refreshing: false })
        .eq('user_id', userId);
      
      return {
        access_token: cachedToken.access_token,
        refresh_token: cachedToken.refresh_token,
        expires_at: cachedToken.expires_at,
      };
    }
  }

  // ✅ 检查2:令牌是否仍有效?使用缓存
  if (cachedToken && cachedToken.access_token !== 'pending_refresh' && isTokenStillValid(cachedToken.expires_at, 300)) {
    const minutesLeft = Math.round((new Date(cachedToken.expires_at).getTime() - Date.now()) / 1000 / 60);
    console.log(`✅ Using cached SoundCloud token from database (valid for ${minutesLeft} more minutes)`);
    
    // 释放锁
    await supabase
      .from('soundcloud_token_cache')
      .update({ refreshing: false })
      .eq('user_id', userId);
    
    return {
      access_token: cachedToken.access_token,
      refresh_token: cachedToken.refresh_token,
      expires_at: cachedToken.expires_at,
    };
  }

  // 令牌已过期或不存在,立即刷新
  console.log('🔄 Token expired or missing, refreshing SoundCloud token...');
  const tokenToUse = cachedToken?.refresh_token || refreshToken;
  console.log(`🔑 Using refresh_token: ...${tokenToUse.slice(-6)} (last 6 chars for debugging)`);

  // 刷新前更新最后一次刷新尝试时间
  await supabase
    .from('profiles')
    .update({ soundcloud_last_refresh_attempt: new Date().toISOString() })
    .eq('id', userId);

  // 🔧 修复:同时在Basic Auth和请求体中发送凭证
  const clientId = Deno.env.get('SOUNDCLOUD_CLIENT_ID') ?? '';
  const clientSecret = Deno.env.get('SOUNDCLOUD_CLIENT_SECRET') ?? '';
  const basicAuth = btoa(`${clientId}:${clientSecret}`);

  // 🔧 修复:使用统一的端点(secure.soundcloud.com)
  const tokenResponse = await fetch('https://secure.soundcloud.com/oauth/token', {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${basicAuth}`,
      'Accept': 'application/json; charset=utf-8',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      client_id: clientId,
      client_secret: clientSecret,
      refresh_token: tokenToUse,
    }),
  });

  if (!tokenResponse.ok) {
    const error = await tokenResponse.text();
    console.error('❌ Token refresh failed:', error);
    console.error(`   Used refresh_token ending in: ...${tokenToUse.slice(-6)}`);
    
    // 始终释放锁
    await supabase
      .from('soundcloud_token_cache')
      .update({ refreshing: false })
      .eq('user_id', userId);
    
    // 🔴 仅当确认是invalid_grant时,标记为无效
    if (error.includes('invalid_grant')) {
      const errorMessage = 'Refresh token expired - user needs to reconnect';
      
      const { error: updateError } = await supabase
        .from('profiles')
        .update({ 
          soundcloud_token_valid: false,
          soundcloud_last_error: errorMessage
        })
        .eq('id', userId);

      if (updateError) {
        console.error('❌ Failed to update profiles.soundcloud_token_valid:', updateError);
      } else {
        // 验证更新结果
        const { data: verifyProfile } = await supabase
          .from('profiles')
          .select('soundcloud_token_valid, soundcloud_last_error')
          .eq('id', userId)
          .single();
        
        if (verifyProfile) {
          console.log(`✅ Verified: token_valid=${verifyProfile.soundcloud_token_valid}, last_error="${verifyProfile.soundcloud_last_error}"`);
        } else {
          console.error('❌ Could not verify profile update');
        }
      }
    } else {
      // 其他情况:可重试错误
      await supabase
        .from('profiles')
        .update({ 
          soundcloud_last_error: `Refresh failed (retryable): ${error.substring(0, 200)}`
        })
        .eq('id', userId);
    }
    
    throw new Error(`Failed to refresh token: ${error}`);
  }

  const tokenData = await tokenResponse.json();
  console.log('✅ Token refreshed successfully, expires in:', tokenData.expires_in, 'seconds');
  console.log(`🔑 New refresh_token ending in: ...${tokenData.refresh_token?.slice(-6) || 'unchanged'}`);
  
  // 计算新的过期时间
  const expiresAt = new Date();
  if (tokenData.expires_in) {
    expiresAt.setSeconds(expiresAt.getSeconds() + tokenData.expires_in);
  } else {
    // 回退方案:1小时
    expiresAt.setHours(expiresAt.getHours() + 1);
  }

  const newTokenData = {
    access_token: tokenData.access_token,
    refresh_token: tokenData.refresh_token || tokenToUse, // 🔧 修复:若无新令牌则使用旧令牌
    expires_at: expiresAt.toISOString(),
  };

  // 💾 更新缓存(唯一可信源)并释放锁
  const { error: cacheError } = await supabase
    .from('soundcloud_token_cache')
    .upsert({
      user_id: userId,
      access_token: newTokenData.access_token,
      refresh_token: newTokenData.refresh_token,
      expires_at: newTokenData.expires_at,
      updated_at: new Date().toISOString(),
      refreshing: false, // 🔒 释放锁
    }, {
      onConflict: 'user_id'
    });

  if (cacheError) {
    console.error('Warning: Failed to cache token:', cacheError);
  } else {
    console.log('💾 Token cached in soundcloud_token_cache');
  }

  // 🔄 同步:更新PROFILES表
  const { error: profileError } = await supabase
    .from('profiles')
    .update({
      soundcloud_access_token: newTokenData.access_token,
      soundcloud_refresh_token: newTokenData.refresh_token,
      soundcloud_token_expires_at: newTokenData.expires_at,
      soundcloud_token_valid: true,
      soundcloud_last_error: null,
      soundcloud_last_refresh_attempt: new Date().toISOString(),
    })
    .eq('id', userId);

  if (profileError) {
    console.error('Warning: Failed to update profiles table:', profileError);
  } else {
    console.log('💾 Token synced to profiles table');
  }

  return newTokenData;
}

问题诊断与解决方案

核心问题排查

你的代码已经覆盖了大部分基础场景,但仍有几个关键隐患会导致令牌短期失效:

  1. 重复使用旧refresh_token
    SoundCloud明确要求每个refresh_token仅可使用一次,但当前锁机制存在漏洞:首次创建缓存记录失败时仍会继续执行刷新,可能导致无锁状态下多个进程使用同一旧令牌请求,触发SoundCloud风控机制标记令牌无效。

  2. refresh_token持久化原子性不足
    缓存表与profiles表的更新顺序没有严格约束,若缓存更新失败但profiles表已更新,会导致令牌数据不一致,后续请求使用错误的refresh_token。

  3. 错误处理误判令牌状态
    遇到invalid_grant错误直接标记令牌无效,但该错误也可能由临时网络波动、请求参数错误导致,直接标记会误判用户状态。

修复方案

1. 强化锁机制,确保单一刷新进程

修改首次创建缓存记录的逻辑,只有成功获取锁后才执行刷新:

// 替换原有的首次创建记录逻辑
if (!existingRow) {
  console.log(`🔒 No cache row exists for user ${userId}, creating with lock...`);
  
  const { data: insertedRow, error: insertError } = await supabase
    .from('soundcloud_token_cache')
    .insert({
      user_id: userId,
      access_token: 'pending_refresh',
      refresh_token: refreshToken,
      expires_at: new Date().toISOString(),
      refreshing: true,
      updated_at: new Date().toISOString()
    })
    .select()
    .maybeSingle();

  if (insertError || !insertedRow) {
    console.error('Failed to create cache row with lock:', insertError);
    // 创建失败则抛出错误,避免无锁刷新
    throw new Error('Failed to acquire refresh lock');
  }
}

2. 严格保证refresh_token的原子更新

先更新缓存表,确认成功后再同步到profiles表,避免数据不一致:

// 修改缓存更新逻辑,确保成功后再同步到profiles
const { error: cacheError } = await supabase
  .from('soundcloud_token_cache')
  .upsert({
    user_id: userId,
    access_token: newTokenData.access_token,
    refresh_token: newTokenData.refresh_token,
    expires_at: newTokenData.expires_at,
    updated_at: new Date().toISOString(),
    refreshing: false,
  }, {
    onConflict: 'user_id'
  });

if (cacheError) {
  console.error('Failed to cache token, aborting:', cacheError);
  throw new Error('Failed to persist new token');
}

// 缓存更新成功后再同步到profiles表
const { error: profileError } = await supabase
  .from('profiles')
  .update({
    soundcloud_access_token: newTokenData.access_token,
    soundcloud_refresh_token: newTokenData.refresh_token,
    soundcloud_token_expires_at: newTokenData.expires_at,
    soundcloud_token_valid: true,
    soundcloud_last_error: null,
    soundcloud_last_refresh_attempt: new Date().toISOString(),
  })
  .eq('id', userId);

3. 优化错误处理逻辑

针对invalid_grant错误增加重试机制,多次失败后再标记令牌无效:

if (!tokenResponse.ok) {
  const error = await tokenResponse.text();
  console.error('❌ Token refresh failed:', error);
  console.error(`   Used refresh_token ending in: ...${tokenToUse.slice(-6)}`);
  
  await supabase.from('soundcloud_token_cache').update({ refreshing: false }).eq('user_id', userId);
  
  if (error.includes('invalid_grant')) {
    // 获取当前重试次数
    const { data: profile } = await supabase.from('profiles').select('soundcloud_refresh_retry_count').eq('id', userId).maybeSingle();
    const retryCount = profile?.soundcloud_refresh_retry_count || 0;
    
    if (retryCount >= 2) {
      // 重试2次后仍失败,标记令牌无效
      const errorMessage = 'Refresh token expired - user needs to reconnect';
      await supabase.from('profiles').update({ 
        soundcloud_token_valid: false,
        soundcloud_last_error: errorMessage
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.11 23:49:28