用Spring Boot与Thymeleaf复刻QQ空间个人主页系统
QQ空间不仅是一代人的网络记忆也是一个典型的内容型个人产品模型。它把个人资料、动态发布、相册管理、留言互动全部放进一个页面背后涉及用户认证、权限控制、内容存储、文件上传和页面渲染。今天我用 Spring Boot、Thymeleaf、Bootstrap 和 MySQL 复刻一个最小可运行的个人空间系统把“一代人有一代人的QQ空间”这个话题落到技术实现上。这套系统不用前后端分离不用微服务也不引入复杂的消息队列。我会从需求拆解开始逐步设计数据库、编写后端接口、渲染前端页面最后给出运行验证和常见问题排查思路。学完之后你可以把它当成课程设计、个人主页原型或者理解内容型 Web 应用落地流程的练习项目。1. 先想清楚QQ空间的核心功能到底在解决什么问题1.1 从用户角度拆解个人空间的需求QQ空间的本质是“个人主页 内容发布 社交互动”。老一代用户记录心情、传照片、写留言本质上是在维护一个属于自己的网络身份。拆开来看核心场景包括展示个人资料头像、昵称、个性签名、生日、学校等。发布说说一段文字、一张配图记录当下的状态。管理相册上传照片、创建相册、展示缩略图。留言板让访客留下文字。访问者身份感知登录后看到自己的空间和访客看到的空间有差异。这些需求决定了系统的功能边界也决定了数据库表设计的粒度。1.2 从开发者角度抽象模块从技术角度看QQ空间可以抽象成几个基础模块用户认证模块注册、登录、Session 会话维护。个人资料模块查询和修改自己的资料。说说模块发布、列表查询、删除。相册模块图片上传、存储路径记录、展示。留言模块提交留言、展示留言列表。模块之间不一定要做成微服务单体应用里用清晰的包结构就可以把它们隔离好。对于一个学习和复刻项目最重要的不是架构复杂度而是链路完整页面发起请求后端处理数据数据库持久化再渲染回页面。1.3 为什么选择 Spring Boot Thymeleaf 而不是前后端分离很多人现在默认用 Vue/React Spring Boot 做前后端分离但个人空间这类场景用服务端渲染反而更合适。页面结构相对固定访问者不需要复杂的交互状态管理。服务端渲染可以直接输出完整 HTML对搜索引擎和内容分享更友好。学习成本低不需要同时维护两套工程和两套部署环境。文件上传和页面跳转在 Spring Boot Thymeleaf 中实现路径很短。如果未来想接入小程序、App再拆出 REST API 也不迟。项目落地时要根据团队熟悉度和部署条件决定不必盲目追求前后端分离。2. 环境准备与项目初始化2.1 环境要求下面这个环境组合比较稳妥新老版本差距不大遇到问题也容易搜索到解决方案。工具推荐版本说明JDK8 或 11项目使用 Java 8 语法即可Maven3.6 以上管理依赖和构建MySQL5.7 或 8.0存储业务数据IDEIntelliJ IDEA 或 VS Code按个人习惯选择不影响结果浏览器Chrome / Edge调试页面和接口如果你的机器已经装了更高版本 JDK比如 JDK 17、21需要确认 Spring Boot 版本兼容。本文使用 Spring Boot 2.7.18支持 JDK 8 到 17是一个兼容性比较好的选择。2.2 创建 Spring Boot 项目并配置依赖使用 Spring Initializr 创建项目或者直接新建 Maven 项目后手动添加父依赖。关键依赖如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependenciesSpring Boot 会自动配置 Web 容器、Thymeleaf 模板引擎和 MyBatis-Plus。MyBatis-Plus 能减少很多单表 CRUD 代码适合快速完成这个学习项目。项目初始化后把配置文件application.yml改成下面这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/personal_space?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword servlet: multipart: max-file-size: 10MB max-request-size: 50MB thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有两个关键点。第一map-underscore-to-camel-case开启后nick_name可以自动映射到实体的nickName属性省去手写 resultMap。第二spring.servlet.multipart配置了上传文件大小限制后面做相册上传时会用到。3. 数据库设计用四张表承载个人空间的记忆3.1 表结构总览个人空间最核心的四张表是用户表、说说表、相册表、留言表。我刻意把“相册”简化成“照片表”一张照片独立保存再通过user_id归属到某个用户避免引入相册分组带来的额外复杂度。表名用途user存储用户账号和资料信息moment存储说说内容photo存储照片路径和描述message存储访客留言3.2 建表 SQL 与字段设计在 MySQL 中创建数据库CREATE DATABASE IF NOT EXISTS personal_space DEFAULT CHARACTER SET utf8mb4; USE personal_space; CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 登录密码生产环境必须加密存储, nick_name VARCHAR(50) NOT NULL COMMENT 空间昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像图片地址, signature VARCHAR(255) DEFAULT COMMENT 个性签名, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE moment ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 发布者ID, content VARCHAR(1000) NOT NULL COMMENT 说说内容, image VARCHAR(255) DEFAULT NULL COMMENT 配图地址, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT说说表; CREATE TABLE photo ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 所属用户ID, url VARCHAR(255) NOT NULL COMMENT 照片访问地址, description VARCHAR(255) DEFAULT COMMENT 照片描述, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT照片表; CREATE TABLE message ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 空间主人ID, visitor_name VARCHAR(50) NOT NULL COMMENT 访客名称, content VARCHAR(500) NOT NULL COMMENT 留言内容, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT留言表;字段设计上要注意几点用户密码字段使用VARCHAR(100)因为 BCrypt 加密后的字符串长度会超过 32 位太短会导致插入失败。text类型不要滥用说说内容用VARCHAR(1000)足够索引性能和查询效率都比text好。所有时间字段都用DATETIME并使用DEFAULT CURRENT_TIMESTAMP减少 Java 层赋值的重复代码。每张业务表都加上idx_user_id索引因为个人空间的核心页面都是按user_id查询列表。注意如果你使用 MySQL 8.x连接 URL 中的serverTimezone建议写成Asia/Shanghai否则时间字段可能出现 8 小时偏移。4. 实现个人空间的后端接口4.1 实体类与 Mapper 层先创建用户实体类public class User { private Long id; private String username; private String password; private String nickName; private String avatar; private String signature; private LocalDateTime createTime; // getter / setter 略 }使用 MyBatis-Plus 的注解标识表名和主键import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; TableName(user) public class User { TableId(type IdType.AUTO) private Long id; // 其他字段同上 }Mapper 接口只需要继承BaseMapperimport com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; Mapper public interface UserMapper extends BaseMapperUser { }同样的方式创建MomentMapper、PhotoMapper、MessageMapper。这个阶段不需要手写 XML单表查询和简单分页可以直接用 MyBatis-Plus 提供的selectList、selectPage、deleteById。4.2 Session 登录与访问身份判断登录功能使用 Session 保存当前用户对象。创建SessionControllerController public class SessionController { Resource private UserMapper userMapper; PostMapping(/login) public String login(String username, String password, HttpSession session, Model model) { User user userMapper.selectOne( new LambdaQueryWrapperUser() .eq(User::getUsername, username) ); if (user null) { model.addAttribute(error, 用户不存在); return login; } // 学习项目里明文比对生产环境必须使用 BCrypt if (!user.getPassword().equals(password)) { model.addAttribute(error, 密码错误); return login; } session.setAttribute(loginUser, user); return redirect:/space/ user.getId(); } }这里要注意Model添加error后返回逻辑视图名loginThymeleaf 会在模板中动态显示错误提示。空间页面需要区分“自己看”和“访客看”。我用user_id作为空间主页路径参数比如/space/1。在 Controller 中判断当前 Session 里的用户是否为该空间主人GetMapping(/space/{userId}) public String space(PathVariable Long userId, HttpSession session, Model model) { User owner userMapper.selectById(userId); if (owner null) { return error/404; } User loginUser (User) session.getAttribute(loginUser); boolean isOwner loginUser ! null loginUser.getId().equals(userId); model.addAttribute(owner, owner); model.addAttribute(isOwner, isOwner); return space; }这个isOwner用于页面上决定是否显示“删除”按钮和“上传照片”入口。4.3 说说发布与列表查询说说模块的核心是写和读。发布入口是表单提交处理逻辑如下PostMapping(/moment/add) public String addMoment(Long userId, String content, HttpSession session) { User loginUser (User) session.getAttribute(loginUser); if (loginUser null || !loginUser.getId().equals(userId)) { return redirect:/login; } Moment moment new Moment(); moment.setUserId(userId); moment.setContent(content); momentMapper.insert(moment); return redirect:/space/ userId; }发布前必须校验 Session防止用户伪造请求往别人的空间里发内容。这是个人空间系统的安全底线。查询列表时按时间倒序GetMapping(/space/{userId}/moments) public String moments(PathVariable Long userId, Model model) { ListMoment moments momentMapper.selectList( new LambdaQueryWrapperMoment() .eq(Moment::getUserId, userId) .orderByDesc(Moment::getCreateTime) ); model.addAttribute(moments, moments); return space; }为了保持示例简单这里没有做分页。如果数据量变大建议使用PageMoment做分页。4.4 照片上传与访问路径处理照片上传需要使用MultipartFile。保存路径放在项目的upload目录同时通过资源映射让浏览器能访问到PostMapping(/photo/upload) public String uploadPhoto(Long userId, RequestParam(file) MultipartFile file, RequestParam(value description, required false) String description, HttpSession session) throws IOException { User loginUser (User) session.getAttribute(loginUser); if (loginUser null || !loginUser.getId().equals(userId)) { return redirect:/login; } String originalFilename file.getOriginalFilename(); String ext ; if (originalFilename ! null originalFilename.contains(.)) { ext originalFilename.substring(originalFilename.lastIndexOf(.)); } String filename System.currentTimeMillis() _ UUID.randomUUID() ext; File dir new File(System.getProperty(user.dir), upload); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); Photo photo new Photo(); photo.setUserId(userId); photo.setUrl(/upload/ filename); photo.setDescription(description); photoMapper.insert(photo); return redirect:/space/ userId; }文件命名使用时间戳 UUID 扩展名可以降低文件名冲突概率。如果不限制扩展名恶意用户可能上传可执行脚本所以生产环境还需要校验文件类型、后缀、文件头内容并禁止上传到可执行目录。在 Spring Boot 中添加静态资源映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: System.getProperty(user.dir) /upload/); } }4.5 留言板的写入与读取留言不需要登录符合 QQ 空间访客留言的真实场景PostMapping(/message/add) public String addMessage(Long userId, String visitorName, String content) { Message message new Message(); message.setUserId(userId); message.setVisitorName(visitorName); message.setContent(content); messageMapper.insert(message); return redirect:/space/ userId; }读取时同样按时间倒序ListMessage messages messageMapper.selectList( new LambdaQueryWrapperMessage() .eq(Message::getUserId, userId) .orderByDesc(Message::getCreateTime) );后端接口到这里已经形成了一个完整闭环登录、展示空间、发布说说、上传照片、提交留言。5. 前端页面让“空间”看起来像那么回事5.1 页面结构与布局页面整体采用 Bootstrap 栅格布局。顶部是资料卡下面用标签页展示说说、相册、留言板。建一个space.html放在src/main/resources/templates目录下。!DOCTYPE html html langzh-CN xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title个人空间/title link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet /head body div classcontainer mt-4 div classcard mb-4 div classcard-body d-flex img th:src${owner.avatar ! null ? owner.avatar : /img/default-avatar.png} classrounded-circle me-3 width80 height80 alt头像 div h3 th:text${owner.nickName}昵称/h3 p classtext-muted th:text${owner.signature}个性签名/p /div /div /div ul classnav nav-tabs idspaceTab roletablist li classnav-item button classnav-link active idmoments-tab>div classcard mb-3 th:eachmoment : ${moments} div classcard-body p classcard-text th:text${moment.content}说说内容/p p classtext-muted small th:text${moment.createTime}时间/p div th:if${moment.image ! null} img th:src${moment.image} classimg-fluid alt配图 /div button th:if${isOwner} classbtn btn-sm btn-outline-danger th:href{/moment/delete/{id}(id${moment.id}, userId${owner.id})} 删除 /button /div /divThymeleaf 的th:each对应后端model.addAttribute(moments, moments)。th:if${isOwner}控制只有空间主人能看到删除按钮。5.3 相册展示与上传表单相册区域展示照片缩略图div classrow div classcol-md-4 mb-3 th:eachphoto : ${photos} div classcard img th:src${photo.url} classcard-img-top alt照片 div classcard-body p classcard-text th:text${photo.description}描述/p /div /div /div /div上传表单只在空间主人访问时显示div th:if${isOwner} classcard p-3 form th:action{/photo/upload} methodpost enctypemultipart/form-data input typehidden nameuserId th:value${owner.id} input typefile namefile classform-control mb-2 required input typetext namedescription classform-control mb-2 placeholder照片描述 button typesubmit classbtn btn-primary btn-sm上传照片/button /form /div这里使用了enctypemultipart/form-dataSpring Boot 才能正确绑定MultipartFile。5.4 留言板和登录页面留言板展示与提交div th:eachmsg : ${messages} classcard mb-2 div classcard-body py-2 strong th:text${msg.visitorName}访客/strong span classtext-muted small ms-2 th:text${msg.createTime}时间/span p classmb-0 th:text${msg.content}留言内容/p /div /div form th:action{/message/add} methodpost input typehidden nameuserId th:value${owner.id} input typetext namevisitorName classform-control mb-2 placeholder你的昵称 required textarea namecontent classform-control mb-2 placeholder想说的话 required/textarea button typesubmit classbtn btn-primary btn-sm提交留言/button /form登录页核心是一个表单后端把错误信息放到error属性中p th:if${error ! null} classtext-danger th:text${error}/p form th:action{/login} methodpost input typetext nameusername classform-control mb-2 required input typepassword namepassword classform-control mb-2 required button typesubmit classbtn btn-success登录/button /form6. 运行验证与常见问题排查6.1 启动项目并访问页面启动 Spring Boot 应用后访问http://localhost:8080/register注册一个测试用户。跳转到登录页使用刚注册的账号登录。登录成功后进入/space/{userId}确认个人资料、说说明、相册、留言板区域正常显示。在“相册”标签页上传图片确认文件出现在upload目录。在“留言板”输入昵称和内容提交确认留言显示在列表顶部。预期输出并不是一个静态页面而是能完成数据持久化和身份控制的动态个人空间。注意不要只验证页面能打开还要验证“非主人是否看不到删除按钮”“未登录访客是否看不到上传入口”这两个关键分支。6.2 常见错误与排查问题现象常见原因检查方式处理建议启动时报数据库连接失败MySQL 没启动、账号密码错误、数据库未创建查看控制台 URL 和驱动报错先本地命令行连接数据库确认账号密码页面显示 404模板路径不对、Controller 路由不对查看控制台是否有/space/映射确认templates目录、类名Controller上传文件后页面 500上传目录无写权限、文件名异常查看异常堆栈给upload目录写入权限校验扩展名中文乱码数据库连接 URL 未指定编码或表格不是 utf8mb4查看数据库表结构URL 加characterEncodingutf8重建表登录后跳转回登录页Session 失效或未正确写入浏览器开发者工具查看 Cookie确认session.setAttribute已执行路径未写错图片访问 404静态资源映射没生效直接访问/upload/xxx.jpg检查WebConfig是否正确注册6.3 推荐排查链路遇到问题时按这个顺序排查先确认输入是否正确表单字段名和后端RequestParam是否一致。再看路径ControllerGetMapping和前端th:action、th:href是否匹配。再看数据库表是否存在、字段名是否和实体属性映射成功。再看日志MyBatis-Plus 已配置StdOutImpl日志会打印 SQL直接查看 SQL 定位数据查询问题。最后才怀疑框架版本或配置类问题。7. 从学习项目到生产系统的改造方向7.1 安全与权限增强当前示例的密码是明文存储这是学习项目最常见的短板。生产环境必须使用 BCrypt 或类似算法加密密码。推荐集成 Spring Security 或至少使用BCryptPasswordEncoder登录时再校验matches。同时需要补充注册时校验用户名唯一性。对留言内容做 HTML 转义避免 XSS 脚本注入。会话过期后自动跳转登录页。文件上传使用白名单校验扩展名和文件头。接口增加访问频率限制防止恶意刷留言。Timeline 操作必须校验“当前登录用户是否为资源 Owner”这是个人空间安全的核心。7.2 性能与存储本地文件存储只适合学习环境。生产环境推荐把图片上传到对象存储服务数据库只保存对象存储的访问 URL。如果图片访问量大还需要 CDN 加速。查询列表时增加分页按时间范围过滤避免一次性加载全部数据。缓存的引入顺序应该是先看 SQL 查询是不是足够高效再看 Redis 缓存热点列表最后才考虑加一层 CDN。7.3 功能扩展这个基础版本还可以扩展很多方向好友关系新增friend表实现双向好友关注。评论与回复留言变为评论 回复两级结构。空间访问量新增 PV/UV 统计使用缓存异步写入。消息通知被留言、被点赞时生成站内消息。私密空间通过开关控制指定内容仅主人可见。建议先把自己的空间模块跑稳定再逐步加社交关系。功能越多权限边界就需要设计得越仔细。8. 最佳实践清单开发个人空间这类内容型应用时下面这份清单可以直接当检查表用。8.1 项目初始化清单确认 JDK 版本与 Spring Boot 版本兼容。数据库连接 URL 指定时区和 utf8mb4 编码。创建数据库后再启动应用避免连接失败。启动模板缓存关闭方便前端调试。8.2 编码实现清单实体类字段使用驼峰命名数据库字段使用下划线命名开启自动映射。所有接口先校验 Session再执行业务操作。文件上传必须限制大小、校验类型、重命名文件。列表查询按创建时间倒序并加入分页。不要在 Controller 中写复杂业务逻辑业务多时拆出 Service 层。8.3 发布前检查清单修改默认端口和数据库密码。密码全部加密存储日志中不打印明文密码。删除测试数据或使用独立测试库。确认上传目录权限和备份策略。验证“未登录访客无法进入空间”和“非主人无法删除内容”两条安全规则。检查前端模板是否存在 HTML 注入风险。准备一份运行环境说明包含 JDK、MySQL、Maven 版本。8.4 日常维护清单每天备份数据库定期备份上传文件。查看应用日志关注数据库连接数、慢查询、接口耗时。给磁盘空间设置监控防止上传文件占满磁盘。升级依赖前先跑一遍核心测试用例。对线上接口做访问日志审计发现异常频率的请求及时处理。完成这个项目之后建议再做一个更有挑战的小改动把留言板升级成嵌套评论或者把说说模块换成 Redis 缓存热数据。每一次改动都会让你更清楚一个内容型 Web 应用从页面到数据库的完整链路是怎样协作的。所谓“一代人有一代人的QQ空间”换到技术视角其实就是一代开发者需要不断掌握的新工具、新模型和新取舍。