从需求到跑起来我用飞算JavaAI两周做了一个在线考试系统两周能不能上线电话那头是我一个在职业培训机构做教务的朋友。他们每期四百多学员结课考试全靠打印纸质卷子、人工批改、Excel 统计成绩一年折腾十几次。他想搞一套在线考试系统。我干了五年 Java 后端。Spring Boot、MySQL、Redis 闭着眼能写前端只到改改 Vue 模板的水平。以前碰到这种活我的标准答案是得找个前端配合。这次我犹豫了三天最后说了句我试试。敢接是因为前面我刚用飞算JavaAI跑通了一个电商订单后台的小项目过程写在上一篇里。但那个只有四张表、十几个接口属于练手。这次是十四张表、四十三个接口还得真的给人用。这篇文章把这两周原样摊开每天在干什么工具给了我什么我改了什么哪里翻了车。一、动手之前先把边界划死第一天我一行代码没写。朋友的需求很散我先把范围钉住——先划边界再谈功能这是我吃过亏之后养成的习惯。这次必须有的这次明确砍掉的题库管理单选 / 多选 / 判断 / 简答学员自主注册与付费组卷固定试卷 随机抽题两种摄像头视频监考在线考试倒计时、草稿保存、到点交卷移动端 App客观题自动判分、主观题人工阅卷复杂的成绩分析报表成绩查询与导出 Excel多租户砍功能比加功能难。朋友一开始想把防作弊做成摄像头抓拍我直接算账光浏览器兼容加录像存储就要多一周第一版不做。他同意了。回头看这是我这两周做的最正确的一个决定。功能一摊开两周根本收不了尾。二、第 1-3 天需求定完被推翻了一次我打开飞算JavaAI的智能会话用/需求分析指令输入了一段很糙的话做一个在线考试系统要有题库管理、组卷、在线考试、自动判分和成绩统计。老师和管理员用后台学员在前台考试。它开始反问。我数了下一共九个问题其中四个戳中了我压根没想到的地方试卷是固定题目还是每次从题库随机抽抽题规则按难度、题型还是分值主观题怎么处理不判分、自动判还是老师后台人工阅卷考试时间到了是强制交卷还是允许延迟几分钟一个人能考几次补考成绩取最高还是取最新我一条条答完它生成了两份文档丢进docs目录需求文档.md 和业务设计文档.md。用例、角色、状态机、字段定义都在。我按实际业务改了大概两成比如它默认每题分值固定我改成了支持按题型批量设分。然后第 3 天下午需求变了。朋友打电话过来简答题必须老师批改而且学员要等老师阅完卷才能看到成绩不能考完立刻出分。一句话把我原设计里交卷即算总分的核心假设全推翻了。变更项原方案新方案受影响的面出分时机交卷后立刻算分展示客观题自动判主观题待阅全阅完才发布考试记录状态机、成绩字段、前端结果页记录状态未开始 / 考试中 / 已提交 / 已阅卷新增待阅卷“阅卷中”后端枚举、列表筛选、老师端菜单新增实体无阅卷任务、阅卷明细、主观题得分3 张新表、6 个新接口前端交卷后跳结果页交卷后显示成绩待公布老师端加阅卷工作台2 个页面重写、1 个页面新增那天下午我把新需求补进描述重跑了一遍/需求分析再让/前后端设计重新出稿。全部改完半天。我后来算过一笔账同样的变更如果发生在代码写完以后表要加、状态机要改、接口要调、页面要重写保守三天还容易留尾巴。这次只花半天不是因为我聪明是因为它发生在还只有文档的时候。这是两周里我体会最深的一点需求阶段的活做扎实是给后面买保险。三、第 4-5 天十四张表和四十三个接口是怎么出来的需求定稿后执行/前后端设计它读上一步的产物输出数据库设计、接口清单、前端页面设计和技术栈选型。技术栈给的是后端 Spring Boot MyBatis-Plus前端 Vue 3 Element Plus Vite Pinia跟我熟的套路一致直接采纳。数据库一共十四张表核心的十张表名作用我在评审时改的地方question题目主表补了 question_type 索引按题型筛选题很频繁question_option选项表补了 (question_id, sort) 联合索引question_tag题目标签原设计没有我要求加的随机抽题要用paper试卷主表加了 generate_type 字段区分固定卷和随机卷paper_question试卷-题目关联补唯一约束防止同一题被重复加进一张卷exam考试主表补开始/结束时间索引列表页按时间查exam_record考试记录核心表补 (exam_id, user_id) 唯一索引防重复考试exam_answer学员作答明细补 (record_id, question_id) 唯一索引幂等靠它marking_task阅卷任务需求变更后新增marking_detail阅卷明细需求变更后新增存每道主观题的得分和评语剩下四张是用户、班级、角色权限和操作日志。这里说句实话它生成的建表语句索引给得非常保守。像 exam_answer 这种读写都密集的表原设计只有一个主键。索引、唯一约束、字段长度是我一行行补的。-- 最终落库的考试记录与作答明细索引部分是我自己补的CREATETABLEexam_record(idBIGINTNOTNULLCOMMENT雪花ID,exam_idBIGINTNOTNULL,user_idBIGINTNOTNULL,paper_idBIGINTNOTNULL,statusTINYINTNOTNULLDEFAULT0COMMENT0考试中 1待阅卷 2阅卷中 3已发布 4作废,objective_scoreDECIMAL(6,2)DEFAULT0COMMENT客观题得分,subjective_scoreDECIMAL(6,2)DEFAULT0COMMENT主观题得分,total_scoreDECIMAL(6,2)DEFAULT0,start_timeDATETIMENOTNULL,submit_timeDATETIMENULL,submit_sourceTINYINTDEFAULT0COMMENT0主动交卷 1到点自动交卷,PRIMARYKEY(id),UNIQUEKEYuk_exam_user(exam_id,user_id),KEYidx_status_time(status,submit_time),KEYidx_user(user_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT考试记录;CREATETABLEexam_answer(idBIGINTNOTNULL,record_idBIGINTNOTNULL,question_idBIGINTNOTNULL,answer_textTEXTCOMMENT客观题存选项ID主观题存文本,is_correctTINYINTNULLCOMMENT客观题判分结果主观题留空,scoreDECIMAL(6,2)DEFAULT0,PRIMARYKEY(id),UNIQUEKEYuk_record_question(record_id,question_id),KEYidx_question(question_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT学员作答明细;接口一共四十三个题库管理 11 个试卷与组卷 8 个考试过程 9 个阅卷 6 个成绩统计 5 个系统与权限 4 个。接口文档我改了三处。分页参数统一成 pageNum / pageSize所有时间字段统一返回yyyy-MM-dd HH:mm:ss字符串错误码按模块分段不再全甩 500。前两处属于规范最后一处是救命的。后面联调的时候前端能靠错误码直接判断是考试已结束还是重复交卷不用猜。四、第 6-9 天后端生成完了真正花时间的是我改的部分/后端开发这一步跑得最顺毕竟是老本行。它按接口文档生成了 Controller、Service、Mapper、Entity、DTO、VO 和全局异常处理分层清楚命名统一。后端代码量最后在一万一千行左右。但生成完了和能上线之间隔着五个我必须自己动手的地方。事务边界。交卷要写考试记录、写几十条作答明细、算客观题得分。生成的 submit 方法里事务注解是有的但它调自动判分时走的是同类内部调用事务压根没生效。我拆成两个 Service保证注解走代理。幂等。学员手抖点两次、网络重试都会重复提交。我用 uk_record_question 唯一索引兜底前端按钮再置灰双保险。并发。开考瞬间几十人同时加载试卷原实现是查试卷 → 循环查每道题一次考试打十几次查询。我改成按 question_id 批量查一次搞定。时间与时区。部署机默认 UTC存进去的时间全差八小时。我把连接串写死serverTimezoneAsia/Shanghai并把所有时间计算收到服务端前端只拿服务端给的剩余秒数。数据权限。这个是它完全没考虑、也不该指望它考虑的老师只能看自己班级的考试和成绩班主任能看全年级。我在 Service 层加了统一的数据范围拦截按角色拼查询条件。交卷接口最后被我改成这样去掉了日志和参数校验只留主干OverrideTransactional(rollbackForException.class)publicSubmitResultVOsubmitExam(SubmitExamDTOdto){LonguserIdSecurityContext.getCurrentUserId();ExamRecordrecordexamRecordMapper.selectById(dto.getRecordId());// 幂等第一道状态机挡住重复提交if(recordnull||!Objects.equals(record.getUserId(),userId)){thrownewBizException(ErrorCode.RECORD_NOT_FOUND);}if(record.getStatus()!ExamRecordStatus.EXAMING.getCode()){thrownewBizException(ErrorCode.EXAM_ALREADY_SUBMITTED);}// 幂等第二道唯一索引兜底重复作答走覆盖写ListExamAnsweranswersdto.getAnswers().stream().map(a-buildAnswer(record.getId(),a)).collect(Collectors.toList());examAnswerMapper.batchUpsert(answers);// 客观题判分主观题留空等老师阅卷BigDecimalobjectiveScorescoreObjective(answers);booleanhasSubjectivequestionMapper.existsSubjective(record.getPaperId());ExamRecordupdatenewExamRecord();update.setId(record.getId());update.setObjectiveScore(objectiveScore);update.setSubmitTime(LocalDateTime.now());update.setSubmitSource(dto.getSubmitSource());// 需求变更的关键点有主观题就进待阅卷没有才直接发布update.setStatus(hasSubjective?ExamRecordStatus.WAIT_MARKING.getCode():ExamRecordStatus.PUBLISHED.getCode());if(!hasSubjective){update.setTotalScore(objectiveScore);}examRecordMapper.updateById(update);if(hasSubjective){markingTaskService.createTask(record.getId());}returnnewSubmitResultVO(update.getStatus(),hasSubjective?null:objectiveScore);}它给我的原始版本只有算分 改状态。状态拆分、幂等、批量 upsert 这三块是我加的写了一个下午。但这一个下午决定了这系统上线后会不会出错。五、第 10-12 天前端从零到能点卡在一个我踩过两次的坑上轮到我最怵的部分。我执行/前端开发。然后它卡住了提示前端设计文档缺失。原因是我第 4 天跑/前后端设计的时候压根没提前端只生成了后端那一半。这个坑我在上一篇里提过一次这次又原样踩了一遍。我补跑了一轮/前后端设计明确写上同时输出前端页面设计页面结构、组件划分、路由、状态管理才继续下去。之后就顺了。几分钟后 frontend 目录下出现了一套完整的 Vue 3 Vite 工程api、components、router、stores、views 齐全。cdfrontendnpmconfigsetregistry https://registry.npmmirror.com# 官方源太慢先切源npminstallnpmrun dev# 开发调试默认 http://localhost:5173npmrun build# 打包产物在 dist/浏览器打开左侧菜单、顶部导航、题库列表、考试记录列表全在样式也过得去。平心而论这一步省掉的是我最怵的从空白目录开始价值最大。但页面能显示不等于能用。这三天我改了三处倒计时。原实现是前端本地计时刷新页面就重新计时等于学员刷新一下就白赚几分钟。我改成进入考试页时从服务端拿剩余秒数前端只做展示递减每 30 秒跟服务端校准一次到点强制调交卷。答案草稿。培训机构那边网络不稳有学员填了半小时被踢出去。我加了每 20 秒自动保存作答到后端重新进入时回填。这条需求文档里没有是我自己想到加的。菜单权限。生成的路由是全量注册的学员账号也能看见题库管理。我按角色做了路由过滤和按钮级 v-if。六、第 13-14 天联调和部署两天全在修这种小东西联调我原以为会很顺毕竟接口文档是统一生成的。结果还是踩了五个坑全是细节雪花 ID 精度丢失。后端返回 Long 型题目 ID前端 Number 装不下 19 位末尾变成 000。现象是打开试卷偶尔少几道题。最后全局把 Long 序列化成 String 才解决。时间格式。LocalDateTime 默认序列化成数组前端拿到的是[2026,9,1,10,30,0]。统一加了 JsonFormat。跨域。开发环境走 Vite 的 proxy生产走 nginx 反代两边配置不一致部署完第一次访问全是 404。Excel 导入题库被拒。nginx 默认client_max_body_size是 1M模板文件一超就被拦。MySQL 时区。部署机是 UTC本地是东八区成绩统计的时间边界差了八小时。部署我用 docker-compose 一次性拉起来省了不少事version:3services:mysql:image:mysql:8.0environment:MYSQL_ROOT_PASSWORD:${DB_PASSWORD}TZ:Asia/Shanghaicommand:--default-time-zone08:00volumes:-./mysql-data:/var/lib/mysql-./init.sql:/docker-entrypoint-initdb.d/init.sqlports:-3306:3306redis:image:redis:7command:redis-server--appendonly yesports:-6379:6379exam-backend:build:./backendenvironment:SPRING_PROFILES_ACTIVE:prodSPRING_DATASOURCE_URL:jdbc:mysql://mysql:3306/exam?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8SPRING_REDIS_HOST:redisports:-8080:8080depends_on:-mysql-redisexam-frontend:image:nginx:alpinevolumes:-./frontend/dist:/usr/share/nginx/html-./nginx.conf:/etc/nginx/nginx.confports:-80:80depends_on:-exam-backend七、两周结束拿到的是这样一组数字指标数字数据库表14 张后端接口43 个后端代码约 11,000 行生成部分 我改动的部分前端代码约 6,300 行总耗时14 天含两个周末需求变更1 次发生在第 3 天上线后遗留问题4 个全是交互细节没有数据错误时间分布我特意记了一下阶段天数工具帮了多少我的判断需求分析3 天省了约一半主要是文档结构值变更成本被压到最低前后端设计2 天省了初稿但索引和约束全靠我值但必须自己评审后端开发4 天省了 CRUD 骨架约三天值核心逻辑还是我写前端开发3 天省得最多工程搭建几乎没花时间最值心理门槛被打掉了联调部署2 天基本没帮上省不了这就是经验活八、复盘我一开始判断错的三件事第一件我以为最难的是前端结果最难的是需求。前端有工具托底反而最快跑通。真正拖时间的是第 3 天那次变更以及设计阶段反复确认的那些边界。技术从来不是瓶颈到底要做什么才是。第二件我以为生成的代码能直接用。结果事务、并发、幂等、数据权限这四样全得自己来。后来想明白了这些东西跟具体业务强绑定工具不知道我的老师只能看自己班级也不知道我要防重复交卷。它给的是骨架肉得我自己长。第三件我低估了收尾。我原本计划联调部署一天搞定实际两天占了总工时的六分之一强。全是 Long 精度、时区、跨域这种不写在任何文档里的小事。这类坑没有任何工具能替你踩只能踩过一次记住。还有一条自我吐槽/前后端设计那一步我偷懒没写要前端设计文档结果被/前端开发卡住多跑了一轮。同一个坑踩两次这个我没什么好解释的。九、写在最后两周做下来工具确实省了时间但它省掉的只是动手写那部分。需求得你自己问清楚设计得你自己评审事务和并发得你自己想明白上线后的锅得你自己背。说句扎心的它能让你更快地把一个系统跑起来但不会替你承担这个系统是对是错的责任。一个 Java 后端真正值钱的部分从来不是写代码的速度而是判断力——而判断力只能靠一次次翻车攒出来。