1. 为什么我选了SpringBoot2Vue3MyBatis-PlusMySQL8.0这套组合做在线问卷调查系统第一反应可能是这不就是一堆表单页面加一张表吗。真上手你就会发现问题的核心从来不是表单怎么画而是题目类型怎么动态扩展问卷怎么发布和回收结果怎么统计这些事。我当初接手这个项目时技术栈已经定好SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前端配了Vue3生态里最常用的组件库后端用标准的Java Web分层结构。做完整个系统回过头看这套组合选得相当稳尤其适合做课程设计、毕业设计或者中小团队内部用的轻量级调研工具。先讲技术选型。SpringBoot2虽然不是最新的大版本但它的生态成熟度、资料密度、第三方兼容性都处于最舒服的状态。SpringBoot3推了Jakarta命名空间很多老项目的依赖要一起升对问卷这种业务逻辑为主、不需要虚拟线程等新特性的系统来说SpringBoot2反而省心。Vue3这边组合式API带来的代码组织能力比Vue2的Options API强太多问卷编辑器这种题目列表动态增删题型切换选项联动的界面用composition API写起来逻辑内聚不容易出现data里一堆字段互相依赖的混乱场面。MyBatis-Plus则把单表CRUD的活全包了复杂统计再手写SQL分工明确。MySQL8.0在这套组合里是地基。8.0的窗口函数做问卷统计极其好用比如算每道题各选项的占比、按时间段分组看回收量一条SQL就能搞定换成5.7你得写一堆临时表。还有一个容易被忽略的点8.0的utf8mb4默认排序规则是utf8mb4_0900_ai_ci对中文和emoji的支持比5.7更自然问卷填答里如果允许用户填文本emoji是肯定会出现的字符集踩坑能提前避开。这套组合真正的价值在于SpringBoot2负责稳定地暴露HTTP接口和做业务编排MyBatis-Plus负责消灭重复的数据库样板代码Vue3负责给用户一个流畅的交互界面MySQL8.0负责把数据和统计扛住。四者各司其职没有哪个环节是为了用而用。顺便说一句如果是刚接触Java Web的同学这个项目的标准目录结构也很有参考价值。后端按controller/service/mapper/entity分层前端按views/components/api分层前后端通过RESTful JSON通信不掺模板引擎不搞服务端渲染这是当前中小型Web项目最主流的协作方式。2. 需求拆解问卷系统最容易被低估的数据模型设计在线问卷系统的核心链路说到底是三段创建问卷 → 投放回收 → 数据统计。但每一段拆开都有细节。创建问卷要支持单选、多选、填空、下拉、评分、矩阵题每种题型的答案结构不一样投放回收要管问卷状态草稿、发布、关闭、填写时间窗、是否允许匿名数据统计要按题汇总、按选项算占比、按填答记录导出明细。这些需求全部指向同一个底层能力数据模型能不能把问卷-题目-选项-答卷这套关系完整表达出来并且支持未来的题型扩展。我当时设计的核心表一共四张这是整个项目中我认为最值得反复琢磨的部分。2.1 四张核心表的职责边界第一张是问卷主表questionnaire只存问卷本身的信息标题、描述、状态、开始时间、结束时间、创建人、创建时间、更新时间。第二张是题目表question通过questionnaire_id关联主表字段包括题型code、题目内容、是否必填、排序号。第三张是选项表question_option通过question_id关联题目只有选择题才需要往这张表写数据填空、评分这类题型不写。第四张是答卷表answer_record关联问卷每条记录代表一份填答同时还有一个答卷明细表answer_detail关联题目和答卷存的是单道题的填答内容。为什么要拆得这么细直接的动机是题型多样化。如果图省事在题目表里加一个options字段存JSON填答内容也存JSON那查询和统计会非常痛苦。MySQL的JSON字段虽然能存但做选出所有选了A选项的人这种筛选时要么用JSON函数硬抠要么在Java内存里过滤数据量一上来就卡。拆成明细表后每个填答项都是一行关系型数据索引、聚合、联表全部走常规SQL路径性能和心理负担都小很多。2.2 我用到的关键DDL设计思路题目表里题型我用VARCHAR存code比如single、multiple、text、rating而不是用数字枚举。原因有两个一是后续加新题型不用改表结构和代码枚举前端根据code渲染对应组件即可二是阅读数据时直接能看到含义排查问题不用对照字典。排序号sort_order用DECIMAL而不是INT为的是在拖动调整顺序场景下插入两个题目之间时可以用小数插队省一次整段重排。不过真正常用的方案还是整数加大步长比如100、200留出插入空间这里看个人习惯。答卷明细表是整个模型里最灵活也最容易想歪的地方。我最终的设计是四列核心业务字段question_id、option_id可空选中的选项ID、answer_text可空填空题内容、rating_score可空评分值。加上一个answer_record_id做关联。之所以不用统一一个content字段存答案是为了在后面做某道题答案的分布统计时能直接按列分组。单选统计查option_id填空统计查answer_text评分统计查rating_score各走各的列语义清晰索引也好建。CREATE TABLE answer_detail ( id bigint NOT NULL AUTO_INCREMENT, answer_record_id bigint NOT NULL COMMENT 答卷记录ID, question_id bigint NOT NULL COMMENT 题目ID, option_id bigint DEFAULT NULL COMMENT 选中的选项ID, answer_text varchar(2000) DEFAULT NULL COMMENT 填空内容, rating_score int DEFAULT NULL COMMENT 评分值, PRIMARY KEY (id), KEY idx_record (answer_record_id), KEY idx_question (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷明细表;还有一条容易被新手忽略的设计纪律所有业务表都要有create_time和update_time。MyBatis-Plus的MetaObjectHandler可以自动填充这两个字段我在实体类上加了TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)通过实现MetaObjectHandler接口统一注入当前时间。后续不管哪个接口在写数据都不用手动维护时间字段统计今天回收了多少份哪个月创建最多时直接能用。3. 后端落地SpringBoot2MyBatis-Plus把问卷动态化做扎实数据模型设计好之后后端开发的核心就变成两件事把模型的读写封装成清晰的接口和处理动态题目带来的非固定结构问题。SpringBoot2在这里提供的是分层规范和依赖管理真正让我少写大量重复代码的是MyBatis-Plus。它和Spring Data JPA的区别在于JPA是全自动的实体关系映射做得多但遇到复杂查询反而难控制MyBatis-Plus是半自动单表操作几乎不用写SQL多表统计则保留SQL的自由度。对问卷这种一半CRUD一半统计的系统来说MyBatis-Plus是更称手的选择。3.1 用Mapper的继承体系消灭单表CRUD项目里的实体类全部继承自MyBatis-Plus的Model接口或者配合BaseMapper使用。我采用的是最常见的写法Mapper接口继承BaseMapper Service层继承IService 实现类继承ServiceImplT, M, E。这样QuestionnaireMapper天然拥有selectById、insert、updateById、deleteById、selectPage这组方法不需要写任何XML。页面上管理问卷列表时直接用LambdaQueryWrapper构建分页条件public PageQuestionnaireVO pageQuestionnaires(long current, long size, String keyword, Integer status) { PageQuestionnaire page new Page(current, size); LambdaQueryWrapperQuestionnaire wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Questionnaire::getTitle, keyword) .eq(status ! null, Questionnaire::getStatus, status) .orderByDesc(Questionnaire::getUpdateTime); PageQuestionnaire result questionnaireMapper.selectPage(page, wrapper); // 此处省略实体转VO的逻辑 }这个方法的写法有两点值得细看。like和eq的第一个参数是boolean条件不成立时MyBatis-Plus会自动忽略这个条件所以keyword为空时不会拼出like %%这种全表匹配的SQL。orderByDesc(Questionnaire::getUpdateTime)用的是Lambda表达式引用编译期就能发现字段名拼写错误比传字符串update_time安全得多。刚开始用MyBatis-Plus的人容易忽略这些细节直接wrapper.eq(update_time, time)字符串写错只能在运行时暴露Lambda写法能提前挡掉这类低级bug。3.2 保存问卷事务与动态列表的配合创建问卷接口是典型的主表子表批量保存场景。请求体是一个大JSON包含问卷基本信息、题目列表、每个题目里的选项列表。后端的处理思路很简单先insert问卷主表拿到问卷ID再遍历题目列表给每个题目set questionnaireId批量insert题目表如果题目是选择题再遍历选项列表set questionId后批量insert选项表。这三步必须在一个事务里完成否则中途抛异常会留下有问卷没题目的脏数据。Transactional(rollbackFor Exception.class) public Long createQuestionnaire(QuestionnaireCreateRequest request) { Questionnaire questionnaire new Questionnaire(); BeanUtils.copyProperties(request, questionnaire); questionnaire.setStatus(QuestionnaireStatus.DRAFT.getCode()); questionnaireMapper.insert(questionnaire); for (QuestionRequest q : request.getQuestions()) { Question question new Question(); BeanUtils.copyProperties(q, question); question.setQuestionnaireId(questionnaire.getId()); questionMapper.insert(question); if (CollectionUtils.isNotEmpty(q.getOptions())) { for (OptionRequest option : q.getOptions()) { QuestionOption optionEntity new QuestionOption(); optionEntity.setQuestionId(question.getId()); optionEntity.setContent(option.getContent()); optionMapper.insert(optionEntity); } } } return questionnaire.getId(); }Transactional(rollbackFor Exception.class)这句是很多人的易错点。Spring默认只在遇到RuntimeException时回滚事务如果方法里抛的是IOException这类受检异常事务不会回滚。这里显式指定rollbackFor为Exception.class就是明确告诉Spring任何异常都回滚防止数据不一致。批量插入这里我写的是循环单条insert数据量不大没问题但如果问卷有几百道题建议改成MyBatis-Plus的saveBatch或自定义XML的foreach批量插入能明显减少数据库连接往返次数。3.3 动态渲染时的一个关键选择DTO用Map还是VO问卷编辑和填写页需要回显题目结构后端如果直接返回Question实体前端拿到的字段是死的题型的差异还得前端根据type字段自己判断。我的处理方式是定义QuestionVO把选项列表List 放进去前端拿到后统一按题目对象渲染具体显示哪种交互组件由前端根据type字段分发。这份VO本身也是半动态的——它不规定这道题具体是单选还是多选只约定所有题型都必要的公共字段可空扩展字段扩展性集中在optionList和type上。接口路径我用了RESTful规范/api/questionnaire、/api/questionnaire/{id}、/api/questionnaire/{id}/publish、/api/questionnaire/{id}/answer等。其中提交答卷和问卷详情这两个接口的并发量最大我在Service层对发布中状态做了校验不在发布状态的问卷不允许提交答卷。这块逻辑很直白但它是业务闭环里最容易漏的一环。真有人绕过前端Vue组件直接调接口如果不校验状态一份停用的问卷还能继续收答卷统计口径就乱了。4. Vue3侧的核心实现组合式API承载问卷动态渲染与编辑器前端这部分我用的是Vue3 Vite Element Plus Axios的组合。Vite作为开发服务器比webpack快非常多问卷编辑器里频繁改代码、看效果时尤其明显——改一个组件热更新基本是毫秒级生效。Vue3的组合式API里setup语法糖script setup是写业务代码最舒服的形态不用写export default不用把数据塞进data()所有变量和函数直接在setup作用域里定义模板拿来即用。问卷填写页的代码结构我大致是这样设计的。4.1 问卷填写页动态组件按题型分发问卷填写页是整个前端链路里最体现动态化价值的地方。一道题可能是单选、多选、填空、评分它们的交互组件完全不同。Vue3里用动态组件绑定实现得很干净遍历题目列表对每道题根据type字段渲染对应的子组件。子组件的职责单一只负责把用户在这道题上的答案向上抛。template div v-for(question, index) in questionList :keyquestion.id component :iscomponentMap[question.type] :questionquestion :model-valueanswerMap[question.id] update:model-value(val) handleAnswerChange(question.id, val) / /div /template script setup import RadioQuestion from ./components/RadioQuestion.vue; import CheckboxQuestion from ./components/CheckboxQuestion.vue; import TextQuestion from ./components/TextQuestion.vue; import RatingQuestion from ./components/RatingQuestion.vue; const componentMap { single: RadioQuestion, multiple: CheckboxQuestion, text: TextQuestion, rating: RatingQuestion }; /script这段代码的核心是componentMap这个映射对象。它把后端传来的题型code映射到组件以后加新题型只要新建一个子组件并往componentMap里注册一个key报表明细、校验逻辑这些通通不用动扩展成本极低。answerMap用reactive定义key是问题IDvalue是用户填答的内容天然解决了每道题的答案需要一个独立状态的需求。子组件里用modelValue双向绑定配合Element Plus的radio-group、checkbox-group等组件交互体验和原生表单一致。4.2 问卷编辑器可增删题目的表单编排相比填写页编辑器复杂得多用户要能添加题目、删除题目、上移下移、切换题型、维护选项。这里我用一个questionDraftList作为reactive数组类似草稿题目列表所有编辑操作都作用在这份数据上最终保存时一次性提交给后端。添加题目的逻辑如下const addQuestion (type) { questionDraftList.push({ id: temp_${Date.now()}, type, title: , required: false, options: type single || type multiple ? [, ] : [] }); };注意这里的id用的是temp_前缀时间戳。新题目还没保存到数据库没有真实ID前端先用临时ID驱动列表渲染和双向绑定提交到后端后由后端重新生成正式ID。如果不这么处理Vue的v-for在用户插入一道题目后所有后续题目的key会整段推移导致输入框内容错乱。这种前端临时ID的思路在编辑器类页面里非常常用算得上一个隐藏避坑点。题型切换时一个容易忽略的地方从选择题切换到填空题原选项数组必须清空从填空切回选择题要补回一个空选项。我在watch监听了questionDraftList里每道题的type字段变化根据题型重置options。这一块的后端模型就已经约定好了选择题才有选项题目表的属性和成绩字段不参与选项逻辑所以前端只要按后端契约处理就不会产生脏数据。4.3 请求层与全局状态Axios请求层我封装了一个request实例统一配置baseURL和拦截器。请求拦截器从localStorage取出token塞进header响应拦截器对HTTP状态码和业务状态码做两层判断。问卷系统里常见的表达是接口返回{ code: 200, data: ..., message: ok }code是业务码HTTP 200只代表网络层成功。如果某次接口调用出现401响应拦截器统一跳转登录页如果业务码是500弹出ElMessage错误提示。这套封装做完各个页面里就不需要重复写错误处理了。service.interceptors.response.use( (response) { const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 操作失败); return Promise.reject(new Error(res.message)); } return res.data; }, (error) { if (error.response?.status 401) { router.push(/login); } ElMessage.error(网络异常请稍后重试); return Promise.reject(error); } );问卷填写页和编辑器页在路由层级都放在布局组件内通过Vue Router的懒加载() import(...)按需加载。这样首屏只加载登录页和仪表盘的基础组件真正的编辑器体积大延迟到点击创建问卷时才拉取。我用Vite的build分析过打包体积编辑器相关组件占了整体大概30%懒加载之后首屏加载时间肉眼可见地降了一截。5. 部署环节MySQL8.0、后端打包与前端Nginx托管的一组实战笔记问卷系统开发完只是第一步真正能跑给评审、答辩或者同事用的是部署在服务器上的一套完整环境。这部分我把实践中验证过的方案按后端、数据库、前端三步整理出来。网上教程很多但不少没说清楚为什么这样配置我补上这层逻辑。5.1 MySQL8.0的安装与字符集/时区问题服务器我用的是LinuxMySQL8.0的安装可以直接用系统的包管理器或者Docker。Docker方式最省心一条命令就能拉起服务数据目录通过-v挂在宿主机升级迁移都方便。需要注意的坑集中在两个配置上字符集和时区。MySQL8.0默认字符集其实是utf8mb4这和5.7默认utf8mb3不一样但保险起见还是要在配置里显式声明。8.0的utf8mb4带来一个问题某些系统表、统计信息表使用的新排序规则在老客户端比如旧版Navicat上可能显示乱码或排序异常。这不是数据库坏了是客户端兼容性问题。实践中我直接统一使用utf8mb4和utf8mb4_unicode_ci并在Docker容器时区挂载和MySQL系统时区都设为Asia/Shanghai。如果后端JDBC连接串上的serverTimezone忘了指定很可能出现存储正确、查询差8小时的迷惑问题。# docker-compose.yml 中 MySQL8.0 服务的关键配置片段 mysql: image: mysql:8.0 container_name: questionnaire-mysql environment: MYSQL_ROOT_PASSWORD: yourpassword MYSQL_DATABASE: questionnaire_db TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci - --default-time-zone8:00 ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql启动后我建议立刻验证这三点SHOW VARIABLES LIKE character_set_server;确认字符集SELECT NOW();确认时间SHOW VARIABLES LIKE default_authentication_plugin;确认认证插件是不是caching_sha2_password。最后一项是8.0和5.7差异最大的地方旧版客户端驱动可能不认识新认证方式需要把用户改成mysql_native_password或者升级驱动。后端连接串我用的JDBC driver是com.mysql.cj.jdbc.Driver配合8.0.33版本的驱动基本不踩这个坑。5.2 后端打jar包与常见启动问题SpringBoot2项目最终产物就是一个可执行jar包。构建时我在pom.xml里配置了maven打包插件并保证finalName固定避免每次打包后文件名带版本号脚本里引用路径方便。执行mvn clean package -DskipTests后target目录下生成jar。我习惯用nohup java -jar questionnaire.jar --spring.profiles.activeprod app.log 21 这种方式启动。生产环境配置application-prod.yml里有三个必改项数据库连接串、Redis地址如果用了、日志路径。尤其数据库连接串要把localhost改成内网地址并加上useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue这几个参数。allowPublicKeyRetrievaltrue是MySQL8.0的caching_sha2_password认证需要的不加可能报Public Key Retrieval is not allowed虽然只在首次连接时触发但报错时很容易让人以为是密码错误或网络问题。启动后用tail -f app.log看日志只要出现Started QuestionnaireApplication基本就是起来了。如果端口冲突检查8080是否被别的程序占用在启动参数里加--server.port8081临时切换。5.3 前端构建产物与Nginx反向代理Vue3项目构建前端非常简单npm run build后生成dist目录。dist里的内容全是静态文件index.html、js/css资源理论上任何静态服务器都能托管。我用的是Nginx配置上有两个关键点。第一个是前端路由的history模式。Vue Router如果用history模式刷新非首页时Nginx会尝试找对应路径的静态文件找不到就404所以需要把所有路径都try_files到index.html由前端路由接管。如果不想配这个也可以用hash模式URL里多个#稍微丑一点但省事。第二个是接口转发。前后端分离部署后前端页面通过Nginx访问接口请求如果写死localhost用户的浏览器会直接请求他自己的机器必须配一层反向代理把/api开头的请求转发到后端地址。server { listen 80; server_name your-domain.com; root /var/www/questionnaire/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有个我在项目里实际踩过的坑proxy_pass http://127.0.0.1:8080;末尾没加斜杠和加了斜杠行为完全不同。没加斜杠时请求/api/questionnaire/list会转发到http://127.0.0.1:8080/api/questionnaire/list后端接口的路径能被完整保留加了斜杠时nginx会替换掉匹配到的location前缀变成http://127.0.0.1:8080/questionnaire/list如果后端没有这个映射就直接404。我一开始按习惯加了斜杠排查半天才发现是这里导致的。前端页面在开发环境用的axios baseURL是/api生产环境靠Nginx这层转发这个约定最好在项目开始时就在文档里写清楚。跨域问题在前后端分离部署里通常由Nginx解决的浏览器访问的是同一个域名所有请求都打向Nginx再由Nginx转发到后端。因此后端SpringBoot里我没有开全局CORS只保留了某个环境变量控制开关防止本地联调时麻烦。如果部署时前端和后端真用了不同域名那必须在后端加CrossOrigin或CorsFilter并指定allowedOriginPatterns不能直接放行所有来源身份认证信息会被浏览器拦下来。6. 开发周期里踩过的四个典型问题代码写完后真正磨人的是各种看着没问题但就是跑不通的意外。这里挑四个我认为最有代表性的记录一下每个都花了不少时间排查希望能帮你绕开。6.1 MyBatis-Plus逻辑删除与唯一索引的冲突我给问卷主表加了deleted字段做逻辑删除配合TableLogic注解。逻辑删除查询是自动过滤deleted1的数据但update by id时如果不小心传了deleted字段会出现把逻辑删除标记覆盖回0的隐患。更隐蔽的问题是如果问卷标题本来就有唯一索引删除一条后立刻新建同标题的问卷逻辑删除的老数据还占着索引插入会直接报重复。我的解决方式是把唯一索引改成联合唯一索引title deleted。因为deleted只有0和1两个值老数据deleted1新数据deleted0可以共存但第二次删除同一标题时会冲突。如果要彻底消除这个限制要么删物理记录要么给deleted加时间戳维度。真实项目里我选择了前者——问卷删除本身是低频操作直接物理删除加备份反而没那么多麻烦。6.2 前端时间格式化与MySQL时区的看似相差8小时问卷列表页显示创建时间数据库里存的是标准DATETIME后端实体类用LocalDateTime接收返回给前端时格式是2025-01-15T10:30:00Element Plus的日期组件能认但直接展示会带着T不美观。我在后端全局配置了Jackson的JavaTimeModule把LocalDateTime统一序列化为yyyy-MM-dd HH:mm:ss。这里有个很关键的点Jackson的格式化默认不包含时区转换如果数据库和服务器时区不一致查询出来的时间很可能比真实时间多或少8小时。排查思路是从底层往上层试先SELECT NOW()看数据库时间再在服务端打印LocalDateTime最后看前端接收的字符串。哪一层和真实时间对不上就是哪一层有问题。我这边的最终状态是MySQL加default-time-zone8:00JDBC串带serverTimezoneAsia/ShanghaiJackson不额外做时区转换。三层一致时间显示就稳定。6.3 前端动态表单的v-model绑定陷阱做编辑器时最初我在v-for里直接使用v-modelquestion.options[index]觉得这样最直接。测试后发现一个问题当用户删除中间的某个选项时后面的选项数组整体前移input框里显示的内容会和索引错位。原因就是v-model绑定的是数组下标对应的值下标变化导致数据错位。解决办法是给选项对象加一个唯一keyv-model绑定到option.content这个对象属性上而不是绑定到数组下标这样数组怎么增删都不会错位。这个坑表面上是绑定方式不对本质上是键控列表的渲染和双向绑定必须基于稳定标识。6.4 高并发下提交答卷的重复数据问卷系统理论上不会遇到秒杀级流量但内部测试时用脚本并发提交同份问卷还是出现了一些重复的answer_record记录。后来我定位到是前端提交按钮没有做防重复处理用户连点两下就发两次请求。这个问题的根治在后端对同一用户同一问卷同一时间窗做幂等性约束。我用一张answer_token表提交答卷时前端带一个一次性token后端在事务里插入token记录利用token的唯一索引挡住重复请求。更轻量级的做法是前端在提交后立即禁用按钮只保证绝大多数场景。真实项目里我建议前端防重后端幂等两者都做成本都不高。7. 文档与代码之外的分层思考标题里带了【含文档】这个点我想单独说说。一套Java Web项目源码代码本身说明的是怎么实现文档回答的是为什么这么设计和别人怎么接手。我当时维护的文档包括三份数据库设计文档表结构、ER关系、字段注释、接口文档路径、参数、返回值示例、部署文档环境要求、初始化SQL、启动步骤。数据库设计文档最容易被忽略但对系统维护的帮助最大。比如你在半年后要加一个题目维度分值的功能如果文档里写清了问卷、题目、选项、答卷四张表的关联方式和设计取舍第一反应就知道该在哪张表加字段、要不要新增子表而不是对着几十个建表语句琢磨。接口文档我用的是Swagger注解自动生成SpringBoot2集成springfox或springdoc都行Swagger页面里能直接点接口测试给前端同学联调时省去不少参数又传错了的来回沟通。部署文档的重要性在于环境不可复现这个痛点。我自己重装过一次开发机如果没有部署文档里写的MySQL8.0初始化SQL和Nginx配置光回忆配置项就要半天。文档里我还记录了各种版本的坑jdk最低版本是17的某个小版本才支持SpringBoot2.7Node版本高于某个大版本后npm install报错需要降级等。这些信息都是实际踩坑后补进去的价值不比代码低。同时代码的结构分层也值得说。后端Controller层只做参数接收和结果封装Service层写业务逻辑Mapper层只管数据库访问。要改一个问卷发布后不允许编辑的规则只动Service层就可以Controller和Mapper都不用碰。前端同样按页面维度拆views每个views下面挂自己的components和hooks公共的request.js放在utils里。这种模块化的好处是开发和问题定位速度快不会出现改一个问卷填写页的样式还得翻遍整个项目找文件名的尴尬。8. 按照这套方案重做一次我还会调整的几个细节最后聊点个人体会。第一点是题目和选项的排序在数据库里尽量用大间隔整数比如100、200、300。当时我用的是连续整数后来在把题目C移到题目A和B之间这种需求上我不得不把所有后面的题目sort_order顺延写了一段全表更新的代码。改成大间隔后大多数挪动只需改一个字段。第二点是问卷详情接口要做缓存。问卷编辑和填写是高频操作读多写少现在的方案每次都查数据库、再查题目、再查选项勉强够用。如果问卷数量过万、答题人数过千这个接口会变成瓶颈。用Spring Cache Redis缓存详情JSON发布或编辑时清掉缓存高并发收益非常明显。当时没做是觉得没必要后来想想顺手加上的话答辩里也能多一个性能优化亮点。第三点是尽量在项目早期就接入统一异常处理。我一开始Controller里每个方法都自己try-catch代码重复多且容易漏。后来用RestControllerAdvice定义全局异常处理器业务异常、参数校验异常、兜底Exception统一返回{code, message}结构整洁度和可维护性提升了一个档次。这个改动越早做越好后期再改会涉及所有接口的返回结构切换。第四点是前端编辑器保存状态要有明确的用户提示。问卷编辑周期通常很长用户很容易在浏览器里点一下后退就丢了全部题。我在编辑器里加了路由守卫beforeRouteLeave和localStorage自动草稿每30秒自动存一份draft刷新后提示检测到未保存的草稿是否恢复。这个功能不是刚需但能明显提升用户好感度也是问卷系统和普通表单页面的体验分水岭。这套SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的在线问卷系统每个环节单独拎出来都是常规技术但把它们组装起来解决动态题目、灵活组卷、稳定回收、方便统计这个实际业务问题时整体设计就显出价值了。如果让我从头再写一遍我会把数据模型设计文档先写扎实再动手写代码——前期的关系梳理清楚后端的CRUD、前端的状态管理、后期的功能扩展都会顺很多。