基于Mongoose实现Spotify OAuth2 refresh token存取与自动刷新
Spotify API OAuth2 持久化授权实现修正方案
现有代码核心问题
- 重复初始化2次
SpotifyWebApi实例,存在冗余逻辑 - 服务启动时没有优先查询数据库中已存储的有效refresh token,无条件执行授权码授予流程,每次重启都要重复授权
- 授权码硬编码在源码中,每次换code都要手动改代码重新部署,流程不闭环
- 存储refresh token时每次都生成新
_id插入文档,会在库中生成多条重复记录,后续查询无法定位到最新有效token - 没有处理refresh token失效的异常分支,token吊销后会无限刷新报错
- 定时刷新任务没有做去重,多次触发会生成重复定时器
第一步:优化RefreshToken模型
给模型增加固定类型字段做唯一约束,保证库中仅存一条有效Spotify refresh token,避免多记录混乱:
const mongoose = require('mongoose'); const tokenSchema = mongoose.Schema({ type: { type: String, default: 'spotify_refresh', unique: true // 固定标识,保证该类型文档全局唯一 }, token: String, updatedAt: { type: Date, default: Date.now } }); module.exports = mongoose.model("RefreshToken", tokenSchema);
后续查询、更新token直接以
type: 'spotify_refresh'为条件即可,不需要手动维护ObjectId,也不会出现重复插入问题。
第二步:重构授权流程逻辑
核心判断规则:服务启动连接数据库完成后,先查询库中是否存在有效refresh token:
- 存在有效token:直接将token设置到API实例,立即触发一次access token刷新,启动定时刷新任务,全程跳过授权码流程
- 不存在有效token:打印授权链接,引导手动访问完成授权,通过回调接口自动接收授权码换取token,持久化到库后再启动刷新任务
- 不要把授权码硬编码在源码中,通过回调路由自动接收Spotify回传的code参数,省掉手动改代码重部署的操作
第三步:修正后的完整业务代码
const express = require('express'); const mongoose = require('mongoose'); const SpotifyWebApi = require('spotify-web-api-node'); // 替换成你自己的RefreshToken模型存放路径 const RefreshToken = require('./models/RefreshToken'); // 生成OAuth流程所需的随机state const generateRandomString = function(length) { let text = ''; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; for (let i = 0; i < length; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; }; // 基础配置常量 const scopes = ['user-read-private', 'user-read-email', 'playlist-read-private', 'playlist-modify-private', 'playlist-modify-public']; const redirectUri = '<替换为你的回调地址>'; const clientId = '<替换为你的Spotify应用clientId>'; const clientSecret = '<替换为你的Spotify应用clientSecret>'; const state = generateRandomString(16); const SERVICE_PORT = 3000; // 替换为你自己的服务端口 // 全局仅初始化一次Spotify API实例,删除原代码中重复初始化的部分 const spotifyApi = new SpotifyWebApi({ redirectUri: redirectUri, clientId: clientId, clientSecret: clientSecret }); const app = express(); // 登录跳转路由 app.get('/login', function(req, res) { const authorizeURL = spotifyApi.createAuthorizeURL(scopes, state); res.redirect(authorizeURL); }); // Spotify授权回调路由,自动接收授权码 app.get('/callback', async (req, res) => { const { code, error } = req.query; if (error) { return res.send(`授权失败:${error}`); } try { const tokenRes = await spotifyApi.authorizationCodeGrant(code); const { access_token, refresh_token, expires_in } = tokenRes.body; // 将获取到的token设置到API实例 spotifyApi.setAccessToken(access_token); spotifyApi.setRefreshToken(refresh_token); // 持久化refresh token到数据库:存在则更新,不存在则新建,保证仅一条有效记录 await RefreshToken.findOneAndUpdate( { type: 'spotify_refresh' }, { token: refresh_token, updatedAt: Date.now() }, { upsert: true, new: true } ); // 启动定时刷新任务 startTokenRefreshTask(); res.send('授权成功,可关闭当前页面'); } catch (err) { console.error('授权码换取token失败:', err); res.send('授权出错,请查看服务端日志'); } }); // access token刷新逻辑 function refreshSpotifyToken() { return spotifyApi.refreshAccessToken() .then(function(data) { console.log('access token刷新完成'); spotifyApi.setAccessToken(data.body['access_token']); console.log('新token有效期:', data.body['expires_in'], '秒'); return data.body['access_token']; }) .catch(function(err) { console.error('access token刷新失败,refresh token可能已失效,请重新授权:', err); // 清空库中无效的旧token RefreshToken.deleteOne({ type: 'spotify_refresh' }).catch(console.error); console.log(`请访问 http://你的服务地址:${SERVICE_PORT}/login 重新完成授权`); }); } let refreshTimer = null; // 统一的定时任务启动方法,避免重复创建定时器 function startTokenRefreshTask() { if (refreshTimer) clearInterval(refreshTimer); // 启动时先刷新一次token,之后每59分钟刷新一次(Spotify access token默认有效期1小时) refreshSpotifyToken(); refreshTimer = setInterval(refreshSpotifyToken, 1000 * 60 * 59); } // 客户端就绪后的启动逻辑 client.on('ready', async () => { try { // 连接MongoDB await mongoose.connect(process.env.MONGODB_SPECTRUM, { useNewUrlParser: true, useUnifiedTopology: true, keepAlive: true }); console.log('已连接到Spectrum数据库'); // 启动回调服务 app.listen(SERVICE_PORT, () => { console.log(`授权回调服务已启动,监听端口${SERVICE_PORT}`); }); // 核心判断:查询库中是否存在已存储的refresh token const storedRefreshToken = await RefreshToken.findOne({ type: 'spotify_refresh' }); if (storedRefreshToken?.token) { console.log('检测到已持久化的refresh token,直接加载'); spotifyApi.setRefreshToken(storedRefreshToken.token); // 启动刷新任务,跳过授权流程 startTokenRefreshTask(); } else { console.log('未检测到有效refresh token,请访问以下地址完成首次授权:'); console.log(`http://你的服务地址:${SERVICE_PORT}/login`); } } catch (err) { console.error('服务启动失败:', err); } });
关键改动说明
- 首次部署时只需访问一次控制台打印的
/login地址完成授权,后续服务重启、进程拉起时只要库中refresh token未失效,就会自动加载刷新,不需要重复操作 - 数据库操作采用upsert模式,保证永远只存一条有效refresh token,不会出现重复数据
- 增加了refresh token失效兜底逻辑:如果刷新接口报错(比如token被Spotify吊销、权限变更),会自动清理旧无效token,提示重新授权,不会无限循环报错
- 定时器做了去重处理,避免多次调用生成重复的定时任务
- 删除了冗余的重复API实例初始化代码,全局使用同一个
spotifyApi实例,避免token状态不一致
内容的提问来源于stack exchange,提问作者flx
相关产品推荐
相关产品推荐

