GQ Music项目相关解答
GQ Music
简单介绍一下这个项目
我这个项目叫 GQ Music 在线音乐播放平台,整体是一个前后端分离的 Web 音乐站点。
从业务上看,这个平台主要做三件事:
- 音乐内容管理:支持歌曲、歌手、专辑、歌单、评论、反馈等完整的内容管理,后面有一个管理员后台可以做上架、推荐、统计。
- 用户端播放与互动:普通用户可以注册登录、创建/收藏歌单、收藏歌曲和歌手、关注用户、查看推荐歌单和推荐歌曲,在 Web 端完成在线播放和基础的社区互动。
- 智能推荐和运营能力:首页会有“为你推荐”的歌单和歌曲,我这边做了一套基于用户收藏偏好 + 固定推荐 + Redis 缓存的推荐逻辑,同时在后台在仪表盘里可以看用户数、歌曲数、专辑数、歌单数等数据。
技术上,后端是 Spring Boot + MyBatis Plus + MySQL + Redis + Nacos + MinIO,用 JWT 做登录认证,Redis 做缓存和会话管理,MinIO 存储音频、封面、歌词等静态资源;前端是 Vue3 单页应用,通过 RESTful API 调用后端服务。整体部署在云服务器上,前面有 Nginx 做反向代理和静态资源分发。
我在这个项目里主要负责的是 后端整体架构设计和核心业务模块的开发,包括认证授权、推荐系统、缓存与搜索热榜、对象存储集成以及部分运营后台接口等。
微信扫码登录是怎么实现的?
微信扫码登录整体就是一句话 前端把人送到微信 → 微信给我一个 code → 后端用 code 换 openId,生成自己的登录态。
具体分几步说的话是这样的
第一步,前端跳到微信授权。
用户点“微信扫码登录”的时候,前端先调我这边一个接口,我用 YunGouOS 的 SDK(软件开发工具包) 生成微信的授权链接,前端直接跳进微信里,让用户在微信里点“确认登录”。如果跳转失败(未安装微信/微信未在后台挂着)则在页面显示二维码让用户手动扫描
第二步,微信带着 code 回调到我们后台。
用户同意之后,微信会回调到我们配置好的 callback 地址,带一个临时的 code。这个回调页面我做了个中转,把这个 code 安全地传回前端指定路由,前端再拿着这个 code 去调我们正式的登录接口。
第三步,后端用 code 换 openId,再生成自己的 token。
登录接口里,我们用 code 可以拿到用户在微信里的信息,比如 openId、昵称、头像这些;
然后用 openId 去查数据库本地用户,没有的话就自动帮他创建一个本地账号(头像和昵称直接同步微信数据);
接着给这个本地用户签发一个 JWT,把 token 写到 Redis 里,最后把 token 返回给前端。
第四步,前端拿到 token 就算登录成功了。
前端把 JWT 存到本地(localStorage/Pinia),然后再调一次 /user/info 把头像、昵称这些拉回来,直接跳到首页,扫码登录这条链路就走完了。
在安全这块,我做了几层保护:
- 扫码相关的几个接口是放行的,其它业务接口全部走 JWT + Redis 校验;
- 微信的 code 本身只能用一次,前端拿到之后会立刻换 token,不会复用;
- Redis 里记录 token 状态,可以支持登出、封禁账号、单端登录这些控制。
你要我一句话总结的话,就是:“前端跳到微信授权,后端用 code 换 openId,自动绑定/创建本地账号,发 JWT 落到 Redis 里做会话管理,前端拿到 token 就完成扫码登录。”
项目中的登录是如何通过JWT+Redis实现的
① 登录时怎么发 token?
用户用邮箱 + 密码登录的时候,我先做几件事:
- 校验账号、密码是否正确、账号是否被禁用;
- 校验通过后,去生成一个 JWT,有效期是 6 小时;
- 同时把这个 token 作为 key 写进 Redis,设置同样的过期时间。
这样一来,JWT 自带签名和过期时间,后续服务是无状态的;而 Redis 里多了一层“登录会话”的控制。
② 每次请求是怎么校验的?
后端这边有一个 LoginInterceptor,所有需要登录的接口都会先过这个拦截器,逻辑大概是:
- 从请求头里拿到
Authorization: Bearer xxx,把 JWT token 抠出来; - 先看这个路径是不是白名单(比如获取 Banner、搜索热词这些公开接口),白名单就直接放行;
- 非白名单的,就会用
JwtUtil去校验这个 token:签名对不对、有没有过期; - 校验通过之后,再到 Redis 里查一眼这个 token 有没有记录:
- 在 Redis 里存在,就说明会话还有效;
- 查不到就认为已经过期/被踢下线,直接返回未登录。
如果一切正常,我会把解析出来的用户信息丢到 ThreadLocal 里,后面的 Service 可以直接拿当前用户 ID、角色,不用反复查库。
③ 登出、改密码这些怎么让 token 立刻失效?
这里就是 Redis 起作用的地方了:
- 用户主动“退出登录”时,我会删掉 Redis 里对应的 token;
- 用户改密码、重置密码时,同样会把当前 token 从 Redis 删除;
因为拦截器每次都会去 Redis 里确认这个 token 是否存在,所以一旦 key 被删掉,即使 JWT 本身还没到过期时间,也会被视为无效,这就解决了纯 JWT 不能主动失效的问题。
④ 可以怎么一句话总结?
我一般会这样总结:
“登录成功后给用户发一个带业务信息的 JWT,同时把 token 写到 Redis;每次请求先验 JWT(看这个路径是不是白名单,白名单就直接放行;非白名单的,就会用 JwtUtil 去校验这个 token ),JWT校验通过再查 Redis,退出或者改密码就删 Redis key,这样既保留了 JWT 的高性能,又能做到即时下线控制。”
项目安全风控是怎么实现的
① 入口安全:邮箱 + 图形验证码双重校验
为了防止撞库、遍历邮箱、暴力发验证码,我做了几层保护:
- 登录/注册 先验证图形验证码,失败次数通过 Redis 计数,再失败会进入“强制验证码 + 冷却期”。
- 邮箱验证码严格控制:一次性、短期、限频次。验证码哈希存 Redis,校验后立即作废,并限制发码频率
一句话:入口先拦截,把机器流量挡在最外圈。
② 会话可控:JWT + Redis 即时失效体系
会话做的是“可控、可踢、可失效”的体系:
- accessToken 短期、refreshToken 长期,JWT 里带 jti + 用户版本号 ver。
- Redis 记录用户当前有效的 jti,实现 单端登录 / 踢下线。
- 需要即时失效时(如登出、封禁、改密码),我们会:
- 删除 Redis 的 jti
- 或提升用户 ver
→ 旧 token 立刻变成 401。
- refreshToken 刷新时做轮换,旧的 refresh 会标记失效,防止重放。
- 前端统一拦截 401/403,做清理与跳转。
一句话:JWT 不可控的问题,用 Redis 做“会话白名单”把它补齐。
③ 前后端联防与风控体系
实现的是“请求没进应用就被网关挡掉 + 后端精细校验 + 前端减少暴露”。
- 网关/Nginx 层
- 登录 / 发验证码类接口限流(IP、UA)
- 黑名单短封
- 强 CORS 白名单 / HTTPS / 安全 Header(HSTS、XFO、CSP 等)
- 后端层
- 所有写操作都鉴权、参数校验
- 敏感操作二次确认
- 文件上传做 MIME / 大小验证
- 审计日志上报:登录失败、验证码异常、多次 401 及时告警
- 前端层
- token 只放内存/Pinia,不打印到日志
- 登录表单失败一定次数后强制验证码
- 使用 encodeURI、规范 URL 构造,避免注入
一句话:网关挡机器流量,后端拦恶意行为,前端减少攻击面。
我把安全做成了三段式:入口用验证码体系挡掉黑产,会话用 JWT + Redis 做即时失效与单端登录,外围通过网关限流 + 后端鉴权 + 前端约束做联防,整体既安全又不影响用户体验。
项目中的缓存是怎么做的?(Spring Cache + Redis)
我这边缓存整体可以理解成两条线:一条是 Spring Cache 管常规业务查询,一条是 RedisTemplate 管一些特殊 Key,底层都是 Redis。
-
第一条线:Spring Cache 管“读多写少”的业务数据
像歌曲、歌单、歌手、专辑这种列表的展示和详情页面接口,我基本都用了@Cacheable做缓存,比如songCache、playlistCache等;
一旦有新增、修改、删除,就在对应的方法上加@CacheEvict(allEntries = true),直接把这个命名空间下的缓存全清掉。 -
这样做的好处是:业务代码很干净,查数据的时候不用手动操作 Redis,而且更新后不会出现读到旧数据的问题。
-
第二条线:RedisTemplate 管“非标准”的缓存
比如:-
用户的推荐歌曲池:
recommended_songs:{userId}(有效期 30 分钟); -
邮箱验证码:
verificationCode:{email}(5 分钟); -
搜索热榜用 Redis ZSet 做排序;
这些就不太适合用注解,我是直接用StringRedisTemplate/RedisTemplate手动读写,TTL 单独控制。为了避免删不干净,我又写了一个
CachePurger(应用级缓存清理器),在删除歌手、歌曲、专辑、歌单这类操作后,会按前缀批量清掉和推荐相关的缓存,保证推荐结果和数据库是同步的。
-
-
一致性这块的策略
我用的是最稳的做法:
- 读操作:先查缓存 → 没命中就回库并回写缓存。
- 写操作:先写数据库 → 再清缓存(命名空间整体删)。
这样能保证:
- 不会出现脏读
- 删除或更新后立即生效
- 而且逻辑简单、维护成本低
如果遇到数据规模增长,我们也可以把前缀删除升级到 SCAN 分批处理。
为什么我用 Spring Cache + Redis,而不是单用一个?
我当时是对比过几种方案的,最后选 Spring Cache + Redis 一起用,主要是想做到:业务代码优雅、又能玩得转各种复杂缓存场景。
-
为什么不只用 Spring Cache?
Spring Cache 写起来是很爽,@Cacheable/@CacheEvict一贴就完了,但它更适合那种“查详情、查列表”这种读多写少的业务缓存;
真到验证码、热搜榜、计数器、推荐列表这些,Spring Cache 就力不从心了,做不到那么细。 要用到 Redis 的 ZSet、List、原子自增、精细 TTL 控制。 -
为什么不只用 RedisTemplate?
只用 RedisTemplate 当然也能干活,但问题是:所有 set/get、过期时间、Key 命名、删缓存全要自己写,
时间一长,缓存逻辑会到处都是,更新一首歌可能要手动删好几个 Key,非常容易漏删、也不好维护。 -
所以我怎么拆的?
我把 “标准业务缓存”全部交给 Spring Cache:比如歌曲/歌单/专辑的列表和详情,用@Cacheable+@CacheEvict(allEntries = true),底层再换成 Redis,既优雅又分布式;
然后把 验证码、热搜榜、推荐池、风控计数这种“特殊 Key”交给 RedisTemplate,用 ZSet/List/INCR 这类操作手动控制,配合一个CachePurger做前缀清理。
一句话总结:Spring Cache 让我在业务层写缓存很舒服,Redis 给了我足够的“底层能力”去处理验证码、推荐、限流这类场景,两者结合起来既好维护、又足够灵活,单用哪一个都会有短板。
MinIO + 分片上传 + 断点续传 + FFmpeg 转码,是怎么做的?
minio这一块我当时是按小文件、大文件和转码处理三条线来设计的
- 先说存储这层(MinIO 接入方式)
后端这边会注入一个 MinIO 客户端,所有文件都传到 MinIO 的桶里,数据库只存对象 key,不直接存完整 URL。
前端访问的时候统一走 https://域名/oss/{key},由 Nginx 反代到 MinIO
之所以这么设计就是因为我当时换了域名进行部署发现,minio的资源都拿不到了就是因为我们在数据库里存的都是死数据,现在我们要换域名、或者接 CDN 只改网关配置就行,业务和数据库完全解耦。
**① 小文件上传:头像、封面走“直传”通道
对于几十 KB~几 MB 的头像、封面这类小文件,我用的是最简单的流式上传:不在服务器本地落盘,成功后返回一个对象 key,后续增删查都围绕这个 key 做。
② 大文件上传:用 MinIO 的分片 + 断点续传
音频、视频这类上百 MB 的文件,我是用 MinIO 自带的 Multipart Upload 能力:
-
前端先调一个“初始化上传”的接口,后端生成 uploadId,并把文件 hash、大小和 uploadId 等信息记在 Redis 里,设一个比较长的 TTL,当成续传窗口
-
断点续传
如果中断了,前端每次上传前会查一下“已上传分片”,我们会从 Redis / MinIO 查询已成功的 part 列表,前端只补上传缺失的部分;
-
合并分片
全部分片上传成功后,前端调“完成上传”接口,MinIO 在服务端合并,顺便做一下校验
整个过程基本不在应用服务器上存大文件,本质上就是:服务端负责握手和校验,真正的数据流直接进 MinIO。
③ FFmpeg 转码处理:上传完再异步做
-
媒体文件写入 MinIO 成功后,我会在后台触发一个异步任务,用封装好的 FFmpegUtils 去做处理,比如:
-
音频统一转成一套标准编码格式,必要时再生成一个短一点的试听版本;
-
视频抽首帧做封面图。
-
所有转码产物也都会按目录规范写回 MinIO,例如:
songs/original、songs/encoded、songs/preview 等。
一句话总结
用 MinIO 做统一对象存储,小文件走后端直传,大文件用 MinIO 的分片上传配合 Redis 做断点续传,文件落到 MinIO 后再通过 FFmpeg 异步转码、抽封面,前端访问统一走 /oss/{key} 这一层网关。 这样既有比较好的上传体验,也方便后期维护和扩展。
歌曲批量导入怎么做的?
“我们的歌曲批量导入我做成了一个两步式、强校验、可部分成功的流程,重点是提升效率又不让脏数据入库。
首先在交互上,是两步走:先选或新建一个专辑,然后进入批量录入页面。这里支持拖拽上传音频和歌词,上传过程有实时进度,音频上传后我会自动解析时长、自动填充发行日期、封面等专辑信息,减少操作量。为了适配听书类内容,还加了一个‘听书模式’,自动同步风格,进一步降低重复输入。
在校验上,我做得比较严格:必填项校验、音频/歌词格式校验、同名重复校验、歌手存在性校验,全都在前端和后端双层兜底。导入失败的行会高亮,并给出原因汇总,方便用户快速修正。
后端处理也是逐行校验、逐行入库的策略。接口接受 FormData,每一行数据通过后再落库,不会因为某一行错误导致整个批次失败。对象存储仍然只存对象键,保证整套系统的数据格式一致。事务上是单行粒度提交,可部分成功、可重试、具备幂等性,例如重复歌曲会被安全跳过。
整体收益也比较明显:一次能导几十首,上线后数据录入效率提升很大;强校验减少了脏数据;拖拽、自动填充、进度条这些细节让整体体验更顺滑。
如果追问,我会补充:这套逻辑已经预留了接入 MinIO 分片上传和断点续传的扩展空间,也支持后续加批量预检或回滚功能。”
你提到了“改造了对象存储逻辑,避免在数据库中存储敏感域名,转而使用安全的对象键存储。”具体说说你为什么这么做,又是怎么实现的?
这个改造其实是我线上踩了坑之后做的:一开始我把 MinIO 回来的 完整 URL 都直接存到数据库里,比如
http://103.236.55.216:19000/vibe-music-data/xxx.jpg。
上线 HTTPS 以后问题就来了:
- 页面是
https://gqmusic.fun,资源却是http://103.236.55.216:19000/...,浏览器直接判定为 Mixed Content,图片音频全被拦掉; - 真实 IP、端口、桶路径全暴露在前端,不安全也不好运维;
- 想走 Nginx 的
/oss缓存、以后接 CDN 都很难,因为 URL 已经“写死”在数据库里了。
所以我后来做了一次比较彻底的改造,思路就是:“数据库只存对象键,不存域名;访问统一走同源的 /oss 前缀。”
大概做了几件事:
-
先在网关上兜一层
Nginx 里把https://gqmusic.fun/oss/**统一反代到本机的 MinIO:http://127.0.0.1:19000/**,
保证前端看到的资源地址永远是/oss/vibe-music-data/...,既同源又能走缓存。 -
改后端:上传只返回 key,数据库只落 key
- MinIO 上传成功后,我不再拼完整 URL,而是只拿对象键,比如
vibe-music-data/albums/xxx-cover.jpg; - 数据库字段统一改成存这个 key;
- 对外返回时,要么直接把 key 给前端,要么在后端用配置里的
ossBase=/oss拼一个url = /oss/{key}。
- MinIO 上传成功后,我不再拼完整 URL,而是只拿对象键,比如
-
改前端:统一用
/oss + key拼地址- 在前端加了
VITE_OSS_BASE=/oss这类配置; - 所有图片、音频、封面、头像的 src 都改成:
toOssUrl(key) => VITE_OSS_BASE + '/' + key; - 代码里彻底干掉
http://103.236.55.216:19000这种硬编码。
- 在前端加了
-
对历史数据做了一次清洗
对库里已经存好的http://103.236.55.216:19000/vibe-music-data/...批量跑sql脚本,把前面的协议、IP、端口去掉,只保留从vibe-music-data/...开始的那一段,当成 key 保存。
这么改完之后的效果就是:
- 线上全部资源都走
https://gqmusic.fun/oss/...,没有 Mixed Content,加载也能命中 Nginx 缓存; - 真实的 MinIO IP 和端口被网关挡在后面,对外只暴露一个
/oss前缀; - 以后换域名、接 CDN、扩容 MinIO,都只用改一处配置,数据库里的数据是“环境无关”的。
如果一句话总结的话就是:我把原来“把完整外链写死在数据库里”的做法,改成了“数据库只存对象键、前后端统一走 /oss 前缀”的方案,既解决了 HTTPS 混合内容和安全问题,也让后续运维扩展简单很多。
你简历提到了“搭建搜索热榜,基于 Redis ZSet 实现并配合定时任务每日清理热搜数据”请你讲讲你是怎么实现的?
口语化回答示例
这个搜索热榜是我自己从零搭的一条小链路,核心就是:用 Redis ZSet 记“谁被搜了多少次”,再做一个每天清零的榜单。
-
先说整体思路
目标很简单:把用户搜过的关键词统计出来,做一个 TopN 给前端搜索框展示。
我选的是 Redis 的 ZSet:key = hot:search:zset,member 是“关键词”,score 就是“被搜的次数”,天然就能按分数排序。 -
后端怎么实现的?
我抽了一个HotSearchService出来,两个核心方法:increaseKeyword(keyword):用StringRedisTemplate.opsForZSet().incrementScore()对这个 keyword 的分数 +1;getTopKeywords(topN):用reverseRange从 ZSet 里取出分数最高的前 N 个关键词。
上面再包一层SearchController:GET /search/getHotKeywords给前端拉热搜列表;POST /search/reportKeyword用来主动上报一次计数。
这两个接口我在登录拦截器里加了白名单,不登录也能访问,方便未登录用户也看到热搜。
-
埋点是怎么做的?
主要有两处:- 歌曲搜索接口里,如果请求体里带了
keyword,我会在进服务层前先调一次increaseKeyword,即使命中了@Cacheable直接返回,也能把搜索行为记进去; - 前端输入框那边,用户回车搜索或点了某个热词时,会再调一次
/search/reportKeyword主动 +1。
这样基本能覆盖大部分正常搜索行为。
- 歌曲搜索接口里,如果请求体里带了
-
每天清零是怎么做的?
我不做复杂的时间衰减,走的是最简单可靠的办法:- 在应用入口上开启
@EnableScheduling; - 写了一个
HotSearchScheduler定时任务,每天 0 点跑一次; - 定时任务里调用
hotSearchService.clearAll(),实际上就是delete("hot:search:zset"),把这一天的热搜数据清掉。
这样前端看到的就是“当日热搜”,第二天会重新从 0 累积。
- 在应用入口上开启
-
前端这边怎么用?
搜索框聚焦的时候,请求一次/search/getHotKeywords把当前 Top10 拉下来展示;
用户点某个热词或者直接回车搜索时,会走一次reportKeyword上报,再跳转到列表页。
如果一句话总结的话就是:我用 Redis ZSet 存“关键词-搜索次数”,通过接口在搜索时埋点计数、在输入框聚焦时拉取 TopN,再用一个 Spring 定时任务每天 0 点清空 ZSet,做成了一个简单但够用的“日热搜榜”。
项目的推荐系统?
这个推荐我其实是分成两条线做的:歌单推荐 和 歌曲推荐,都是“轻量规则 + Redis 缓存”的思路,不搞特别重的算法,但把“稳定、去重、性能”这几件事做到位。
-
歌单推荐:固定推荐优先 + 个性化补齐 + 随机兜底
歌单这块我先定了一个固定长度:永远返回 10 个。
1)第一步是“固定推荐”(Pinned):运营在后台把某些官方歌单标记成推荐,落在一张tb_playlist_recommendation表里,我按权重降序取前 N 个,顺序是固定、持久的;
2)第二步是补齐:- 未登录用户就直接随机补齐到 10 个;
- 登录用户会先根据他收藏的歌单算“风格偏好”(统计每个风格出现频次,排个序),再按这些风格去查一批个性化歌单过来补齐;
3)最后用LinkedHashMap这种结构做一次去重和合并:先塞固定推荐,再塞个性化推荐,不够再塞随机的,保证固定推荐永远在前且不被覆盖,整体又没有重复项。
管理端这块我还做了区分:后台只管理官方歌单(user_id IS NULL),用户自建歌单是只读,这样推荐位既可运营,又不会被用户数据污染。
-
歌曲推荐:缓存候选池 + 按偏好抽样 + 去重兜底
歌曲这块目标是固定返回 20 首。
1)未登录用户就简单粗暴:直接从数据库随机 20 首歌,带上歌手信息返回;
2)登录用户会复杂一点:- 先拿他收藏过的歌曲,算出“最常见的风格 ID 列表”;
- 然后以
recommended_songs:{userId}为 key,在 Redis 里维护一个“候选推荐池”List,一次性塞 80 首符合他偏好的歌,缓存 30 分钟; - 每次首页要推荐时,从这个候选池里 shuffle 一下,取前 20 首;如果候选不够,再从随机歌曲里补齐,并且用 Set 做一次去重。
这样做的好处是:对同一个用户来说,一段时间内推荐的调性是稳定的(候选池不变),但又不会每次一模一样(抽样 + 打乱),同时大幅减少了每次推荐都查复杂 SQL 的压力。
-
一致性和体验上的细节
- 更新歌手、专辑、歌曲、歌单的时候,我会通过
CachePurger按前缀清掉recommended_songs:这类推荐缓存,避免推荐到已删除或被下架的内容; - 在
SongVO上我还统一了likeStatus,默认是 0,如果用户已登录就批量把已收藏的歌标记成 1,这样前端心形图标和推荐结果是一致的。
- 更新歌手、专辑、歌曲、歌单的时候,我会通过
如果用一句话总结的话,就是:“歌单推荐走‘固定推荐优先 + 个性化 + 随机兜底’,歌曲推荐走‘Redis 候选池 + 偏好抽样’,中间用去重、缓存和前缀清理保证推荐既稳定又高性能。”
怎么独立搭建云服务器环境的?
前端:npm run build 打包为dist
后端 打jar包 mvn clean install -U
服务器上在docker中下载minio和nacos镜像
简单介绍一下你项目中的互动模块是怎么设计的?(点赞/评论/关注)
这个项目的互动我大概分成三块:点赞/收藏、评论、关注,整体思路是围绕“用户行为表 + 统计展示 + 幂等保护”来设计的。
-
点赞 / 收藏(歌、歌单、专辑、歌手)
实际上我这边是用一张user_favorite表来统一做“点赞/收藏”行为的,里面有user_id + 目标 id + 类型(歌曲/歌单/专辑/歌手)和状态枚举。
接口上是成对设计的,比如collectSong / cancelCollectSong、collectPlaylist / cancelCollectPlaylist,每次操作都会先根据当前登录用户和目标 ID 查一遍,防止重复插入或重复取消,保证幂等。
在返回列表、推荐结果时,我会先把所有songId查出来,批量去user_favorite里看用户有没有收藏,然后把SongVO.likeStatus补上,这样前端一拿到数据就知道哪些是“已点赞/已收藏”。 -
评论(歌曲、歌单下的评论)
评论是单独一张comment表,结合CommentTypeEnum区分是“歌曲评论”还是“歌单评论”,支持楼中楼回复。
设计上有rootId/parentId/replyUserId这些字段,一条评论既可以是根评论,也可以是回复别人;
对外接口包括:发表评论、删除自己的评论、分页拉取评论列表,按时间或热度排序。
为了避免一条评论刷新就消失,我这块读写是直接走数据库,列表做了分页和简单的敏感校验,必要的时候可以再加审核流。 -
关注(用户之间的关注/粉丝)
关注关系用的是user_follow表,记录follower_id和followee_id。
在 UserService 里我提供了followUser / cancelFollowUser / isUserFollowed / getFollowing / getFollowers这一组接口:- 先从 JWT + ThreadLocal 里拿当前用户 ID,禁止自己关注自己;
- 关注前会先查是否已存在这条关系,避免重复插入;
- 个人主页接口里,会同时查出 TA 的粉丝数、关注数,以及“我是否已关注 TA”,方便前端一并展示。
整体上,这几个互动模块都绑定在统一的登录体系下:请求先走拦截器校验 JWT + Redis 会话,拿到当前用户 ID 后,在各自的行为表里做幂等插入/删除,再在各种 VO(歌曲、歌单、用户资料)上补充状态字段,这样前端拿到一份数据就能把点赞、评论数、关注状态都展示完整。
你们现在是单体还是微服务?如果要拆微服务,你会怎么拆 GQ Music 这个项目?
现在这套 GQ Music 后端,其实就是一个Spring Boot 单体应用,所有模块都在一个工程里:用户、音乐库、推荐、互动、后台管理、对象存储这些都是同一个进程、同一个数据库里。
这样做的原因也比较现实:项目体量和并发都不算特别大,一个人/小团队开发维护起来成本最低、迭代也快。
如果以后访问量上来了或者团队变大,需要往微服务拆,我大概会按业务边界去拆,优先拆“变化频率高、资源消耗重、职责清晰”的几块:
-
用户与认证中心(user-auth-service)
- 负责用户注册登录、JWT 签发校验、微信扫码登录、权限角色这些。
- 独立库存 user / admin 表,其他服务通过 RPC/HTTP 拿用户基本信息或校验 token。
- 好处是登录、权限逻辑统一,后面要接别的终端(小程序、App)也直接复用。
-
音乐内容服务(music-catalog-service)
- 管理歌曲、歌手、专辑、歌单这些“内容类”数据和搜索分页。
- 现在的
SongController/ArtistController/AlbumController/PlaylistController这堆都放进来,配一套自己的 MySQL + Redis 缓存。 - 这是最核心的读写流量,单独拆出来便于横向扩容。
-
推荐与搜索服务(recommend-search-service)
- 专门负责歌单推荐、歌曲推荐、搜索热词统计(Redis ZSet 那一块)。
- 它可以订阅用户行为(收藏、播放)做异步计算,把推荐结果、候选池写到自己的 Redis 里,对外只暴露“取推荐结果”“取热搜 TopN”的接口。
- 好处是推荐逻辑可以独立演进,甚至以后可以上更重的算法或独立部署。
-
互动服务(social-interaction-service)
- 点赞/收藏(
user_favorite)、评论(comment)、关注关系(user_follow)这类写多、和业务强相关但又可以相对独立的行为数据放在一起。 - 对外提供“收藏/取消收藏”“评论 CRUD”“关注/取消关注/粉丝列表”等接口。
- 这样一来,某些高频互动峰值(比如大促活动)只需要扩这一块,不会把整个系统拖慢。
- 点赞/收藏(
-
媒体与对象存储服务(media-service)
- 负责和 MinIO 打交道:文件上传、分片/断点续传、删除、FFmpeg 转码、抽封面这些。
- 其他服务只保存对象 key,需要文件就调用它来生成访问 URL 或做转码。
- 像转码这种 CPU 密集型任务可以单独水平扩展,避免跟业务接口抢资源。
-
网关 + 统一鉴权(api-gateway)
- 前端统一打到 API 网关
/api/**,由网关做路由、限流、灰度发布、统一日志和跨域。 - 鉴权部分可以在网关上做一次 JWT 校验,然后把用户信息透传给后端服务。
- 前端统一打到 API 网关
往微服务拆的时候,我会遵循两个原则:
- 先拆边界清晰、调用相对收敛的服务(比如认证、媒体、推荐),不要一上来就把一个 Controller 一个服务那样“细碎拆”。
- 一服务一库,通过 ID 和消息做关联,尽量避免跨库大事务,能接受最终一致的地方就用异步补偿。
一句话总结:现在是单体,为了开发效率;如果要拆微服务,我会沿着用户认证、音乐内容、推荐、互动、媒体存储这几条“业务线”做纵向拆分,再加一个网关把入口统一起来,让每块都能独立扩容和演进。
除了 JWT + Redis,这个项目在“防止恶意刷接口 / 播放量”等方面做了哪些风控?如果现在要加限流,你会怎么设计?
先说现在这项目里已经有的风控,再说如果要加限流我会怎么设计。
1.现在已经做了哪些防刷手段?
-
登录这块
- 我这边登录一定要走邮箱验证码 + 图形验证码(注册/重置密码也是),验证码存在 Redis 里设置了有效期,防止被暴力猜。
- 登录成功之后发 JWT,同时把 token 写到 Redis 里,后面所有需要权限的接口都要带 token,通过拦截器做校验,未登录用户只能访问那一小撮白名单接口(banner、公开歌单、歌曲详情、热搜等)。
- 账号状态还加了一个
UserStatusEnum,一旦被封禁,登录就会直接拦截,相当于“人工风控开关”。
-
行为类接口的“软防护”
- 点赞/收藏/关注这些操作都做了幂等校验:比如已经收藏过的歌,再点一次会被拦下来,不会一直插数据。
- 搜索热榜虽然是开放接口,但我在后端做了
trim().toLowerCase()、空串过滤,避免有人刷乱七八糟的 key;同时有定时任务每天把热搜清掉,刷出来的脏数据不会长期影响结果。
整体来说,现在是用登录态 + 验证码 + 权限拦截 + 幂等做了一层基础防护,还没有做到那种强限流级别。
2. 如果要加“真正的限流”,我会怎么做?
我会分两层做:网关粗粒度 + 应用层细粒度。
-
第一层:Nginx / 网关做 IP 级粗限流
- 在 Nginx 上用
limit_req/limit_conn按 IP 做一个全局的 QPS 限制,比如单 IP 全站 QPS 不超过 50,异常 IP 直接 429 或封一段时间; - 这一层主要是防止被 DDoS 或脚本疯狂打接口,把后端保护住。
- 在 Nginx 上用
-
第二层:应用里按“接口 + 用户/IP”做细颗粒度限流
这里我会封装一个基于 Redis 的简单限流组件,比如@RateLimit注解 + AOP:- 每次请求根据“接口名 + 用户 ID 或 IP”组一个 key,比如:
- 登录:
rate:login:ip:{ip} - 验证码:
rate:captcha:email:{email} - 搜索:
rate:search:user:{userId}/ 匿名用 IP
- 登录:
- 在 Redis 里
INCR一下并设置过期时间(滑动窗口),比如 60 秒; - 超过阈值就直接返回“操作太频繁,请稍后再试”,同时可以记一条告警日志。
不同接口阈值会不一样,大概会这样配:
- 登录接口:同一个 IP 1 分钟最多 10 次登录尝试;同一个账号连续失败太多次,可以临时锁定一会。
- 验证码发送:同一邮箱 60 秒内只允许发一次,当天最多 10 次;超限后要求换验证方式或联系客服。
- 搜索 / 热搜上报:单用户每秒不超过 3 次,每分钟不超过几十次,超过就只返回缓存结果,不再继续记热度。
- 播放量上报:可以做成“同一个用户对同一首歌,5 分钟内只记一次播放”,Redis 里用 Set 记录
play:{songId}:{userId},设置短 TTL 就行。 - 评论 / 点赞:每个用户每分钟限制操作次数,超了就提示稍后再试,配合人工封禁入口。
- 每次请求根据“接口名 + 用户 ID 或 IP”组一个 key,比如:
如果面试官要一句总结,我会这么说:
“现在项目靠 JWT+Redis、验证码、白名单拦截和幂等做了基础风控,如果要再上一个台阶,我会在 Nginx 做 IP 级粗限流,在应用里用 Redis 封装一个 @RateLimit 注解,针对登录、验证码、搜索、播放统计这些高风险接口做‘按用户/按 IP 的滑动窗口限流’,这样既能挡住恶意刷接口/刷播放量,又不会给正常用户太大影响。”
邮箱验证码、扫码登录这些接口,怎样防止被刷爆?你有没有考虑过滑块/图形验证码、频控之类的方案?
可以分两块回答:现在是怎么做的,以及如果要更强防刷我会怎么加强。
① 现在的做法(已经在用的)
-
基础校验 + 图形验证码
- 发送邮箱验证码前,会先走一次图形验证码校验(或者短信/邮箱前置一个简单的验证码接口),至少让“脚本裸刷”变困难。
- 图形验证码本身放在 Redis 里,有有效期和一次性校验,过期或已经用过就必须重新获取。
-
频控 + 黑名单思路
- 针对邮箱验证码发送接口,会做一个简单的频率限制,例如:
- 同一个邮箱 60 秒内只能发一次验证码;
- 同一个 IP 或邮箱每天最多发 N 次;
- 这些计数用 Redis 做
INCR + EXPIRE,一旦超限就直接返回“操作太频繁”。 - 对于明显异常的 IP 或账号,可以把状态打成“封禁”,所有登录/验证码相关接口直接拒绝。
- 针对邮箱验证码发送接口,会做一个简单的频率限制,例如:
-
扫码登录链路控制
- 微信扫码这块,code 本身是一次性的,而且有有效期;
- 我在回调里也会校验:同一个 code 只能换一次 token,用完就丢弃,防止被重放;
- 同时对“生成授权链接/扫码登录”等接口也可以按 IP 做基本频控,避免有人疯狂弹登录二维码。
② 如果要更强防刷,我会再加这几层
-
滑块 / 行为验证码
- 对“高风险操作”(比如频繁请求验证码、连续登录失败)触发滑块验证码或更复杂的人机校验;
- 粗略策略是:正常第一次/第二次只要图形验证码,达到阈值后再升级为滑块甚至手机二次验证。
-
更细粒度的限流策略
- 在 Nginx 或网关层,对
/sendVerificationCode、/user/login、/wx/xxx这些接口按 IP 做 QPS 限流; - 在应用层用 Redis 做“用户 + 接口”级别的限流(类似
@RateLimit):- 同一邮箱 1 分钟最多发 1 封验证码、1 小时最多 5 封;
- 同一 IP 登录失败超过 N 次,临时拉黑 10–30 分钟。
- 在 Nginx 或网关层,对
-
埋点 + 告警
- 在验证码、扫码登录这些接口里打点:IP、UA、失败原因,发现某个 IP 在短时间内异常高频,就自动拉黑/告警。
如果要一句话讲给面试官听,我会说:
“现在验证码和扫码登录是用‘图形验证码 + JWT+Redis 登录态 + 基本频控’做的基础防刷,如果要再增强,我会在网关加 IP 限流,在应用层用 Redis 做按邮箱/IP 的滑动窗口限流,配合滑块验证码和黑名单机制,把恶意刷验证码、刷扫码登录的行为拦在外面。”
现在你把对象存储改成只存 key,如果未来要接入 CDN、切换到阿里云 OSS,你需要改哪些地方?
先交代一下前提:
我现在的做法是:数据库只存对象 key,比如 vibe-music-data/albums/xxx-cover.jpg;
前端访问统一用一个前缀拼出来,比如 /oss/${key},这个 /oss 背后现在是 Nginx 反代 MinIO。
所以以后不管是上 CDN,还是把 MinIO 换成阿里云 OSS,其实都是在“配置层”动刀,业务代码几乎不用动。
① 接入 CDN,我会改哪里?
-
前端配置改一下 OSS 前缀
现在前端是VITE_OSS_BASE=/oss,以后接入 CDN,可以直接改成:
VITE_OSS_BASE=https://cdn.gqmusic.fun
这样所有toOssUrl(key)自动变成走 CDN 域名,不用改一行业务代码。 -
Nginx/网关按需保留
- 如果 CDN 回源到 Nginx
/oss,那 Nginx 这一段可以继续反代 MinIO 或 OSS; - 如果 CDN 直接回源到对象存储(比如阿里云 OSS),那
/oss这层都可以慢慢下掉,只保留 API 反代。
- 如果 CDN 回源到 Nginx
数据库完全不用动,因为里面存的只是 key。
② 从 MinIO 换到阿里云 OSS,要改哪些地方?
我会主要动三层:
-
存储适配层(后端 Service)
- 现在有个
MinioService,如果要更优雅,会抽一层接口,比如StorageService,底下可以有MinioStorageService/AliOssStorageService; - 换 OSS 的时候,改这个实现:
- 用阿里云 OSS SDK 初始化 client;
- 实现
uploadFile / deleteFile / readText / uploadStream这些方法;
- 对上层业务来说,还是拿到一个 key,完全无感知。
- 现在有个
-
配置文件
application.yml里把 MinIO 的配置换成 OSS 的:endpoint、accessKey、secret、bucket等;- 如果有
app.minio.public-base-url,就改成app.oss.public-base-url或者直接走 CDN 域名。
-
Nginx 或网关(可选)
- 如果仍然希望通过
/oss/{key}访问,可以让 Nginx 反代到 OSS 或 CDN:location /oss/ { proxy_pass https://bucket.oss-cn-hangzhou.aliyuncs.com/; }
- 如果前端已经改用 CDN 域名,其实
/oss这层可以只在开发环境保留,线上直接走 CDN。
- 如果仍然希望通过
一句话总结:
因为现在数据库里只存 key、不存完整 URL,所以未来无论是接 CDN 还是从 MinIO 换到阿里云 OSS,本质上就是:
- 前端改一行
VITE_OSS_BASE; - 后端把
MinioService换成通用的StorageService实现 + 更新配置; - 网关/Nginx 按需改一下
/oss的转发。
业务代码、数据结构基本都不用动,迁移成本会小很多。
线上如果用户反馈“播放很卡”或者“登录偶发失败”,你会从哪几个维度排查?
我一般会先问一句:“是所有人都卡 / 都登不上,还是个别用户偶发?”,然后按“前端 → 网关 / 网络 → 后端服务 → 依赖中间件”这几个层级去排。
一、播放很卡,我会这样排
-
先区分:首帧加载慢,还是播放过程卡顿?
- 首帧慢:多半是 音频 URL 获取/首请求慢;
- 播放过程卡:更像是 带宽不足、CDN/MinIO 抖动、Range 请求不正常。
-
前端侧快速确认
- 让测试开着 DevTools,看这首歌对应的音频请求:
- 看 URL,是不是走我们预期的
/oss/...(排除走错域名/IP); - 看首包时间 / TTFB / 总耗时,是否明显异常;
- 看响应是不是 200/206,有没有大量重试、断流。
- 看 URL,是不是走我们预期的
- 也会问一句:是不是特定运营商/地区慢,还是任何网络都慢。
- 让测试开着 DevTools,看这首歌对应的音频请求:
-
网关 / Nginx & 对象存储
- 登录服务器看 Nginx 日志和状态:
/oss路径的 QPS、平均响应时间,有没有 5xx 或连接超时;- 看机器 CPU、带宽是否打满。
- 再看 MinIO(或后端存储)的状态:
- 磁盘 IO 是否飙高,MinIO 日志里是否有超时、连接拒绝。
- 登录服务器看 Nginx 日志和状态:
-
后端接口与数据库 / Redis
- 如果是获取播放地址接口慢(而不是音频本身):
- 看 Spring Boot 的访问日志 / APM:接口耗时是不是突然增大;
- 查 MySQL 的慢查询、连接数是否打满,Redis 是否有大 key 或阻塞。
- 如果是获取播放地址接口慢(而不是音频本身):
-
定位到点后给方案
- 如果是网络/带宽问题:加缓存、接 CDN、增加带宽或分区域节点;
- 如果是 MinIO / OSS 抖动:做重试、降级(比如提示“网络较差,稍后重试”),同时扩容或迁移;
- 如果是接口本身慢:针对性做缓存(歌曲详情、URL 生成)、索引优化。
二、登录偶发失败,我会这样排
-
先问清“失败表现”
- 是接口直接报错(比如 5xx / 超时),还是前端提示“账号/密码错误、验证码错误、会话过期”?
- 多发生在什么时间段、什么类型用户(新注册 / 老用户 / 管理员)。
-
前端请求情况
- 看浏览器 Network:
/user/login、验证码接口、扫码回调这些的响应码、耗时;- 是否出现跨域 / Mixed Content / HTTPS 证书问题。
- 看浏览器 Network:
-
后端登录链路排查
- 查登录接口日志(我这边会打 email、结果码、异常栈):
- 看是不是验证逻辑过严,比如验证码过期时间太短、频控误伤;
- 看偶发失败时,是否有 Redis 连接失败、超时的异常。
- 再看 Redis:
- 连接数、CPU、内存是否正常,有没有短暂不可用;
- token 写入是否成功(登录成功但 Redis 写失败,也会导致后续请求被当成未登录)。
- 查登录接口日志(我这边会打 email、结果码、异常栈):
-
外部依赖:邮箱 / 微信扫码
- 如果是邮箱验证码登录偶发失败:
- 查邮件发送失败率,是否被服务商限流 / 被当成垃圾邮件;
- 看验证码 Redis key 是否正确写入 / 过早过期。
- 如果是微信扫码:
- 查 YunGou / 微信回调日志,看 code 是否被重复使用、是否超时;
- 网络抖动导致偶发
code2Session失败时,有没有做重试或兜底提示。
- 如果是邮箱验证码登录偶发失败:
-
总结给面试官的思路
- 播放问题我会从“前端表现 + OSS/Nginx 日志 + 后端接口耗时 + 中间件健康”这几层一步步缩小范围;
- 登录问题则重点看“前端错误信息 + 登录接口日志 + Redis/验证码/第三方服务状态”,优先排除限流、防刷策略把正常用户误伤的情况。
一句话说就是:我不会盲目猜,而是按链路从前到后逐层看日志和指标,把问题先归类是网络、存储、服务性能还是风控策略,然后再给对应的优化或修复方案。
你做这个GQ Music 项目时遇到什么难点?你是怎么解决的?
是的,这个项目里我踩过几个比较典型的坑,印象比较深的有这三件事:
案例一:对象存储存的是混合内容和访问很慢
一开始我把对象存储返回的完整地址直接写进数据库,比如带 IP、端口那种链接,在本地没问题,但一上 HTTPS 之后就出事了:
浏览器老报“混合内容”,而且所有图片都绕过了网关,直连存储,既不安全,访问也明显变慢。
后来我干脆把这块重构了一遍:
后端和数据库只保存“对象键”,前端访问统一走一个 /oss 前缀,由网关反代到存储服务;
再把历史数据里的老链接批量执行SQL脚本清洗成纯 key。
这样改完之后,线上都是同源的 HTTPS 资源了,浏览器不再报错,资源还能被网关缓存下来,访问速度和后期接 CDN 的空间都出来了。
案例二:删了数据但推荐区还在,缓存总是“脏”的
还有一个坑是删除相关的:
我们同时用了注解缓存和手写的 Redis 缓存,删歌、删歌单的时候,只会自动把注解那一层清掉,推荐用的那批手写 Key 其实还在。
结果就是后台把歌删掉了,首页推荐里还是会刷出那首歌,给人感觉“怎么删不干净”。
我最后做的是封了一层“小型缓存清理器”:
为了避免删不干净,我又写了一个
CachePurger(应用级缓存清理器),在删除歌手、歌曲、专辑、歌单这类操作后,会按前缀批量清掉和推荐相关的缓存,保证推荐结果和数据库是同步的。
把“删歌手、删专辑、删歌曲、删歌单”的这些业务事件统一收口,在对应的删除流程末尾顺手调一下这个组件,让它按前缀把相关的推荐缓存一并清掉。
对业务代码来说只多了一行调用,但缓存清理的逻辑都集中到一个地方维护,不容易漏,也方便以后扩展别的前缀。
改完之后,再删内容,推荐区就不会再出现那种“僵尸数据”了。
如果面试官继续追问,我一般会补一句:
“整体上,我遇到问题的处理思路都是:先把现象和根因捋清楚,再抽象出一层小组件或一套策略,把当时的补丁固化成通用能力,后面类似问题就不会一遍遍踩了。”





