首页 / 资讯中心 / 文章详情

Spring Boot问卷系统源码:题型建模、提交幂等与统计聚合

Spring Boot问卷系统源码:题型建模、提交幂等与统计聚合 ★ FEATURED ARTICLE
简介这套基于Spring Boot的在线问卷调查系统源码面向具备Java Web基础、希望完整实践后端业务与前后端协作的开发者可用于课程设计、毕业项目或二次开发练手。项目以questionnaire-master为主体覆盖登录注册与权限控制、问卷模板创建编辑、社交分享、结果统计图表、个人中心及RESTful API等模块对权限控制、问卷模板与题目选项表、统计图表等实现细节也有较完整呈现能帮助读者理解从数据库表设计到问卷数据流转的完整实现。压缩包共294个文件约37.17MB以100个Java源码、26个HTML页面、26个JavaScript脚本和8个CSS样式为主另有SQL建表脚本、properties配置、图片与字体等静态资源目录分层便于按功能检索。已有269人学习下载适合顺着控制器、服务层与持久层代码梳理业务链路并参考现成页面与接口组织方式完成自己的问卷系统或功能扩展。1. 从一张被复制粘贴的问卷说起这个系统解决的是什么问题前阵子帮一个做运营的朋友看需求他们公司要做员工满意度调研用市面上的在线表单工具题目逻辑稍微复杂一点就要升级付费版答卷数据想自己拉下来做交叉分析还得再开企业版。这类需求其实一套 Spring Boot 写的问卷系统就能兜住问卷创建、单选 / 多选 / 填空 / 评分四种题型、发布收回、答卷统计、导出表格前后端都在一个工程里改字段不用跨仓库对接口。这份源码适合两类人——想找个完整 CRUD 加统计的工程练手的中级开发者以及被第三方表单工具卡住、需要私有部署的业务开发。它不追求问卷逻辑引擎那种复杂度胜在结构干净、每一层都能看懂、改起来有下手的地方。2. 工程骨架与技术选型一个请求从 Controller 落到 Mapper 的完整链路拿到一个 Spring Boot 源码包我的习惯是先不跑翻pom.xml和包结构十分钟就能判断这工程是「能改」还是「只能看」。问卷系统这种以增删改查和统计为主的项目技术选型基本决定了后期改造的难度ORM 选得好加一个题型可能就是加一个类选得拧巴加个字段要改四五个地方。2.1 依赖清单为什么是 MyBatis-Plus 而不是 JPA这类工程最常见的组合是 Spring Boot 2.7.x MyBatis-Plus MySQL 8 Redis Thymeleafpom.xml里真正需要看清的其实就七八个坐标剩下的都是它们拖进来的传递依赖。dependencies !-- Web 层问卷的增删改查、答卷提交都靠它 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 参数校验NotBlank / Min 这类注解的落地依赖缺了它注解不生效 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- MyBatis-Plus 3.5.x省掉单表 CRUD 的 XML复杂统计仍然手写 SQL -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 驱动8.x 版本注意 driver-class-name 要写 com.mysql.cj.jdbc.Driver -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Redis用来做答卷提交的幂等 token 和统计结果缓存 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- POI答卷导出 xlsx 用只导出不导入的话 poi-ooxml 就够了 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies选 MyBatis-Plus 而不是 JPA 的理由很实际问卷系统的统计页会写不少「按题目分组、按选项拆分计数」的 SQLJPA 的 Criteria 写起来绕原生 SQL 又丢掉了 ORM 的好处。MyBatis-Plus 的模式是单表用BaseMapper自动生成复杂查询在Select或 XML 里手写边界清晰。poi-ooxml这类依赖容易被忽略版本5.x 和 4.x 的XSSFWorkbookAPI 有差异直接从老项目抄代码的话先确认一下版本。提示spring-boot-starter-validation在 Spring Boot 2.3 之后不再随 web starter 传递进来很多从老教程抄来的工程Valid不生效就是这个原因。2.2 包结构一次「打开问卷填写页」的请求怎么走工程骨架基本是标准的三层加一个 common关键看点在于有没有把 DTO 和 VO 分开、异常是不是统一处理。com.example.survey ├── SurveyApplication.java # 启动类MapperScan 在这里 ├── common │ ├── Result.java # 统一返回体 {code, msg, data} │ ├── PageResult.java # 分页返回避免前端再拼一层 │ ├── BizException.java # 业务异常带 code │ └── GlobalExceptionHandler.java # RestControllerAdvice 兜底 ├── config │ ├── MybatisPlusConfig.java # 分页插件、逻辑删除配置 │ ├── WebMvcConfig.java # 跨域、静态资源、拦截器注册 │ └── RedisConfig.java # 序列化方式默认 JDK 序列化在 redis-cli 里看不懂 ├── controller │ ├── SurveyController.java # 问卷的增删改查、发布、关闭 │ ├── AnswerController.java # 答卷提交、我的答卷 │ └── StatController.java # 统计与导出 ├── service │ ├── SurveyService.java │ ├── AnswerService.java │ └── impl ├── mapper │ ├── SurveyMapper.java │ ├── QuestionMapper.java │ ├── AnswerSheetMapper.java │ └── AnswerItemMapper.java ├── entity # 与表一一对应 ├── dto # 入参SurveyCreateDTO、AnswerSubmitDTO ├── vo # 出参SurveyDetailVO、StatVO └── util # ExcelUtil、IpUtil、SnowflakeIdUtil以填写页为例链路是这样的前端GET /api/survey/{id}/fill打到SurveyControllerController 只做一件事——把路径参数转成Long然后调 ServiceSurveyServiceImpl先查survey主表拿到状态判断是不是「已发布」且在时间窗内再查question表按sort_no升序取题目最后按题目 id 批量查option表一次in查询不要在循环里查这是最常见的 N1 翻车点组装成SurveyDetailVO返回。整个过程没有事务因为纯读加了Transactional(readOnly true)也只是让某些数据库走只读连接收益不大。DTO 和 VO 分开这件事值得强调。答卷提交的入参是一棵嵌套结构答卷头 答题明细列表而entity是对表的直接映射两者字段名和校验规则都不一样。共用实体类的后果是前端多传一个idinsert的时候就把主键写脏了。2.3 配置文件application.yml 里真正会出问题的三段server: port: 8080 servlet: context-path: / # 如果前端部署在子路径这里改了之后所有接口前缀都要跟着改 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/survey?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 # 问卷系统并发不高10 够用调大要先看数据库连接数上限 connection-timeout: 3000 redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 2000ms jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 default-property-inclusion: non_null # 返回值里 null 字段不输出前端少判空 mybatis-plus: configuration: map-underscore-to-camel-case: true # create_time - createTime关掉的话实体全是 null log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 只在 dev 环境开 global-config: db-config: logic-delete-field: deleted # 逻辑删除字段调查询时会自动拼 deleted 0 logic-delete-value: 1 logic-not-delete-value: 0url那行里characterEncodingutf8mb4是必须的不是utf8。MySQL 的utf8是三个字节存不下 emoji问卷的填空题里用户打个表情整条提交就 500。这个坑我在两个项目里都踩过后面讲排查还会提。logic-delete-field配上之后selectById会自动加deleted 0但手写 SQL 不会。统计接口如果自己写了Select(select count(*) from answer_sheet where survey_id #{id})删掉的答卷也会被算进去数字对不上就是这么来的。log-impl那行打印 SQL 很方便但生产环境必须关掉一是日志量爆炸二是会把答卷内容打进日志。建议拆application-dev.yml和application-prod.yml用spring.profiles.active切换别偷懒只留一份。3. 问卷建模五种题型、一套表结构和一份发布快照问卷系统看着简单建模上的取舍全在「题目类型」这一块。单选、多选、填空、评分这四种是刚需能不能优雅地加第五种决定了这个源码包是练手项目还是能真的往里加需求。3.1 五张核心表为什么明细表要设计成「一条答题一行」-- 问卷主表 CREATE TABLE survey ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, description VARCHAR(500) DEFAULT , status TINYINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2已结束 schema_json LONGTEXT NULL, -- 发布那一刻的结构快照见 3.3 start_time DATETIME NULL, end_time DATETIME NULL, create_by BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 题目表一份问卷 N 道题 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, survey_id BIGINT NOT NULL, type TINYINT NOT NULL, -- 1单选 2多选 3填空 4评分 title VARCHAR(300) NOT NULL, required TINYINT NOT NULL DEFAULT 1, sort_no INT NOT NULL DEFAULT 0, min_score TINYINT NULL, -- 评分题的区间比如 1~5 max_score TINYINT NULL, KEY idx_survey_sort (survey_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选项表单选/多选的候选项 CREATE TABLE question_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_id BIGINT NOT NULL, content VARCHAR(200) NOT NULL, sort_no INT NOT NULL DEFAULT 0, KEY idx_question (question_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 答卷头表一次提交一行 CREATE TABLE answer_sheet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, survey_id BIGINT NOT NULL, respondent VARCHAR(64) NULL, -- 实名问卷存工号匿名问卷存设备指纹 client_token VARCHAR(64) NOT NULL, -- 幂等 token前端生成 duration INT NULL, -- 填答耗时秒 submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ip VARCHAR(45) NULL, UNIQUE KEY uk_survey_token (survey_id, client_token), -- 幂等靠这个唯一索引兜底 KEY idx_survey_time (survey_id, submit_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 答题明细表一道题一行 CREATE TABLE answer_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sheet_id BIGINT NOT NULL, question_id BIGINT NOT NULL, option_ids VARCHAR(255) NULL, -- 多选存 12,15,18单选存 12 text_value VARCHAR(1000) NULL, -- 填空题的内容 score TINYINT NULL, -- 评分题的分值 KEY idx_sheet (sheet_id), KEY idx_question (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;明细表设计成「一道题一行」而不是「一次答卷一行、把答案塞 JSON」理由是统计。按题目分组计数的场景下JSON 字段要么用 MySQL 8 的JSON_EXTRACT逐条扫要么全量捞到内存里解析答卷上十万条之后都会很难受。拆成行之后GROUP BY question_id直接走索引成本可控。代价是提交时要做批量插入一次 20 道题就是 20 行。这里用 MyBatis-Plus 的saveBatch或者手写foreach的insert into ... values (...),(...),(...)都比循环调insert快一个量级。注意option_ids存逗号分隔是典型的反范式做法好处是简单坏处是统计多选时要拆字符串。后面 4.3 会讲怎么绕过去。3.2 题型枚举与多态渲染加一种题型改动有多小题型在 Java 侧用一个枚举收口前端侧靠type分发到不同的渲染函数这样加「排序题」「矩阵题」的时候不需要动主流程。public enum QuestionType { SINGLE(1, single), MULTIPLE(2, multiple), TEXT(3, text), RATE(4, rate); private final int code; private final String renderKey; // 前端渲染时用它做 switch 的 key QuestionType(int code, String renderKey) { this.code code; this.renderKey renderKey; } public int getCode() { return code; } public String getRenderKey() { return renderKey; } // 反查避免在 Service 里写一长串 if-else public static QuestionType of(Integer code) { if (code null) { throw new BizException(题型不能为空); } for (QuestionType t : values()) { if (t.code code) { return t; } } throw new BizException(未知题型: code); } // 只有单选、多选需要选项填空和评分不需要 public boolean needOptions() { return this SINGLE || this MULTIPLE; } }needOptions()这个小方法是给自己埋的伏笔保存题目时校验「多选题至少两个选项」、渲染时决定要不要查question_option表都靠它。我见过不少项目把这个判断散在四五个地方写成type 1 || type 2加题型的时候漏改一处就是一个线上 bug。前端那边Thymeleaf 模板或 Vue 组件都一样用一个映射表分发// 题型 - 渲染函数新增题型只在这里加一行主流程不用动 const RENDERERS { single: renderRadioGroup, multiple: renderCheckboxGroup, text: renderTextarea, rate: renderStarRate }; function renderQuestion(q) { const fn RENDERERS[q.renderKey]; if (!fn) { console.warn(未支持的题型, q.renderKey); return null; // 宁可少渲染一题也不要把整个表单搞崩 } return fn(q); }renderKey由后端下发而不是前端从type数字猜是为了以后加题型时能灰度——老版本前端拿到不认识的 key 就跳过不会白屏。3.3 发布快照问卷改了之后老答卷为什么还能算对这是问卷系统里最容易被低估的一个设计。假设问卷上线一周收了 500 份答卷运营想把「选项 3」的文案从「一般」改成「还可以」如果统计时直接 join 现在的question_option表那 500 份历史答卷的统计结果会跟着文案一起变——数字没变但含义变了报表就没法解释了。更狠的情况是删掉一个选项历史答卷里引用了不存在的选项 id统计页直接报空指针。常见做法是在发布动作上加一份快照Transactional(rollbackFor Exception.class) public void publish(Long surveyId) { Survey survey surveyMapper.selectById(surveyId); if (survey null) { throw new BizException(问卷不存在); } if (survey.getStatus() 1) { throw new BizException(已发布的问卷不能重复发布); } // 1. 查出完整结构 ListQuestion questions questionMapper.selectBySurveyId(surveyId); for (Question q : questions) { if (QuestionType.of(q.getType()).needOptions()) { q.setOptions(optionMapper.selectByQuestionId(q.getId())); } } // 2. 序列化成 JSON 存进 survey.schema_json这份结构此后不再变化 SurveySchema schema new SurveySchema(survey.getTitle(), questions); survey.setSchemaJson(JSON.toJSONString(schema)); survey.setStatus(1); survey.setStartTime(LocalDateTime.now()); surveyMapper.updateById(survey); }发布之后填写页和统计页都以schema_json为准question和question_option表变成「编辑态数据」只在草稿阶段被修改。用户在填写页看到的题目顺序、选项文案和他提交时的结构永远一致。这个设计有个副作用要想清楚问卷发布后就不能直接改题目了想改只能「复制一份新问卷 结束旧问卷」。业务上这其实是对的——问卷是「一次调查的契约」中途改题目本身就不合规。但如果产品经理坚持要能改那就得引入版本号survey加version字段answer_sheet记录答题时的版本统计时按版本分组。改造量不小这是二次开发那一章会讲的一个切入点。4. 答卷链路提交接口的事务边界、幂等控制与统计聚合问卷系统真正会出线上问题的从来不是创建问卷而是答卷提交。用户点了提交、转圈、网断了、再点一次——你就有了两份一样的答卷。这一章把提交链路拆开看。4.1 提交接口参数校验、事务边界和批量插入Controller 保持极薄只做参数绑定和调用RestController RequestMapping(/api/answer) public class AnswerController { private final AnswerService answerService; public AnswerController(AnswerService answerService) { this.answerService answerService; } // Valid 触发 DTO 上的注解校验失败由 GlobalExceptionHandler 统一转成 Result PostMapping(/submit) public ResultLong submit(RequestBody Valid AnswerSubmitDTO dto) { Long sheetId answerService.submit(dto); return Result.ok(sheetId); } }DTO 上把必填和长度约束写死别指望 Service 里手动判空Data public class AnswerSubmitDTO { NotNull(message 问卷id不能为空) private Long surveyId; // 前端进入填写页时生成的 uuid用于幂等不传的话后端没法防重复提交 NotBlank(message 提交凭证不能为空) private String clientToken; private Integer duration; NotEmpty(message 答卷内容不能为空) Valid private ListAnswerItemDTO items; } Data public class AnswerItemDTO { NotNull private Long questionId; private ListLong optionIds; // 选择类题目 Size(max 1000, message 填空内容过长) private String textValue; // 填空题 Min(0) Max(10) private Integer score; // 评分题 }Service 层是核心事务边界必须卡在这里而且rollbackFor要显式写Exception.classService public class AnswerServiceImpl implements AnswerService { Override Transactional(rollbackFor Exception.class) // 默认只回滚 RuntimeException受检异常不回滚 public Long submit(AnswerSubmitDTO dto) { Survey survey surveyMapper.selectById(dto.getSurveyId()); // 1. 状态校验只有已发布且在时间窗内的问卷能提交 if (survey null || survey.getStatus() ! 1) { throw new BizException(问卷未发布或已结束); } LocalDateTime now LocalDateTime.now(); if (survey.getEndTime() ! null now.isAfter(survey.getEndTime())) { throw new BizException(问卷已过截止时间); } // 2. 写入答卷头。client_token 有唯一索引重复提交会抛 DuplicateKeyException AnswerSheet sheet new AnswerSheet(); sheet.setSurveyId(dto.getSurveyId()); sheet.setClientToken(dto.getClientToken()); sheet.setDuration(dto.getDuration()); sheet.setSubmitTime(now); try { answerSheetMapper.insert(sheet); } catch (DuplicateKeyException e) { // 已经提交过直接返回原答卷 id前端体验上等同于「提交成功」 AnswerSheet exist answerSheetMapper.selectByToken(dto.getSurveyId(), dto.getClientToken()); return exist.getId(); } // 3. 明细逐条转换后批量插入 ListAnswerItem items new ArrayList(dto.getItems().size()); for (AnswerItemDTO item : dto.getItems()) { AnswerItem entity new AnswerItem(); entity.setSheetId(sheet.getId()); entity.setQuestionId(item.getQuestionId()); entity.setOptionIds(joinIds(item.getOptionIds())); // ListLong - 12,15 entity.setTextValue(item.getTextValue()); entity.setScore(item.getScore()); items.add(entity); } answerItemMapper.insertBatch(items); // 一条 insert ... values (...),(...) 搞定 return sheet.getId(); } }几个容易翻车的点。第一Transactional加在private方法或者同类内部自调用上不生效这是 Spring AOP 代理机制决定的别在submit里再调this.saveItems()然后指望它单独开事务。第二DuplicateKeyException是在事务内捕获的捕获之后事务不会自动回滚因为这个异常被吞了但如果后面还有别的写操作抛异常整个事务还是会回滚——所以捕获之后最好直接return不要继续往下写。第三批量插入的条数如果超过几千考虑分批MySQL 默认max_allowed_packet是 4MB一次塞太多会报PacketTooBigException。4.2 幂等与限流前端按钮禁用只是心理安慰前端在点击后把按钮置灰能挡住 80% 的重复提交剩下 20% 是网络超时重试、用户狂点、以及有人拿 Postman 打接口。后端必须自己兜底。幂等靠uk_survey_token(survey_id, client_token)这个唯一索引思路是「让数据库来判重」比在应用层先select再insert可靠得多——后者在并发下存在检查与写入之间的时间窗口两个请求同时通过检查的例子太常见了。限流解决的是另一类问题恶意刷答卷。做法是在 Redis 上按 IP 做滑动窗口计数Component public class SubmitRateLimiter { private final StringRedisTemplate redis; public SubmitRateLimiter(StringRedisTemplate redis) { this.redis redis; } /** * param ip 客户端 IP * param limit 窗口内允许的次数 * param seconds 窗口长度秒 */ public void check(String ip, int limit, int seconds) { String key rate:submit: ip; Long count redis.opsForValue().increment(key); if (count ! null count 1L) { // 第一次访问才设过期避免每次刷新都把窗口延长 redis.expire(key, Duration.ofSeconds(seconds)); } if (count ! null count limit) { throw new BizException(提交过于频繁请稍后再试); } } }这个实现是固定窗口边界上会有两倍突刺第 59 秒打满 10 次第 61 秒又能打 10 次。问卷场景够用了真要做严格限流就上滑动窗口或者令牌桶。注意increment和expire是两次命令中间如果进程挂了 key 会永不过期稳妥写法是Lua脚本合并成一次或者用setIfAbsent 过期时间。真正拦人还得看业务手段匿名问卷加图形验证码实名问卷校验工号唯一。技术手段只是第二道闸。4.3 统计聚合多选拆分怎么不走全表扫描统计页要的通常是这几类数每道题每个选项被选了多少次、填空题的原始文本列表、评分题的平均分和分布。单选、填空、评分都好办难的是多选——option_ids存的是12,15,18。答卷量小几千份以内时最省事的做法是把明细捞到内存里聚合Service public class StatServiceImpl implements StatService { public ListOptionStatVO statSingleChoice(Long surveyId, Long questionId) { ListAnswerItem items answerItemMapper.selectBySurveyAndQuestion(surveyId, questionId); // key optionId, value 被选次数 MapLong, Long counter new HashMap(); for (AnswerItem item : items) { // 单选只有一个 id多选是逗号分隔统一按逗号拆 for (String s : item.getOptionIds().split(,)) { if (s.isEmpty()) continue; counter.merge(Long.parseLong(s), 1L, Long::sum); } } // 补上 0 次的选项否则前端图表缺柱 return optionMapper.selectByQuestionId(questionId).stream() .map(opt - new OptionStatVO(opt.getId(), opt.getContent(), counter.getOrDefault(opt.getId(), 0L))) .collect(Collectors.toList()); } }答卷量上万之后这条路走不通内存和耗时都会顶不住。这时候有两个方向一是把统计挪到 SQL 侧用一张数字辅助表配合FIND_IN_SET拆串能出结果但索引基本用不上适合离线跑报表。二是更推荐的做法——提交时增量计数。在answer_sheet写入成功后异步Async或发消息把每道题的每个选项HINCRBY到 Redis 的 hash 里key 形如stat:{surveyId}:{questionId}统计接口直接读 hash再配一个定时任务回写 MySQL 做持久化。代价是数据可能短暂不一致异步丢失换来的是统计接口恒定 O(1)。-- 离线报表兜底方案一次查出每道题每个选项的命中次数 SELECT ai.question_id, SUBSTRING_INDEX(SUBSTRING_INDEX(ai.option_ids, ,, n.n), ,, -1) AS option_id, COUNT(*) AS cnt FROM answer_item ai JOIN numbers n ON n.n (LENGTH(ai.option_ids) - LENGTH(REPLACE(ai.option_ids, ,, )) 1) WHERE ai.sheet_id IN (SELECT id FROM answer_sheet WHERE survey_id #{surveyId} AND deleted 0) GROUP BY ai.question_id, option_id;numbers是一张只有n一列、存 1 到 100 的小表用「第 n 段」的方式把逗号串拆开。这段 SQL 在答卷量十万级的时候大概几秒放在夜间跑的报表任务里可以接受直接给前端接口调就别了。另外注意answer_sheet那边带了deleted 0——如果用了逻辑删除手写 SQL 一定要自己补这个条件MyBatis-Plus 的自动拼接只对BaseMapper的方法生效。5. 常见问题排查上线后才冒出来的五个坑下面这几条基本都是我在同类工程里真遇到过或者接手后被坑过的按「现象 → 原因 → 解决」写遇到了直接对着查。5.1 填写页题目顺序每次刷新都不一样现象同一份问卷作者看到的题目顺序是 1、2、3用户刷新一下变成 3、1、2选项顺序也乱。原因查询question表时没写ORDER BY sort_no。MySQL 不加排序条件时返回顺序取决于执行计划和存储引擎小表看着稳定数据量一变就开始飘。解决questionMapper.selectBySurveyId的 SQL 里补ORDER BY sort_no ASC, id ASC选项同理。sort_no允许重复所以要加id做兜底排序否则相同sort_no的两行仍然无序。5.2 偶发答卷明细丢失头表有记录、明细一条没有现象用户在「我的答卷」里能看到提交记录但统计页里这份答卷一道题都没算进去。原因submit方法上没加Transactional或者加了但被同类内部调用绕过了代理。头表插入成功批量插入明细时抛了异常比如某个text_value超过 1000 字符异常被GlobalExceptionHandler捕获返回给前端但已经写入的头表数据没有回滚。解决确认submit是public且通过代理调用注解写Transactional(rollbackFor Exception.class)同时在 DTO 上用Size(max 1000)提前拦住超长文本别让它进到写库阶段。5.3 填空题提交报 500日志里是 Incorrect string value现象用户在某道填空题里打了一个表情符号提交直接 500后端日志Incorrect string value: \xF0\x9F...。原因表的字符集是utf8三字节而不是utf8mb4四字节存不下四字节的 emoji。连接串里的characterEncoding写成utf8也一样会出问题。解决ALTER TABLE answer_item CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接串改成characterEncodingutf8mb4顺便检查 MySQL 服务端的character_set_server。已经在生产环境的表要留出锁表时间别在高峰期改。5.4 统计出来的答卷数和列表里的条数对不上现象问卷管理页显示回收 320 份统计页的「有效答卷」是 340 份。原因统计走的是手写 SQL没带逻辑删除条件被作者删掉的 20 份测试答卷也被算进去了或者反过来列表走BaseMapper自动带了deleted 0统计 SQL 没带。解决所有手写统计 SQL 统一在子查询里带上deleted 0并且把这个条件抽成 XML 里的sql片段复用避免哪天又漏一处。上线前用「列表条数 统计基数」做一次对拍成本很低。5.5 多人同时提交偶发 Duplicate entry 报错直接抛给用户现象压测或者问卷刚发出去的那几分钟部分用户收到「系统繁忙」日志里是DuplicateKeyException。原因client_token的唯一索引生效了但代码里没捕获这个异常直接冒泡到全局处理器变成了 500。另外如果前端生成 token 的逻辑放在页面mounted里用户双开标签页会拿到两个不同 token同一份答卷能提交两次——这是产品层面的漏洞技术侧兜不住。解决Service 里try-catch住DuplicateKeyException查到已有记录就按成功返回同时把 token 的生成挪到「进入填写页」时由后端下发并写入 Cookie/Session双开标签页也共用同一个 token。6. 二次开发改造加题型、加跳转、接身份从哪下手最省事拿到这个源码包之后真正会做的改造大概率是下面三件事我按改动量从小到大排。改造目标主要改动点预估工作量风险点加一种题型如排序题QuestionType枚举、answer_item加字段、前端RENDERERS加一项、统计逻辑0.51 天统计聚合要单独处理新字段题目逻辑跳转选 A 才显示第 5 题question加visible_ruleJSON 字段、前端渲染前过滤、后端提交时校验必答23 天必答校验和跳转规则容易打架接入统一身份工号/SSO拦截器 respondent落库、去掉匿名入口、按人限制提交次数12 天和历史匿名答卷的数据口径要统一加题型这件事最省事的路径不是改表是把新题型的数据塞进现有字段。排序题本质上是「多选 顺序」把option_ids按用户拖拽后的顺序存成15,12,18统计时按位置拆开就能算出「平均排位」。这样answer_item一行代码都不用改只需要在QuestionType里加一个SORT(5, sort)前端加一个拖拽组件后端提交校验里允许optionIds有重复顺序语义。我一般会先跑一遍「创建问卷 → 发布 → 提交 → 统计」的四步冒烟确认新题型在每一环都不报错再往下写。加跳转规则是真正的分水岭。visible_rule我建议存成{questionId: 3, operator: eq, value: [12]}这种结构前端在renderQuestion之前跑一遍规则过滤后端在submit里重新算一遍可见题目集合只对可见的必答题做非空校验。千万别只在前端过滤——只要后端不校验绕过前端提交一堆空答卷是分分钟的事。这里有个坑规则可能形成环第 2 题依赖第 3 题、第 3 题又依赖第 2 题保存问卷时要做一次拓扑排序检测有环直接拒绝保存别等到用户填到一半页面白屏。接身份改造最考验对现有代码的理解。匿名问卷靠client_token防重实名之后应该改成uk_survey_respondent(survey_id, respondent)client_token降级成单纯的防连点。历史数据里respondent是空的迁移脚本要给它补一个anon_前缀的占位值否则唯一索引加不上去。这个顺序不能反先补数据再加索引。验证方法我习惯写三个层次的用例Service 层的单元测试覆盖「重复提交返回同一 id」「超时问卷抛异常」这两条分支接口层用一组固定的 JSON 走完整链路最后手工在浏览器里把 emoji、超长文本、双开标签页这三种脏数据各试一遍。前两层能挡住逻辑回归第三层挡的是那些只有真实浏览器才会暴露的问题。从那以后我每次接这类问卷/表单类工程都强制先把「发布快照」和「提交幂等」这两条链路跑通再动业务代码——这两块一旦地基没打好后面加什么功能都是在补窟窿。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站