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,所有需要登录的接口都会先过这个拦截器,逻辑大概是:

  1. 从请求头里拿到 Authorization: Bearer xxx,把 JWT token 抠出来;
  2. 先看这个路径是不是白名单(比如获取 Banner、搜索热词这些公开接口),白名单就直接放行;
  3. 非白名单的,就会用 JwtUtil 去校验这个 token:签名对不对、有没有过期;
  4. 校验通过之后,再到 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 做缓存,比如 songCacheplaylistCache 等;
    一旦有新增、修改、删除,就在对应的方法上加 @CacheEvict(allEntries = true),直接把这个命名空间下的缓存全清掉。

  • 这样做的好处是:业务代码很干净,查数据的时候不用手动操作 Redis,而且更新后不会出现读到旧数据的问题。

  • 第二条线:RedisTemplate 管“非标准”的缓存
    比如:

    • 用户的推荐歌曲池:recommended_songs:{userId}(有效期 30 分钟);

    • 邮箱验证码:verificationCode:{email}(5 分钟);

    • 搜索热榜用 Redis ZSet 做排序;
      这些就不太适合用注解,我是直接用 StringRedisTemplate/RedisTemplate 手动读写,TTL 单独控制。

      为了避免删不干净,我又写了一个 CachePurger(应用级缓存清理器),在删除歌手、歌曲、专辑、歌单这类操作后,会按前缀批量清掉和推荐相关的缓存,保证推荐结果和数据库是同步的。

  • 一致性这块的策略

我用的是最稳的做法:

  1. 读操作:先查缓存 → 没命中就回库并回写缓存。
  2. 写操作:先写数据库 → 再清缓存(命名空间整体删)。

这样能保证:

  • 不会出现脏读
  • 删除或更新后立即生效
  • 而且逻辑简单、维护成本低

如果遇到数据规模增长,我们也可以把前缀删除升级到 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 能力:

  1. 前端先调一个“初始化上传”的接口,后端生成 uploadId,并把文件 hash、大小和 uploadId 等信息记在 Redis 里,设一个比较长的 TTL,当成续传窗口

  2. 断点续传

    如果中断了,前端每次上传前会查一下“已上传分片”,我们会从 Redis / MinIO 查询已成功的 part 列表,前端只补上传缺失的部分;

  3. 合并分片
    全部分片上传成功后,前端调“完成上传”接口,MinIO 在服务端合并,顺便做一下校验

整个过程基本不在应用服务器上存大文件,本质上就是:服务端负责握手和校验,真正的数据流直接进 MinIO。

③ FFmpeg 转码处理:上传完再异步做

  • 媒体文件写入 MinIO 成功后,我会在后台触发一个异步任务,用封装好的 FFmpegUtils 去做处理,比如:

    • 音频统一转成一套标准编码格式,必要时再生成一个短一点的试听版本;

    • 视频抽首帧做封面图。

所有转码产物也都会按目录规范写回 MinIO,例如:
songs/originalsongs/encodedsongs/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 前缀。”

