diff --git a/docs/dev1.md b/docs/dev1.md index 3b08f2e..c473868 100644 --- a/docs/dev1.md +++ b/docs/dev1.md @@ -11,6 +11,12 @@ | 退出登录 | `POST /auth/logout`(需 `satoken` header) | | 健康检查 | `GET /actuator/health` | | 排行榜聚合(公开,前端 Vercel build 用) | `GET /api/public/leaderboard` | +| 原创文章 feed(公开) | `GET /api/posts/feed` | +| 文章详情/分享页(公开) | `GET /api/posts/{username}/{slug}` | +| 创建文章(需 satoken) | `POST /api/posts` | +| 我的文章(需 satoken) | `GET /api/posts/mine` | + +详细契约见 [`docs/posts/README.md`](posts/README.md)。 --- diff --git a/docs/posts/README.md b/docs/posts/README.md new file mode 100644 index 0000000..ab1ab58 --- /dev/null +++ b/docs/posts/README.md @@ -0,0 +1,132 @@ +# posts 模块文档 + +用户原创文章功能(直接落库,不走 Git PR)。 + +## 背景 + +站内编辑器写完后可以直接发布到 `posts` 表,在 `/feed` 原创 Tab 和个人主页展示,与 Fumadocs (`/docs`) 精选知识库完全隔离。文章带"转正"入口,作者可一键跳 GitHub 新建文件页把文章升级为 Fumadocs contributor 贡献。 + +## API 端点 + +所有端点前缀 `/api/posts`(后端 `http://localhost:8081`)。 + +| 方法 | 路径 | 鉴权 | 说明 | +|---|---|---|---| +| `POST` | `/api/posts` | 需登录 | 创建文章,返回 201 + PostView | +| `PUT` | `/api/posts/{id}` | 需登录 + owner | 更新文章内容 | +| `DELETE` | `/api/posts/{id}` | 需登录 + owner | 物理删除 | +| `GET` | `/api/posts/mine` | 需登录 | 当前用户所有文章(全状态)| +| `GET` | `/api/posts/feed` | **公开** | 已发布公开文章列表,分页 | +| `GET` | `/api/posts/{username}/{slug}` | **公开** | 详情/分享页 | +| `POST` | `/api/posts/{id}/promote` | 需登录 + owner | 记录转正 PR 链接 | + +### 鉴权 Header + +``` +satoken: +``` + +不是 `Authorization: Bearer`,与站内其他登录态接口一致。 + +### POST /api/posts 请求体 + +```json +{ + "title": "string(必填)", + "description": "string | null", + "tags": ["string"] | null, + "contentMd": "string(必填,原始 markdown)", + "coverUrl": "string | null", + "slug": "string | null ← 不传则由 title 自动生成 kebab-case" +} +``` + +### PostView 响应结构(详情,含 contentMd) + +```json +{ + "id": 1, + "slug": "my-first-post", + "title": "...", + "description": "...", + "tags": ["tag1"], + "contentMd": "# markdown 正文", + "coverUrl": null, + "visibility": "PUBLIC", + "status": "PUBLISHED", + "promotedPrUrl": null, + "promotedAt": null, + "viewCount": 0, + "createdAt": "2026-05-24T15:12:07.881679Z", + "updatedAt": "2026-05-24T15:12:07.881679Z", + "authorUsername": "alice", + "authorDisplayName": "Alice", + "authorAvatar": null +} +``` + +前端直发后跳转路径:`/u/${data.authorUsername}/posts/${data.slug}` + +### PostSummaryView 响应结构(列表摘要,无 contentMd) + +```json +{ + "id": 1, + "slug": "my-first-post", + "title": "...", + "description": "...", + "tags": ["tag1"], + "coverUrl": null, + "visibility": "PUBLIC", + "status": "PUBLISHED", + "promoted": false, + "viewCount": 0, + "createdAt": "2026-05-24T15:12:07.881679Z", + "authorUsername": "alice", + "authorDisplayName": "Alice", + "authorAvatar": null +} +``` + +## 数据库表 + +```sql +CREATE TABLE IF NOT EXISTS posts ( + id BIGSERIAL PRIMARY KEY, + author_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + slug VARCHAR(128) NOT NULL, + title TEXT NOT NULL, + description TEXT, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + content_md TEXT NOT NULL, + cover_url TEXT, + visibility VARCHAR(16) NOT NULL DEFAULT 'PUBLIC', + status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED', + promoted_pr_url TEXT, + promoted_at TIMESTAMPTZ, + view_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (author_id, slug) +); +``` + +`SPRING_SQL_INIT_MODE=always` 启动时自动建表,无需手动迁移。 + +## SaTokenConfigure 白名单 + +`GET /api/posts/feed` 和 `GET /api/posts/*/*` 已加入公开白名单,匿名可访问。 +写接口(POST/PUT/DELETE)和 `/mine` 由方法级 `@SaCheckLogin` 守卫。 + +## 部署说明 + +feat/posts-module 合并 main 后,重建后端镜像时 posts 模块随新镜像一次性上线: + +```bash +cd /home/ubuntu/involution-hell +git pull origin main +docker compose build backend +docker compose up -d backend +``` + +新镜像启动时 `schema.sql` 自动追加 `posts` 表(`IF NOT EXISTS` 幂等),已有数据不受影响。 diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java index f81c0ac..4a400bb 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java @@ -53,6 +53,12 @@ public void addInterceptors(InterceptorRegistry registry) { // 用 /** 覆盖子路径(/internal 提交 + /internal/summary 查询)。 .notMatch("/api/community/links/internal", "/api/community/links/internal/**") .notMatch("/api/chat/sessions/save") // AI 对话持久化(匿名 / 登录都写,登录时自动关联 userId) + // Posts 公开读接口: + // GET /api/posts/feed - /feed 原创 Tab 列表(匿名可访问) + // GET /api/posts/{username}/{slug} - 详情/分享页(匿名可访问) + // 写接口(POST/PUT/DELETE)和 /mine 由方法级 @SaCheckLogin 守卫,无需在此放行。 + .notMatch("/api/posts/feed") + .notMatch("/api/posts/*/*") .check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException })).addPathPatterns("/**"); } diff --git a/src/main/java/com/involutionhell/backend/posts/README.md b/src/main/java/com/involutionhell/backend/posts/README.md new file mode 100644 index 0000000..4b28a11 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/README.md @@ -0,0 +1,30 @@ +# posts 模块 + +用户原创文章功能,对应数据库表 `posts`。 + +## 职责 + +提供文章的创建、更新、删除、列表、详情查询,以及"一键转正"记录功能。 +与 Fumadocs (`/docs`) 体系完全隔离,不走 Git PR 流程。 + +## 子包说明 + +| 包 | 职责 | +|---|---| +| `model/` | `Post` record + `PostStatus` / `PostVisibility` 常量类 | +| `dto/` | `PostRequest`(写请求)/ `PostView`(详情)/ `PostSummaryView`(列表摘要)| +| `repository/` | `PostRepository` 接口 + `JdbcPostRepository` Spring JDBC 实现 | +| `service/` | `PostService`:核心业务逻辑(slug 生成/去重、owner 校验)| +| `controller/` | `PostController`:7 个 REST 端点,路径前缀 `/api/posts` | + +## API 端点总览 + +``` +POST /api/posts 创建文章(需登录) +PUT /api/posts/{id} 更新文章(需登录 + owner) +DELETE /api/posts/{id} 删除文章(需登录 + owner) +GET /api/posts/mine 我的文章(需登录) +GET /api/posts/feed 公开 feed 列表(匿名可访问) +GET /api/posts/{username}/{slug} 详情/分享页(匿名可访问) +POST /api/posts/{id}/promote 记录转正 PR(需登录 + owner) +``` diff --git a/src/main/java/com/involutionhell/backend/posts/controller/PostController.java b/src/main/java/com/involutionhell/backend/posts/controller/PostController.java new file mode 100644 index 0000000..17a28ac --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/controller/PostController.java @@ -0,0 +1,132 @@ +package com.involutionhell.backend.posts.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.stp.StpUtil; +import com.involutionhell.backend.common.api.ApiResponse; +import com.involutionhell.backend.posts.dto.PostRequest; +import com.involutionhell.backend.posts.dto.PostSummaryView; +import com.involutionhell.backend.posts.dto.PostView; +import com.involutionhell.backend.posts.service.PostService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 用户原创文章接口(posts 模块)。 + * + * 路由总览: + * POST /api/posts - 创建文章(需登录) + * PUT /api/posts/{id} - 更新文章(需登录 + owner) + * DELETE /api/posts/{id} - 删除文章(需登录 + owner) + * GET /api/posts/mine - 我的文章(需登录) + * GET /api/posts/feed - 公开 feed 列表(匿名可访问) + * GET /api/posts/{username}/{slug} - 详情/分享页(匿名可访问) + * POST /api/posts/{id}/promote - 记录转正 PR(需登录 + owner) + * + * 公开读路由(feed / 详情)在 SaTokenConfigure 白名单里放行。 + * 写路由由方法级 @SaCheckLogin 守卫。 + */ +@RestController +@RequestMapping("/api/posts") +public class PostController { + + private final PostService postService; + + public PostController(PostService postService) { + this.postService = postService; + } + + /** + * 创建文章。 + * 作者 id 从 Sa-Token 登录态取得,不接受前端传入(防伪造)。 + */ + @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)); + } + + /** + * 更新文章内容。 + * Service 层做 owner 校验,非作者返回 403。 + */ + @PutMapping("/{id}") + @SaCheckLogin + public ApiResponse update(@PathVariable Long id, + @RequestBody PostRequest req) { + long callerId = StpUtil.getLoginIdAsLong(); + PostView view = postService.update(callerId, id, req); + return ApiResponse.ok(view); + } + + /** + * 删除文章(物理删除)。 + * Service 层做 owner 校验,非作者返回 403。 + */ + @DeleteMapping("/{id}") + @SaCheckLogin + public ApiResponse delete(@PathVariable Long id) { + long callerId = StpUtil.getLoginIdAsLong(); + postService.delete(callerId, id); + return ApiResponse.okMessage("deleted"); + } + + /** + * 查询我的所有文章(全状态,含草稿)。 + */ + @GetMapping("/mine") + @SaCheckLogin + public ApiResponse> mine() { + long authorId = StpUtil.getLoginIdAsLong(); + return ApiResponse.ok(postService.listByAuthor(authorId)); + } + + /** + * 公开 feed 列表(/feed 原创 Tab 使用)。 + * 只返回 PUBLISHED + PUBLIC 的文章,支持分页。 + */ + @GetMapping("/feed") + public ApiResponse> feed( + @RequestParam(defaultValue = "20") int limit, + @RequestParam(defaultValue = "0") int offset) { + return ApiResponse.ok(postService.listFeed(limit, offset)); + } + + /** + * 文章详情/分享页(公开,匿名可访问)。 + * 路径 /api/posts/{username}/{slug} 对应前端 /u/{username}/posts/{slug}。 + */ + @GetMapping("/{username}/{slug}") + public ResponseEntity> detail( + @PathVariable String username, + @PathVariable String slug) { + Optional result = postService.getByAuthorAndSlug(username, slug); + if (result.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ApiResponse<>(false, "文章不存在", null)); + } + return ResponseEntity.ok(ApiResponse.ok(result.get())); + } + + /** + * 记录文章转正 PR 链接。 + * 前端点"转正"按钮后跳 GitHub 新建文件页,完成后回传 PR URL。 + * + * 请求体:{"prUrl": "https://github.com/..."} + */ + @PostMapping("/{id}/promote") + @SaCheckLogin + public ApiResponse promote(@PathVariable Long id, + @RequestBody Map body) { + long callerId = StpUtil.getLoginIdAsLong(); + String prUrl = body != null ? body.get("prUrl") : null; + postService.markPromoted(callerId, id, prUrl); + return ApiResponse.okMessage("promoted"); + } +} diff --git a/src/main/java/com/involutionhell/backend/posts/dto/PostRequest.java b/src/main/java/com/involutionhell/backend/posts/dto/PostRequest.java new file mode 100644 index 0000000..c387f68 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/dto/PostRequest.java @@ -0,0 +1,20 @@ +package com.involutionhell.backend.posts.dto; + +import java.util.List; + +/** + * 创建/更新文章的请求体。 + * + * slug 可选:不传时由 title 自动生成(title → kebab-case → 去重后缀)。 + * 更新时传 slug 可覆盖已有 slug,但需调用方保证唯一性(service 层会校验)。 + * + * contentMd 是原始 markdown,图片已经是 R2 公开 URL(前端编辑器上传完成后替换 blob)。 + */ +public record PostRequest( + String title, + String description, + List tags, + String contentMd, + String coverUrl, + String slug // 可选,传 null 由 service 自动生成 +) {} diff --git a/src/main/java/com/involutionhell/backend/posts/dto/PostSummaryView.java b/src/main/java/com/involutionhell/backend/posts/dto/PostSummaryView.java new file mode 100644 index 0000000..640578c --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/dto/PostSummaryView.java @@ -0,0 +1,53 @@ +package com.involutionhell.backend.posts.dto; + +import com.involutionhell.backend.posts.model.Post; + +import java.time.Instant; +import java.util.List; + +/** + * 文章列表摘要视图(/feed 原创 Tab 和 /u/{username}/posts 列表页使用)。 + * + * 不包含 contentMd(列表页只需摘要,避免传输过大)。 + * 作者信息冗余在此,前端卡片无需再发请求。 + */ +public record PostSummaryView( + Long id, + String slug, + String title, + String description, + List tags, + String coverUrl, + String visibility, + String status, + boolean promoted, // promotedPrUrl != null 即为已转正 + int viewCount, + Instant createdAt, + // 作者冗余字段 + String authorUsername, + String authorDisplayName, + String authorAvatar +) { + /** 从领域对象 + 作者信息组装摘要视图。 */ + public static PostSummaryView from(Post p, + String authorUsername, + String authorDisplayName, + String authorAvatar) { + return new PostSummaryView( + p.id(), + p.slug(), + p.title(), + p.description(), + p.tags(), + p.coverUrl(), + p.visibility(), + p.status(), + p.promotedPrUrl() != null, + p.viewCount(), + p.createdAt(), + authorUsername, + authorDisplayName, + authorAvatar + ); + } +} diff --git a/src/main/java/com/involutionhell/backend/posts/dto/PostView.java b/src/main/java/com/involutionhell/backend/posts/dto/PostView.java new file mode 100644 index 0000000..2a4697e --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/dto/PostView.java @@ -0,0 +1,61 @@ +package com.involutionhell.backend.posts.dto; + +import com.involutionhell.backend.posts.model.Post; + +import java.time.Instant; +import java.util.List; + +/** + * 文章详情视图(详情页 / 分享页使用)。 + * + * 包含完整 contentMd;作者信息由 authorUsername / authorDisplayName / authorAvatar 冗余, + * 避免列表/详情分开查两张表。 + * + * 不暴露 authorId(内部 ID,前端用 username 路由即可)。 + */ +public record PostView( + Long id, + String slug, + String title, + String description, + List tags, + String contentMd, + String coverUrl, + String visibility, + String status, + String promotedPrUrl, + Instant promotedAt, + int viewCount, + Instant createdAt, + Instant updatedAt, + // 作者冗余字段,前端无需再查 /api/user-center/profile + String authorUsername, + String authorDisplayName, + String authorAvatar +) { + /** 从领域对象 + 作者信息组装视图。 */ + public static PostView from(Post p, + String authorUsername, + String authorDisplayName, + String authorAvatar) { + return new PostView( + p.id(), + p.slug(), + p.title(), + p.description(), + p.tags(), + p.contentMd(), + p.coverUrl(), + p.visibility(), + p.status(), + p.promotedPrUrl(), + p.promotedAt(), + p.viewCount(), + p.createdAt(), + p.updatedAt(), + authorUsername, + authorDisplayName, + authorAvatar + ); + } +} diff --git a/src/main/java/com/involutionhell/backend/posts/model/Post.java b/src/main/java/com/involutionhell/backend/posts/model/Post.java new file mode 100644 index 0000000..7773fbd --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/model/Post.java @@ -0,0 +1,33 @@ +package com.involutionhell.backend.posts.model; + +import java.time.Instant; +import java.util.List; + +/** + * 用户原创文章领域对象,对应数据库表 posts。 + * + * 设计取向: + * - 与 Fumadocs(/docs) 体系完全隔离:posts 直接落库,不走 Git PR + * - 与 shared_links 解耦:shared_links 是外部 URL,posts 是站内 markdown 长文 + * - slug 在同一作者下唯一(author_id + slug UNIQUE),构成 /u/{username}/posts/{slug} + * - tags 存 JSONB 字符串数组,查询时由 JdbcPostRepository 反序列化为 List + * - visibility / status 用 PostVisibility / PostStatus 常量,避免魔法字符串散落 + * - promotedPrUrl:文章"转正"后记录 GitHub PR 链接,并同步写 promotedAt 时间戳 + */ +public record Post( + Long id, + Long authorId, + String slug, + String title, + String description, + List tags, + String contentMd, + String coverUrl, + String visibility, // PostVisibility 常量 + String status, // PostStatus 常量 + String promotedPrUrl, + Instant promotedAt, + int viewCount, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/com/involutionhell/backend/posts/model/PostStatus.java b/src/main/java/com/involutionhell/backend/posts/model/PostStatus.java new file mode 100644 index 0000000..f52abe2 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/model/PostStatus.java @@ -0,0 +1,18 @@ +package com.involutionhell.backend.posts.model; + +/** + * 文章发布状态枚举常量。 + * + * DRAFT - 草稿,作者未发布(预留,MVP 阶段暂不开放,前端不传此值) + * PUBLISHED - 已发布,对外可见 + */ +public final class PostStatus { + + /** 草稿(预留) */ + public static final String DRAFT = "DRAFT"; + + /** 已发布 */ + public static final String PUBLISHED = "PUBLISHED"; + + private PostStatus() {} +} diff --git a/src/main/java/com/involutionhell/backend/posts/model/PostVisibility.java b/src/main/java/com/involutionhell/backend/posts/model/PostVisibility.java new file mode 100644 index 0000000..13f1b81 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/model/PostVisibility.java @@ -0,0 +1,18 @@ +package com.involutionhell.backend.posts.model; + +/** + * 文章可见性枚举常量。 + * + * PUBLIC - 公开,任何人可访问(但 noindex,不进搜索引擎) + * UNLISTED - 仅凭链接访问(预留,MVP 阶段暂不暴露给前端) + */ +public final class PostVisibility { + + /** 公开 */ + public static final String PUBLIC = "PUBLIC"; + + /** 不列出(仅凭链接访问,预留) */ + public static final String UNLISTED = "UNLISTED"; + + private PostVisibility() {} +} diff --git a/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java b/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java new file mode 100644 index 0000000..9e7f77d --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/repository/JdbcPostRepository.java @@ -0,0 +1,199 @@ +package com.involutionhell.backend.posts.repository; + +import com.involutionhell.backend.posts.model.Post; +import com.involutionhell.backend.posts.model.PostStatus; +import com.involutionhell.backend.posts.model.PostVisibility; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * posts 表的 Spring JDBC 实现,镜像 JdbcSharedLinkRepository 的范式。 + * + * tags 列:Postgres 侧 JSONB,H2 测试侧 VARCHAR。 + * 读写均用 setObject(Types.OTHER) + JSON 字符串,与 JdbcSharedLinkRepository.flags 一致。 + * + * 时间戳:统一用 Timestamp → Instant 转换,避免时区差异。 + */ +@Repository +public class JdbcPostRepository implements PostRepository { + + private static final Logger log = LoggerFactory.getLogger(JdbcPostRepository.class); + + /** tags JSONB 反序列化目标类型。 */ + private static final TypeReference> TAGS_TYPE = new TypeReference<>() {}; + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public JdbcPostRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + /** RowMapper:将 ResultSet 一行映射为 Post record。 */ + private final RowMapper rowMapper = (rs, rowNum) -> new Post( + rs.getLong("id"), + rs.getLong("author_id"), + rs.getString("slug"), + rs.getString("title"), + rs.getString("description"), + parseTags(rs.getString("tags")), + rs.getString("content_md"), + rs.getString("cover_url"), + rs.getString("visibility"), + rs.getString("status"), + rs.getString("promoted_pr_url"), + toInstant(rs.getTimestamp("promoted_at")), + rs.getInt("view_count"), + toInstant(rs.getTimestamp("created_at")), + toInstant(rs.getTimestamp("updated_at")) + ); + + @Override + public Post insert(Post draft) { + KeyHolder kh = new GeneratedKeyHolder(); + String sql = "INSERT INTO posts " + + "(author_id, slug, title, description, tags, content_md, cover_url, visibility, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + jdbc.update(conn -> { + PreparedStatement ps = conn.prepareStatement(sql, new String[]{"id"}); + ps.setLong(1, draft.authorId()); + ps.setString(2, draft.slug()); + ps.setString(3, draft.title()); + // description 可空 + if (draft.description() == null) ps.setNull(4, Types.VARCHAR); + else ps.setString(4, draft.description()); + // tags 走 JSONB / VARCHAR 兼容写法 + ps.setObject(5, serializeTags(draft.tags()), Types.OTHER); + ps.setString(6, draft.contentMd()); + // cover_url 可空 + if (draft.coverUrl() == null) ps.setNull(7, Types.VARCHAR); + else ps.setString(7, draft.coverUrl()); + ps.setString(8, draft.visibility() != null ? draft.visibility() : PostVisibility.PUBLIC); + ps.setString(9, draft.status() != null ? draft.status() : PostStatus.PUBLISHED); + return ps; + }, kh); + Long id = kh.getKey() != null ? kh.getKey().longValue() : null; + return findById(id).orElseThrow(() -> new IllegalStateException("insert returned no row")); + } + + @Override + public Optional findById(Long id) { + if (id == null) return Optional.empty(); + return jdbc.query("SELECT * FROM posts WHERE id = ?", rowMapper, id) + .stream().findFirst(); + } + + @Override + public Optional findByAuthorAndSlug(Long authorId, String slug) { + return jdbc.query( + "SELECT * FROM posts WHERE author_id = ? AND slug = ?", + rowMapper, authorId, slug) + .stream().findFirst(); + } + + @Override + public List findByAuthor(Long authorId) { + return jdbc.query( + "SELECT * FROM posts WHERE author_id = ? ORDER BY created_at DESC", + rowMapper, 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); + } + + @Override + public int countByAuthorAndSlugPrefix(Long authorId, String slugPrefix) { + // 查询 slug = slugPrefix 或 slug LIKE slugPrefix-{数字},用于生成唯一后缀 + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM posts WHERE author_id = ? AND (slug = ? OR slug LIKE ?)", + Integer.class, + authorId, slugPrefix, slugPrefix + "-%"); + return n != null ? n : 0; + } + + @Override + public void update(Long id, String slug, String title, String description, + String tagsJson, String contentMd, String coverUrl) { + jdbc.update(conn -> { + PreparedStatement ps = conn.prepareStatement( + "UPDATE posts SET slug = ?, title = ?, description = ?, tags = ?, " + + "content_md = ?, cover_url = ?, updated_at = NOW() WHERE id = ?"); + ps.setString(1, slug); + ps.setString(2, title); + if (description == null) ps.setNull(3, Types.VARCHAR); + else ps.setString(3, description); + // tagsJson 已是 JSON 字符串(由 service 层序列化传入) + ps.setObject(4, tagsJson, Types.OTHER); + ps.setString(5, contentMd); + if (coverUrl == null) ps.setNull(6, Types.VARCHAR); + else ps.setString(6, coverUrl); + ps.setLong(7, id); + return ps; + }); + } + + @Override + public void delete(Long id) { + jdbc.update("DELETE FROM posts WHERE id = ?", id); + } + + @Override + public void markPromoted(Long id, String prUrl) { + jdbc.update( + "UPDATE posts SET promoted_pr_url = ?, promoted_at = NOW(), updated_at = NOW() " + + "WHERE id = ?", + prUrl, id); + } + + // ========== 私有工具方法 ========== + + /** 将 List 序列化为 JSON 字符串,写入 JSONB 列。 */ + private String serializeTags(List tags) { + try { + return objectMapper.writeValueAsString(tags == null ? List.of() : tags); + } catch (Exception e) { + log.warn("serialize tags failed, falling back to empty array: {}", e.getMessage()); + return "[]"; + } + } + + /** 将 JSONB 列读出的 JSON 字符串反序列化为 List。 */ + private List parseTags(String json) { + if (json == null || json.isEmpty()) return new ArrayList<>(); + try { + return objectMapper.readValue(json, TAGS_TYPE); + } catch (Exception e) { + log.warn("parse tags failed, returning empty list: {}", e.getMessage()); + return new ArrayList<>(); + } + } + + /** Timestamp → Instant,null 安全。 */ + private Instant toInstant(Timestamp ts) { + return ts == null ? null : ts.toInstant(); + } +} diff --git a/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java b/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java new file mode 100644 index 0000000..95e14cb --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/repository/PostRepository.java @@ -0,0 +1,46 @@ +package com.involutionhell.backend.posts.repository; + +import com.involutionhell.backend.posts.model.Post; + +import java.util.List; +import java.util.Optional; + +/** + * posts 仓库接口。 + * + * 实现由 JdbcPostRepository 提供(裸 JDBC,与 JdbcSharedLinkRepository 同范式)。 + * Service 层不直接依赖实现类,便于未来测试替换。 + */ +public interface PostRepository { + + /** 插入新文章,返回带 id 的完整对象。 */ + Post insert(Post draft); + + /** 按 id 查找文章。 */ + Optional findById(Long id); + + /** 按作者 id + slug 查找(用于详情页路由:/u/{username}/posts/{slug})。 */ + Optional findByAuthorAndSlug(Long authorId, String slug); + + /** 查询某作者所有文章(全状态),按 created_at DESC。 */ + List findByAuthor(Long authorId); + + /** + * 公开 feed 列表:status=PUBLISHED + visibility=PUBLIC,按 created_at DESC 分页。 + * limit 上限由 Service 层限定,避免超大 offset 拖垮 DB。 + */ + List findFeed(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); + + /** 删除文章(物理删除)。 */ + void delete(Long id); + + /** 记录转正:写入 promoted_pr_url + promoted_at=NOW()。 */ + void markPromoted(Long id, String prUrl); +} diff --git a/src/main/java/com/involutionhell/backend/posts/service/PostService.java b/src/main/java/com/involutionhell/backend/posts/service/PostService.java new file mode 100644 index 0000000..6ab97e6 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/posts/service/PostService.java @@ -0,0 +1,370 @@ +package com.involutionhell.backend.posts.service; + +import com.involutionhell.backend.common.error.AccessDeniedBusinessException; +import com.involutionhell.backend.posts.dto.PostRequest; +import com.involutionhell.backend.posts.dto.PostSummaryView; +import com.involutionhell.backend.posts.dto.PostView; +import com.involutionhell.backend.posts.model.Post; +import com.involutionhell.backend.posts.model.PostStatus; +import com.involutionhell.backend.posts.model.PostVisibility; +import com.involutionhell.backend.posts.repository.PostRepository; +import com.involutionhell.backend.usercenter.model.UserAccount; +import com.involutionhell.backend.usercenter.repository.UserAccountRepository; +import com.involutionhell.backend.usercenter.service.UserCenterService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + +import java.text.Normalizer; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * 用户原创文章业务服务。 + * + * 功能覆盖: + * - create:校验请求、生成唯一 slug、落库 + * - update:owner 校验 + 更新内容字段 + * - delete:owner 校验 + 物理删除 + * - getByAuthorAndSlug:按路由(username + slug)查询详情 + * - listByAuthor:查当前用户自己的所有文章 + * - listFeed:公开 feed 列表(status=PUBLISHED + visibility=PUBLIC) + * - markPromoted:记录文章转正 PR 链接 + * + * 跨模块依赖: + * - PostRepository:数据层,只经过接口,不直接摸 Jdbc 实现 + * - UserCenterService:查作者账号信息(用于视图组装),跨模块只经过 service + */ +@Service +public class PostService { + + private static final Logger log = LoggerFactory.getLogger(PostService.class); + + /** 公开 feed 单次查询最大条数,防止前端传超大 limit 打穿 DB。 */ + private static final int MAX_FEED_LIMIT = 100; + + /** slug 生成:非 ASCII 字母数字替换为连字符,最终去首尾连字符。 */ + private static final Pattern NON_SLUG_CHAR = Pattern.compile("[^a-z0-9]+"); + + /** slug 最大长度,超出截断。 */ + private static final int MAX_SLUG_LEN = 100; + + private final PostRepository postRepo; + /** + * 跨模块查作者账号信息,通过 UserCenterService facade,不直接摸 repository。 + * 需要 findByUsername(根据 username 路由查文章)。 + */ + private final UserCenterService userCenterService; + /** + * 需要按 id 查作者信息以组装视图,UserCenterService 无此 Optional 返回方法, + * 故直接注入 UserAccountRepository(同一个 Spring context 内部使用,不跨进程)。 + */ + private final UserAccountRepository userAccountRepository; + private final ObjectMapper objectMapper; + + public PostService(PostRepository postRepo, + UserCenterService userCenterService, + UserAccountRepository userAccountRepository, + ObjectMapper objectMapper) { + this.postRepo = postRepo; + this.userCenterService = userCenterService; + this.userAccountRepository = userAccountRepository; + this.objectMapper = objectMapper; + } + + // ========== 写操作 ========== + + /** + * 创建文章。 + * + * slug 策略: + * 1. 前端传了 slug → 直接使用(需不为空字符串) + * 2. 未传 slug → 由 title 生成 kebab-case 基础 slug + * 3. 基础 slug 在该作者下已存在 → 追加 -{n}(n 从 2 递增) + * + * @param authorId 当前登录用户 id(由 controller 从 StpUtil 取得) + * @param req 请求体 + * @return 组装好作者信息的详情视图 + */ + @Transactional(rollbackFor = Exception.class) + public PostView create(Long authorId, PostRequest req) { + validateRequest(req); + + String slug = resolveSlug(authorId, req.slug(), req.title()); + + Post draft = new Post( + null, + authorId, + slug, + req.title(), + req.description(), + req.tags(), + req.contentMd(), + req.coverUrl(), + PostVisibility.PUBLIC, + PostStatus.PUBLISHED, + null, null, // promotedPrUrl / promotedAt + 0, // viewCount + null, null // createdAt / updatedAt(由 DB DEFAULT NOW() 填充) + ); + + Post saved = postRepo.insert(draft); + log.info("post created: id={} author={} slug={}", saved.id(), authorId, saved.slug()); + + return buildView(saved); + } + + /** + * 更新文章内容。 + * + * @param callerId 当前登录用户 id(owner 校验用) + * @param postId 目标文章 id + * @param req 更新请求体 + */ + @Transactional(rollbackFor = Exception.class) + public PostView update(Long callerId, Long postId, PostRequest req) { + validateRequest(req); + + Post existing = requirePost(postId); + checkOwner(callerId, existing); + + // slug 处理:前端可传新 slug(改 URL 场景),不传则沿用旧 slug + String newSlug = (req.slug() != null && !req.slug().isBlank()) + ? req.slug() + : existing.slug(); + + // 若 slug 改变,需要检查新 slug 是否与该作者其他文章冲突 + if (!newSlug.equals(existing.slug())) { + boolean taken = postRepo.findByAuthorAndSlug(callerId, newSlug).isPresent(); + if (taken) { + throw new IllegalArgumentException("slug 已被使用:" + newSlug); + } + } + + String tagsJson = serializeTags(req.tags()); + postRepo.update(postId, newSlug, req.title(), req.description(), + tagsJson, req.contentMd(), req.coverUrl()); + + 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); + } + + /** + * 删除文章(物理删除)。 + * + * @param callerId 当前登录用户 id(owner 校验用) + * @param postId 目标文章 id + */ + @Transactional(rollbackFor = Exception.class) + public void delete(Long callerId, Long postId) { + Post existing = requirePost(postId); + checkOwner(callerId, existing); + postRepo.delete(postId); + log.info("post deleted: id={} author={}", postId, callerId); + } + + /** + * 记录文章转正 PR 链接。 + * 转正后前端已跳 GitHub 新建文件页,此处只做状态持久化,不阻塞用户操作。 + * + * @param callerId 当前登录用户 id(owner 校验用) + * @param postId 目标文章 id + * @param prUrl GitHub PR 链接(前端跳转后回传) + */ + @Transactional(rollbackFor = Exception.class) + public void markPromoted(Long callerId, Long postId, String prUrl) { + if (prUrl == null || prUrl.isBlank()) { + throw new IllegalArgumentException("prUrl 不能为空"); + } + Post existing = requirePost(postId); + checkOwner(callerId, existing); + postRepo.markPromoted(postId, prUrl); + log.info("post promoted: id={} prUrl={}", postId, prUrl); + } + + // ========== 读操作 ========== + + /** + * 按 username + slug 查询文章详情(详情页 / 分享页路由)。 + * 公开接口,匿名可访问(白名单路由)。 + * + * @param username 作者用户名(URL 路径参数) + * @param slug 文章 slug + */ + @Transactional(readOnly = true) + public Optional getByAuthorAndSlug(String username, String slug) { + // 先查作者,再查文章,避免 authorId 泄漏给 URL + Optional author = userCenterService.findByUsername(username); + if (author.isEmpty()) return Optional.empty(); + + return postRepo.findByAuthorAndSlug(author.get().id(), slug) + .map(p -> PostView.from(p, + author.get().username(), + author.get().displayName(), + author.get().avatarUrl())); + } + + /** + * 查询当前登录用户自己的所有文章(含草稿 / 各状态)。 + * + * @param authorId 当前登录用户 id + */ + @Transactional(readOnly = true) + public List listByAuthor(Long authorId) { + // 查作者信息用于视图组装 + UserAccount author = userAccountRepository.findById(authorId) + .orElseThrow(() -> new IllegalStateException("author not found: " + authorId)); + + return postRepo.findByAuthor(authorId).stream() + .map(p -> PostSummaryView.from(p, + author.username(), + author.displayName(), + author.avatarUrl())) + .toList(); + } + + /** + * 公开 feed 列表(/feed 原创 Tab 使用)。 + * 只返回 status=PUBLISHED + visibility=PUBLIC 的文章,分页。 + * + * @param limit 每页条数(上限 MAX_FEED_LIMIT) + * @param offset 偏移量 + */ + @Transactional(readOnly = true) + 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(); + } + + // ========== 私有工具方法 ========== + + /** + * 校验创建/更新请求的必填字段。 + */ + private void validateRequest(PostRequest req) { + if (req == null || req.title() == null || req.title().isBlank()) { + throw new IllegalArgumentException("title 不能为空"); + } + if (req.contentMd() == null || req.contentMd().isBlank()) { + throw new IllegalArgumentException("contentMd 不能为空"); + } + } + + /** + * 按 id 查文章,不存在则抛 404 语义的 IllegalArgumentException。 + */ + private Post requirePost(Long postId) { + return postRepo.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("文章不存在:" + postId)); + } + + /** + * owner 校验:文章 authorId != callerId 时抛 AccessDeniedBusinessException(→ 403)。 + */ + private void checkOwner(Long callerId, Post post) { + if (!post.authorId().equals(callerId)) { + throw new AccessDeniedBusinessException("无权操作他人文章:postId=" + post.id()); + } + } + + /** + * 解析最终 slug:优先用前端传入的,否则从 title 生成。 + * 若基础 slug 在作者下已存在,追加 -{n} 直到唯一。 + */ + private String resolveSlug(Long authorId, String requestedSlug, String title) { + String base; + if (requestedSlug != null && !requestedSlug.isBlank()) { + base = sanitizeSlug(requestedSlug); + } else { + base = titleToSlug(title); + } + + // 检查该 slug(或以其为前缀的带数字后缀)是否已存在 + int count = postRepo.countByAuthorAndSlugPrefix(authorId, base); + if (count == 0) { + return base; + } + // 从 count+1 开始尝试,避免 base-1 被占用后跳号 + for (int i = count + 1; i <= count + 100; i++) { + String candidate = base + "-" + i; + // 再精确查一次,防止 countByAuthorAndSlugPrefix 估算偏差 + if (postRepo.findByAuthorAndSlug(authorId, candidate).isEmpty()) { + return candidate; + } + } + // 极端情况:100 次后仍冲突,加时间戳兜底 + return base + "-" + System.currentTimeMillis(); + } + + /** + * title → kebab-case slug: + * 1. Unicode normalize(NFD 去掉变音符) + * 2. 转小写 + * 3. 非字母数字替换为连字符 + * 4. 首尾连字符去掉 + * 5. 超长截断 + */ + private String titleToSlug(String title) { + String normalized = Normalizer.normalize(title, Normalizer.Form.NFD) + .replaceAll("\\p{M}", ""); // 去掉变音符 + String lower = normalized.toLowerCase(Locale.ROOT); + String slugged = NON_SLUG_CHAR.matcher(lower).replaceAll("-"); + String trimmed = slugged.replaceAll("^-+|-+$", ""); // 去首尾连字符 + if (trimmed.isEmpty()) trimmed = "post"; + return trimmed.length() > MAX_SLUG_LEN ? trimmed.substring(0, MAX_SLUG_LEN) : trimmed; + } + + /** + * 清理前端传入的 slug,保证只含小写字母、数字和连字符。 + */ + private String sanitizeSlug(String raw) { + String lower = raw.trim().toLowerCase(Locale.ROOT); + String slugged = NON_SLUG_CHAR.matcher(lower).replaceAll("-"); + String trimmed = slugged.replaceAll("^-+|-+$", ""); + if (trimmed.isEmpty()) trimmed = "post"; + return trimmed.length() > MAX_SLUG_LEN ? trimmed.substring(0, MAX_SLUG_LEN) : trimmed; + } + + /** + * 将 List tags 序列化为 JSON(给 JdbcPostRepository.update 用)。 + */ + private String serializeTags(List tags) { + try { + return objectMapper.writeValueAsString(tags == null ? List.of() : tags); + } catch (Exception e) { + log.warn("serialize tags failed in service, fallback to []: {}", e.getMessage()); + return "[]"; + } + } + + /** + * 组装 PostView:查作者信息附加在视图上。 + */ + private PostView buildView(Post 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 PostView.from(p, username, displayName, avatarUrl); + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 9739cd9..6385fc2 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -232,4 +232,41 @@ CREATE TABLE IF NOT EXISTS link_reports ( UNIQUE (link_id, reporter_id) ); +-- ============================================================================= +-- Posts(用户原创文章)相关表 +-- ============================================================================= +-- 背景:站内编辑器写完后直接落库,不走 Git PR,与 Fumadocs(/docs) 体系完全隔离。 +-- 展示位置:/feed 原创 Tab + /u/{username}/posts 个人文章列表 + 详情/分享页。 +-- SEO 隔离:visibility=PUBLIC 但页面带 noindex,不进 sitemap,不被搜索引擎收录。 +-- 转正:文章上有"一键转正"按钮,跳 GitHub 新建文件页半自动发 PR,promoted_pr_url 记录链接。 +-- +-- visibility 枚举: +-- PUBLIC - 公开(任何人可访问,noindex 隔离 SEO) +-- UNLISTED - 仅凭链接访问(预留,MVP 暂不暴露给前端) +-- +-- status 枚举: +-- DRAFT - 草稿(预留,MVP 阶段前端发布直接走 PUBLISHED) +-- PUBLISHED - 已发布,对外可见 +CREATE TABLE IF NOT EXISTS posts ( + id BIGSERIAL PRIMARY KEY, + author_id BIGINT NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE, + slug VARCHAR(128) NOT NULL, -- 分享 URL 用,作者内唯一 + title TEXT NOT NULL, + description TEXT, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + content_md TEXT NOT NULL, -- 原始 markdown(图片已是 R2 公开 URL) + cover_url TEXT, + visibility VARCHAR(16) NOT NULL DEFAULT 'PUBLIC', -- PUBLIC / UNLISTED + status VARCHAR(16) NOT NULL DEFAULT 'PUBLISHED',-- DRAFT / PUBLISHED + promoted_pr_url TEXT, -- 转正后记录 GitHub PR 链接 + promoted_at TIMESTAMPTZ, + view_count INT NOT NULL DEFAULT 0, -- 预留曝光计数 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (author_id, slug) +); + +CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_id); +CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, visibility, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_link_reports_link ON link_reports(link_id);