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响应截图

相关代码
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
相关产品推荐
相关产品推荐

