Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ public ResponseEntity<ApiResponse<Void>> handleAccessDeniedBusiness(AccessDenied
.body(ApiResponse.fail(e.getMessage()));
}

/**
* 业务资源不存在(404)。
* 区别于 IllegalArgumentException(400 参数错误):资源找不到是 404,不是请求格式问题。
*/
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleResourceNotFound(ResourceNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.fail(e.getMessage()));
}
Comment on lines +97 to +101

// ==========================================
// 业务与通用异常拦截
// ==========================================
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -43,26 +44,37 @@ public PostController(PostService postService) {
/**
* 创建文章。
* 作者 id 从 Sa-Token 登录态取得,不接受前端传入(防伪造)。
* 并发/重试触发 (author_id, slug) UNIQUE 冲突时返回 409。
*/
@PostMapping
@SaCheckLogin
public ResponseEntity<ApiResponse<PostView>> 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));
}
Comment on lines +56 to +59
}

/**
* 更新文章内容。
* Service 层做 owner 校验,非作者返回 403。
* Service 层做 owner 校验,非作者返回 403;slug 冲突返回 409
*/
@PutMapping("/{id}")
@SaCheckLogin
public ApiResponse<PostView> update(@PathVariable Long id,
@RequestBody PostRequest req) {
public ResponseEntity<ApiResponse<PostView>> 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));
}
Comment on lines +74 to +77
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -115,14 +116,28 @@ public List<Post> findByAuthor(Long authorId) {
}

@Override
public List<Post> 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<PostSummaryView> 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");
Comment on lines +119 to +133
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
Expand All @@ -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 = ?");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Post> findFeed(int limit, int offset);
List<PostSummaryView> findFeedWithAuthor(int limit, int offset);

Comment on lines 29 to 35
/** 按 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)));
}

/**
Expand Down Expand Up @@ -241,19 +247,8 @@ public List<PostSummaryView> listByAuthor(Long authorId) {
public List<PostSummaryView> listFeed(int limit, int offset) {
int safeLimit = Math.min(Math.max(limit, 1), MAX_FEED_LIMIT);
int safeOffset = Math.max(offset, 0);

List<Post> 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);
}

// ========== 私有工具方法 ==========
Expand All @@ -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));
}

/**
Expand Down