大概做了几件事:

  1. 先在网关上兜一层
    Nginx 里把 https://gqmusic.fun/oss/** 统一反代到本机的 MinIOhttp://127.0.0.1:19000/**
    保证前端看到的资源地址永远是 /oss/vibe-music-data/...既同源又能走缓存

  2. 改后端:上传只返回 key,数据库只落 key

    • MinIO 上传成功后,我不再拼完整 URL,而是只拿对象键,比如 vibe-music-data/albums/xxx-cover.jpg
    • 数据库字段统一改成存这个 key;
    • 对外返回时,要么直接把 key 给前端,要么在后端用配置里的 ossBase=/oss 拼一个 url = /oss/{key}
  3. 改前端:统一用 /oss + key 拼地址

    • 在前端加了 VITE_OSS_BASE=/oss 这类配置;
    • 所有图片、音频、封面、头像的 src 都改成:toOssUrl(key) => VITE_OSS_BASE + '/' + key
    • 代码里彻底干掉 http://103.236.55.216:19000 这种硬编码。
  4. 对历史数据做了一次清洗
    对库里已经存好的 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 的 ZSetkey = 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 用来主动上报一次计数。
      这两个接口我在登录拦截器里加了白名单,不登录也能访问,方便未登录用户也看到热搜。
  • 埋点是怎么做的?
    主要有两处:

    1. 歌曲搜索接口里,如果请求体里带了 keyword,我会在进服务层前先调一次 increaseKeyword,即使命中了 @Cacheable 直接返回,也能把搜索行为记进去;
    2. 前端输入框那边,用户回车搜索或点了某个热词时,会再调一次 /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 / cancelCollectSongcollectPlaylist / cancelCollectPlaylist,每次操作都会先根据当前登录用户和目标 ID 查一遍,防止重复插入或重复取消,保证幂等。
    在返回列表、推荐结果时,我会先把所有 songId 查出来,批量去 user_favorite 里看用户有没有收藏,然后把 SongVO.likeStatus 补上,这样前端一拿到数据就知道哪些是“已点赞/已收藏”。

  • 评论(歌曲、歌单下的评论)
    评论是单独一张 comment 表,结合 CommentTypeEnum 区分是“歌曲评论”还是“歌单评论”,支持楼中楼回复
    设计上有 rootId/parentId/replyUserId 这些字段,一条评论既可以是根评论,也可以是回复别人;
    对外接口包括:发表评论、删除自己的评论、分页拉取评论列表,按时间或热度排序。
    为了避免一条评论刷新就消失,我这块读写是直接走数据库,列表做了分页和简单的敏感校验,必要的时候可以再加审核流。

  • 关注(用户之间的关注/粉丝)
    关注关系用的是 user_follow 表,记录 follower_idfollowee_id
    在 UserService 里我提供了 followUser / cancelFollowUser / isUserFollowed / getFollowing / getFollowers 这一组接口:

    • 先从 JWT + ThreadLocal 里拿当前用户 ID,禁止自己关注自己;
    • 关注前会先查是否已存在这条关系,避免重复插入;
    • 个人主页接口里,会同时查出 TA 的粉丝数、关注数,以及“我是否已关注 TA”,方便前端一并展示。

整体上,这几个互动模块都绑定在统一的登录体系下:请求先走拦截器校验 JWT + Redis 会话,拿到当前用户 ID 后,在各自的行为表里做幂等插入/删除,再在各种 VO(歌曲、歌单、用户资料)上补充状态字段,这样前端拿到一份数据就能把点赞、评论数、关注状态都展示完整。

你们现在是单体还是微服务?如果要拆微服务,你会怎么拆 GQ Music 这个项目?

现在这套 GQ Music 后端,其实就是一个Spring Boot 单体应用,所有模块都在一个工程里:用户、音乐库、推荐、互动、后台管理、对象存储这些都是同一个进程、同一个数据库里。
这样做的原因也比较现实:项目体量和并发都不算特别大,一个人/小团队开发维护起来成本最低、迭代也快。

