目录
- [0. 业务背景:达人探店是什么](#0. 业务背景:达人探店是什么)
- [1. 发布探店笔记](#1. 发布探店笔记)
- [2. 查看探店笔记](#2. 查看探店笔记)
- [3. 点赞功能](#3. 点赞功能)
- [4. 点赞排行榜](#4. 点赞排行榜)
- [5. 关键技术原理总结](#5. 关键技术原理总结)
- [6. 练习题](#6. 练习题)
- [7. 练习题参考答案与逐题反馈](#7. 练习题参考答案与逐题反馈)
- 附录:源码索引
0. 业务背景:达人探店是什么
0.1 业务场景
"达人探店"是平台上的用户内容模块:
- 用户到店消费后,可以发布一篇探店笔记;
- 笔记包含标题、文字描述、最多 9 张图片、关联的商户;
- 其他用户可以在首页看到热门笔记,进入详情页阅读;
- 用户可以对笔记点赞/取消点赞;
- 详情页展示最早点赞的 5 个用户头像,形成"点赞排行榜"。
0.2 数据模型
核心表是 tb_blog(resources/db/hmdp.sql:24-36):
| 字段 | 类型 | 含义 |
|---|---|---|
id |
bigint unsigned auto_increment | 主键 |
shop_id |
bigint | 关联商户 id |
user_id |
bigint unsigned | 发布者 id |
title |
varchar(255) | 标题 |
images |
varchar(2048) | 图片路径,最多 9 张,逗号分隔 |
content |
varchar(2048) | 文字描述 |
liked |
int unsigned default 0 | 点赞数 |
comments |
int unsigned | 评论数 |
create_time / update_time |
timestamp | 创建/更新时间 |
对应实体 Blog.java,其中三个字段不是数据库列:
java
@TableField(exist = false)
private String icon; // 发布者头像,查询时填充
@TableField(exist = false)
private String name; // 发布者昵称,查询时填充
@TableField(exist = false)
private Boolean isLike; // 当前登录用户是否点过赞,查询时填充
这三个字段是"展示态字段":数据库里没有,返回给前端时才组装进去。
0.3 接口总览
| 模块 | 方法 | 路径 | 登录要求 |
|---|---|---|---|
| 发布 | POST | /blog |
需要 |
| 图片上传 | POST | /upload/blog |
不需要(/upload/** 被排除) |
| 图片删除 | GET | /upload/blog/delete |
不需要 |
| 热门列表 | GET | /blog/hot?current= |
不需要 |
| 我的笔记 | GET | /blog/of/me |
需要 |
| 笔记详情 | GET | /blog/{id} |
需要 |
| 点赞/取消 | PUT | /blog/like/{id} |
需要 |
| 点赞排行榜 | GET | /blog/likes/{id} |
需要 |
登录要求来自 MvcConfig.java:22-31 的 excludePathPatterns。/blog/hot、/upload/** 被排除,其余 /blog/** 会走 LoginInterceptor,没有登录用户就返回 401。
1. 发布探店笔记
1.1 业务流程
MySQL BlogController UploadController blog-edit.html 用户 MySQL BlogController UploadController blog-edit.html 用户 #mermaid-svg-jckFnkI1CmWvG86D{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jckFnkI1CmWvG86D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jckFnkI1CmWvG86D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jckFnkI1CmWvG86D .error-icon{fill:#552222;}#mermaid-svg-jckFnkI1CmWvG86D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jckFnkI1CmWvG86D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jckFnkI1CmWvG86D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jckFnkI1CmWvG86D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jckFnkI1CmWvG86D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jckFnkI1CmWvG86D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jckFnkI1CmWvG86D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jckFnkI1CmWvG86D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jckFnkI1CmWvG86D .marker.cross{stroke:#333333;}#mermaid-svg-jckFnkI1CmWvG86D svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jckFnkI1CmWvG86D p{margin:0;}#mermaid-svg-jckFnkI1CmWvG86D .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jckFnkI1CmWvG86D text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jckFnkI1CmWvG86D .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-jckFnkI1CmWvG86D .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D .sequenceNumber{fill:white;}#mermaid-svg-jckFnkI1CmWvG86D #sequencenumber{fill:#333;}#mermaid-svg-jckFnkI1CmWvG86D #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-jckFnkI1CmWvG86D .messageText{fill:#333;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jckFnkI1CmWvG86D .labelText,#mermaid-svg-jckFnkI1CmWvG86D .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .loopText,#mermaid-svg-jckFnkI1CmWvG86D .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jckFnkI1CmWvG86D .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jckFnkI1CmWvG86D .noteText,#mermaid-svg-jckFnkI1CmWvG86D .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-jckFnkI1CmWvG86D .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jckFnkI1CmWvG86D .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jckFnkI1CmWvG86D .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jckFnkI1CmWvG86D .actorPopupMenu{position:absolute;}#mermaid-svg-jckFnkI1CmWvG86D .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-jckFnkI1CmWvG86D .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jckFnkI1CmWvG86D .actor-man circle,#mermaid-svg-jckFnkI1CmWvG86D line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-jckFnkI1CmWvG86D :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 选择图片 POST /upload/blog (multipart file) Result.ok("/blogs/7/14/uuid.jpg") fileList.push("/imgs" + data) 填写标题/内容,选择商户,点击发布 POST /blog {title, content, images, shopId} 从 UserHolder 取 userId INSERT tb_blog Result.ok(blogId) 跳转 info.html GET /blog/of/me 我的笔记列表
发布分为两段:
- 图片先上传,得到服务器上的文件路径;
- 提交笔记,把标题、内容、图片路径、商户 id 一起发给后端。
1.2 图片上传接口
UploadController.java:20-36:
java
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
createNewFileName(UploadController.java:47-63):
java
private String createNewFileName(String originalFilename) {
// 获取后缀
String suffix = StrUtil.subAfter(originalFilename, ".", true);
// 生成目录
String name = UUID.randomUUID().toString();
int hash = name.hashCode();
int d1 = hash & 0xF;
int d2 = (hash >> 4) & 0xF;
// 判断目录是否存在
File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR,
StrUtil.format("/blogs/{}/{}", d1, d2));
if (!dir.exists()) {
dir.mkdirs();
}
// 生成文件名
return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);
}
设计要点:
- 文件名用 UUID,避免用户上传同名文件互相覆盖,也避免原始文件名里的特殊字符;
- 目录按 hash 分成 16×16 个桶,避免单个目录下文件过多;
- 返回的是相对路径
/blogs/7/14/uuid.jpg,不含/imgs前缀; - 前端拿到后拼成
/imgs/blogs/7/14/uuid.jpg(blog-edit.html:127-129):
js
axios.post("/upload/blog", formData, config)
.then(({data}) => this.fileList.push('/imgs' + data))
1.3 图片存放位置与 nginx
SystemConstants.java:4:
java
public static final String IMAGE_UPLOAD_DIR =
"D:\\BaiduNetdiskDownload\\heimadianping\\nginx-1.18.0\\html\\hmdp\\imgs";
这个目录就是 nginx 静态资源的 imgs 目录。前端用 /imgs/... 访问时,nginx 直接返回该目录下的文件。
因此上传接口和 nginx 必须指向同一个目录,否则会出现:
- 数据库里有路径;
- 浏览器访问 404。
这个路径是硬编码的 Windows 绝对路径,换机器/部署到 Linux 时需要修改,属于当前项目的可移植性问题。
1.4 发布接口
BlogController.java:35-44:
java
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}
前端提交(blog-edit.html:108-113):
js
submitBlog() {
let {...data} = this.params;
data.images = this.fileList.join(",");
data.shopId = this.selectedShop.id;
axios.post("/blog", data)
.then(resp => location.href = "/info.html")
.catch(this.$message.error)
}
几个关键点:
userId由后端从UserHolder取,不信任前端传值;images是前端把多个图片路径用逗号拼起来的字符串;shopId来自页面选中的商户;blogService.save(blog)是 MyBatis-Plus 的插入方法,id由数据库自增(@TableId(type = IdType.AUTO));- 返回
blog.getId(),前端不使用返回值,直接跳转到info.html。
1.5 发布模块的边界与风险
| 问题 | 位置 | 触发条件 | 影响 | 当前是否处理 |
|---|---|---|---|---|
客户端可以伪造 liked、comments、createTime 等非身份字段 |
BlogController.saveBlog 的 @RequestBody Blog |
直接构造请求体额外字段 | 点赞数/评论数/创建时间可被伪造(id 由数据库自增,通常不受客户端控制) |
未处理 |
| 没有服务端字段校验 | saveBlog |
标题为空、内容超长、图片超过 9 张 | 数据库字段 NOT NULL/长度限制可能导致 500;业务规则只靠前端 | 未处理 |
shopId 未校验是否存在 |
saveBlog |
传入不存在的商户 id | 产生关联不到商户的笔记 | 未处理 |
| 上传接口不需要登录 | MvcConfig 排除 /upload/** |
匿名调用 | 可被匿名往服务器写文件 | 未处理 |
| 删除接口路径穿越 | UploadController.java:38-45 |
name=../../xxx |
可删除上传目录之外的文件 | 未处理 |
| 删除接口无登录校验 | 同上 | 匿名调用 | 任何人可删除图片 | 未处理 |
| 文件类型/大小未校验 | uploadImage |
上传非图片、超大文件 | 存储滥用;Spring 默认单文件 1MB,超限会抛异常 | 未处理 |
| 上传目录硬编码 | SystemConstants.java:4 |
换环境 | 上传成功但访问 404 | 未处理 |
| 内容 XSS | Blog.content + 前端 v-html |
用户提交 <script> 等 |
详情页执行恶意脚本 | 后端未过滤,前端直接渲染 |
这些不是本次功能的核心逻辑,但在真实项目中需要处理。
2. 查看探店笔记
查看分为三个入口:
- 首页热门列表:
GET /blog/hot?current= - 详情页:
GET /blog/{id} - 我的笔记:
GET /blog/of/me
2.1 热门列表
BlogServiceImpl.java:42-56:
java
@Override
public Result queryHotBlog(Integer current){
//根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
//获取当页数据
List<Blog> records = page.getRecords();
//查询用户
records.forEach(blog->{
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
流程:
- 按
liked倒序分页,每页MAX_PAGE_SIZE = 10条; - 对每条笔记调用
queryBlogUser填充昵称和头像; - 调用
isBlogLiked填充当前用户的点赞状态; - 返回
Result.ok(records)。
前端(index.html:86-92):
js
axios.get("/blog/hot?current=" + this.current)
.then(({data}) => {
data.forEach(b => b.img = b.images.split(",")[0]);
this.blogs = this.blogs.concat(data);
})
- 前端把
images按逗号拆分,取第一张作为封面; current递增实现滚动分页;/blog/hot是公开接口,未登录也能访问。
2.2 用户信息填充
BlogServiceImpl.java:146-151:
java
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
icon、name 在数据库里不存在(@TableField(exist = false)),所以每次查询笔记后要单独查一次用户表填充。
2.3 详情页
BlogServiceImpl.java:60-72:
java
@Override
public Result queryBlogById(Long id){
//查询blog
Blog blog = getById(id);
if(blog==null){
return Result.fail("笔记不存在!");
}
//查询blog相关用户
queryBlogUser(blog);
//查询blog是否被点赞
isBlogLiked(blog);
return Result.ok(blog);
}
前端 blog-detail.html:211-221:
js
axios.get("/blog/" + id)
.then(({data}) => {
data.images = data.images.split(",")
this.blog = data;
this.$nextTick(this.init);
this.queryShopById(data.shopId)
this.queryLikeList(id);
this.queryLoginUser();
})
详情页一次性拿到的数据:
- 笔记本身(标题、内容、图片、点赞数、评论数);
- 发布者的
name/icon; - 当前用户是否点过赞(
isLike)。
页面随后再调用:
/shop/{shopId}查询关联商户;/blog/likes/{id}查询点赞排行榜;/user/me查询当前登录用户(用于判断是否关注)。
2.4 我的笔记
BlogController.java:52-62:
java
@GetMapping("/of/me")
public Result queryMyBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
// 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", user.getId())
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}
和热门列表的区别:
- 按
user_id = 当前用户过滤; - 不填充
name/icon/isLike(自己的笔记页面不需要); - 没有显式排序,通常按主键/存储顺序返回;如果业务需要"最新发布在前",应补
orderByDesc("create_time")。
前端 info.html:126-130 在"我的笔记"和"关注"两个 tab 中调用;/blog/of/follow 目前还没有实现,属于后续关注功能。
2.5 查看模块的边界与风险
| 问题 | 位置 | 说明 |
|---|---|---|
| N+1 查询 | queryHotBlog + queryBlogUser |
每页 10 条笔记会额外查 10 次用户表;可以用 listByIds 批量查一次再做映射 |
| 用户被删除导致 NPE | queryBlogUser:149 |
userService.getById 返回 null 时直接 getNickName() 会 NPE |
| 详情内容 XSS | 前端 v-html="blog.content" |
用户提交的 HTML 会被直接渲染 |
images 用逗号分隔 |
Blog.images |
文件名含逗号会解析错;当前 UUID 文件名不会含逗号 |
| 热门列表没有缓存 | queryHotBlog |
每次请求都查数据库;高并发下可加缓存 |
| 分页参数未校验 | queryHotBlog / queryMyBlog |
传入 0 或负数时 MyBatis-Plus 的行为需额外确认 |
其中 N+1 是最值得优化的点:queryBlogLikes 已经用了 listByIds 的思路,热门列表也可以借鉴。
3. 点赞功能
3.1 业务规则
- 一个用户对同一篇笔记只能点一次赞;
- 再次点击表示取消点赞;
- 点赞数展示在笔记上(
blog.liked); - 点赞状态展示在详情页/首页(
blog.isLike); - 点赞记录要能按时间排序,供排行榜使用。
3.2 数据结构:为什么用 ZSet
Redis key:
java
// RedisConstants.java:21
public static final String BLOG_LIKED_KEY = "blog:liked:";
实际 key 为 blog:liked:{blogId},例如 blog:liked:4。
BlogServiceImpl.java:74-76:
java
private String likedKey(Long blogId) {
return RedisConstants.BLOG_LIKED_KEY + blogId;
}
ZSet 的 member 是用户 id,score 是点赞时间戳:
ZADD blog:liked:4 1792724437761 1011
ZADD blog:liked:4 1792724445317 1010
为什么不用 Set:
- Set 只能判断"有没有点过赞",不能排序;
- 排行榜需要按点赞时间取前 5 人,Set 没有 score,做不到;
- ZSet 同时具备"去重(member 唯一)"和"排序(score)"两个能力。
为什么不用数据库:
- 点赞/取消是高频操作,每次都写数据库压力大;
- Redis ZSet 的
ZADD/ZREM/ZSCORE都是 O(log N),而且天然支持范围查询; - 数据库只保留聚合后的
liked数,作为展示和兜底。
3.3 判断是否点过赞
BlogServiceImpl.java:78-89:
java
private void isBlogLiked(Blog blog) {
//未登录用户直接视为未点赞(/blog/hot 是公开接口)
UserDTO user = UserHolder.getUser();
if (user == null) {
blog.setIsLike(false);
return;
}
//判断是否已经点过赞
Double score = stringRedisTemplate.opsForZSet()
.score(likedKey(blog.getId()), user.getId().toString());
blog.setIsLike(score != null);
}
要点:
ZSCORE存在说明点过赞,结果为 null 说明没点过;/blog/hot是公开接口,UserHolder.getUser()可能为 null,必须先判断,否则 NPE;- 这里不再使用
opsForSet().isMember,因为同一个 key 是 ZSet,用 Set 命令会报WRONGTYPE。
3.4 点赞/取消点赞
BlogServiceImpl.java:91-116:
java
@Override
public Result likeBlog(Long id) {
//获取登录用户
Long userId = UserHolder.getUser().getId();
//判断是否已经点过赞
String key = likedKey(id);
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if(score==null){
//如果未点赞,可以点赞
//数据库点赞数加一
boolean isSuccess = update().setSql("liked=liked+1").eq("id", id).update();
//保存用户到redis的set集合
if(isSuccess){
stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
}
}else{
//如果已经点赞,取消点赞
//数据库点赞数-1
boolean isSuccess = update().setSql("liked=liked-1").eq("id", id).update();
//把用户从redis的set集合移除
if(isSuccess){
stringRedisTemplate.opsForZSet().remove(key,userId.toString());
}
}
return Result.ok();
}
流程可以用一张图表示:
渲染错误: Mermaid 渲染失败: Parse error on line 2: ... APUT /blog/like/{id} --> B{ZSCORE b -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'DIAMOND_START'
关键设计:
- 以 Redis 的 ZSCORE 作为"是否点赞"的判据,数据库只维护计数;
- DB 更新成功后才更新 Redis,避免数据库失败但 Redis 已经变化;
ZADD的 score 用当前毫秒时间戳,为排行榜提供排序依据。
3.5 点赞模块的边界与风险
| 问题 | 位置 | 触发条件 | 影响 | 当前是否处理 |
|---|---|---|---|---|
| DB 与 Redis 非原子 | likeBlog |
DB 更新成功、Redis 操作失败 | DB 计数与 ZSet 不一致,isLike/排行榜错误 |
未处理 |
| 并发重复点赞 | likeBlog |
同一用户并发两次请求 | 两个请求都读到 score == null,DB 可能 +2,但 ZSet 只有一个 member |
未处理 |
| 并发重复取消 | likeBlog |
同一用户并发取消两次 | DB 可能 -2,ZSet 只删除一次 | 未处理 |
| 不校验笔记是否存在 | likeBlog |
id 不存在 | update 影响 0 行,仍然返回成功 |
未处理 |
liked 为无符号 |
tb_blog.liked |
计数已为 0 仍执行 -1 | 严格模式可能报错,非严格模式可能被截断 | 未处理 |
| ZSet 没有 TTL | blog:liked:{id} |
长期运行 | 每个笔记一个 key,内存随点赞关系增长 | 未处理 |
| ZSet member 必须是数字 | queryBlogLikes |
数据被手动写坏 | Long.valueOf 抛 NumberFormatException |
未处理 |
其中"并发重复点赞"是比较典型的 check-then-act 竞态:
请求A: ZSCORE → null
请求B: ZSCORE → null
请求A: UPDATE liked = liked + 1
请求B: UPDATE liked = liked + 1 // 计数 +2
请求A: ZADD userId // 集合里只有一个 member
请求B: ZADD userId // 幂等,仍然是同一个 member
最终 liked 比实际点赞人数多 1。要彻底解决,可以让 Redis 的 ZADD 返回值(0 表示已存在、1 表示新增)决定是否更新数据库,或者对 user+blog 加分布式锁。
4. 点赞排行榜
4.1 业务需求
详情页展示"最早点赞的 5 个用户"的头像:
- 排行榜按点赞时间排序;
- 只展示前 5 个;
- 每个元素需要
id和icon,前端用icon渲染头像; - 返回结构是
Result.ok(List<UserDTO>)。
前端调用(blog-detail.html:230-235):
js
queryLikeList(id){
axios.get("/blog/likes/" + id)
.then(({data}) => this.likes = data)
.catch(this.$message.error)
}
模板(blog-detail.html:78-82 附近):
html
<div class="zan-list">
<div class="user-icon-mini" v-for="u in likes" :key="u.id">
<img :src="u.icon || '/imgs/icons/default-icon.png'" alt="">
</div>
<div>{{blog.liked}}人点赞</div>
</div>
4.2 接口实现
BlogController.java:69-72:
java
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable("id") Long id) {
return blogService.queryBlogLikes(id);
}
BlogServiceImpl.java:118-144:
java
@Override
public Result queryBlogLikes(Long id) {
//ZRANGE key 0 4:按点赞时间升序取前5个(最早点赞的5人)
Set<String> idStrings = stringRedisTemplate.opsForZSet()
.range(likedKey(id), 0, 4);
if (idStrings == null || idStrings.isEmpty()) {
//必须返回空列表,不能是 Result.ok()(data 会是 null)
return Result.ok(Collections.emptyList());
}
List<Long> userIds = idStrings.stream()
.map(Long::valueOf)
.collect(Collectors.toList());
//listByIds 不保证顺序,按 ZSet 顺序重建
Map<Long, User> userMap = userService.listByIds(userIds).stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
List<UserDTO> users = new ArrayList<>(userIds.size());
for (Long userId : userIds) {
User user = userMap.get(userId);
//用户已被删除则跳过
if (user != null) {
users.add(BeanUtil.copyProperties(user, UserDTO.class));
}
}
return Result.ok(users);
}
4.3 逐段解释
第一步:取前 5 个用户 id
ZRANGE key 0 4:
- ZSet 按 score 升序排列;
- score 是点赞时间戳,所以升序就是"最早点赞";
0到4是闭区间,正好 5 个。
如果要"最近点赞的 5 人",改成:
java
stringRedisTemplate.opsForZSet().reverseRange(likedKey(id), 0, 4);
第二步:批量查用户
userService.listByIds(userIds):
- 一条
SELECT ... WHERE id IN (...),避免 N+1; - 但 SQL 的
IN不保证返回顺序,可能和 ZSet 顺序不同。
第三步:按 ZSet 顺序重排
先把用户按 id 放进 Map,再按 userIds 的顺序遍历取出:
java
Map<Long, User> userMap = ...;
for (Long userId : userIds) {
User user = userMap.get(userId);
...
}
这样最终列表顺序严格等于 ZSet 的 score 顺序。
第四步:转成 UserDTO
用 BeanUtil.copyProperties(user, UserDTO.class):
UserDTO只有id、nickName、icon;- 避免把
phone、password等敏感字段返回给前端。
第五步:处理空数据和缺失用户
- ZSet 为空:返回
Result.ok(Collections.emptyList()),前端得到[]; - 如果写成
Result.ok(),data是 null,前端likes会变成 null,模板v-for可能报错; - 用户被删除:
listByIds查不到,Map 里没有,遍历时跳过,列表可能不足 5 个。
4.4 排行榜模块的边界与风险
| 问题 | 说明 |
|---|---|
| "排行榜"是按时间,不是按点赞数 | 每个用户只能赞一次,所以没有"点赞数排名";这里展示的是最早点赞的用户 |
| score 相同 | 同一毫秒内点赞的用户,ZSet 会按 member 字典序排列,不是严格的时间先后 |
| ZSet member 必须是数字 | 正常由 likeBlog 写入 userId;手工写入非数字会抛 NumberFormatException |
| 没有 TTL | 排行榜 key 和点赞数据长期存在,内存随点赞关系增长 |
| 返回用户信息有限 | 只返回 id、nickName、icon;如果前端需要更多字段要扩展 UserDTO |
| 未登录不能看 | /blog/likes/** 没有加入排除列表,匿名访问返回 401;详情页本身也需要登录,所以与现有前端流程一致 |
5. 关键技术原理总结
5.1 登录态与 ThreadLocal
RefreshTokenInterceptor拦截所有请求,从authorization请求头取 token,查 Redis 的login:token:{token},把UserDTO放进UserHolder;LoginInterceptor只判断UserHolder.getUser()是否为 null,为 null 返回 401;afterCompletion中UserHolder.removeUser(),避免线程池复用导致用户信息串号。
这解释了为什么 saveBlog、likeBlog、queryMyBlog 可以直接从 UserHolder 取当前用户,而不需要手动传 userId。
5.2 MyBatis-Plus
ServiceImpl<BlogMapper, Blog>提供save、getById、query()、update();query().orderByDesc("liked").page(...)构造分页查询;update().setSql("liked=liked+1").eq("id", id).update()生成原子 SQL 自增/自减;@TableId(type = IdType.AUTO)让插入后回填自增 id;@TableField(exist = false)标记非数据库字段,避免 MyBatis-Plus 拼进 SQL。
5.3 Redis ZSet
| 命令 | Spring API | 用途 |
|---|---|---|
ZADD |
opsForZSet().add |
点赞,score 为时间戳 |
ZREM |
opsForZSet().remove |
取消点赞 |
ZSCORE |
opsForZSet().score |
判断是否点过赞 |
ZRANGE |
opsForZSet().range |
按 score 升序取前 5 |
ZREVRANGE |
opsForZSet().reverseRange |
按 score 降序(如需最近点赞) |
ZSet 同时解决了"去重"和"排序"两个需求,这是它比 Set 更适合点赞场景的原因。
5.4 静态资源与文件上传
MultipartFile.transferTo(File)把上传文件写到磁盘;- 上传目录必须是 nginx 的静态资源目录,前端才能通过
/imgs/...访问; - UUID 文件名 + 二级 hash 目录是常见的文件存储组织方式;
- 真实项目通常会把文件放到对象存储(OSS/MinIO),而不是本地磁盘。
6. 练习题
-
发布链路 :从用户在
blog-edit.html点击"发布"开始,到笔记出现在"我的笔记"页面,请完整描述经过的接口、参数和表字段。userId为什么由后端从UserHolder取,而不是前端传? -
图片上传 :上传接口返回的字符串是什么格式?前端为什么要拼
/imgs?SystemConstants.IMAGE_UPLOAD_DIR和 nginx 是什么关系?删除图片接口存在哪些安全问题? -
查看笔记 :
queryHotBlog为什么要对每条笔记额外调用queryBlogUser和isBlogLiked?icon、name、isLike为什么标@TableField(exist = false)?这样做带来了什么性能问题? -
点赞功能:为什么点赞关系用 Redis ZSet 而不是 Set 或数据库表?请描述点赞和取消点赞的完整流程。数据库更新和 Redis 更新是两步操作,在并发或故障时可能出现什么问题?
-
点赞排行榜 :
ZRANGE key 0 4的含义是什么?为什么listByIds之后还要按 ZSet 顺序重排?如果把需求改成"最近点赞的 5 人",代码应该怎么改?
7. 练习题参考答案与逐题反馈
第 1 题:发布链路
标准答案
完整路径:
1. 用户选择图片
blog-edit.html: fileSelected()
→ POST /upload/blog,multipart 字段名 file
2. UploadController.uploadImage
→ 生成 UUID 文件名,保存到 IMAGE_UPLOAD_DIR/blogs/{d1}/{d2}/uuid.ext
→ 返回 Result.ok("/blogs/{d1}/{d2}/uuid.ext")
3. 前端把返回值拼成 "/imgs" + 路径,加入 fileList
4. 用户填写标题、内容、选择商户,点击发布
→ data.images = fileList.join(",")
→ data.shopId = selectedShop.id
→ POST /blog,JSON 请求体
5. BlogController.saveBlog
→ UserHolder.getUser() 获取当前用户
→ blog.setUserId(user.getId())
→ blogService.save(blog)
6. MySQL INSERT INTO tb_blog
→ id 自增回填
→ liked 使用默认值 0,comments 默认为 0/NULL
→ create_time/update_time 使用数据库默认值
7. 返回 Result.ok(blog.getId())
前端跳转 info.html
8. info.html 调用 GET /blog/of/me
→ 按 user_id = 当前用户分页查询 tb_blog
→ 返回该用户的笔记列表
userId 由后端从 UserHolder 取的原因:
UserHolder里的用户来自 token 对应的 Redis 登录信息,是服务端可信的;- 如果前端传
userId,恶意用户可以伪造别人的 id 发布笔记,冒充他人; - 这是"永远不要信任客户端提交的身份字段"的典型例子。
常见误区
- ❌ "前端传了 userId,后端直接用就行。"------身份必须来自服务端会话/token。
- ❌ "图片和笔记是一次请求提交的。"------当前实现是图片先上传、笔记后提交两次请求。
- ❌ "
images是 JSON 数组。"------数据库存的是逗号分隔字符串,前端负责拆分。 - ❌ "
liked是前端传的初始值。"------数据库默认 0,客户端不应该控制这个字段。
第 2 题:图片上传
标准答案
返回格式:
json
{"success": true, "data": "/blogs/7/14/uuid.jpg"}
返回的是相对路径 ,以 /blogs/ 开头,不含 /imgs。
为什么要拼 /imgs:
- 后端保存目录是
IMAGE_UPLOAD_DIR,对应 nginx 站点下的imgs目录; - 浏览器访问静态资源时,URL 必须以
/imgs/...开头; - 前端把
/imgs和返回的相对路径拼起来,得到完整可访问地址。
IMAGE_UPLOAD_DIR 与 nginx 的关系:
SystemConstants.IMAGE_UPLOAD_DIR 指向 nginx 的静态资源根目录下的 imgs 文件夹。上传写文件到这个目录,nginx 就能通过 /imgs/... 直接把文件返回给浏览器。两者必须指向同一个物理目录,否则上传成功但访问 404。
删除接口的安全问题:
GET /upload/blog/delete?name=...:
/upload/**被排除在登录拦截之外,匿名用户也能调用;name没有做路径校验,传入../可以跳出上传目录,删除其他文件(路径穿越);file.isDirectory()只判断目录,没有判断文件是否存在、是否是图片;- 没有校验文件归属,任何人可以删除别人的图片。
改进方向:要求登录、只允许删除当前用户上传的文件、用文件名白名单/规范化路径校验、限制扩展名和大小。
常见误区
- ❌ "返回的是完整 URL。"------返回的是
/blogs/...,/imgs是前端拼的。 - ❌ "上传目录可以随便放。"------必须和 nginx 静态目录一致。
- ❌ "删除接口有登录拦截。"------
/upload/**被排除了。 - ❌ "UUID 文件名就绝对安全。"------UUID 只解决重名,不解决鉴权和路径穿越。
第 3 题:查看笔记
标准答案
为什么要额外调用两个方法:
tb_blog表里只有user_id,没有用户昵称和头像;icon、name需要根据user_id查tb_user才能得到;isLike是"当前登录用户是否点过赞",数据库里没有这个字段,需要查 Redis ZSet 的ZSCORE。
为什么标 @TableField(exist = false):
- 这三个字段不是
tb_blog的列; - 标记后 MyBatis-Plus 在生成
INSERT/SELECT时不会把它们当成表字段; - 它们只是查询后临时填充、随 JSON 返回给前端的展示字段。
性能问题:N+1 查询
queryHotBlog 每页 10 条笔记:
1 次分页查询 tb_blog
10 次 userService.getById(userId) // 每条笔记一次
10 次 Redis ZSCORE // 每条笔记一次
用户表的 10 次查询可以优化成一次 listByIds:
java
Set<Long> userIds = records.stream().map(Blog::getUserId).collect(Collectors.toSet());
Map<Long, User> userMap = userService.listByIds(userIds).stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
records.forEach(blog -> {
User user = userMap.get(blog.getUserId());
...
});
常见误区
- ❌ "
icon是tb_blog的字段。"------不是,标记了exist=false。 - ❌ "
isLike从数据库查。"------来自 Redis ZSet。 - ❌ "
getById查 10 次没关系。"------这是典型的 N+1,数据量大时明显拖慢接口。 - ❌ "未登录也能查
isLike。"------未登录时isBlogLiked直接设为 false。
第 4 题:点赞功能
标准答案
为什么用 ZSet:
- ZSet 的 member 唯一,天然保证"一个用户只能一条点赞记录";
- score 可以存点赞时间戳,支持按时间排序,排行榜需要;
- Set 只能判断存在性,不能按时间排序;
- 数据库表存点赞关系会带来高频写压力,当前方案用 Redis 承担高频读写,数据库只维护计数。
点赞流程:
1. 从 UserHolder 取 userId
2. ZSCORE blog:liked:{id} userId
3. 如果为 null(未点赞):
3.1 UPDATE tb_blog SET liked = liked + 1 WHERE id = ?
3.2 更新成功 → ZADD blog:liked:{id} userId 当前时间戳
4. 如果不为 null(已点赞):
4.1 UPDATE tb_blog SET liked = liked - 1 WHERE id = ?
4.2 更新成功 → ZREM blog:liked:{id} userId
5. 返回 Result.ok()
两步操作的问题:
- 不是原子操作:DB 更新成功后 Redis 操作失败(Redis 宕机、网络异常),会出现 DB 计数 +1 但 ZSet 没有记录,导致
isLike=false、排行榜少人; - 并发重复点赞:两个请求同时读到
score == null,都执行 DB +1,ZSet 因 member 唯一只保留一个,最终计数比实际多; - 并发取消同理,可能 -2;
likeBlog没有校验笔记是否存在,id 不存在时update影响 0 行,但接口仍返回成功。
常见误区
- ❌ "Redis 和数据库是原子的。"------两次独立操作,中间可能失败。
- ❌ "ZSet 和 Set 差不多。"------ZSet 有 score,能排序;Set 不能。
- ❌ "点赞数只在 Redis 里。"------DB 的
liked才是展示计数,Redis 存的是用户关系。 - ❌ "并发重复点赞不会发生。"------同一用户快速双击或多个请求重试都可能触发。
第 5 题:点赞排行榜
标准答案
ZRANGE key 0 4 的含义:
- 从 ZSet 中按 score 从小到大取下标 0 到 4 的元素,共 5 个;
- score 是点赞时间戳,所以取到的是最早点赞的 5 个用户;
- 返回值是 member(用户 id 字符串)的有序集合。
为什么 listByIds 后还要重排:
SELECT ... WHERE id IN (...)的返回顺序不保证与传入 id 顺序一致;- 如果不重排,前端拿到的头像顺序可能和点赞时间不一致;
- 用 Map 按 id 映射,再按 ZSet 的
userIds顺序遍历,就能保持正确顺序。
改成"最近点赞的 5 人":
java
Set<String> idStrings = stringRedisTemplate.opsForZSet()
.reverseRange(likedKey(id), 0, 4);
reverseRange 按 score 从大到小取,最近点赞的排在前面。
常见误区
- ❌ "ZRANGE 0 4 是最新 5 个。"------是 score 最小的 5 个,即最早点赞的。
- ❌ "listByIds 会按传入顺序返回。"------SQL
IN不保证顺序。 - ❌ "排行榜是按点赞数排名。"------每个用户只能赞一次,没有点赞数差异;这里按时间排。
- ❌ "返回
Result.ok()也可以。"------data为 null,前端likes期望数组。
附录:源码索引
| 内容 | 位置 |
|---|---|
| 发布接口 | controller/BlogController.java:35-44 |
| 点赞接口 | controller/BlogController.java:46-50 |
| 我的笔记 | controller/BlogController.java:52-62 |
| 热门列表接口 | controller/BlogController.java:64-67 |
| 排行榜接口 | controller/BlogController.java:69-72 |
| 详情接口 | controller/BlogController.java:74-77 |
| 热门列表实现 | service/impl/BlogServiceImpl.java:42-56 |
| 详情实现 | service/impl/BlogServiceImpl.java:60-72 |
| likedKey | service/impl/BlogServiceImpl.java:74-76 |
| isBlogLiked | service/impl/BlogServiceImpl.java:78-89 |
| likeBlog | service/impl/BlogServiceImpl.java:91-116 |
| queryBlogLikes | service/impl/BlogServiceImpl.java:118-144 |
| queryBlogUser | service/impl/BlogServiceImpl.java:146-151 |
| 图片上传 | controller/UploadController.java:20-36 |
| 图片删除 | controller/UploadController.java:38-45 |
| 文件名生成 | controller/UploadController.java:47-63 |
| 上传目录 | utils/SystemConstants.java:4 |
| 点赞 key 常量 | utils/RedisConstants.java:21 |
| 登录拦截配置 | config/MvcConfig.java:22-31 |
| Blog 实体 | entity/Blog.java |
| tb_blog 建表 | resources/db/hmdp.sql:24-36 |
前端接口对照
| 页面 | 调用 |
|---|---|
blog-edit.html |
POST /upload/blog、GET /upload/blog/delete、POST /blog、GET /shop/of/name |
index.html |
GET /blog/hot、PUT /blog/like/{id}、GET /blog/{id} |
blog-detail.html |
GET /blog/{id}、GET /blog/likes/{id}、PUT /blog/like/{id}、GET /shop/{id} |
info.html |
GET /blog/of/me、PUT /blog/like/{id}、GET /blog/{id} |