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

Android Kotlin中Spotify Web API认证空响应及双URL处理求助

Spotify Web API认证问题排查与解决

问题概述

  • 发送认证请求时返回状态码200,但响应内容为空,无法获取Token
  • 不清楚如何处理Spotify认证Base URL(https://accounts.spotify.com/api/)与API Base URL(https://api.spotify.com/v1/)不一致的问题

App Inspector响应截图

Response in App Inspector

相关代码

ApiModule

@Module
@InstallIn(SingletonComponent::class)
object ApiModule {

    @Provides
    @Singleton
    fun provideApi(builder: Retrofit.Builder): SpotifyApi {
        return builder
            .build()
            .create(SpotifyApi::class.java)
    }

    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit.Builder {
        return Retrofit.Builder()
            .baseUrl(ApiConstants.BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create())
    }


    @Singleton
    @Provides
    fun provideOkHttpClient(
        networkStatusInterceptor: NetworkStatusInterceptor,
        authenticationInterceptor: AuthenticationInterceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(networkStatusInterceptor)
            .addInterceptor(authenticationInterceptor)
            .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
    }
}

AuthenticationInterceptor

class AuthenticationInterceptor @Inject constructor(
    private val preferences: Preferences
) : Interceptor {

    companion object {
        const val UNAUTHORIZED = 401
    }

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = preferences.getToken()
        val tokenExpirationTime = Instant.ofEpochSecond(preferences.getTokenExpirationTime())
        val request = chain.request()

        val interceptedRequest: Request

        if (tokenExpirationTime.isAfter(Instant.now())) {
            interceptedRequest = chain.createAuthenticatedRequest(token)
        } else {

            val tokenRefreshResponse = chain.refreshToken()

            interceptedRequest = if (tokenRefreshResponse.isSuccessful) {
                val newToken = mapToken(tokenRefreshResponse)
                if (newToken.isValid()) {
                    storeNewToken(newToken)
                    chain.createAuthenticatedRequest(newToken.accessToken!!)
                } else {
                    request
                }
            } else {
                request
            }
        }
        return chain.proceedDeletingTokenIfUnauthorized(interceptedRequest)
    }

    private fun Interceptor.Chain.createAuthenticatedRequest(token: String): Request {
        return request()
            .newBuilder()
            .addHeader(AUTH_HEADER, TOKEN_TYPE + token)
            .build()
    }

    private fun Interceptor.Chain.refreshToken(): Response {

        val requestBody = FormBody.Builder()
            .add(GRANT_TYPE_KEY, GRANT_TYPE_VALUE)
            .add(CLIENT_ID, CLIENT_ID_VALUE)
            .add(CLIENT_SECRET, CLIENT_SECRET_VALUE)
            .build()

        val request = request()
            .newBuilder()
            .url("https://accounts.spotify.com/api/token")
            .post(requestBody)
            .build()

        return proceedDeletingTokenIfUnauthorized(request)
    }

    private fun Interceptor.Chain.proceedDeletingTokenIfUnauthorized(request: Request): Response {
        val response = proceed(request)

        if (response.code == UNAUTHORIZED) {
            preferences.deleteTokenInfo()
        }

        return response
    }

    private fun mapToken(tokenRefreshResponse: Response): ApiToken {
        val moshi = Moshi.Builder().build()
        val tokenAdapter = moshi.adapter(ApiToken::class.java)
        val responseBody = tokenRefreshResponse.body!!

        return tokenAdapter.fromJson(responseBody.toString()) ?: ApiToken.INVALID
    }

    private fun storeNewToken(apiToken: ApiToken) {
        with(preferences) {
            putTokenType(apiToken.tokenType!!)
            putTokenExpirationTime(apiToken.expiresAt)
            putToken(apiToken.accessToken!!)
        }
    }
}

SpotifyApi接口

interface SpotifyApi {
    @GET(ApiConstants.PLAYLIST_ENDPOINT + "{playlistId}")
    suspend fun getPlaylist(
        @Path("playlistId") playlistId: String
    ): ApiPlaylist
}

解决方案

1. 修复响应内容为空的问题

问题出在mapToken方法中,responseBody.toString()返回的是ResponseBody对象的字符串标识,而非实际的JSON响应内容。需修改为读取响应体的原始字符串:

private fun mapToken(tokenRefreshResponse: Response): ApiToken {
    val moshi = Moshi.Builder().build()
    val tokenAdapter = moshi.adapter(ApiToken::class.java)
    val responseBody = tokenRefreshResponse.body!!
    
    // 读取实际JSON响应内容
    val jsonString = responseBody.source().readString(Charsets.UTF_8)
    // 读取后关闭响应体避免内存泄漏
    responseBody.close()
    
    return tokenAdapter.fromJson(jsonString) ?: ApiToken.INVALID
}

