Skip to content

Commit 5a93a4a

Browse files
longsizhuoCopilot
andauthored
fix(posts): Copilot CR 6 条健壮性修复(N+1/404/409/slug)
- N+1:findFeedWithAuthor 改 JOIN 一次取回作者字段,消除 service 层逐行 findById - update() slug 规范化:前端传入的 slug 统一经 sanitizeSlug(),与 create 行为一致 - requirePost() 改抛 ResourceNotFoundException(404),GlobalExceptionHandler 单独处理 - create() / update() 捕获 DuplicateKeyException 返回 409(参考 SharedLinkController) - repository.update() 返回 int(affected rows);0 行时 service 抛 404 而非 IllegalStateException Co-authored-by: copilot-pull-request-reviewer[bot] <198982749+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
1 parent 1ceda79 commit 5a93a4a

6 files changed

Lines changed: 100 additions & 45 deletions

File tree

src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ public ResponseEntity<ApiResponse<Void>> handleAccessDeniedBusiness(AccessDenied
9090
.body(ApiResponse.fail(e.getMessage()));
9191
}
9292

93+
/**
94+
* 业务资源不存在(404)。
95+
* 区别于 IllegalArgumentException(400 参数错误):资源找不到是 404,不是请求格式问题。
96+
*/
97+
@ExceptionHandler(ResourceNotFoundException.class)
98+
public ResponseEntity<ApiResponse<Void>> handleResourceNotFound(ResourceNotFoundException e) {
99+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
100+
.body(ApiResponse.fail(e.getMessage()));
101+
}
102+
93103
// ==========================================
94104
// 业务与通用异常拦截
95105
// ==========================================
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.involutionhell.backend.common.error;
2+
3+
/**
4+
* 业务资源不存在(404 语义)。
5+
*
6+
* 用独立类型而非 IllegalArgumentException:
7+
* IllegalArgumentException 在 GlobalExceptionHandler 里统一映射成 400,
8+
* 找不到资源应是 404,不是请求参数错误。
9+
*
10+
* GlobalExceptionHandler 把它转成 404 + 自定义 message。
11+
*/
12+
public class ResourceNotFoundException extends RuntimeException {
13+
14+
public ResourceNotFoundException(String message) {
15+
super(message);
16+
}
17+
}

src/main/java/com/involutionhell/backend/posts/controller/PostController.java

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.involutionhell.backend.posts.dto.PostSummaryView;
88
import com.involutionhell.backend.posts.dto.PostView;
99
import com.involutionhell.backend.posts.service.PostService;
10+
import org.springframework.dao.DuplicateKeyException;
1011
import org.springframework.http.HttpStatus;
1112
import org.springframework.http.ResponseEntity;
1213
import org.springframework.web.bind.annotation.*;
@@ -43,26 +44,37 @@ public PostController(PostService postService) {
4344
/**
4445
* 创建文章。
4546
* 作者 id 从 Sa-Token 登录态取得,不接受前端传入(防伪造)。
47+
* 并发/重试触发 (author_id, slug) UNIQUE 冲突时返回 409。
4648
*/
4749
@PostMapping
4850
@SaCheckLogin
4951
public ResponseEntity<ApiResponse<PostView>> create(@RequestBody PostRequest req) {
5052
long authorId = StpUtil.getLoginIdAsLong();
51-
PostView view = postService.create(authorId, req);
52-
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(view));
53+
try {
54+
PostView view = postService.create(authorId, req);
55+
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(view));
56+
} catch (DuplicateKeyException e) {
57+
return ResponseEntity.status(HttpStatus.CONFLICT)
58+
.body(new ApiResponse<>(false, "slug 已被占用,请修改文件名后重试", null));
59+
}
5360
}
5461

