ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot+Vue的树洞论坛系统:从表结构到前后端部署

基于SpringBoot+Vue的树洞论坛系统:从表结构到前后端部署 简介这是一份基于SpringBoot与Vue的树洞论坛系统完整源码目标读者是计算机相关专业毕业生、全栈开发初学者以及需要快速搭建可演示项目的人群。项目围绕匿名倾诉与问答交流场景实现了用户管理、问题发布、回答互动、敏感词过滤、Redis缓存等模块KeyWordFilter负责清理不当言论RedisServiceImpl提升热点数据访问速度QuestionServiceImpl与AnswerServiceImpl完成问答核心流程后端采用SpringBoot整合MyBatis与Redis前端基于Vue并附带SQL脚本整体覆盖毕业设计常见考点。压缩包共790个文件以Java、XML、Vue、JavaScript、Properties、YML等源码与配置为主另有jar依赖包、class编译产物及样式资源整体约93.83MB目录结构清晰便于按模块阅读与二次开发。目前已有58人学习下载适合用来准备毕业答辩、课程设计或作为论坛类系统的开发参考。1. 树洞论坛系统的技术栈与定位树洞论坛和普通论坛在需求层面最大的不同是它不关心“你是谁”用户发帖不需要维护社交关系甚至不需要暴露真实身份产品核心只剩“倾诉 → 审核 → 评论”三个动作。基于SpringBoot与Vue的树洞论坛系统源码.zip正是一套把这三个动作做成完整闭环的前后端分离工程——SpringBoot负责REST接口、数据库读写和审核状态机Vue负责树洞广场、详情页和管理后台两边用JSON通信。它适合两类人做毕设或课设、需要完整可运行项目的学生想低成本搭建匿名倾诉社区、希望底子干净可扩展的开发者。下面按我实际落地这类系统的工作顺序写从表结构、后端接口、前端页面一直讲到部署避坑目标是拿到源码后两小时内跑起来。2. 从需求到表结构树洞论坛先设计数据模型在写Controller之前我会先花半天把表结构定死。树洞系统的特殊点在于“匿名”是产品层表现数据库层仍然要能追溯到具体用户否则管理员面对人身攻击、广告刷屏会毫无办法。基于这个前提我一般设计四张表user、post、comment、admin其中admin直接复用user表并用role字段区分这样少一张表、少一套权限逻辑。2.1 核心表设计帖子、评论与用户的三张主表三张主表的职责边界要划清楚user存登录凭证和对外匿名身份post存树洞内容和审核状态comment存楼内回复。post表是整个系统的重心字段上至少要覆盖内容、图片、分类、状态、计数、审核时间和驳回原因。以下是我实际用的建表SQL字符集、备注和索引都按可直接投入使用的标准写CREATE DATABASE treehole DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE treehole; CREATE TABLE user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(255) NOT NULL COMMENT BCrypt密文, nickname VARCHAR(50) DEFAULT 匿名用户 COMMENT 对外昵称, anonymous_name VARCHAR(50) DEFAULT NULL COMMENT 树洞匿名代号, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像地址, role TINYINT NOT NULL DEFAULT 0 COMMENT 0-普通用户 1-管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 0-禁用 1-正常, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_role (role) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE post ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 帖子ID, user_id BIGINT UNSIGNED NOT NULL COMMENT 发帖用户ID, content TEXT NOT NULL COMMENT 树洞内容, images VARCHAR(1000) DEFAULT NULL COMMENT 图片URL多张用逗号分隔, category_id INT NOT NULL DEFAULT 0 COMMENT 分类ID0为默认树洞, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待审核 1-已发布 2-已驳回 3-已删除, is_top TINYINT NOT NULL DEFAULT 0 COMMENT 1-置顶, like_count INT NOT NULL DEFAULT 0 COMMENT 点赞数, view_count INT NOT NULL DEFAULT 0 COMMENT 浏览量, comment_count INT NOT NULL DEFAULT 0 COMMENT 评论数, is_hidden TINYINT NOT NULL DEFAULT 0 COMMENT 1-仅自己可见, audit_time DATETIME DEFAULT NULL COMMENT 审核时间, audit_remark VARCHAR(255) DEFAULT NULL COMMENT 驳回原因, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_create (status, is_top, create_time), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT树洞帖子表; CREATE TABLE comment ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 评论ID, post_id BIGINT UNSIGNED NOT NULL COMMENT 帖子ID, user_id BIGINT UNSIGNED NOT NULL COMMENT 评论用户ID, content VARCHAR(500) NOT NULL COMMENT 评论内容, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待审核 1-已发布 2-已驳回, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_post_status (post_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评论表;这段DDL里最容易被复现时忽略的是字符集库和表都要用utf8mb4而不是utf8因为树洞内容几乎必然出现emojiutf8只支持三个字节遇到“”这种四字节字符会直接报Incorrect string value整个INSERT失败。post.content用TEXT而不是VARCHAR(255)是因为倾诉内容的长度不可控很多人把树洞当日记写一段话上千字很正常。复合索引idx_status_create放在列表查询的过滤字段和排序字段上树洞广场每次都是WHERE status1并按置顶、时间倒序这个索引能同时覆盖过滤和排序数据量到十万级也不会明显慢。user表里有两个容易被当成多余的字段anonymous_name匿名代号和nickname对外昵称。我的设计逻辑是nickname是用户自己改的不填就显示“匿名用户”anonymous_name是系统第一次发帖时自动分配的固定代号比如“沉默的蓝鲸”用来给用户一个连续的匿名身份感这是树洞类产品提升留存的关键小细节。role字段直接复用user表做管理员标识省去单独的管理员工表。2.2 匿名与审核如何落地字段和流程设计“匿名”落到字段上是一套组合登录必须有username但对外接口一律不返回username只返回anonymous_name或nickname后端在序列化user对象时用JsonIgnore把username和password屏蔽掉前端永远看不到真实登录名。而管理员在后台又需要知道一条帖子的真实用户ID用于封禁和追溯所以user_id必须留在post表里不设置成完全匿名。审核流程我用一个简单状态机描述这也是树洞系统区别于普通论坛的核心新帖status为0待审核管理员审核通过变成1驳回变成2并写入audit_remark用户可以根据驳回原因修改后重新提交。这里有两个边界要注意审核动作只允许发生在待审核状态上已经审核过的帖子不能反复改状态防止管理后台误操作覆盖驳回的帖子用户改完再提交要回到0而不是继续用之前的1或2。这个状态机在第三章的Service代码里会体现。评论表同样带status字段。公开树洞的评论如果完全不审核广告链接会顺着帖子评论区蔓延所以我把评论默认也置为0管理员在同一个后台列表里批量审核。列表页只查status1的数据这样即使有漏网之词前台也不会立刻展示。置顶和删除也建议用字段控制而不是物理删除。is_top用于置顶列表排序时ORDER BY is_top DESC, create_time DESC删除时把status改成3保留数据做审计。物理DELETE一旦误操作用户申诉后你就没有后悔药了。2.3 初始化数据与演示账号表结构定完先灌一批演示数据这样前后端联调时不用先注册账号就能看到页面效果。初始化脚本放在源码包的sql目录下我习惯命名为treehole.sql建库建表和初始化数据放同一个文件导入一次搞定INSERT INTO user (username, password, nickname, anonymous_name, role, status) VALUES (admin, $2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2, 管理员, 沉默的蓝鲸, 1, 1); INSERT INTO post (user_id, content, status, create_time) VALUES (1, 今天加班到十一点地铁末班车只剩我一个人说出来好受一点。, 1, NOW()), (1, 和认识五年的朋友闹掰了不知道该不该挽回蹲一个陌生人建议。, 1, NOW()), (1, 这是一条待审核的测试帖请到管理后台做审核操作。, 0, NOW()); INSERT INTO comment (post_id, user_id, content, status, create_time) VALUES (1, 1, 抱抱你明天会好的。, 1, NOW());管理员账号的明文密码是123456但表里存的是BCrypt密文不是明文。密文必须用Spring Security的PasswordEncoder生成不能手写网上随便抄一段密文很容易格式不对导致登录时校验失败。这个示例密文只适合本地演示上线前一定要替换成自己的密码并走一次完整的注册流程。插入post时故意留了一条status0的待审核帖目的是让第一次运行的人能在管理后台直接看到审核入口而不是对着空列表不知道去哪测审核功能。这里多说一句数据导入方式我一般用命令行执行mysql -uroot -p sql/treehole.sql来建库导数据而不是在Navicat里手动粘贴。命令行导入能正确识别文件里的CREATE DATABASE语句遇到SQL报错也能看到具体行号比图形工具的报错好排查得多。3. SpringBoot后端接口设计与关键业务实现表结构定完就可以写SpringBoot工程了。这套后端遵循最标准的SpringBoot项目结构启动类在最外层包controller、service、mapper、entity按包分层配置文件放在resources下SQL脚本放在项目根目录的sql文件夹。我用MyBatis-Plus做数据库操作理由是树洞系统的单表CRUD占绝大多数BaseMapper能省掉大量重复的Mapper XML联表查询再单独写XML补齐。3.1 从项目结构看接口风格统一返回与异常处理项目骨架长这样treehole-server/ ├── pom.xml ├── sql/ │ └── treehole.sql ├── src/main/java/com/treehole/ │ ├── TreeholeApplication.java │ ├── common/ │ │ ├── Result.java │ │ └── BizException.java │ ├── config/ │ │ ├── WebMvcConfig.java │ │ └── MybatisPlusConfig.java │ ├── controller/ │ │ ├── AuthController.java │ │ ├── PostController.java │ │ └── CommentController.java │ ├── service/ │ │ └── impl/ │ │ └── PostServiceImpl.java │ ├── mapper/ │ │ └── PostMapper.java │ └── entity/ │ ├── User.java │ ├── Post.java │ └── Comment.java └── src/main/resources/ ├── application.yml └── mapper/ └── PostMapper.xml接口风格是前后端分离项目最常见的统一返回体所有Controller的方法都返回Result对象Vue端Axios只认Result里的codeData public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T ResultT error(String message) { ResultT r new Result(); r.setCode(500); r.setMessage(message); return r; } }这个类的价值在于它把业务错误和HTTP状态码解耦。比如审核时发现帖子状态不对业务层抛BizException全局异常处理器捕获后返回code500和具体提示但HTTP状态仍然是200。这样前端拦截器只需要判断code不用为401、403、500分别写处理分支联调时两边都轻松。全局异常处理器再加一个兜底的ExceptionHandler防止未捕获异常直接吐出堆栈给浏览器。3.2 发帖与审核的Service实现状态机是关键PostServiceImpl里的两个方法是整个后台最核心的逻辑。发帖时要做三件事内容非空校验、初始化状态为待审核、写入图片列表和计数初值审核时做四件事查帖子是否存在、校验当前状态是不是待审核、写目标状态和审核时间、写驳回原因。代码Service RequiredArgsConstructor public class PostServiceImpl extends ServiceImplPostMapper, Post implements PostService { Override public Long createPost(Post post, Long userId) { if (StringUtils.isBlank(post.getContent())) { throw new BizException(树洞内容不能为空); } if (post.getContent().length() 5000) { throw new BizException(内容太长了树洞也装不下); } post.setUserId(userId); post.setStatus(0); post.setLikeCount(0); post.setViewCount(0); post.setCommentCount(0); save(post); return post.getId(); } Override public void auditPost(Long id, Integer targetStatus, String remark) { Post post getById(id); if (post null) { throw new BizException(帖子不存在); } if (!post.getStatus().equals(0)) { throw new BizException(只有待审核的帖子才能执行审核); } post.setStatus(targetStatus); post.setAuditTime(LocalDateTime.now()); if (targetStatus.equals(2)) { post.setAuditRemark(remark); } updateById(post); } }这个实现里有三个参数值得解释。targetStatus只允许传1或2对应通过和驳回业务入口处最好再用枚举校验一次防止调用方乱传4、5把状态机搞乱remark只在驳回时写入通过时置空这样用户端可以看到“为什么被拒”管理员端也不会被历史备注干扰createPost里显式setStatus(0)而不是依赖数据库默认值是因为MyBatis-Plus的insert会忽略字段为null的列如果数据库默认值是0但实体里没赋值最终插入可能变成其他状态显式赋值最稳。列表查询我直接用MyBatis-Plus的LambdaQueryWrapper不做复杂SQLWHERE status 1 AND is_hidden 0按is_top倒序再按create_time倒序分页。前端树洞广场拉取的就是这个接口。浏览量不建议在列表接口里用UPDATE语句实时加常见做法是详情接口返回时异步加一次或者用Redis的INCR定期刷回源码里先做简单同步加一即可。3.3 JWT登录与“伪匿名”的取舍登录体系不用明文密码存储用JWT签发令牌。user表里的password是BCrypt密文登录接口用PasswordEncoder.matches校验校验通过后生成一个tokentoken里只放userId和role两个字段过期时间我一般设24小时。关键配置在拦截器这段代码决定了哪些接口要登录、哪些对游客开放Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/register, /api/post/list, /api/post/detail/**, /api/comment/list/** ); } }白名单的设计逻辑要贴合产品定位树洞广场和帖子详情必须对游客开放否则用户点进来什么都看不到也就没有注册动力发帖、评论、点赞、管理后台接口全部走拦截器验证token游客调用直接返回401。AuthInterceptor里要做两件事从请求头Authorization里取Bearer开头的token解析出userId后放入request的attributeService层从attribute取值而不是前端把userId当参数传过来——后者等于把防伪标识交给客户端随便改包就能冒充别人发帖。“伪匿名”就在这一步实现token里有userIdController返回帖子数据时只组装anonymousName或nickname绝不返回username。管理员后台单独调一个审计接口按user_id反查真实用户普通用户没有这个权限。3.4 application.yml里必调的四个参数后端能跑起来一半靠代码一半靠配置文件。下面是我这套源码的核心配置重点参数都加了注释server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/treehole?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 50MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl custom: upload-dir: ./upload这些参数里最容易翻车的是serverTimezoneAsia/Shanghai。MySQL驱动8.x连接时如果URL不带时区参数会把服务器本地时区映射成UTC导致数据写入和读取相差8小时排查时间不对劲先想这个。characterEncodingutf8要和数据库的utf8mb4配合理解JDBC连接串里写utf8是为了让驱动按UTF-8处理字符真正存emoji靠的是库表本身的utf8mb4两处缺一不可。map-underscore-to-camel-case如果设成falseuser_id查出来映射不到userId实体里全是一串null这是新手复现时最高频的“数据查到了但全是空值”问题。upload-dir我写成相对路径./upload方便本地运行但部署到服务器后必须在启动脚本里改成绝对路径否则上传图片会出现在你意想不到的目录。log-impl用StdOutImpl是开发期把SQL打到控制台方便排查生产环境建议去掉或改成logback配置输出到文件避免日志量过大。4. Vue前端从工程初始化到页面渲染后端接口就绪后前端要做的事情是围绕“树洞广场、帖子详情、发布、管理后台”四个页面把工程搭起来。我用Vue CLI生成的标准工程依赖用npm安装核心就vue-router、axios加一个组件库。工程结构里要注意views目录和router目录的对应关系页面组件按业务拆分公共的请求封装单独放api目录。4.1 工程结构与vue.config.js先解决跨域前端工程长这样treehole-web/ ├── public/ ├── src/ │ ├── api/ │ │ ├── request.js │ │ ├── post.js │ │ └── auth.js │ ├── router/ │ │ └── index.js │ ├── views/ │ │ ├── Home.vue │ │ ├── PostDetail.vue │ │ ├── Login.vue │ │ └── Admin/ │ │ ├── Index.vue │ │ └── AuditList.vue │ ├── App.vue │ └── main.js ├── package.json └── vue.config.js开发环境最先要解决的是跨域。前后端分离项目里前端跑在3000端口后端跑在8080端口浏览器会拦截前端发往8080的请求。解决办法不是在后端开CORS硬放行——那样每加一个域名都要改后端——而是在vue.config.js里配置devServer转发把请求转出去const { defineConfig } require(vue/cli-service) module.exports defineConfig({ devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }, outputDir: dist })这个配置的逻辑前端所有请求都写/api前缀浏览器看到的是同源的http://localhost:3000/apidevServer把它原样转发到8080端口的SpringBoot。changeOrigin设为true是为了让后端看到的请求头Host是8080否则某些校验会认为来源不合法。pathRewrite在这个场景下不需要写因为前后端约定的接口本来就是/api开头保持一致最省事。等前端打包放进SpringBoot的static目录后浏览器和后端同源这个转发配置就自动失效不需要改代码。4.2 Axios封装与登录态请求拦截器统一带token所有接口调用我统一走一个request.js不在每个页面里散落axios.create。好处是登录态注入、错误提示、接口超时都收敛在一个文件里页面代码只关心业务数据import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(treehole_token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response { if (response.data.code 200) { return response.data } if (response.data.code 401) { localStorage.removeItem(treehole_token) window.location.href /#/login return Promise.reject(new Error(请先登录)) } return Promise.reject(new Error(response.data.message)) }, error { return Promise.reject(error) } ) export default requesttoken存localStorage而不是cookie理由是前后端分离场景下cookie要处理跨域携带、CSRF防护一堆事localStorage配合Authorization头是当下最主流、最不容易踩坑的方案。baseURL写成/api这样开发环境走转发生产环境和后端同源两种环境都不用改代码。响应拦截器里只认Result的codeHTTP状态码200但业务code500的情况会被当作业务错误抛出前端统一弹message不会出现“接口报错但页面没反应”的尴尬。请求封装好之后页面里发帖就是一句request.post(/api/post/add, form)审核就是一句request.post(/api/post/audit, params)页面自身不关心token和错误拦截。4.3 树洞广场的触底加载滚动分页与防抖树洞广场的列表不能一次加载全部数据量大了页面会卡死。常见做法是首屏加载第一页滚动到底部再加载下一页。我在Home.vue里用监听scroll事件配合loading锁实现触底加载不引额外插件export default { data() { return { page: 1, pageSize: 10, posts: [], loading: false, finished: false } }, mounted() { this.loadMore() window.addEventListener(scroll, this.onScroll) }, methods: { onScroll() { const doc document.documentElement const distance doc.scrollHeight - doc.scrollTop - doc.clientHeight if (distance 50 !this.loading !this.finished) { this.loadMore() } }, async loadMore() { if (this.loading || this.finished) return this.loading true try { const res await request.get(/post/list, { params: { page: this.page, pageSize: this.pageSize, status: 1 } }) this.posts.push(...res.data.records) this.page if (this.page res.data.pages) { this.finished true } } finally { this.loading false } } } }这里的三个细节直接决定体验distance判断里留了50px的提前量避免滚动到绝对底部才触发导致明显卡顿loading锁防止连续触底时同一页请求多次这是最容易出现问题的地方不加锁的话网络慢的时候会瞬间发十几条重复请求finished状态避免已经到底后还在监听滚动、每次都打一次无效请求。分页参数page和pageSize与后端MyBatis-Plus的Page对象直接对应pages字段是总页数没有更多时就停止。4.4 vue路由与打包进SpringBoot的静态目录前端路由我在这套源码里用hash模式这是个被很多人忽视但性价比极高的选择。history模式url好看但打包进SpringBoot后用户刷新页面后端没有对应的路由映射会返回404。解决方案要么在后端加一个Controller把非/api请求转发到index.html要么直接用hash。源码我直接用hash减少一个运维口子import Vue from vue import Router from vue-router Vue.use(Router) const router new Router({ mode: hash, routes: [ { path: /, component: () import(/views/Home.vue) }, { path: /post/:id, component: () import(/views/PostDetail.vue) }, { path: /login, component: () import(/views/Login.vue) }, { path: /admin, component: () import(/views/Admin/Index.vue) } ] }) export default router路由懒加载用动态import打包时每个页面单独成chunk首屏只加载Home管理后台的代码用户不点击就不会下载。如果以后要按用户角色动态菜单就改成动态路由在登录后根据role字段用router.addRoute追加管理端路由这是Vue路由的常规进阶操作。把前端塞进后端是很多拿到源码的人卡住的一步。命令如下npm install npm run build # 执行后生成 dist 目录 cp -r dist/* ../treehole-server/src/main/resources/static/npm install如果因为网络或镜像问题反复失败先去配置镜像源再删掉node_modules和package-lock.json重装比反复重试有效。打包产物拷贝到后端resources/static后再启动SpringBoot直接访问http://localhost:8080就能看到首页前后端完全同源。注意拷贝时机必须在mvn package之前拷否则mvn clean会把static目录清掉这种问题表现是能启动、页面空白、控制台一串静态资源404后面避坑章节还会再提。5. 运行部署与避坑指南从本机启动到线上发布代码和配置都齐了现在按“最小启动路径”把整条链路跑通然后讲我在复现和交付过程中遇到最多的坑。顺序很重要先数据库、再后端、再前端任何一个环节启动失败都先回看日志。5.1 最小启动路径两条命令跑通前后端假设源码包已经解压到本地命令行依次执行下面几步# 1. 导入数据库路径按源码实际位置改 mysql -uroot -p sql/treehole.sql # 2. 启动后端8080端口 cd treehole-server mvn spring-boot:run # 3. 另开一个终端启动前端开发环境 cd ../treehole-web npm install npm run dev验证路径要按产品流程走一遍才算跑通浏览器打开http://localhost:3000能看到树洞广场的演示帖点进详情页能看到评论点登录用admin/123456能进管理后台在管理后台把那条status0的测试帖审核成通过回前台刷新新帖出现在列表顶部。这个闭环里任何一个环节断了就从对应步骤的日志开始排查后端看控制台异常堆栈前端看浏览器F12的Network和Console。后端接口单独验证可以用curl快速判断是接口问题还是前端问题curl http://localhost:8080/api/post/list?page1pageSize10 | head -c 500返回JSON且code200说明后端正常返回HTML或者连接拒绝说明后端没起来或者端口不对。前端跨域报错时也用这个命令在后端侧确认接口本身是不是好的能把问题切分得很快。提示前后端联调时先看后端控制台有没有打印SQL日志如果SQL没执行到问题在前端请求链路如果SQL执行了但数据不对问题在表结构或参数映射。5.2 六个高频踩坑点现象、原因、解决下面六条是这套源码被反复问到的问题按现象、原因、解决写清楚。第一前端请求接口报跨域但后端接口用curl能通。原因是浏览器直接请求了8080端口而不是走3000的转发。解决把页面里的请求URL统一改成/api开头确保走vue.config.js的转发配置如果想双保险后端再加一个CorsFilter允许前端开发地址但转发修好后CorsFilter不是必需品。第二mvn package后启动页面空白、静态资源全部404。原因是dist在mvn clean之后才拷进resources/static被清理掉了。解决先cp dist到src/main/resources/static再执行mvn clean package顺序不能反。第三中文正常但emoji写入报Incorrect string value。原因是数据库或表的字符集还是utf8。解决把整个库表重建为utf8mb4注意连接串里的characterEncoding也要带utf8这是最有迷惑性的组合坑。第四列表接口返回空但数据库里明明有数据。原因有两个高频来源实体类没有开启驼峰映射user_id映射不到userId或者status字段值不是1数据在待审核状态。解决检查mybatis-plus配置map-underscore-to-camel-case是否true用SQL查一下post表里status的真实值。第五图片上传成功但前端访问返回404。原因是upload-dir用的相对路径启动位置不同时图片落到了别的目录SpringBoot没映射到。解决配置绝对路径并给目录设置正确的写权限如果图片存在本地磁盘还需要一个资源映射把/upload/**指向磁盘路径。第六前端刷新页面白屏404。原因是vue-router用了history模式。解决改用hash模式或在后端加一个forward控制器把非/api路径都转发到index.html。我在这套里直接用hash简单到底。5.3 发布到Linux服务器打包、上传与守护进程本机跑通后上线Linux流程比本地多注意三件事打包、资源分离、进程守护。我习惯的发布命令如下# 前端打包产物已经包含在后端jar里 cd treehole-web npm run build cp -r dist/* ../treehole-server/src/main/resources/static/ # 后端打jar包 cd ../treehole-server mvn clean package -DskipTests # 上传jar到服务器 scp target/treehole-server-0.0.1.jar useryour-server:/opt/treehole/ # 到服务器上启动 cd /opt/treehole nohup java -Xms512m -Xmx512m -jar treehole-server-0.0.1.jar \ --spring.datasource.urljdbc:mysql://内网IP:3306/treehole?serverTimezoneAsia/ShanghaicharacterEncodingutf8 \ app.log 21 -DskipTests跳过测试避免测试类连不上数据库导致打包失败nohup加让进程脱离终端关掉SSH窗口也不会被杀日志重定向到app.log排查问题时tail -f app.log是最直接的入口。数据库连接串里的localhost如果在服务器上也连着本机MySQL可以不变但推荐改成内网IP避免DNS解析带来的额外耗时。重启时要先kill旧进程再启动新的我一般用ps -ef | grep treehole找到PID确认端口释放再执行启动命令避免端口被占导致新实例起不来。公网环境的服务器上传目录一定要用绝对路径比如/opt/treehole/upload并把这个路径加到application.yml的custom.upload-dir里相对路径在不同工作目录下会飘这是线上图片404的重灾区。6. 进阶玩法把树洞论坛改造成带内容风控的产品6.1 从“能跑”到“能上线”敏感词、审核工作台与数据看板跑通之后想真正上线光有审核状态机不够。我在源码基础上会再补三个能力敏感词拦截、审核工作台、发帖趋势看板。敏感词拦截放在发帖和评论两个入口最简单的方案是维护一个敏感词列表发帖时同步遍历private static final ListString SENSITIVE_WORDS Arrays.asList(广告词A, 广告词B, 引流词C); public boolean checkSensitive(String content) { for (String word : SENSITIVE_WORDS) { if (content.contains(word)) { return false; } } return true; }这个实现适合源码演示和千级词库场景词库上万后要换成DFA或AC自动机把O(n*m)的遍历降成O(n)否则每个帖子都遍历千个词接口会有明显延迟。更完整的做法是配合一个敏感词管理表管理员在后台增删词条不修改代码。审核工作台复用第三章的auditPost接口前端给管理员做一个待审列表页展示内容、发布时间、操作按钮通过和驳回各一个按钮。驳回时必须填写原因原因会展示在用户端的“审核结果”提示里。数据看板用一条聚合SQL就能做按天统计发帖量、审核通过率、待审数SELECT DATE(create_time) AS day, COUNT(*) AS total, SUM(CASE WHEN status 1 THEN 1 ELSE 0 END) AS approved FROM post WHERE create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE(create_time) ORDER BY day;这个查询放到管理后台的Dashboard页再用ECharts画一条趋势线内容运营就能直观看到社区活跃度变化。我第一次做树洞类系统时觉得审核纯粹是多余流程结果上线不到两天就被广告机刷了几百条帖子后来才老老实实把status默认值改成待审核又加了敏感词拦截社区才算真正能看。如果你也是从源码起步改自己的产品建议先把这个“审核拦截”的底子打好再考虑置顶、加精、匿名身份卡这些锦上添花的功能。希望帮到你。本文还有配套的精品资源点击获取
返回列表