Java 论坛项目从零搭建:用户注册、发帖与评论功能实现

运营教程2小时前发布 jdkjadmin
0 00
Java 论坛项目从零搭建:用户注册、发帖与评论功能实现

Java 论坛项目从零搭建:用户注册、发帖与评论功能实现

本文从一个可运行的后端最小版本出发,使用 Spring Boot 3、Java 17、Spring Data JPA 和 Spring Security,实现论坛的用户注册、HTTP Basic 登录、发帖、浏览帖子和发表评论。代码采用 REST API,不包含前端页面,适合学习项目、内部工具原型或已有前端的后端接口。

示例重点是把“谁在执行操作”交给服务端确定:发帖和评论时,作者来自登录身份,而不是请求体里任意传入的用户 ID。这样可以避免最常见的身份伪造问题。示例仍是基础实现,生产环境还需要结合实际业务补充限流、内容审核、监控和部署配置。

一、技术选型与接口范围

  • Java 17、Spring Boot 3:应用框架与运行环境。
  • Spring Web:提供 JSON REST 接口。
  • Spring Data JPA:持久化用户、帖子和评论。
  • Spring Security:使用 BCrypt 保存密码,并用 HTTP Basic 进行接口认证。
  • H2:本地开发数据库。示例默认使用内存库,重启后数据会清空。
  • Jakarta Validation:校验请求字段的长度和格式。

接口设计如下:

  • POST /api/auth/register:注册用户。
  • GET /api/posts:分页浏览帖子,无需登录。
  • POST /api/posts:登录后发帖。
  • GET /api/posts/{id}/comments:浏览某帖评论,无需登录。
  • POST /api/posts/{id}/comments:登录后发表评论。

这里不实现找回密码、邮箱验证、编辑删除、附件上传和管理员权限。特别是注册接口仅证明用户提交了一个用户名和密码,并不代表邮箱或手机号经过验证。

二、创建项目

新建 Maven 项目,目录结构可以采用常见的分层方式:

src/main/java/com/example/forum/
  ForumApplication.java
  config/SecurityConfig.java
  user/ForumUser.java
  user/ForumUserRepository.java
  user/CustomUserDetailsService.java
  post/ForumPost.java
  post/Comment.java
  post/PostRepository.java
  post/CommentRepository.java
  api/AuthController.java
  api/PostController.java
  api/ApiExceptionHandler.java
src/main/resources/application.properties
pom.xml

下面的依赖使用 Spring Boot 3.3.5。项目应使用 Java 17 或更新的兼容版本。

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
    <relativePath/>
  </parent>

  <groupId>com.example</groupId>
  <artifactId>forum</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <properties>
    <java.version>17</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

应用入口类:

package com.example.forum;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ForumApplication {
    public static void main(String[] args) {
        SpringApplication.run(ForumApplication.class, args);
    }
}

开发环境配置写入 src/main/resources/application.properties:

spring.datasource.url=jdbc:h2:mem:forum;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
spring.h2.console.enabled=true

create-drop 适合快速验证实体映射,不适合正式环境:应用关闭或重建时数据库结构会被重新创建,数据也会丢失。生产环境应使用 PostgreSQL、MySQL 等持久化数据库,并用 Flyway 或 Liquibase 管理迁移;不要把生产表结构交给 ddl-auto=update 自动猜测。

三、定义用户与密码存储

用户表只保存密码哈希,不保存明文密码。用户名加唯一约束,避免并发注册时出现重复账号。数据库约束是最后一道保障,注册接口仍需要把重复情况转换成清晰的响应。

package com.example.forum.user;

import jakarta.persistence.*;

@Entity
@Table(name = "forum_users",
       uniqueConstraints = @UniqueConstraint(
           name = "uk_forum_users_username", columnNames = "username"))
public class ForumUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 30)
    private String username;

    @Column(nullable = false, length = 100)
    private String passwordHash;

    protected ForumUser() {}

    public ForumUser(String username, String passwordHash) {
        this.username = username;
        this.passwordHash = passwordHash;
    }

    public Long getId() { return id; }
    public String getUsername() { return username; }
    public String getPasswordHash() { return passwordHash; }
}
package com.example.forum.user;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ForumUserRepository extends JpaRepository<ForumUser, Long> {
    Optional<ForumUser> findByUsername(String username);
    boolean existsByUsername(String username);
}

BCrypt 是带随机盐的密码哈希算法;相同密码多次编码通常会得到不同结果,验证时使用编码器的 matches 方法,而不是比较字符串。密码哈希仍属于敏感凭据数据,应限制数据库访问和日志输出。

四、配置注册与 HTTP Basic 认证