如果以后访问量上来了或者团队变大,需要往微服务拆,我大概会按业务边界去拆,优先拆“变化频率高、资源消耗重、职责清晰”的几块:

  1. 用户与认证中心(user-auth-service)

    • 负责用户注册登录、JWT 签发校验、微信扫码登录、权限角色这些。
    • 独立库存 user / admin 表,其他服务通过 RPC/HTTP 拿用户基本信息或校验 token。
    • 好处是登录、权限逻辑统一,后面要接别的终端(小程序、App)也直接复用。
  2. 音乐内容服务(music-catalog-service)

    • 管理歌曲、歌手、专辑、歌单这些“内容类”数据和搜索分页。
    • 现在的 SongController/ArtistController/AlbumController/PlaylistController 这堆都放进来,配一套自己的 MySQL + Redis 缓存。
    • 这是最核心的读写流量,单独拆出来便于横向扩容。
  3. 推荐与搜索服务(recommend-search-service)

    • 专门负责歌单推荐、歌曲推荐、搜索热词统计(Redis ZSet 那一块)。
    • 它可以订阅用户行为(收藏、播放)做异步计算,把推荐结果、候选池写到自己的 Redis 里,对外只暴露“取推荐结果”“取热搜 TopN”的接口。
    • 好处是推荐逻辑可以独立演进,甚至以后可以上更重的算法或独立部署。
  4. 互动服务(social-interaction-service)

    • 点赞/收藏(user_favorite)、评论(comment)、关注关系(user_follow)这类写多、和业务强相关但又可以相对独立的行为数据放在一起。
    • 对外提供“收藏/取消收藏”“评论 CRUD”“关注/取消关注/粉丝列表”等接口。
    • 这样一来,某些高频互动峰值(比如大促活动)只需要扩这一块,不会把整个系统拖慢。
  5. 媒体与对象存储服务(media-service)

    • 负责和 MinIO 打交道:文件上传、分片/断点续传、删除、FFmpeg 转码、抽封面这些。
    • 其他服务只保存对象 key,需要文件就调用它来生成访问 URL 或做转码。
    • 像转码这种 CPU 密集型任务可以单独水平扩展,避免跟业务接口抢资源。
  6. 网关 + 统一鉴权(api-gateway)

    • 前端统一打到 API 网关 /api/**,由网关做路由、限流、灰度发布、统一日志和跨域。
    • 鉴权部分可以在网关上做一次 JWT 校验,然后把用户信息透传给后端服务。

往微服务拆的时候,我会遵循两个原则:

  • 先拆边界清晰、调用相对收敛的服务(比如认证、媒体、推荐),不要一上来就把一个 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 或脚本疯狂打接口,把后端保护住。
  • 第二层:应用里按“接口 + 用户/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 就行。
    • 评论 / 点赞:每个用户每分钟限制操作次数,超了就提示稍后再试,配合人工封禁入口。

如果面试官要一句总结,我会这么说:
“现在项目靠 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 分钟。
  • 埋点 + 告警

    • 在验证码、扫码登录这些接口里打点: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 反代。

数据库完全不用动,因为里面存的只是 key。


② 从 MinIO 换到阿里云 OSS,要改哪些地方?

我会主要动三层:

  1. 存储适配层(后端 Service)

    • 现在有个 MinioService,如果要更优雅,会抽一层接口,比如 StorageService,底下可以有 MinioStorageService / AliOssStorageService
    • 换 OSS 的时候,改这个实现:
      • 用阿里云 OSS SDK 初始化 client;
      • 实现 uploadFile / deleteFile / readText / uploadStream 这些方法;
    • 对上层业务来说,还是拿到一个 key,完全无感知。
  2. 配置文件

    • application.yml 里把 MinIO 的配置换成 OSS 的:endpoint、accessKey、secret、bucket 等;
    • 如果有 app.minio.public-base-url,就改成 app.oss.public-base-url 或者直接走 CDN 域名。
  3. 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 的转发。

业务代码、数据结构基本都不用动,迁移成本会小很多。

线上如果用户反馈“播放很卡”或者“登录偶发失败”,你会从哪几个维度排查?

我一般会先问一句:“是所有人都卡 / 都登不上,还是个别用户偶发?”,然后按“前端 → 网关 / 网络 → 后端服务 → 依赖中间件”这几个层级去排。


一、播放很卡,我会这样排

  1. 先区分:首帧加载慢,还是播放过程卡顿?

    • 首帧慢:多半是 音频 URL 获取/首请求慢
    • 播放过程卡:更像是 带宽不足、CDN/MinIO 抖动、Range 请求不正常
  2. 前端侧快速确认

    • 让测试开着 DevTools,看这首歌对应的音频请求:
      • 看 URL,是不是走我们预期的 /oss/...(排除走错域名/IP);
      • 看首包时间 / TTFB / 总耗时,是否明显异常;
      • 看响应是不是 200/206,有没有大量重试、断流。
    • 也会问一句:是不是特定运营商/地区慢,还是任何网络都慢。
  3. 网关 / Nginx & 对象存储

    • 登录服务器看 Nginx 日志和状态
      • /oss 路径的 QPS、平均响应时间,有没有 5xx 或连接超时
      • 看机器 CPU、带宽是否打满。
    • 再看 MinIO(或后端存储)的状态:
      • 磁盘 IO 是否飙高,MinIO 日志里是否有超时、连接拒绝。
  4. 后端接口与数据库 / Redis

    • 如果是获取播放地址接口慢(而不是音频本身):
      • 看 Spring Boot 的访问日志 / APM:接口耗时是不是突然增大;
      • 查 MySQL 的慢查询、连接数是否打满,Redis 是否有大 key 或阻塞。
  5. 定位到点后给方案

    • 如果是网络/带宽问题:加缓存、接 CDN、增加带宽或分区域节点;
    • 如果是 MinIO / OSS 抖动:做重试、降级(比如提示“网络较差,稍后重试”),同时扩容或迁移;
    • 如果是接口本身慢:针对性做缓存(歌曲详情、URL 生成)、索引优化。

二、登录偶发失败,我会这样排

  1. 先问清“失败表现”

    • 接口直接报错(比如 5xx / 超时),还是前端提示“账号/密码错误、验证码错误、会话过期”?
    • 多发生在什么时间段什么类型用户(新注册 / 老用户 / 管理员)。
  2. 前端请求情况

    • 看浏览器 Network:
      • /user/login、验证码接口、扫码回调这些的响应码、耗时;
      • 是否出现跨域 / Mixed Content / HTTPS 证书问题。
  3. 后端登录链路排查

    • 查登录接口日志(我这边会打 email、结果码、异常栈):
      • 看是不是验证逻辑过严,比如验证码过期时间太短、频控误伤;
      • 看偶发失败时,是否有 Redis 连接失败、超时的异常。
    • 再看 Redis:
      • 连接数、CPU、内存是否正常,有没有短暂不可用;
      • token 写入是否成功(登录成功但 Redis 写失败,也会导致后续请求被当成未登录)。
  4. 外部依赖:邮箱 / 微信扫码

    • 如果是邮箱验证码登录偶发失败:
      • 查邮件发送失败率,是否被服务商限流 / 被当成垃圾邮件;
      • 看验证码 Redis key 是否正确写入 / 过早过期。
    • 如果是微信扫码
      • 查 YunGou / 微信回调日志,看 code 是否被重复使用、是否超时;
      • 网络抖动导致偶发 code2Session 失败时,有没有做重试或兜底提示。
  5. 总结给面试官的思路

    • 播放问题我会从“前端表现 + OSS/Nginx 日志 + 后端接口耗时 + 中间件健康”这几层一步步缩小范围;
    • 登录问题则重点看“前端错误信息 + 登录接口日志 + Redis/验证码/第三方服务状态”,优先排除限流、防刷策略把正常用户误伤的情况。

一句话说就是:我不会盲目猜,而是按链路从前到后逐层看日志和指标,把问题先归类是网络、存储、服务性能还是风控策略,然后再给对应的优化或修复方案。

你做这个GQ Music 项目时遇到什么难点?你是怎么解决的?


是的,这个项目里我踩过几个比较典型的坑,印象比较深的有这三件事:

案例一:对象存储存的是混合内容和访问很慢

一开始我把对象存储返回的完整地址直接写进数据库,比如带 IP、端口那种链接,在本地没问题,但一上 HTTPS 之后就出事了:
浏览器老报“混合内容”,而且所有图片都绕过了网关,直连存储,既不安全,访问也明显变慢。

后来我干脆把这块重构了一遍:
后端和数据库只保存“对象键”,前端访问统一走一个 /oss 前缀,由网关反代到存储服务;
再把历史数据里的老链接批量执行SQL脚本清洗成纯 key。
这样改完之后,线上都是同源的 HTTPS 资源了,浏览器不再报错,资源还能被网关缓存下来,访问速度和后期接 CDN 的空间都出来了。


案例二:删了数据但推荐区还在,缓存总是“脏”的

还有一个坑是删除相关的:
我们同时用了注解缓存和手写的 Redis 缓存,删歌、删歌单的时候,只会自动把注解那一层清掉,推荐用的那批手写 Key 其实还在。
结果就是后台把歌删掉了,首页推荐里还是会刷出那首歌,给人感觉“怎么删不干净”。

我最后做的是封了一层“小型缓存清理器”:

为了避免删不干净,我又写了一个 CachePurger(应用级缓存清理器),在删除歌手、歌曲、专辑、歌单这类操作后,会按前缀批量清掉和推荐相关的缓存,保证推荐结果和数据库是同步的。

把“删歌手、删专辑、删歌曲、删歌单”的这些业务事件统一收口,在对应的删除流程末尾顺手调一下这个组件,让它按前缀把相关的推荐缓存一并清掉。
对业务代码来说只多了一行调用,但缓存清理的逻辑都集中到一个地方维护,不容易漏,也方便以后扩展别的前缀。
改完之后,再删内容,推荐区就不会再出现那种“僵尸数据”了。


如果面试官继续追问,我一般会补一句:
“整体上,我遇到问题的处理思路都是:先把现象和根因捋清楚,再抽象出一层小组件或一套策略,把当时的补丁固化成通用能力,后面类似问题就不会一遍遍踩了。”