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
6 changes: 6 additions & 0 deletions docs/dev1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

---

Expand Down
132 changes: 132 additions & 0 deletions docs/posts/README.md
Original file line number Diff line number Diff line change
@@ -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: <token值>
```

不是 `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` 幂等),已有数据不受影响。
Original file line number Diff line number Diff line change
Expand Up @@ -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("/**");
}
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/com/involutionhell/backend/posts/README.md
Original file line number Diff line number Diff line change
@@ -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)
```
Original file line number Diff line number Diff line change
@@ -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<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));
}
Comment on lines +47 to +53

/**
* 更新文章内容。
* Service 层做 owner 校验,非作者返回 403。
*/
@PutMapping("/{id}")
@SaCheckLogin
public ApiResponse<PostView> update(@PathVariable Long id,
@RequestBody PostRequest req) {
long callerId = StpUtil.getLoginIdAsLong();
PostView view = postService.update(callerId, id, req);
return ApiResponse.ok(view);
}
Comment on lines +59 to +66

/**
* 删除文章(物理删除)。
* Service 层做 owner 校验,非作者返回 403。
*/
@DeleteMapping("/{id}")
@SaCheckLogin
public ApiResponse<Void> delete(@PathVariable Long id) {
long callerId = StpUtil.getLoginIdAsLong();
postService.delete(callerId, id);
return ApiResponse.okMessage("deleted");
}

/**
* 查询我的所有文章(全状态,含草稿)。
*/
@GetMapping("/mine")
@SaCheckLogin
public ApiResponse<List<PostSummaryView>> mine() {
long authorId = StpUtil.getLoginIdAsLong();
return ApiResponse.ok(postService.listByAuthor(authorId));
}

/**
* 公开 feed 列表(/feed 原创 Tab 使用)。
* 只返回 PUBLISHED + PUBLIC 的文章,支持分页。
*/
@GetMapping("/feed")
public ApiResponse<List<PostSummaryView>> 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<ApiResponse<PostView>> detail(
@PathVariable String username,
@PathVariable String slug) {
Optional<PostView> 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<Void> promote(@PathVariable Long id,
@RequestBody Map<String, String> body) {
long callerId = StpUtil.getLoginIdAsLong();
String prUrl = body != null ? body.get("prUrl") : null;
postService.markPromoted(callerId, id, prUrl);
return ApiResponse.okMessage("promoted");
}
}
Original file line number Diff line number Diff line change
@@ -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<String> tags,
String contentMd,
String coverUrl,
String slug // 可选,传 null 由 service 自动生成
) {}
Original file line number Diff line number Diff line change
@@ -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<String> 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
);
}
}
Loading