认证服务根据用户名查用户,Spring Security 使用 BCrypt 验证密码。此例允许浏览帖子和评论,其他接口要求认证。注册接口公开,但不能因此把整个 API 设置为公开。

package com.example.forum.user;

import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final ForumUserRepository users;

    public CustomUserDetailsService(ForumUserRepository users) {
        this.users = users;
    }

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        ForumUser user = users.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

        return User.withUsername(user.getUsername())
                .password(user.getPasswordHash())
                .roles("USER")
                .build();
    }
}
package com.example.forum.config;

import com.example.forum.user.ForumUserRepository;
import org.springframework.context.annotation.*;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
                .requestMatchers("/h2-console/**").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .headers(headers ->
                headers.frameOptions(frame -> frame.sameOrigin()));
        return http.build();
    }
}

此处关闭 CSRF 的前提是使用无服务端会话的 HTTP Basic API,并且客户端不会自动携带浏览器会话 Cookie。HTTP Basic 每次请求都携带可逆传输的凭据,因此必须通过 HTTPS 使用;它不是密码加密方案,也不适合作为面向公众的浏览器登录体验。若改用 Cookie 会话,应重新评估 CSRF 防护,不能照搬这一配置。H2 控制台只应在本地开发启用,不应暴露到公网。

五、实现注册接口

注册请求使用专门的 DTO,不直接接收实体对象。用户名限定为 3 到 30 个 ASCII 字母、数字或下划线,密码限定为 12 到 72 个字符。这是本示例定义的业务规则,不是 Spring 或 BCrypt 对所有应用强制规定的统一限制。BCrypt 对输入字节数有实现层面的长度边界;限制密码长度可以避免超长输入,并应在采用其他哈希算法或升级依赖时复核具体实现行为。

package com.example.forum.api;

import com.example.forum.user.ForumUser;
import com.example.forum.user.ForumUserRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

record RegisterRequest(
    @NotBlank
    @Pattern(regexp = "^[A-Za-z0-9_]{3,30}$")
    String username,

    @NotBlank
    @Size(min = 12, max = 72)
    String password
) {}

record UserResponse(Long id, String username) {}

@RestController
@RequestMapping("/api/auth")
class AuthController {
    private final ForumUserRepository users;
    private final PasswordEncoder passwordEncoder;

    AuthController(ForumUserRepository users, PasswordEncoder passwordEncoder) {
        this.users = users;
        this.passwordEncoder = passwordEncoder;
    }

    @PostMapping("/register")
    ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {
        if (users.existsByUsername(request.username())) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(new ErrorResponse("用户名已被使用"));
        }

        try {
            ForumUser saved = users.save(new ForumUser(
                    request.username(),
                    passwordEncoder.encode(request.password())));
            return ResponseEntity.status(HttpStatus.CREATED)
                    .body(new UserResponse(saved.getId(), saved.getUsername()));
        } catch (DataIntegrityViolationException ex) {
            // 并发注册时,唯一约束仍可能由数据库拒绝。
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(new ErrorResponse("用户名已被使用"));
        }
    }
}

record ErrorResponse(String message) {}

输入参数校验不是 SQL 注入防护的替代品,也不应被当作唯一安全边界。JPA 参数化查询能减少拼接 SQL 的风险;数据库唯一约束则处理并发竞争。实际系统可根据账号隐私策略决定是否对外区分“用户名已存在”,并增加注册限流、邮箱验证和异常审计。

六、定义帖子和评论模型

帖子保存标题、正文、作者和创建时间;评论关联所属帖子与作者。作者关系使用外键,删除策略在示例中未实现。避免直接返回实体对象:实体可能带出密码哈希、触发懒加载,或者在关系扩大后产生递归 JSON。

package com.example.forum.post;

import com.example.forum.user.ForumUser;
import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "forum_posts")
public class ForumPost {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 120)
    private String title;

    @Column(nullable = false, length = 10000)
    private String body;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false)
    private ForumUser author;

    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    protected ForumPost() {}

    public ForumPost(String title, String body, ForumUser author) {
        this.title = title;
        this.body = body;
        this.author = author;
        this.createdAt = Instant.now();
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getBody() { return body; }
    public ForumUser getAuthor() { return author; }
    public Instant getCreatedAt() { return createdAt; }
}
package com.example.forum.post;