同时确保ApiToken类的字段与Spotify返回的JSON字段对应,添加Moshi注解:

data class ApiToken(
    @Json(name = "access_token") val accessToken: String?,
    @Json(name = "token_type") val tokenType: String?,
    @Json(name = "expires_in") val expiresIn: Long?,
    // 计算过期时间戳
    val expiresAt: Long get() = Instant.now().epochSecond + (expiresIn ?: 0)
) {
    companion object {
        val INVALID = ApiToken(null, null, null)
    }

    fun isValid() = accessToken != null && tokenType != null && expiresIn != null
}

2. 处理双Base URL的问题

不要在拦截器中直接构建认证请求,应创建两个独立的Retrofit实例,分别对应认证接口和业务API接口,避免拦截器互相干扰。

步骤1:新增认证API接口

interface SpotifyAuthApi {
    @FormUrlEncoded
    @POST("token")
    suspend fun getToken(
        @Field("grant_type") grantType: String,
        @Field("client_id") clientId: String,
        @Field("client_secret") clientSecret: String
    ): ApiToken
}

步骤2:在ApiModule中提供双Retrofit实例

@Module
@InstallIn(SingletonComponent::class)
object ApiModule {
    private const val API_BASE_URL = "https://api.spotify.com/v1/"
    private const val AUTH_BASE_URL = "https://accounts.spotify.com/api/"

    // 业务API实例
    @Provides
    @Singleton
    fun provideSpotifyApi(@Named("api_retrofit") retrofit: Retrofit): SpotifyApi {
        return retrofit.create(SpotifyApi::class.java)
    }

    // 认证API实例
    @Provides
    @Singleton
    fun provideSpotifyAuthApi(@Named("auth_retrofit") retrofit: Retrofit): SpotifyAuthApi {
        return retrofit.create(SpotifyAuthApi::class.java)
    }

    // 业务API的Retrofit(带认证拦截器)
    @Provides
    @Singleton
    @Named("api_retrofit")
    fun provideApiRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl(API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
    }

    // 认证API的Retrofit(独立拦截器,避免循环依赖)
    @Provides
    @Singleton
    @Named("auth_retrofit")
    fun provideAuthRetrofit(): Retrofit {
        val loggingInterceptor = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }
        val okHttpClient = OkHttpClient.Builder()
            .addInterceptor(loggingInterceptor)
            .build()

        return Retrofit.Builder()
            .baseUrl(AUTH_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
    }

    // 业务API用的OkHttp(带认证拦截器)
    @Singleton
    @Provides
    fun provideOkHttpClient(
        networkStatusInterceptor: NetworkStatusInterceptor,
        authenticationInterceptor: AuthenticationInterceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(networkStatusInterceptor)
            .addInterceptor(authenticationInterceptor)
            .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
    }
}

步骤3:修改AuthenticationInterceptor使用认证API实例

class AuthenticationInterceptor @Inject constructor(
    private val preferences: Preferences,
    private val spotifyAuthApi: SpotifyAuthApi
) : Interceptor {

    companion object {
        const val UNAUTHORIZED = 401
        const val AUTH_HEADER = "Authorization"
        const val TOKEN_TYPE = "Bearer "
        const val GRANT_TYPE_VALUE = "client_credentials"
        const val CLIENT_ID_VALUE = "你的Client ID"
        const val CLIENT_SECRET_VALUE = "你的Client Secret"
    }

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = preferences.getToken()
        val tokenExpirationTime = Instant.ofEpochSecond(preferences.getTokenExpirationTime())
        val request = chain.request()

        val interceptedRequest: Request = if (tokenExpirationTime.isAfter(Instant.now())) {
            chain.createAuthenticatedRequest(token)
        } else {
            try {
                // 调用认证API获取新Token
                val newToken = spotifyAuthApi.getToken(
                    GRANT_TYPE_VALUE,
                    CLIENT_ID_VALUE,
                    CLIENT_SECRET_VALUE
                )
                if (newToken.isValid()) {
                    storeNewToken(newToken)
                    chain.createAuthenticatedRequest(newToken.accessToken!!)
                } else {
                    request
                }
            } catch (e: Exception) {
                request
            }
        }

        val response = chain.proceed(interceptedRequest)
        if (response.code == UNAUTHORIZED) {
            preferences.deleteTokenInfo()
        }
        return response
    }

    private fun Interceptor.Chain.createAuthenticatedRequest(token: String): Request {
        return request()
            .newBuilder()
            .addHeader(AUTH_HEADER, TOKEN_TYPE + token)
            .build()
    }

    private fun storeNewToken(apiToken: ApiToken) {
        with(preferences) {
            putTokenType(apiToken.tokenType!!)
            putTokenExpirationTime(apiToken.expiresAt)
            putToken(apiToken.accessToken!!)
        }
    }
}

内容的提问来源于stack exchange,提问作者msy

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.30 10:50:57