diff --git a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java index f166738..b6d0e1f 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -90,6 +90,16 @@ public ResponseEntity> handleAccessDeniedBusiness(AccessDenied .body(ApiResponse.fail(e.getMessage())); } + /** + * 业务资源不存在(404)。 + * 区别于 IllegalArgumentException(400 参数错误):资源找不到是 404,不是请求格式问题。 + */ + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleResourceNotFound(ResourceNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.fail(e.getMessage())); + } + // ========================================== // 业务与通用异常拦截 // ========================================== diff --git a/src/main/java/com/involutionhell/backend/common/error/ResourceNotFoundException.java b/src/main/java/com/involutionhell/backend/common/error/ResourceNotFoundException.java new file mode 100644 index 0000000..1a53fb4 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/common/error/ResourceNotFoundException.java @@ -0,0 +1,17 @@ +package com.involutionhell.backend.common.error; + +/** + * 业务资源不存在(404 语义)。 + * + * 用独立类型而非 IllegalArgumentException: + * IllegalArgumentException 在 GlobalExceptionHandler 里统一映射成 400, + * 找不到资源应是 404,不是请求参数错误。 + * + * GlobalExceptionHandler 把它转成 404 + 自定义 message。 + */ +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/involutionhell/backend/posts/controller/PostController.java b/src/main/java/com/involutionhell/backend/posts/controller/PostController.java index 17a28ac..edd9d63 100644 --- a/src/main/java/com/involutionhell/backend/posts/controller/PostController.java +++ b/src/main/java/com/involutionhell/backend/posts/controller/PostController.java @@ -7,6 +7,7 @@ import com.involutionhell.backend.posts.dto.PostSummaryView; import com.involutionhell.backend.posts.dto.PostView; import com.involutionhell.backend.posts.service.PostService; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -43,26 +44,37 @@ public PostController(PostService postService) { /** * 创建文章。 * 作者 id 从 Sa-Token 登录态取得,不接受前端传入(防伪造)。 + * 并发/重试触发 (author_id, slug) UNIQUE 冲突时返回 409。 */ @PostMapping @SaCheckLogin public ResponseEntity> create(@RequestBody PostRequest req) { long authorId = StpUtil.getLoginIdAsLong(); - PostView view = postService.create(authorId, req); - return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(view)); + try { + PostView view = postService.create(authorId, req); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(view)); + } catch (DuplicateKeyException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ApiResponse<>(false, "slug 已被占用,请修改文件名后重试", null)); + } } /** * 更新文章内容。 - * Service 层做 owner 校验,非作者返回 403。 + * Service 层做 owner 校验,非作者返回 403;slug 冲突返回 409。 */ @PutMapping("/{id}") @SaCheckLogin - public ApiResponse update(@PathVariable Long id, - @RequestBody PostRequest req) { + public ResponseEntity> update(@PathVariable Long id, + @RequestBody PostRequest req) { long callerId = StpUtil.getLoginIdAsLong(); - PostView view = postService.update(callerId, id, req); - return ApiResponse.ok(view); + try { + PostView view = postService.update(callerId, id, req); + return ResponseEntity.ok(ApiResponse.ok(view)); + } catch (DuplicateKeyException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ApiResponse<>(false, "slug 已被占用,请修改文件名后重试", null)); + } } /** diff --git a/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java b/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java index 9e7f77d..7b81353 100644 --- a/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java +++ b/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java @@ -1,5 +1,6 @@ package com.involutionhell.backend.posts.repository; +import com.involutionhell.backend.posts.dto.PostSummaryView; import com.involutionhell.backend.posts.model.Post; import com.involutionhell.backend.posts.model.PostStatus; import com.involutionhell.backend.posts.model.PostVisibility; @@ -115,14 +116,28 @@ public List findByAuthor(Long authorId) { } @Override - public List findFeed(int limit, int offset) { - // 只返回已发布且公开的文章,按最新排序 - return jdbc.query( - "SELECT * FROM posts WHERE status = ? AND visibility = ? " - + "ORDER BY created_at DESC LIMIT ? OFFSET ?", - rowMapper, - PostStatus.PUBLISHED, PostVisibility.PUBLIC, - limit, offset); + public List findFeedWithAuthor(int limit, int offset) { + // JOIN user_accounts 一次拿回作者字段,消除 service 层 N+1 查询 + String sql = "SELECT p.id, p.author_id, p.slug, p.title, p.description, p.tags, " + + "p.cover_url, p.visibility, p.status, p.promoted_pr_url, " + + "p.view_count, p.created_at, p.updated_at, " + + "u.username AS author_username, " + + "u.display_name AS author_display_name, " + + "u.avatar_url AS author_avatar_url " + + "FROM posts p " + + "LEFT JOIN user_accounts u ON u.id = p.author_id " + + "WHERE p.status = ? AND p.visibility = ? " + + "ORDER BY p.created_at DESC LIMIT ? OFFSET ?"; + return jdbc.query(sql, (rs, rowNum) -> { + Post p = rowMapper.mapRow(rs, rowNum); + String username = rs.getString("author_username"); + String displayName = rs.getString("author_display_name"); + String avatarUrl = rs.getString("author_avatar_url"); + return PostSummaryView.from(p, + username != null ? username : "unknown", + displayName != null ? displayName : "", + avatarUrl); + }, PostStatus.PUBLISHED, PostVisibility.PUBLIC, limit, offset); } @Override @@ -136,9 +151,10 @@ public int countByAuthorAndSlugPrefix(Long authorId, String slugPrefix) { } @Override - public void update(Long id, String slug, String title, String description, - String tagsJson, String contentMd, String coverUrl) { - jdbc.update(conn -> { + public int update(Long id, String slug, String title, String description, + String tagsJson, String contentMd, String coverUrl) { + // 返回受影响行数;0 表示 id 不存在(被并发删除) + return jdbc.update(conn -> { PreparedStatement ps = conn.prepareStatement( "UPDATE posts SET slug = ?, title = ?, description = ?, tags = ?, " + "content_md = ?, cover_url = ?, updated_at = NOW() WHERE id = ?"); diff --git a/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java b/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java index 95e14cb..e650789 100644 --- a/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java +++ b/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.involutionhell.backend.posts.repository; +import com.involutionhell.backend.posts.dto.PostSummaryView; import com.involutionhell.backend.posts.model.Post; import java.util.List; @@ -27,16 +28,20 @@ public interface PostRepository { /** * 公开 feed 列表:status=PUBLISHED + visibility=PUBLIC,按 created_at DESC 分页。 + * JOIN user_accounts 一次取回作者信息,避免 service 层 N+1 查询。 * limit 上限由 Service 层限定,避免超大 offset 拖垮 DB。 */ - List findFeed(int limit, int offset); + List findFeedWithAuthor(int limit, int offset); /** 按 slug 前缀统计同作者已存在的文章数(slug 去重时用)。 */ int countByAuthorAndSlugPrefix(Long authorId, String slugPrefix); - /** 更新文章内容字段(title/description/tags/contentMd/coverUrl/slug)。 */ - void update(Long id, String slug, String title, String description, - String tagsJson, String contentMd, String coverUrl); + /** + * 更新文章内容字段(title/description/tags/contentMd/coverUrl/slug)。 + * 返回受影响行数;0 表示 id 不存在(并发删除场景)。 + */ + int update(Long id, String slug, String title, String description, + String tagsJson, String contentMd, String coverUrl); /** 删除文章(物理删除)。 */ void delete(Long id); diff --git a/src/main/java/com/involutionhell/backend/posts/service/PostService.java b/src/main/java/com/involutionhell/backend/posts/service/PostService.java index 6ab97e6..0263341 100644 --- a/src/main/java/com/involutionhell/backend/posts/service/PostService.java +++ b/src/main/java/com/involutionhell/backend/posts/service/PostService.java @@ -1,6 +1,7 @@ package com.involutionhell.backend.posts.service; import com.involutionhell.backend.common.error.AccessDeniedBusinessException; +import com.involutionhell.backend.common.error.ResourceNotFoundException; import com.involutionhell.backend.posts.dto.PostRequest; import com.involutionhell.backend.posts.dto.PostSummaryView; import com.involutionhell.backend.posts.dto.PostView; @@ -13,6 +14,7 @@ import com.involutionhell.backend.usercenter.service.UserCenterService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tools.jackson.databind.ObjectMapper; @@ -132,28 +134,32 @@ public PostView update(Long callerId, Long postId, PostRequest req) { Post existing = requirePost(postId); checkOwner(callerId, existing); - // slug 处理:前端可传新 slug(改 URL 场景),不传则沿用旧 slug + // slug 处理:前端可传新 slug(改 URL 场景),不传则沿用旧 slug。 + // 必须经 sanitizeSlug 规范化,保证与 create 路径的格式一致(小写、连字符、长度限制)。 String newSlug = (req.slug() != null && !req.slug().isBlank()) - ? req.slug() + ? sanitizeSlug(req.slug()) : existing.slug(); - // 若 slug 改变,需要检查新 slug 是否与该作者其他文章冲突 + // slug 冲突检查基于规范化后的值,排除自身(slug 未变则跳过) if (!newSlug.equals(existing.slug())) { boolean taken = postRepo.findByAuthorAndSlug(callerId, newSlug).isPresent(); if (taken) { - throw new IllegalArgumentException("slug 已被使用:" + newSlug); + throw new DuplicateKeyException("slug 已被使用:" + newSlug); } } String tagsJson = serializeTags(req.tags()); - postRepo.update(postId, newSlug, req.title(), req.description(), + // update 返回受影响行数;0 表示记录在校验完成后被并发删除 + int affected = postRepo.update(postId, newSlug, req.title(), req.description(), tagsJson, req.contentMd(), req.coverUrl()); + if (affected == 0) { + throw new ResourceNotFoundException("文章已不存在:" + postId); + } log.info("post updated: id={} author={}", postId, callerId); - Post updated = postRepo.findById(postId) - .orElseThrow(() -> new IllegalStateException("post not found after update: " + postId)); - return buildView(updated); + return buildView(postRepo.findById(postId) + .orElseThrow(() -> new ResourceNotFoundException("文章不存在:" + postId))); } /** @@ -241,19 +247,8 @@ public List listByAuthor(Long authorId) { public List listFeed(int limit, int offset) { int safeLimit = Math.min(Math.max(limit, 1), MAX_FEED_LIMIT); int safeOffset = Math.max(offset, 0); - - List posts = postRepo.findFeed(safeLimit, safeOffset); - - // 批量拼作者信息:每篇独立查一次(MVP 量级可接受;后续可加缓存或 JOIN 优化) - return posts.stream() - .map(p -> { - UserAccount author = userAccountRepository.findById(p.authorId()).orElse(null); - String username = author != null ? author.username() : "unknown"; - String displayName = author != null ? author.displayName() : ""; - String avatarUrl = author != null ? author.avatarUrl() : null; - return PostSummaryView.from(p, username, displayName, avatarUrl); - }) - .toList(); + // JOIN 查询一次取回作者信息,消除 N+1 + return postRepo.findFeedWithAuthor(safeLimit, safeOffset); } // ========== 私有工具方法 ========== @@ -271,11 +266,11 @@ private void validateRequest(PostRequest req) { } /** - * 按 id 查文章,不存在则抛 404 语义的 IllegalArgumentException。 + * 按 id 查文章,不存在则抛 ResourceNotFoundException(→ 404)。 */ private Post requirePost(Long postId) { return postRepo.findById(postId) - .orElseThrow(() -> new IllegalArgumentException("文章不存在:" + postId)); + .orElseThrow(() -> new ResourceNotFoundException("文章不存在:" + postId)); } /**