import com.example.forum.user.ForumUser;
import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "forum_comments")
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "post_id", nullable = false)
    private ForumPost post;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false)
    private ForumUser author;

    @Column(nullable = false, length = 3000)
    private String body;

    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    protected Comment() {}

    public Comment(ForumPost post, ForumUser author, String body) {
        this.post = post;
        this.author = author;
        this.body = body;
        this.createdAt = Instant.now();
    }

    public Long getId() { return id; }
    public ForumPost getPost() { return post; }
    public ForumUser getAuthor() { return author; }
    public String getBody() { return body; }
    public Instant getCreatedAt() { return createdAt; }
}

表字段长度是数据库约束,不等同于完整的内容安全策略。页面渲染评论和帖子正文时,应按纯文本进行 HTML 转义;若产品允许富文本,则应使用经过维护的 HTML 清洗方案限制标签和属性。不能仅依赖前端校验防止存储型 XSS。

七、请求 DTO、仓库与业务逻辑

新建帖子要求标题 1 到 120 个字符、正文 1 到 10000 个字符;评论正文限制为 1 到 3000 个字符。边界包含最大值,空白字符串会被 @NotBlank 拒绝。这些是示例业务限制,实际产品应结合数据库、客户端和内容政策调整。

package com.example.forum.post;

import java.util.Optional;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository<ForumPost, Long> {
    Page<ForumPost> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
    Page<Comment> findByPostIdOrderByCreatedAtAsc(Long postId, Pageable pageable);
}

如果将两个公开接口写在同一个文件中,Java 会因多个顶层 public 类型而编译失败。将它们分别保存为 PostRepository.java 和 CommentRepository.java,包名均为 com.example.forum.post。

package com.example.forum.api;

import jakarta.validation.constraints.*;

record CreatePostRequest(
    @NotBlank @Size(max = 120) String title,
    @NotBlank @Size(max = 10000) String body
) {}

record CreateCommentRequest(
    @NotBlank @Size(max = 3000) String body
) {}

record PostResponse(Long id, String title, String body,
                    String author, java.time.Instant createdAt) {}

record CommentResponse(Long id, Long postId, String body,
                       String author, java.time.Instant createdAt) {}

下面的控制器将登录名解析到数据库用户,并以该用户作为作者。创建帖子时客户端不能指定作者,创建评论时客户端也不能指定作者或所属帖子 ID;帖子 ID 来自 URL 路径。

package com.example.forum.api;

import com.example.forum.post.*;
import com.example.forum.user.*;
import jakarta.validation.Valid;
import org.springframework.data.domain.*;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
class PostController {
    private final PostRepository posts;
    private final CommentRepository comments;
    private final ForumUserRepository users;

    PostController(PostRepository posts, CommentRepository comments,
                  ForumUserRepository users) {
        this.posts = posts;
        this.comments = comments;
        this.users = users;
    }

    @GetMapping("/posts")
    Page<PostResponse> listPosts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        Pageable pageable = PageRequest.of(
                Math.max(page, 0),
                Math.min(Math.max(size, 1), 50));
        return posts.findAllByOrderByCreatedAtDesc(pageable)
                .map(this::toPostResponse);
    }

    @PostMapping("/posts")
    ResponseEntity<PostResponse> createPost(
            @Valid @RequestBody CreatePostRequest request,
            @AuthenticationPrincipal UserDetails principal) {
        ForumUser author = currentUser(principal.getUsername());
        ForumPost saved = posts.save(
                new ForumPost(request.title(), request.body(), author));
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(toPostResponse(saved));
    }

    @GetMapping("/posts/{postId}/comments")
    Page<CommentResponse> listComments(
            @PathVariable Long postId,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        if (!posts.existsById(postId)) {
            throw new ResourceNotFoundException("帖子不存在");
        }
        Pageable pageable = PageRequest.of(
                Math.max(page, 0),
                Math.min(Math.max(size, 1), 50));
        return comments.findByPostIdOrderByCreatedAtAsc(postId, pageable)
                .map(this::toCommentResponse);
    }

    @PostMapping("/posts/{postId}/comments")
    ResponseEntity<CommentResponse> createComment(
            @PathVariable Long postId,
            @Valid @RequestBody CreateCommentRequest request,
            @AuthenticationPrincipal UserDetails principal) {
        ForumPost post = posts.findById(postId)
                .orElseThrow(() -> new ResourceNotFoundException("帖子不存在"));
        ForumUser author = currentUser(principal.getUsername());
        Comment saved = comments.save(
                new Comment(post, author, request.body()));
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(toCommentResponse(saved));
    }

    private ForumUser currentUser(String username) {
        return users.findByUsername(username)
                .orElseThrow(() -> new ResourceNotFoundException("用户不存在"));
    }

    private PostResponse toPostResponse(ForumPost post) {
        return new PostResponse(post.getId(), post.getTitle(), post.getBody(),
                post.getAuthor().getUsername(), post.getCreatedAt());
    }

    private CommentResponse toCommentResponse(Comment comment) {
        return new CommentResponse(comment.getId(), comment.getPost().getId(),
                comment.getBody(), comment.getAuthor().getUsername(),
                comment.getCreatedAt());
    }
}

