
1. 答疑系统的业务边界一个提问从发起到完结到底要经过多少双“手”1.1 看起来只有提问和回答为什么还要引入课程维度先说一个我在真实项目里踩过的坑。很早之前我做过一个通用问答系统用户注册登录后随便提问、随便回答组长看完了说“这不行没有边界。”我当时不明白后来上线试运行才发现问题培训机构有几十个老师数学老师打开列表看到的全是“这道化学题怎么做”根本没法工作学生提问后不知道自己该等哪个老师回复助教也不知道这个问题是不是自己负责的班级提的月底统计课时和答疑量的时候数据乱成一锅粥。所以课程答疑系统的第一个设计决策就是给“提问”加一个强约束——每一条提问必须绑定到具体的课程、章节甚至具体的知识点标签。这就是这套源码里最核心的业务逻辑以“课程”为隔离单位。学生能看哪些课程由course_member表决定老师能回答哪些课程由course_teacher表决定管理员则拥有跨课程的管理权。这样一来一条问题从创建开始就知道自己属于哪个课程、该由谁来处理责任划分非常清晰。业务边界先划清楚技术设计才有意义——很多系统做得烂不是代码差而是需求边界没切好。1.2 功能清单与角色权限矩阵从源码的菜单和路由里能看到这套答疑系统模块划分得很明确课程管理、提问管理、回答管理、消息通知、用户管理、系统日志。每个模块的服务对象不一样权限也不一样。我用一张表把核心功能与角色权限的关系列出来方便你对照源码理解功能模块学生教师/助教管理员课程列表与课程详情仅已加入课程仅自己教授的课程全部课程发布提问仅限已加入课程不允许不允许或可配置回答问题不允许仅限自己课程可代答采纳答案仅限自己提问不允许可干预关闭问题仅限自己提问可关闭超时/违规问题全部举报与审核可举报可标记审核处理消息中心查看自己的消息查看自己的消息查看系统通知这个矩阵不是拍脑袋定的它对应着源码里的permission表和接口上的权限注解。比如“学生不能回答问题”很多简单Demo根本不管但企业级系统里如果学生也能回答就会出现“学生抢答、答案质量无法保证、老师权威性受挑战”等一系列运营问题。所以技术上的权限控制本质上是在帮你执行运营规则。1.3 动态菜单权限不是前端说了算我见过很多项目把菜单直接写在router/index.js里然后通过v-ifrole admin这种手段隐藏按钮。这种做法最大的问题是前端代码加载的时候菜单项和路由其实已经全部暴露在浏览器里稍微懂点前端的人打开控制台就能绕过限制而且后端接口如果没有做同样的校验等于大门开着只挂了块“闲人免进”的牌子。这套源码的做法是登录后请求/api/menu/list后端根据当前用户的角色和权限动态生成菜单树返回给前端前端再用router.addRoute()把动态路由注册进去。菜单长什么样、能跳转哪些页面是后端说了算。前端只保留登录页、404页这类基础路由其余全部由后端数据驱动。这一层虽然改动成本不大但对企业级系统来说价值很高因为它贯彻了一个原则永远不要信任前端传来的角色信息权限判断必须发生在后端。2. 数据库建模与MyBatis细节状态机、防刷与查询优化的实战2.1 五张核心表的设计思路与字段说明这套源码的数据库设计不复杂但每张表都踩过优化坑。核心表就五张user用户、course课程、course_member课程成员、question提问、answer答案另外还有辅助表如message、operation_log。以question表为例除去ID和时间字段关键字段如下CREATE TABLE question ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, course_id bigint(20) NOT NULL COMMENT 所属课程ID, user_id bigint(20) NOT NULL COMMENT 提问人ID, title varchar(200) NOT NULL COMMENT 标题, content text COMMENT 富文本内容, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0待回答 1已回答 2已采纳 3已关闭, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, view_count int(11) NOT NULL DEFAULT 0 COMMENT 浏览数, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_course_status_time (course_id, status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程提问表;这里有一个细节要注意status字段用的是tinyint而不是字符串。原因很简单字节型字段存储空间小、索引性能好而且状态含义在Java枚举里维护比在数据库里存一个WAITING字符串更可控。状态机本身也不复杂待回答 - 已回答 - 已采纳任意状态下都可以被关闭。这样的流转语义清晰也不会出现“已采纳的问题还能继续回答”这种逻辑矛盾。2.2 为什么选MyBatis而不是MyBatis-Plus我知道很多人看到这个标题会问现在MyBatis-Plus不是挺火的吗CRUD都不用写SQL为什么这份源码还用原生MyBatis我的判断是答疑系统的查询场景太适合手写SQL了。比如“某个课程下所有未完成问题按时间倒序分页”这种多表关联加状态过滤的查询用MP的LambdaQueryWrapper写出来很长而且最终生成的SQL你可能根本不知道它是什么样的一旦慢查询出现排查成本很高。原生MyBatis的好处是SQL写在哪里、执行什么语句、走了哪个索引清清楚楚。配合EXPLAIN看执行计划问题一目了然。MyBatis-Plus适合纯CRUD、后台管理系统那种“表驱动接口”的快速开发但它把SQL细节藏起来了对求知欲强的人反而不友好。源码选原生MyBatis还有一个原因这份源码本身就是给人学习参考的把SQL显式写出来比自动生成更能展示底层逻辑。2.3 三个高频踩坑的配置点第一个是驼峰映射。Java里字段叫courseId数据库列叫course_id如果你不配置下面这一行查询结果里courseId永远是nullmybatis: configuration: map-underscore-to-camel-case: true这个配置写不写直接决定实体类字段能不能自动映射。很多新手跑完一个查询发现全是null十有八九就是漏了这句。第二个是分页插件。源码里用的PageHelper但要注意版本兼容。PageHelper 5.x和SpringBoot 2.x搭配时需要引入pagehelper-spring-boot-starter而不是单独的pagehelper包。如果你在项目里发现分页不生效或者页码错乱先检查依赖坐标对不对。第三个是动态SQL。多条件的筛选列表页用where标签可以自动去掉多余的AND举个例子select idselectQuestionPage resultTypecom.example.entity.Question SELECT q.*, u.nickname AS ownerName FROM question q LEFT JOIN user u ON q.user_id u.id where if testcourseId ! null AND q.course_id #{courseId} /if if teststatus ! null AND q.status #{status} /if if testkeyword ! null and keyword ! AND (q.title LIKE CONCAT(%, #{keyword}, %) OR q.content LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY q.create_time DESC /select配合PageHelper后这个查询天然支持分页LIKE的写法也要注意用CONCAT拼模糊查询的%比直接写在字符串里更能防止SQL注入。2.4 查询优化索引怎么建深分页怎么破答疑系统最大的数据增长点就是question和answer两张表所以索引不能乱建。我的建议是优先覆盖高频查询条件按课程看问题列表是最常见的操作所以联合索引(course_id, status, create_time)非常有效如果系统还有“按用户看我的提问”这种页面再加一个(user_id, status)的索引就够了。不要每个字段都加索引写多读少的表索引越多插入越慢。真正让人头疼的是深分页。当用户翻到第1000页LIMIT 10000, 10会产生大量无效回表查询越来越慢。这套源码里用的是“上一页最后一条记录ID”的方案SELECT * FROM question WHERE course_id #{courseId} AND status 0 AND id lt; #{lastId} ORDER BY id DESC LIMIT #{pageSize}前端把列表最后一条的ID传回来后端直接从这个ID往后取。这种方案比LIMIT OFFSET稳定得多且无论翻到多深都是同一级别的性能。缺点是不能直接跳页但课程答疑场景里用户几乎都是顺序往下刷的不需要跳页所以这个取舍非常划算。3. 后端接口与事务边界把“回答已读”这种小需求设计得不后悔3.1 核心接口清单提问、回答、采纳三条主线源码里的接口设计并没有做成什么“XX管理系统的CRUD四件套”而是围绕业务闭环来组织这也是我想重点说的地方。主链路有三条提问、回答、采纳。提问接口大概长这样POST /api/question/create参数是courseId、title、content。它背后的逻辑不只是insert一条记录还要做四件事校验当前用户是否是该课程成员校验该课程是否存在且状态正常处理富文本内容去隐患标签插入提问记录并写一条操作日志。如果这些步骤散落在Service里一层层嵌套而不做事务很容易出现“问题创建成功了日志没记上”这种数据不一致。回答接口POST /api/answer/submit则要额外校验当前用户是不是该课程的老师或助教问题当前状态不能是“已采纳”或“已关闭”回答内容非空。插入答案后还要把问题的status从0改成1。这里涉及两张表的写操作同样必须放在同一个事务里。采纳接口POST /api/question/adopt是权限最敏感的只能由提问人本人操作而且只能采纳该问题下的回答采纳后问题状态变成“已采纳”。3.2 事务边界状态流转时的并发控制状态流转的并发问题是这类系统最容易出bug的地方。举个例子两个助教同时提交答案A先提交B后提交如果没有并发控制B可能把A刚插入的答案顶掉甚至两个请求同时读到status0都去更新状态逻辑上就乱了。我的方案是在question表里加一个version字段更新时用乐观锁UPDATE question SET status #{targetStatus}, version version 1 WHERE id #{id} AND version #{oldVersion}受影响行数为0说明版本冲突了业务层再提示用户“问题状态已变化请刷新后重试”。乐观锁的好处是不用给数据库加悲观锁非常适合这种低频状态变更场景。Transactional负责事务的原子性乐观锁负责并发冲突的兜底两者配合这条主链路的可靠性就基本稳了。实际开发里我强烈建议给状态更新接口都加上这种version保护哪怕现在并发量不大也能防止未来运营活动带来的突发流量。3.3 站内信为什么用一张表就能避免用户量膨胀消息通知是个看着简单、设计起来容易翻车的模块。最粗暴的做法是“一条消息给N个用户复制N行”用户量一上来几千个学生同时发一条通知瞬间多出几万行数据完全没必要。这套源码的做法是message主表存消息本体字段包括message_type、content、target_type。target_type区分“单发”和“全体通知”。已读状态存在另一张message_read表里只记录“谁读了哪条消息”默认就是“未读”。查未读数量时SELECT COUNT(*) FROM message m LEFT JOIN message_read r ON m.id r.msg_id AND r.user_id #{userId} WHERE m.target_type ALL OR m.receiver_id #{userId} AND r.id IS NULL这个设计的核心思路是“读操作才产生数据”用户不读就永远只占一条主表记录。随着用户量增长主表不会爆炸已读表膨胀速度也远低于“每用户复制一条”的方案。后来我把它扩展到邮件通知、短信通知的“是否发送”记录逻辑完全复用很香。4. 前端Vue落地登录态、路由守卫和富文本那些事4.1 工程结构与动态路由的实现前端技术栈是Vue 3 Vite目录结构并不复杂src/api放接口请求、src/router放路由、src/views放页面、src/store放状态。这样一个中小型后台管理系统的工程模块划分越简单越不容易乱。路由守卫是前端的咽喉登录态都在这里把关。源码的逻辑是router.beforeEach里判断localStorage里有没有token有token但本地没有用户信息就先调用/api/user/info拉取用户信息和权限菜单再addRoute动态注册路由没有token则跳转登录页。这里有一个很容易踩的坑动态路由注册后刷新页面路由会丢失因为router.addRoute是内存里的操作。解决方式是把菜单和路由状态保存到store里刷新后重新拉取并注册或者干脆在main.js启动时先异步加载路由再mount源码用的就是“刷新后重新拉取菜单再addRoute”的方案实践下来最稳。4.2 富文本编辑器的选型与XSS双重过滤提问内容是富文本这就牵扯到XSS的问题。我选编辑器时没有用特别重的方案而是选择了对中文支持好、体积小的wangEditor扩展插件也够用。但不管用什么编辑器都绕不开一个核心动作内容过滤。前端过滤做一层后端必须再过滤一层。为什么后端不能省因为接口可以被绕过如果有用户直接调接口提交带有script标签的内容而你只在前端做了限制等于门户大开。后端我用的是一个白名单标签过滤逻辑只允许保留p、span、strong、img、code、pre等安全标签a标签的href只允许http://和https://开头img的src禁止javascript:协议。存储前处理一次展示时再通过Vue的插值表达式渲染双重保障。4.3 Axios封装与防抖搜索的配合Axios拦截器这套体系很多项目都有但细节决定体验。源码里的封装做了三件事请求拦截器统一把token加到请求头响应拦截器统一处理业务码例如code ! 200时直接弹提示code 401时清空登录态并跳转登录页对于下载文件类型的请求单独设置responseType: blob避免乱码。搜索场景我额外做了防抖。课程答疑列表页有“按关键词搜索”的输入框如果用户每敲一个字母就发一次请求既白费流量又加重数据库压力。用lodash的debounce方法设置300毫秒延迟只有用户停止输入后才触发查询。这里有个细节不要把防抖函数写在组件外层否则所有组件实例共享同一个防抖函数状态会互相干扰应该写在setup或methods内部并按组件隔离。5. 权限、安全与企业级差距三道防线怎么补5.1 接口权限基于注解的RBAC拦截前端菜单和按钮权限都藏得再好后端接口如果裸奔也等于白干。这套源码的后端接口权限用注解加拦截器实现我做了个自定义注解RequirePermission例如在回答问题接口上标注RequirePermission(question:answer) PostMapping(/answer/submit) public Result? submitAnswer(RequestBody AnswerDTO dto) { ... }拦截器在请求进入Controller之前先从当前登录用户角色关联的权限集合里查一下没有这个权限直接返回403。这里的关键是权限集合不要每次请求都查数据库登录成功后一次性加载到Redis或者应用内存的ThreadLocal缓存里否则高并发下性能很难看。5.2 数据权限能打开页面不等于能看到数据比接口权限更隐蔽的是数据权限。举个例子一个老师能打开“问题列表”页面但这不代表他能看到全校所有课程的问题。如果查询SQL不限制课程范围他只需要调接口遍历courseId就能把其他老师课程下的敏感数据全捞出来这属于越权漏洞比XSS还致命。源码的处理是在Mapper层做约束老师角色的查询SQL里强制拼接AND c.teacher_id #{currentUserId}助教则通过course_teacher子查询来判断是否有权查看。管理员不拼这个条件。这一层如果写在Service层用if判断很容易漏掉某个查询接口写在SQL层漏掉SQL就查不出来数据反而更安全。做二次开发时我建议沿用这个模式凡是在线列表接口都问自己一句“当前角色应该只能看到哪部分数据”然后在SQL层把条件写死。5.3 安全检查清单SQL注入、越权、上传“三件套”这套源码在安全上按下面这个清单自查过你也可以拿去用在别的项目上SQL注入所有动态条件必须用#{param}预编译禁止用${param}拼接用户输入除非极端情况下需要动态传入表名/排序字段且值必须经过白名单校验。越权访问详情接口都要校验“当前用户与该数据之间的关系”。学生只能看自己加入的课程下的提问老师只能看自己教授的课程下的提问不要让用户通过改ID参数绕过。文件上传如果后续要加头像/附件功能不要信任前端传来的文件名扩展名白名单校验只是第一层还得校验文件头魔数文件名改成UUID随机字符串存储目录和Web根目录隔离避免上传恶意脚本文件直接被访问执行。依赖版本SpringBoot版本不是越高越好。Boot 2.x对应JDK8Boot 3.x强制要求JDK17如果本机只装了JDK8盲目升Boot 3.x会导致启动直接报错。源码用的版本组合在README里写清楚了照做能少踩很多版本坑。6. 拿到源码之后怎么跑起来部署、环境与二次开发路径6.1 环境版本选择与本地启动注意事项按照这套源码的默认配置本地环境建议是JDK 8或11Maven 3.6MySQL 5.7或8.0Node.js 16以上Vue 3 Vite项目需要。数据库导入项目里的sql/init.sql里面包含建库、建表和初始化管理员账号首次登录后记得立刻改密码。application.yml里有几个地方需要根据自己的环境调整数据源地址、数据库用户名密码、file.upload-path文件上传路径如果启用了附件功能、以及logging.level日志级别。我最想提醒的一点是本地调试时不要图方便把密码直接明文写在配置文件里提交到Git仓库正确做法是用application-dev.yml和application-prod.yml区分环境生产环境的连接串通过环境变量DB_PASSWORD注入这样源码即便公开也不会泄露你服务器的真实密码。6.2 前后端分离部署Nginx代理与单体内嵌两种方式部署方式我实际用过两种。正式环境推荐前后端分离server { listen 80; server_name yourdomain.com; root /opt/answer-system/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }这里把/api/开头的请求反向代理到SpringBoot的8080端口前端静态文件由Nginx直接托管try_files配置是为了让Vue的路由在history模式下刷新不404。如果项目体量小、用户量少也可以用第二种方式把前端执行npm run build生成的dist目录复制到SpringBoot的src/main/resources/static下打成单jar包运行访问端口就是后端端口。这种方式部署简单但静态资源和接口混在一起上线后不方便做CDN加速适合内网小场景不太建议正式对外使用。6.3 二次开发建议先改哪、后改哪、哪里最容易扩展源码拿到手很多人的第一反应是直接跑起来看这没错但我建议多看三层先跑通提问到采纳的完整链路再读permission相关代码理解权限模型最后才去动前端页面样式。如果要做二次开发我的路径建议是第一个阶段加一个“匿名提问”开关问题表加is_anonymous字段即可改动小但业务价值明显第二个阶段把消息模块从站内信升级为站内信实时推送在message表加一个channel字段接WebSocket推送“老师已回答了你提出的问题”第三个阶段可以扩展课程维度加入“课程分组”概念把course表加parent_id实现多级课程的归属关系。这三个方向都是“加字段、加接口、加页面”的最小改动路径不会伤筋动骨。我个人在实际开发里的体会是源码的价值从来不在于“跑起来”那一刻而在于你读它的时候能发现多少“当时为什么这么设计”的思考。这套答疑系统里事务边界、乐观锁、数据权限、动态菜单这几个点我建议你动手改一改比如试着去掉乐观锁并发更新会发生什么、去掉SQL层的数据权限前角色会看到什么这种对比实验比翻十篇文章都管用。最后再分享一个调试技巧本地起项目时给后端接口都打上完整请求日志用logback配置一个单独的/api/**过滤logger这样你能清清楚楚看到每个请求的参数和响应排查问题速度快很多。