ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SurveyKing问卷系统源码解析:Spring Boot与MyBatis实战

SurveyKing问卷系统源码解析:Spring Boot与MyBatis实战 简介SurveyKing 是一套基于 Java 构建的开源问卷系统设计源码面向有问卷系统开发学习需求的中高级 Java 开发者、前端工程师以及需要私有化部署问卷工具的个人或团队。这份源码包共 802 个文件、约 46.63MB核心为 336 个 Java 源文件承担问卷设计、逻辑判断、数据存储等后端逻辑配以 95 个 JavaScript 文件实现前端交互、57 个 CSS 文件处理界面样式并包含 png、jpg 等图片资源以及 TypeScript、SQL、Dockerfile、bat 等部署与配置类文件基本覆盖完整可运行项目的全貌。目前已有 401 人学习/下载适合用来分析问卷系统的模块划分与实现思路。源码中除了核心业务代码还带 README 文档、英文说明、License 授权信息以及 website、server、client、scripts 等模块目录便于用户对照学习安装部署、问题定制、逻辑跳转等功能的具体实现。对于想从零搭建一套问卷平台或是研究 JavaWeb 项目工程化结构的人来说是一份内容具体、可直接参考的开源学习资料。1. 拿到SurveyKing源码先别急着写问卷拿到一套“基于Java的SurveyKing问卷系统设计源码”很多人的第一反应是启动项目、创建问卷、填两份卷子看看效果。这没错但大概率会错过这个项目对你最有价值的部分一个完整的问卷系统本质上是表单引擎 权限模型 数据采集管道的组合体而SurveyKing把这三点用Java生态里的主流组件做成了教科书级别的参照实现。相比网上那些只覆盖基础CRUD的“学生管理系统”SurveyKing涉及的题目类型抽象、答卷并发写入、编辑态与发布态分离等设计恰恰是一个Java工程师从“会写接口”走向“能设计业务系统”时最需要补的那一课。所以这篇内容会沿着源码的骨架拆开讲先交代架构和选型理由再分模块啃核心实现然后落地到部署配置和排错最后聊怎么在这个基础上做二次开发。每段代码都保证能直接编译运行或者至少能照抄思路目标就一个——让你读完能动手。2. SurveyKing技术选型与整体架构设计2.1 为什么是Spring Boot MyBatis的组合而不是其他问卷系统不是一个高并发、大数据量的场景但它对业务复杂度的表达能力要求很高。SurveyKing的定位决定了它的技术选型必须以“快速开发、易于二次开发、生态成熟”为首要目标。Spring Boot MyBatis在这个场景下几乎是默认答案Spring Boot负责把Web层、事务、缓存、定时任务这些基础设施全部自动装配好让开发者把精力放在问卷业务本身MyBatis则提供了灵活的动态SQL能力因为问卷题目、选项、答卷明细这些表的查询条件组合非常多样用MyBatis的where、foreach标签可以写出比JPA更可控的SQL。我一般会先看它的pom.xml依赖不需要看全重点看三件事是否用了MyBatis-Plus、是否引入了Redis、前端是否走静态资源打包。SurveyKing的源码在这三个位置分别对应了持久化增强、缓存与分布式登录态、以及前后端一体化部署的取舍。如果你拿到的是包含前端静态资源的完整包那么部署时就不需要额外搭建Node环境这对快速交付非常重要。如果是纯后端源码那么前端构建链路就需要自己维护。2.2 数据库表结构设计问卷、题目、选项、答卷的ER级关系问卷系统的核心数据模型不是一张表而是一组围绕“问卷模板”和“答题实例”双主线的表集合。理解这套表关系是读懂源码的第一步因为你看到的每个Service方法最终都要落到对这几张表的操作上。-- 核心表关系简化描述 survey -- 问卷主表id, title, description, status(1草稿/2发布/3结束), created_by, create_time survey_question -- 题目表id, survey_id, type(单选/多选/填空/评分), title, required, sort_order survey_option -- 选项表id, question_id, content, sort_order survey_answer -- 答卷主表id, survey_id, user_id(匿名则空), submit_time survey_answer_detail -- 答卷明细表id, answer_id, question_id, question_type, answer_content这五张表构成了问卷系统的数据底座。类设计上Survey对应问卷、Question对应题目、Option对应选项、Answer对应答卷、AnswerDetail对应答卷明细类与类之间是一对多的关联关系。在阅读源码时我建议你拿到ER图后先画一遍主键和逻辑外键的连线重点记住survey_answer_detail表里存的是题目的快照内容包括题目原文和答案原文而不是题目ID引用这个设计决定了后续做答卷导出时不需要再去关联题目表即使问卷后续被编辑修改历史答卷的数据也仍然完整可追溯。2.3 源码目录结构先找到这五个包再说源码的包结构通常遵循按业务模块分包的原则这比按技术分层分包更容易维护。SurveyKing的典型目录结构是这样的com.surveyking ├── controller -- 接收HTTP请求参数校验后转发给service ├── service -- 业务逻辑层事务边界在这里声明 │ └── impl -- service接口实现类 ├── mapper -- MyBatis数据访问接口配合xml或注解SQL ├── entity -- 数据库实体类字段与表结构一一对应 ├── dto -- 数据传输对象controller与service层之间不直接暴露实体 ├── config -- 配置类Redis、拦截器、跨域、全局异常处理 └── common -- 通用工具类、统一返回结果、常量定义解读这个结构核心价值在于理解职责边界controller层不写业务代码只做参数接收和结果包装service层是事务和业务校验的归宿mapper层只碰SQL。我看到很多二次开发者在service里直接new一个实体塞给mapper这没问题但如果你发现某个Service方法超过200行就要警惕了——问卷系统里最容易腐化的地方是QuestionService因为题目类型的判断逻辑极度容易堆积成if-else山。SurveyKing的做法是抽象出题目类型接口后面第3章会专门拆这块。先把这个五包结构记在脑子里阅读源码时无论从哪个类切入都能快速定位到它所属的分层。3. 核心功能模块的实现从题目抽象到并发答卷3.1 题目类型的抽象设计别再写if-else判断题目类型问卷系统的题目类型通常有单选、多选、填空、下拉、评分、日期等。最容易写成烂代码的方式是在前端提交时用一个type字段走switch分支然后每加一种题型就要改原来已经稳定的代码。SurveyKing的源码中通常会定义题目类型枚举和对应的处理器接口这是值得看明白的核心设计。public enum QuestionType { SINGLE(1, 单选题), MULTIPLE(2, 多选题), FILL(3, 填空题), SCORE(4, 评分题), DROP(5, 下拉题); private final int code; private final String desc; QuestionType(int code, String desc) { this.code code; this.desc desc; } // getter...可根据code反向获取枚举 } public interface QuestionHandler { /** * 校验答案格式是否合法 */ void validate(String answerContent); /** * 将前端提交的答案解析成标准化格式 */ String parseAnswer(String rawAnswer); } Component public class SingleQuestionHandler implements QuestionHandler { Override public void validate(String answerContent) { if (!answerContent.matches(^[A-Z]$)) { throw new IllegalArgumentException(单选题答案必须是大写字母); } } Override public String parseAnswer(String rawAnswer) { return rawAnswer.trim().toUpperCase(); } }逻辑说明QuestionType枚举负责定义题型元信息和与前端交互的编码而QuestionHandler接口定义了每种题型必须具备的两项能力validate方法校验用户提交内容是否合法parseAnswer方法把原始输入标准化后存入数据库。这样每增加一种题型只需要新增一个枚举值和对应的Handler实现类不需要改动原有逻辑。参数说明这里的answerContent是指用户勾选或填写的答案原文。如果你在二次开发中加了“矩阵题”这类复杂题型validate方法里可能要解析JSON字符串而不是简单的正则匹配。这种基于接口的题型扩展方式配合Spring的依赖注入可以直接在一个MapQuestionType, QuestionHandler里完成自动路由。3.2 答卷提交中的事务边界与并发控制问卷被多人同时填写时答卷写入的频率会比较高。SurveyKing处理答卷提交的核心逻辑在AnswerService.submitAnswer方法里事务边界是这类功能不能出错的地方。Service RequiredArgsConstructor public class AnswerServiceImpl implements AnswerService { private final SurveyAnswerMapper answerMapper; private final SurveyAnswerDetailMapper detailMapper; private final SurveyMapper surveyMapper; /** * 提交一套答卷 */ Override Transactional(rollbackFor Exception.class) public Long submitAnswer(AnswerSubmitDTO dto) { // 1. 检查问卷是否处于发布状态草稿问卷不允许提交 Survey survey surveyMapper.selectById(dto.getSurveyId()); if (survey null || survey.getStatus() ! SurveyStatus.PUBLISHED.getCode()) { throw new BusinessException(问卷不存在或不在发布状态); } // 2. 创建答卷主记录 SurveyAnswer answer new SurveyAnswer(); answer.setSurveyId(dto.getSurveyId()); answer.setUserId(dto.getUserId()); // 匿名问卷时为空 answer.setSubmitTime(new Date()); answerMapper.insert(answer); // 3. 逐题校验并写入明细 ListAnswerDetailDTO details dto.getAnswers(); for (AnswerDetailDTO detail : details) { QuestionHandler handler questionHandlerRouter.get(detail.getQuestionType()); handler.validate(detail.getAnswerContent()); // 额外校验该题目确实属于这张问卷 if (!checkQuestionBelongToSurvey(detail.getQuestionId(), dto.getSurveyId())) { throw new BusinessException(题目不属于该问卷); } SurveyAnswerDetail detailEntity new SurveyAnswerDetail(); detailEntity.setAnswerId(answer.getId()); detailEntity.setQuestionId(detail.getQuestionId()); detailEntity.setAnswerContent(handler.parseAnswer(detail.getAnswerContent())); detailMapper.insert(detailEntity); } return answer.getId(); } }逻辑说明Transactional(rollbackFor Exception.class)声明了事务边界意味着无论内置的RuntimeException还是自定义的BusinessException只要方法抛出异常主表和明细表的写入都会全部回滚。questionHandlerRouter是前面题目类型设计里提到的路由表每种题型对应的Handler会在循环内完成校验和标准化处理。这里每一条明细都做了归属校验防止提交的题目ID不属于当前问卷。参数说明AnswerSubmitDTO里的answers是一个列表每个元素包含questionId、answerContent和questionType。前端在提交前必须把题目类型传过来后端才能路由到正确的Handler。如果问卷包含不限量题目循环写入明细表会变成批量插入问题SurveyKing的做法通常是维持单条插入换取代码简单但如果你的场景需要秒级提交几百道题的答卷可以把detailMapper改为一次batchInsert。3.3 基于RBAC的权限控制管理者、普通用户、匿名受访者的边界问卷系统的权限模型比较特殊它同时存在“管理端用户”和“受访者”两类角色。管理端负责创建和编辑问卷受访者只需要能提交答卷。如果受访者也必须登录那么问卷的传播门槛会提高很多所以SurveyKing一般会支持未登录匿名提交。这就涉及两套认证逻辑的共存。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 白名单路径直接放行比如问卷填写接口 String uri request.getRequestURI(); if (isWhitelisted(uri)) { return true; } // 校验用户登录态 String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { throw new UnauthorizedException(未登录); } // 解析token获取用户信息存入ThreadLocal JwtClaims claims JwtUtil.parseToken(token); UserContext.set(claims); return true; } private boolean isWhitelisted(String uri) { // /api/survey/{id}/submit 允许匿名提交 // /api/survey/published/** 允许查看已发布问卷的详情 return uri.matches(.*/anonymous/.*); } }逻辑说明拦截器的核心策略是区分白名单和受保护路径。/api/survey/anonymous/前缀下的接口允许未登录访问其余接口统一走Token解析。UserContext是一个使用ThreadLocal的上下文类这样在Service层可以直接通过UserContext.getUserId()获取当前登录用户而不需要把用户ID作为参数逐层传递这是Java后端常见但容易写不好的设计点。参数说明白名单匹配用的正则表达式要严格控制我见过有人直接放行/api/**导致管理接口暴露的情况。建议白名单只匹配具体的匿名提交和已发布问卷详情接口其余路径一律校验登录态。如果你需要更细粒度的权限控制比如只有问卷创建者才能编辑问卷可以在Service方法里再对UserContext中的用户ID和survey.getCreatedBy()做一次比对不要依赖拦截器做这条校验。3.4 编辑态与发布态分离问卷编辑不影响已在填写的答卷问卷系统一个容易被新手忽略的问题管理员正在编辑问卷时用户不应该看到“半截”状态问卷发布后管理员的修改也不应该立刻影响已经在填写中的答卷。SurveyKing在这个问题上的设计思路通常是通过状态机和草稿发布分离来解决。问卷主表的status字段是这张表最重要的状态字段。1代表草稿编辑中2代表已发布可填写3代表已结束不可填写。管理员对问卷的增删改操作接口会先校验状态只有草稿状态下才能修改题目发布操作是一个独立接口由草稿转为发布。发布后如果还要修改SurveyKing一般会提供“复制生成新版”的能力旧版数据保持不动。阅读源码时看到SurveyService.copySurvey之类的方法就是干这个用的。这种设计和企业级CMS的内容版本管理思路一致你能在SurveyKing里理解它的实现对理解更复杂的订单系统里的快照设计也有帮助。4. 部署配置与排错从源码到可访问的问卷系统4.1 本地环境准备JDK、Maven和配置文件的三步检查拿到源码后的第一个技术动作不是mvn spring-boot:run而是检查三样东西JDK版本、Maven仓库配置、数据库初始化脚本。常见做法是先看pom.xml里的java.versionSurveyKing这类项目通常会要求JDK 8或11以上。如果你本机装了多个JDK版本记得在IDE里把Project Structure和Maven的JRE设置同步指向同一个版本JVM版本不一致导致的启动失败占了这类项目排错的第一大比例。# 1. 检查JDK版本信息 java -version # 2. 编译打包跳过单元测试节省时间 mvn clean package -DskipTests # 3. 初始化数据库以MySQL为例 mysql -uroot -p -e CREATE DATABASE surveyking CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p surveyking doc/sql/init.sql命令说明编译和初始化数据库的顺序不能颠倒必须先有可用的数据库结构再启动应用。init.sql脚本通常是建表和基础数据一体的SurveyKing会预置一个管理员账号。如果你的源码包里没有doc/sql目录可以在application-dev.yml中开启spring.sql.init.modealways让应用启动时自动执行schema.sql但这不建议在生产环境中使用。4.2application.yml中的关键参数改错一个都起不来配置文件是整个部署流程里最容易踩坑的地方。你需要关注的不只是spring.datasource.url还有几个很容易被忽视的参数。这里有张参数清单表建议逐项确认后再启动。配置项参数示例作用与注意事项spring.datasource.urljdbc:mysql://localhost:3306/surveyking?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai必须加serverTimezone否则JDBC驱动会报时区错误characterEncoding不配置会导致中文乱码spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver高版本MySQL驱动类名是cj结尾老配置敲错就会报ClassNotFoundspring.redis.host/spring.redis.portlocalhost/6379如果源码里配置了Redis作为缓存和登录态存储Redis没启动会导致登录接口一直报错server.servlet.context-path留空或/api若配置了/api所有接口路径都会带前缀Nginx反代时要对应改写mybatis-plus.configuration.map-underscore-to-camel-casetrue开启后数据库submit_time自动映射到submitTime关闭会你的实体类字段全是null注意如果你本机没有装Redis排查问题时可以先看application-dev.yml是否有spring.autoconfigure.excludeorg.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration这样的排除项。没有的话最简单的临时方案是docker跑一个单机Redisdocker run -p 6379:6379 redis:7一条命令的事不值得为关掉它去改源码。4.3 启动失败的三类高频问题端口、时区、缓存按出现频率排SurveyKing这类项目启动或使用中的问题通常是这三类。第一是端口被占用server.port默认8080你本机可能已经有别的Java进程占着端口日志里报Port already in use解决办法最常见的是改配置文件指定一个新端口或者lsof -i:8080找到进程kill掉。第二是数据库连接失败错误提示一般是Access denied for user或Communications link failure前者是账号密码问题后者是host和port不对这件事的排查路径很固定先用命令行客户端mysql -h127.0.0.1 -P3306 -uroot -p验证一遍连接串能连上再谈应用报错。第三是时区问题MySQL连接串里没有serverTimezoneAsia/ShanghaiJava 8及以上会默认用UTC时区导致你存入数据库的时间比北京时间早8个小时这类问题日志不会报错但数据一看就不对。遇到这类“数据看起来不太对”的bug我建议先检查连接串的时区参数再检查实体类里的日期映射是否用了LocalDateTime整个路径排查下来基本能覆盖掉九成情况。5. 二次开发进阶新增题型、数据导出和性能检查点拿到SurveyKing源码后的真正价值在于二次开发下面这三个方向是实际项目中最常被要求加的功能相关的改动路径和关键代码这里直接给你完整示例。5.1 用两步扩展一个新题型以“滑块评分题”为例基于前述的QuestionHandler设计增加题型已经是纯粹的新增代码不需要修改原有逻辑。// 第一步先给枚举加一个值 public enum QuestionType { SINGLE(1, 单选题), // 其他类型... SLIDER(6, 滑块评分题); // 不需要改动任何原有结构 } // 第二步实现一个Handler注入Spring容器即可被自动识别 Component public class SliderQuestionHandler implements QuestionHandler { Override public void validate(String answerContent) { int score Integer.parseInt(answerContent); if (score 1 || score 10) { throw new IllegalArgumentException(评分范围必须在1到10之间); } } Override public String parseAnswer(String rawAnswer) { return String.valueOf(Integer.parseInt(rawAnswer)); } }代码说明核心改动全部集中在新类里Component注解让Spring在扫描时自动注册到questionHandlerRouter这个MapQuestionType, QuestionHandler中路由表不需要任何修改。第二步的validate和parseAnswer是所有题目类型必须履行的契约。前端模板如果是一体化打包的还需要找到对应的渲染配置如果是纯后端接前端项目则前端需要增加一个题型渲染组件这是唯一需要触碰前端的工作。5.2 把答卷数据导出成Excel的轻量实现问卷数据采集完成后最常见的诉求是导出。有人会直接引入Apache POI或EasyExcel但先想清楚你的系统需要的是“生成文件”还是“导出并下载”两个动作。下面的代码是典型的EasyExcel写法Override public void exportAnswers(Long surveyId, HttpServletResponse response) throws IOException { // 1. 查询出所有答卷数据按提交时间排序 ListSurveyAnswer answers answerMapper.selectList( new LambdaQueryWrapperSurveyAnswer() .eq(SurveyAnswer::getSurveyId, surveyId) .orderByAsc(SurveyAnswer::getSubmitTime) ); // 2. 数据量小时直接查内存拼接数据量大时分页查询不要一次性查全表 ListAnswerExportRow rows answers.stream() .map(answer - { AnswerExportRow row new AnswerExportRow(); row.setAnswerId(answer.getId()); row.setSubmitTime(answer.getSubmitTime()); return row; }) .collect(Collectors.toList()); // 3. 写出到响应流 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(答卷_ surveyId, UTF-8).replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); EasyExcel.write(response.getOutputStream(), AnswerExportRow.class).sheet(答卷).doWrite(rows); }逻辑说明LambdaQueryWrapper是MyBatis-Plus的条件构造器用它代替手写SQL可以避免字符串拼接出错。导出Excel的编码处理是很多人的坑直接拼文件名会遇到中文乱码URLEncoder.encode结合filename*utf-8的写法是兼容性最好的方案。性能方面超过10万条答卷建议不要走同步接口改造为异步任务做“查询生成文件后上传OSS再通过消息通知用户下载”会更合理。5.3 性能检查点缓存、索引和慢SQL容量高了之后SurveyKing主要遇到三个瓶颈。第一是问卷详情接口被同一个问卷的大量受访者同时读取这时候可以在SurveyService.getSurveyDetail方法上加缓存Cacheable(cacheNames survey:detail, key #surveyId, unless #result null) public SurveyDetailDTO getSurveyDetail(Long surveyId) { return buildDetail(surveyId); }Cacheable会让同一份问卷详情在缓存TTL内只查一次数据库后续请求直接命中Redis这对“同一问卷短时间内被大量访问”的场景效果立竿见影。第二是survey_answer_detail表在数据量大时查询慢需要在survey_id和question_id上建联合索引SQL层面最简单的是ALTER TABLE survey_answer_detail ADD INDEX idx_survey_question (survey_id, question_id);。第三是慢SQL的定位把MyBatis的SQL日志级别调成DEBUG逐个查看每张表的扫描行数如果看到type: ALL的索引全表扫描就要对WHERE条件里的字段建索引。SurveyKing这类系统单表数据量到百万级时只要索引设计合理查询性能仍然没有问题真正需要担心的只是统计类报表SQL不要实时跑在业务库上。本文还有配套的精品资源点击获取
返回列表