5562
/**
5663
* 更新文章内容。
57-
* Service 层做 owner 校验,非作者返回 403。
64+
* Service 层做 owner 校验,非作者返回 403;slug 冲突返回 409
5865
*/
5966
@PutMapping("/{id}")
6067
@SaCheckLogin
61-
public ApiResponse<PostView> update(@PathVariable Long id,
62-
@RequestBody PostRequest req) {
68+
public ResponseEntity<ApiResponse<PostView>> update(@PathVariable Long id,
69+
@RequestBody PostRequest req) {
6370
long callerId = StpUtil.getLoginIdAsLong();
64-
PostView view = postService.update(callerId, id, req);
65-
return ApiResponse.ok(view);
71+
try {
72+
PostView view = postService.update(callerId, id, req);
73+
return ResponseEntity.ok(ApiResponse.ok(view));
74+
} catch (DuplicateKeyException e) {
75+
return ResponseEntity.status(HttpStatus.CONFLICT)
76+
.body(new ApiResponse<>(false, "slug 已被占用,请修改文件名后重试", null));
77+
}
6678
}
6779

6880
/**

src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.involutionhell.backend.posts.repository;
22

3+
import com.involutionhell.backend.posts.dto.PostSummaryView;
34
import com.involutionhell.backend.posts.model.Post;
45
import com.involutionhell.backend.posts.model.PostStatus;
56
import com.involutionhell.backend.posts.model.PostVisibility;
@@ -115,14 +116,28 @@ public List<Post> findByAuthor(Long authorId) {
115116
}
116117

117118
@Override
118-
public List<Post> findFeed(int limit, int offset) {
119-
// 只返回已发布且公开的文章,按最新排序
120-
return jdbc.query(
121-
"SELECT * FROM posts WHERE status = ? AND visibility = ? "
122-
+ "ORDER BY created_at DESC LIMIT ? OFFSET ?",
123-
rowMapper,
124-
PostStatus.PUBLISHED, PostVisibility.PUBLIC,
125-
limit, offset);
119+
public List<PostSummaryView> findFeedWithAuthor(int limit, int offset) {
120+
// JOIN user_accounts 一次拿回作者字段,消除 service 层 N+1 查询
121+
String sql = "SELECT p.id, p.author_id, p.slug, p.title, p.description, p.tags, "
122+
+ "p.cover_url, p.visibility, p.status, p.promoted_pr_url, "
123+
+ "p.view_count, p.created_at, p.updated_at, "
124+
+ "u.username AS author_username, "
125+
+ "u.display_name AS author_display_name, "
126+
+ "u.avatar_url AS author_avatar_url "
127+
+ "FROM posts p "
128+
+ "LEFT JOIN user_accounts u ON u.id = p.author_id "
129+
+ "WHERE p.status = ? AND p.visibility = ? "
130+
+ "ORDER BY p.created_at DESC LIMIT ? OFFSET ?";
131+
return jdbc.query(sql, (rs, rowNum) -> {
132+
Post p = rowMapper.mapRow(rs, rowNum);
133+
String username = rs.getString("author_username");
134+
String displayName = rs.getString("author_display_name");
135+
String avatarUrl = rs.getString("author_avatar_url");
136+
return PostSummaryView.from(p,
137+
username != null ? username : "unknown",
138+
displayName != null ? displayName : "",
139+
avatarUrl);
140+
}, PostStatus.PUBLISHED, PostVisibility.PUBLIC, limit, offset);
126141
}
127142

128143
@Override
@@ -136,9 +151,10 @@ public int countByAuthorAndSlugPrefix(Long authorId, String slugPrefix) {
136151
}
137152

138153
@Override
139-
public void update(Long id, String slug, String title, String description,
140-
String tagsJson, String contentMd, String coverUrl) {
141-
jdbc.update(conn -> {
154+
public int update(Long id, String slug, String title, String description,
155+
String tagsJson, String contentMd, String coverUrl) {
156+
// 返回受影响行数;0 表示 id 不存在(被并发删除)
157+
return jdbc.update(conn -> {
142158
PreparedStatement ps = conn.prepareStatement(
143159
"UPDATE posts SET slug = ?, title = ?, description = ?, tags = ?, "
144160
+ "content_md = ?, cover_url = ?, updated_at = NOW() WHERE id = ?");

src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.involutionhell.backend.posts.repository;
22

3+
import com.involutionhell.backend.posts.dto.PostSummaryView;
34
import com.involutionhell.backend.posts.model.Post;
45

56
import java.util.List;
@@ -27,16 +28,20 @@ public interface PostRepository {
2728

2829
/**
2930
* 公开 feed 列表:status=PUBLISHED + visibility=PUBLIC,按 created_at DESC 分页。
31+
* JOIN user_accounts 一次取回作者信息,避免 service 层 N+1 查询。
3032
* limit 上限由 Service 层限定,避免超大 offset 拖垮 DB。
3133
*/
32-
List<Post> findFeed(int limit, int offset);
34+
List<PostSummaryView> findFeedWithAuthor(int limit, int offset);
3335

3436
/** 按 slug 前缀统计同作者已存在的文章数(slug 去重时用)。 */
3537
int countByAuthorAndSlugPrefix(Long authorId, String slugPrefix);
3638

37-
/** 更新文章内容字段(title/description/tags/contentMd/coverUrl/slug)。 */
38-
void update(Long id, String slug, String title, String description,
39-
String tagsJson, String contentMd, String coverUrl);
39+
/**
40+
* 更新文章内容字段(title/description/tags/contentMd/coverUrl/slug)。
41+
* 返回受影响行数;0 表示 id 不存在(并发删除场景)。
42+
*/
43+
int update(Long id, String slug, String title, String description,
44+
String tagsJson, String contentMd, String coverUrl);
4045

4146
/** 删除文章(物理删除)。 */
4247
void delete(Long id);

src/main/java/com/involutionhell/backend/posts/service/PostService.java

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.involutionhell.backend.posts.service;
22

33
import com.involutionhell.backend.common.error.AccessDeniedBusinessException;
4+
import com.involutionhell.backend.common.error.ResourceNotFoundException;
45
import com.involutionhell.backend.posts.dto.PostRequest;
56
import com.involutionhell.backend.posts.dto.PostSummaryView;
67
import com.involutionhell.backend.posts.dto.PostView;
@@ -13,6 +14,7 @@
1314
import com.involutionhell.backend.usercenter.service.UserCenterService;
1415
import org.slf4j.Logger;
1516
import org.slf4j.LoggerFactory;
17+
import org.springframework.dao.DuplicateKeyException;
1618
import org.springframework.stereotype.Service;
1719
import org.springframework.transaction.annotation.Transactional;
1820
import tools.jackson.databind.ObjectMapper;
@@ -132,28 +134,32 @@ public PostView update(Long callerId, Long postId, PostRequest req) {
132134
Post existing = requirePost(postId);
133135
checkOwner(callerId, existing);
134136

135-
// slug 处理:前端可传新 slug(改 URL 场景),不传则沿用旧 slug
137+
// slug 处理:前端可传新 slug(改 URL 场景),不传则沿用旧 slug。
138+
// 必须经 sanitizeSlug 规范化,保证与 create 路径的格式一致(小写、连字符、长度限制)。
136139
String newSlug = (req.slug() != null && !req.slug().isBlank())
137-
? req.slug()
140+
? sanitizeSlug(req.slug())
138141
: existing.slug();
139142

140-
// slug 改变,需要检查新 slug 是否与该作者其他文章冲突
143+
// slug 冲突检查基于规范化后的值,排除自身(slug 未变则跳过)
141144
if (!newSlug.equals(existing.slug())) {
142145
boolean taken = postRepo.findByAuthorAndSlug(callerId, newSlug).isPresent();
143146
if (taken) {
144-
throw new IllegalArgumentException("slug 已被使用:" + newSlug);
147+
throw new DuplicateKeyException("slug 已被使用:" + newSlug);
145148
}
146149
}
147150

148151
String tagsJson = serializeTags(req.tags());
149-
postRepo.update(postId, newSlug, req.title(), req.description(),
152+
// update 返回受影响行数;0 表示记录在校验完成后被并发删除
153+
int affected = postRepo.update(postId, newSlug, req.title(), req.description(),
150154
tagsJson, req.contentMd(), req.coverUrl());
155+
if (affected == 0) {
156+
throw new ResourceNotFoundException("文章已不存在:" + postId);
157+
}
151158

152159
log.info("post updated: id={} author={}", postId, callerId);
153160

154-
Post updated = postRepo.findById(postId)
155-
.orElseThrow(() -> new IllegalStateException("post not found after update: " + postId));
156-
return buildView(updated);
161+
return buildView(postRepo.findById(postId)
162+
.orElseThrow(() -> new ResourceNotFoundException("文章不存在:" + postId)));
157163
}
158164

159165
/**
@@ -241,19 +247,8 @@ public List<PostSummaryView> listByAuthor(Long authorId) {
241247
public List<PostSummaryView> listFeed(int limit, int offset) {
242248
int safeLimit = Math.min(Math.max(limit, 1), MAX_FEED_LIMIT);
243249
int safeOffset = Math.max(offset, 0);
244-
245-
List<Post> posts = postRepo.findFeed(safeLimit, safeOffset);
246-
247-
// 批量拼作者信息:每篇独立查一次(MVP 量级可接受;后续可加缓存或 JOIN 优化)
248-
return posts.stream()
249-
.map(p -> {
250-
UserAccount author = userAccountRepository.findById(p.authorId()).orElse(null);
251-
String username = author != null ? author.username() : "unknown";
252-
String displayName = author != null ? author.displayName() : "";
253-
String avatarUrl = author != null ? author.avatarUrl() : null;
254-
return PostSummaryView.from(p, username, displayName, avatarUrl);
255-
})
256-
.toList();
250+
// JOIN 查询一次取回作者信息,消除 N+1
251+
return postRepo.findFeedWithAuthor(safeLimit, safeOffset);
257252
}
258253

259254
// ========== 私有工具方法 ==========
@@ -271,11 +266,11 @@ private void validateRequest(PostRequest req) {
271266
}
272267

273268
/**
274-
* 按 id 查文章,不存在则抛 404 语义的 IllegalArgumentException
269+
* 按 id 查文章,不存在则抛 ResourceNotFoundException(→ 404)
275270
*/
276271
private Post requirePost(Long postId) {
277272
return postRepo.findById(postId)
278-
.orElseThrow(() -> new IllegalArgumentException("文章不存在:" + postId));
273+
.orElseThrow(() -> new ResourceNotFoundException("文章不存在:" + postId));
279274
}
280275

281276
/**

0 commit comments

Comments
 (0)