分页参数在服务端被限制到每页 1 至 50 条,负页码会归零。这是接口层的资源保护与体验约定,不代表所有数据库查询都自然安全地具备上限。使用 Spring Data 的分页查询会请求对应页的数据;不要在控制器中先加载全部记录,再在内存里截取。

八、统一错误响应

接口至少要区分参数错误、未登录、资源不存在和业务冲突。以下处理器覆盖验证失败与找不到资源;Spring Security 会负责认证失败响应。生产 API 通常还会统一处理数据库异常,并给每个错误生成可追踪的请求 ID。

package com.example.forum.api;

import java.util.Map;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

class ResourceNotFoundException extends RuntimeException {
    ResourceNotFoundException(String message) { super(message); }
}

@RestControllerAdvice
class ApiExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    ResponseEntity<ErrorResponse> notFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse(ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<Map<String, String>> invalid(
            MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(error -> error.getField() + " 参数不合法")
                .orElse("请求参数不合法");
        return ResponseEntity.badRequest().body(Map.of("message", message));
    }
}

示例错误处理器为便于阅读只返回第一条字段错误。不要将异常堆栈、SQL、数据库连接信息或密码哈希放进客户端响应;详细异常留在受控日志中,并避免记录认证头和原始密码。

九、启动与调用

在项目根目录启动应用:

mvn spring-boot:run

注册用户。密码在请求中以明文形式发送,因此实际调用必须通过 HTTPS;下面只适用于本机开发:

curl -i -X POST http://localhost:8080/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice_01","password":"correct-horse-battery"}'

发帖时使用 HTTP Basic 认证,用户名和密码在每次请求中随认证头发送:

curl -i -u alice_01:correct-horse-battery \
  -X POST http://localhost:8080/api/posts \
  -H 'Content-Type: application/json' \
  -d '{"title":"第一次发帖","body":"这是论坛的第一篇帖子。"}'

浏览帖子不需要认证:

curl 'http://localhost:8080/api/posts?page=0&size=20'

发表评论:

curl -i -u alice_01:correct-horse-battery \
  -X POST http://localhost:8080/api/posts/1/comments \
  -H 'Content-Type: application/json' \
  -d '{"body":"欢迎来到论坛。"}'

成功注册和创建资源返回 HTTP 201;参数不合法通常返回 400;资源不存在返回 404;重复用户名返回 409;缺少或提供错误凭据会由 Spring Security 返回认证错误。具体响应体格式受 Spring Security 和应用错误处理配置影响,客户端不应只靠错误文本判断业务状态。

十、常见错误与边界检查

  • 把密码直接保存到数据库:数据库泄露后会暴露用户密码。只保存经过密码哈希算法处理的结果,验证使用 matches。
  • 让客户端提交 authorId:用户可以伪造其他人的 ID。作者应从服务端认证主体推导,不能信任请求体提供的身份字段。
  • 直接把 JPA 实体作为 JSON 返回:可能暴露内部字段、造成循环引用或触发意外查询。使用请求 DTO 和响应 DTO。
  • 只用 existsByUsername 防重复:两个并发请求可能同时通过检查。唯一索引才是最终约束,应用层还应处理冲突。
  • 认为长度校验等于防 XSS:长度限制控制输入大小,不会自动清理 HTML。输出到网页时按上下文转义,富文本需单独清洗。
  • 公开 H2 控制台或使用默认开发库上线:示例配置只为本地演示。生产环境应关闭控制台、配置持久化数据库与迁移、使用 HTTPS 并妥善管理密钥。
  • 无限制读取列表:一次返回全部帖子会增加内存和数据库负担。使用分页,并对页大小设置服务端上限。
  • 将 HTTP Basic 当成安全传输:Basic 只是认证方案,不提供传输加密。必须使用 HTTPS;面向浏览器的正式产品通常还需要评估会话、短期令牌、刷新机制和 CSRF 策略。

将这套基础功能用于更完整的论坛时,可以继续加入帖子详情接口、编辑与删除权限检查、软删除、评论分页排序、账号验证、限流和内容审核。每项新功能都要明确资源所有权与状态转换规则:例如修改帖子时,不能只检查用户已登录,还要验证当前用户是否为作者或具备管理员权限。把这些边界放在服务端实现,才能让接口在不同客户端和并发请求下保持一致。

© 版权声明

相关文章