WISHBOTTLE / 学习板块

Java 实习完整自学计划

用设备运维工单平台学习 Java、数据库、接口、权限、测试与部署。每周完成一个具体成果,保留考试和复习的弹性。

20 周项目主线每周 15—18 小时14 篇课程与参考资料2027 春季投递安排
下载完整自学包(HTML + Markdown)↗
打开课程目录
按学习周查找

Java 实习完整自学计划

这套资料面向 2028 届软件工程本科生,目标是在 2027 年暑期获得 Java 后端实习机会。适合已接触对象、集合遍历、异常和文件读写的学习者,从自己创建工单对象开始,用一个完整项目学习后端开发。

采用每周目标制,周内自行安排。 每周约 15—18 小时,通常为每天 3 小时、每周 5—6 天;忙碌时顺延。正文、自测题、关键示例、参考答案和排错步骤均可下载后离线阅读,也可以在博客按章节阅读。资料是学习路线和自学辅导手册,不是已经实现或测试完成的项目源码。

阅读顺序

文件 什么时候用
20周弹性日程 每周开始选择目标、核对前置条件
第01至06周 Java与数据库 从当前的 Ticket 类学到 SQL、JDBC
第07至13周 Web与权限 把业务接入接口、数据库、页面与登录权限
第14至20周 测试部署与求职 完成并发、自动测试、部署、排错与项目表达
2027年春季投递与面试 项目初步完成后,持续投递和准备面试
资料索引与排错手册 下载工具、查概念、解决环境和运行问题
验收与记录模板 留下每周完成证据、缺陷记录和投递反馈
数据与接口约定 第4周以后避免表名、字段、状态和角色各写一套
第01至06周答案 先答题、尝试编码,再核对
第07至13周答案 查关键实现思路与 Web 常见错误
第14至20周答案 检查测试、并发、部署和面试回答

可以打开连续阅读版,或下载完整自学包后解压,使用浏览器打开 reader.html。包内 Markdown 可在 IDEA 或文本编辑器中阅读;保留解压后的文件夹结构即可使用内部链接。

项目会做成什么

项目名为设备运维工单平台。使用者报修设备,管理员派给工程师,工程师记录处理过程,报修人验收或退回。最终交付设备管理、工单流转、分页筛选、三类角色权限、评论和操作记录、并发保护、测试、简易页面和部署说明。全程使用虚构数据,不需要实际设备。

普通 Java 对象 → 集合与文件 → 数据库 → HTTP 接口
             → 权限与事务 → 并发测试 → 打包部署 → 求职演示

主要技术是 Java 21、Maven、Spring Boot 4.1 系列、Spring MVC、MyBatis、MySQL、Spring Security 和配套测试。开始时只用 Java;工具按阶段增加。知识库、缓存和 AI 都属于选做,核心没有通过验收时不加新组件。

每周怎样执行

  1. 先看本周“成果”和“验收”,知道要做成什么。
  2. 在这一周内自由安排任务,保留顺序依赖;例如先建表,再写 Mapper。
  3. 一个任务通常包含理解、小步实现和验证,可以拆成几次学习。
  4. 每次只修改一个能观察到结果的小点,及时保存和提交。
  5. 独立回答本周自测题,再看对应参考答案。答案中的局部代码按说明补入自己的项目,不要把分散片段当成整套程序直接运行。
  6. 周末按验收清单检查,写下一周的第一步。

各周任务表列出的小时数已经包含阅读、编码、基础练习和复盘,不要在18小时之外再叠加额外的每日训练量。可参考“2小时项目、45分钟基础或算法、15分钟复盘”的日内比例,但不必每天机械切换。

时间不够时如何调整

一周能投入 执行方式
15—18小时 正常完成本周必做任务;选做根据剩余时间决定
8—12小时 一周内容拆成两周,先做业务核心与验收,算法减量但不完全中断
少于8小时 保留两次短练习、一次复盘;记录停在什么地方,暂停开启新框架

日期只是准备窗口,顺序由前置能力决定。未通过核心验收就顺延,不能把“到下一周了”当成掌握。若连续两周落后,先删去看板美化、缓存、知识库和 AI;保留工单闭环、权限、数据一致性和交付。考试结束先用一次复盘恢复进度,不从头重学全部课程。

没有助手时怎样解决卡点

先花20—30分钟缩小问题:记下输入、预期、实际、第一条报错及根本原因;把相关逻辑缩成一个小方法或单条SQL。然后查本包的排错手册和对应周的答案,再查官方文档的准确类名或错误文字。

如果还是卡住,把任务缩小一级。例如“保存工单失败”先变成“写一行固定文字”,“登录后访问失败”先变成“查看登录请求、Cookie、CSRF和后续请求”。可以向同学、老师或技术社区展示最小复现和已经尝试的方法;这不需要购买服务。

不要为了把错误提示消掉而同时换 JDK、框架、数据库和依赖版本。先固定版本并改变一个因素,再观察结果。没有理解的参考实现记为“参考完成”,第二天关掉答案做一个小变体,能做出变体再记为独立掌握。

第一周从哪里开始

先能解释:private 控制访问,但不会自动检查数据范围;构造器和修改方法都需要执行校验,才能避免创建或修改出非法对象。

创建或打开 ops-ticket 普通 Java 项目,在 src 中创建 Ticket.java 与 TicketDemo.java。先写编号、标题、构造器和两个 getter,再创建两张工单观察数据互不覆盖。具体步骤见第一课:工单对象与封装。

首次学习只完成第一课 A 步即可,不需要先阅读全部20周材料,也不需要当天配好数据库或框架。

本地资料与外部资源

正文是原创教学说明和练习,没有打包第三方整本教程或课程视频。代码依赖、开发工具和外部参考资料仍需按需要下载;第一次构建往往需要网络。所有必做任务都可以在本机或本地虚拟环境完成,不要求云服务器、付费课程、付费模型API或公开部署。

资料编制日期为2026年10月12日。版本与兼容依据在资源页和对应章节标注,真正搭建时使用正式版本并记录版本清单。招聘日期、资格和名额要在投递时以实际公告核对。

设备运维工单平台项目说明

这是为 Java 实习准备设计的原创教学项目。一个园区或实验室需要记录设备故障:使用者报修,管理员分派工程师,工程师填写处理结果,报修人验收,负责人查询处理记录与统计。设备数据采用模拟记录,不需要硬件开发经验或真实设备接入。

项目的完整性来自可用的业务流程、权限、数据一致性、测试和部署。前期从少量普通 Java 类开始,最终完成可演示的 Web 应用;每一版延续同一套业务规则。

为什么选择这个项目

Java 实习常见能力可以映射为具体项目练习:需求与设计对应角色和业务规则,编码与缺陷修复对应完整工单闭环,数据库能力对应关联查询与聚合,工程交付对应测试、打包和部署。故障知识库可在主线完成后作为选做扩展。

教学实现由我们逐步设计,不把现成项目更名后当作独立作品。联网参考采用 Spring REST 官方示例 的最小接口学习方式;Spring Petclinic 留作后期阅读参考,用于观察一个完整 Spring 示例的组织方式。它们不替代本项目的业务设计和自己写代码的过程。

角色和完整业务

角色 能做什么 关键边界
报修人 REPORTER 查看设备;提交、查看自己的工单;评论;验收或退回处理结果 不能查看或修改别人的工单;不能自行指定处理人
工程师 ENGINEER 查看分配给自己的工单;评论;填写处理结果 不能处理未分配给自己的工单,也不能替报修人验收
管理员 ADMIN 维护设备;查看全量工单;给待派单工单分配工程师;查看统计 第一版不代替报修人验收,不支持任意修改状态

三个角色采用固定枚举和明确规则。第一版不提供公开注册、动态权限配置或复杂组织关系;演示账号通过初始化数据生成。进入数据库阶段再加入用户和角色,不在第一课一次写完。

工单状态约定为:

OPEN 待派单 → IN_PROGRESS 处理中 → RESOLVED 待验收 → CLOSED 已关闭
                       ↑                  │
                       └──── 验收退回 ────┘

管理员只能对 OPEN 工单派单;分配成功后进入 IN_PROGRESS。工程师提交非空处理说明后进入 RESOLVED。报修人可以确认关闭,或填写退回原因返回 IN_PROGRESS。CLOSED 在第一版中不再变更。评论不会自动改变状态。

状态迁移通过 assign、resolve、accept、reject 等业务方法表达,不提供任意设置状态的公共接口。关键操作写入操作记录,并与工单更新处于同一个数据库事务。

三个交付版本

版本 必须完成 能展示的能力
v0.1 Java 练习版 工单对象、创建与查询、状态筛选、简单存档、按职责拆类 面向对象、集合、异常、IO、接口;确认能自己写
v0.5 Web 开发版 MySQL 持久化、Spring MVC 接口、MyBatis、完整流转、分页筛选、评论与操作记录 HTTP、SQL、分层、事务、接口联调;仅在本地学习环境运行
v1.0 可投递版 登录与三角色权限、并发派单保护、核心测试、简单页面、运行与部署说明、演示数据 一套能运行、能解释、能独立修改和交付的 Java 后端项目

简单页面包括登录、工单列表与创建、工单详情与操作、设备管理四类页面。页面可以提供骨架,学习重点是请求参数、接口结果、权限和错误处理。图表美化不作为完成主线的前提。

技术选择

用途 计划采用 选择理由与实施边界
Java Java 21 统一学习和运行环境;先学对象与业务逻辑,再按需要认识较新语法
构建 Maven 与 Maven Wrapper 让依赖与构建命令可复现;在第 3 周学习包结构和构建
Web Spring Boot 4.1.x、Spring MVC 使用注解、JSON 和可执行应用;按正式版本的文档教学
数据访问 MyBatis Spring Boot Starter 4.0.x 保留手写 SQL 的训练,先学 JDBC 再理解框架省去了什么
数据库 MySQL;独立部署环境计划 8.4 系列 现有 8.0 环境可先做 SQL 练习;项目数据库单独建立,不迁移其他数据
登录与权限 Spring Security,Session Cookie 先做同源应用的认证授权;保留 CSRF 防护,使用框架密码编码器
测试 JUnit Jupiter 与 Spring 配套测试组件 具体依赖跟随所选 Boot 版本,验证业务行为与数据库一致性
交付 Git、日志、Linux、Docker Compose 形成可复现的开发和部署过程,容器化放在业务完成之后

2026 年 10 月 12 日访问的 Spring Boot 系统要求 页面显示 4.1.1,要求 Java 至少 17;Java 21 在该兼容范围内。MyBatis 官方兼容表 将 Starter 4.0 对应到 Spring Boot 4.0 及以上。正式开建 Web 版时重新核对并固定补丁版本,不选 milestone 或 snapshot,也不把旧 Boot 2/3 教程的依赖直接混入。

开始前分别检查终端 JDK、IDEA 项目 SDK 和 Maven 使用的 Java。第3周统一为 Java 21;数据库阶段用 SELECT VERSION() 核对实际服务端版本。命令不在 PATH 中时先查安装位置,不据此断定工具未安装。

数据和接口设计

开始时只有 Ticket 的编号和标题,后续随课程逐步扩充。Web 版预计包含以下六张主要表:

表 主要内容
app_user 用户编号、唯一用户名、密码哈希、固定角色、启用状态
device 设备编号、唯一设备编码、名称、位置、是否启用
ticket 工单编号、标题、描述、设备、报修人、处理人、状态、版本号、创建/更新时间
ticket_comment 工单、评论作者、内容、时间
ticket_action_log 工单、操作者、动作、前后状态、说明、时间
ticket_resolution 工单、工程师、每次处理说明、时间;退回后可再次提交

保留设备停用状态,不直接删除被历史工单引用的设备。用户停用也保留历史记录。展示时间与数据库时间的转换在接口设计时明确约定,列表使用固定排序并加编号作为同时间下的排序依据。

核心接口草案:

请求 功能
POST /api/tickets 创建自己的报修单
GET /api/tickets 在当前用户可见范围内分页、筛选
GET /api/tickets/{id} 查看有权限访问的工单详情
POST /api/tickets/{id}/assign 管理员派单,携带期望版本与工程师编号
POST /api/tickets/{id}/resolve 当前处理工程师提交处理说明
POST /api/tickets/{id}/accept 报修人验收关闭
POST /api/tickets/{id}/reject 报修人说明原因并退回
POST /api/tickets/{id}/comments 有访问权限的用户发表评论

身份来自登录会话,不接受请求体中伪造的报修人或操作人编号。分页限制单页大小;不存在、参数错误、未登录、无权操作、版本冲突分别返回清楚的结果。设备、用户与登录接口在相应课程补齐,不在第一课背整张接口表。

最重要的工程练习

避免重复派单。 两名管理员同时看到 OPEN 工单时,不能后一次静默覆盖前一次。用包含编号、OPEN 状态和期望版本的数据库条件更新,并检查受影响行数;成功时版本递增并写日志,失败时返回冲突。状态更新和日志写入共用事务。用两个从相同初始版本发起的请求验证结果。这个问题不需要先引入分布式锁。

测试业务边界。 核心用例包括有效创建、空标题、无效设备、越权查询、错误处理人、非法状态迁移、重复验收、并发派单以及日志写入失败后的事务回滚。单元测试验证规则,接口测试验证请求行为,真实 MySQL 集成测试验证数据库约束与事务;不能只靠模拟对象证明事务和锁正确。

定位和修复问题。 至少保留一个真实开发错误的复现步骤、预期与实际结果、原因、修复和回归测试。日志应能定位请求与业务异常;数据库账号和密钥不进入源码。

交付给另一个使用者。 README 应包含版本、启动命令、配置示例、初始化数据、测试、演示流程和已知限制。演示使用虚构账号与设备;部署练习包括启动、停止、查看日志、健康检查、备份与恢复。

扩展只选有用的部分

扩展 加入条件 可验证的结果
SQL 统计看板 核心接口与权限完成 按设备/状态统计,处理时长口径清楚,关联和聚合结果可核对
Redis 缓存 能解释查询成本与一致性需求 只缓存一个统计查询,验证过期、失效和缓存不可用时的处理
有界线程池任务 已理解线程、共享状态和异常 用小型任务演示并行、失败和队列容量,解释后台任务与同步请求的差别
故障知识库 v1.0 验收通过 先做文章维护和关键词检索,再考虑模型辅助
AI 故障建议 知识库可用且不挤占投递 只根据有权限读取的资料给建议与出处;无依据或调用失败时明确提示,不自动执行派单和验收

AI 阶段如接入外部模型,会在选择服务时确认费用与数据范围;用模拟资料开发。用一组预先整理的故障问题检查命中、无答案和超时处理,再决定是否需要 RAG。它是求职项目的可选扩展,不等同于算法训练经验。

第一版不加入微服务拆分、Kubernetes、消息队列、复杂工作流、真实硬件通信或支付。若后续目标岗位明确要求某项,再通过有业务理由的小实验补充。

完成标准

  • 三种账号能演示报修、派单、处理、验收及退回,权限检查在服务端生效。
  • 工单重启后不丢失,操作记录完整,并发派单不会互相覆盖。
  • 核心异常场景有验证,自动测试可重复运行。
  • 能按说明在另一环境启动、查看日志并恢复测试数据库备份。
  • 能独立修改一个业务规则,解释 SQL、事务、接口、权限和一次排错过程。
  • 简历只描述实际实现和实际测得的结果,不编造用户规模、吞吐量或企业上线经历。

20 周弹性学习日程

主线预计从2026年10月12日至2027年2月28日。每周完成一个可展示成果,通常投入15—18小时;每周6天、每天3小时对应最多约360小时。下面是计划日期,不是每天必须打卡的期限。延后时按顺序移动任务,不通过压缩基础训练追赶日期。

全部周目标

周 参考日期 本周唯一主要成果 核心验收 课程
01 2026-10-12至10-18 有效的工单对象 两对象独立;非法标题/编号被拒绝;修改失败保持原值 Java与数据库第1周
02 10-19至10-25 可筛选、可存档的小工单列表 重启读回;坏记录不崩溃;重复编号有明确处理 Java与数据库第2周
03 10-26至11-01 按职责拆分的控制台版 同一业务服务能替换存储;Java21与Maven基本构建一致 Java与数据库第3周
04 11-02至11-08 三张核心表和演示数据 主外键有效;能手写CRUD和说明约束 Java与数据库第4周
05 11-09至11-15 可核对的查询与事务实验 JOIN/聚合/分页正确;观察提交与回滚 Java与数据库第5周
06 11-16至11-22 JDBC创建和查询工单 参数绑定、资源关闭、重启仍有数据;理解HTTP请求 Java与数据库第6周
07 11-23至11-29 第一个工单HTTP接口 创建和查询返回正确JSON及状态码 Web与权限第7周
08 11-30至12-06 接入MyBatis的持久层 从请求讲到SQL;启动重启可查询;生成主键映射正确 Web与权限第8周
09 12-07至12-13 完整状态流转和操作记录 合法流转成功,非法跳转失败;业务与日志同事务 Web与权限第9周
10 12-14至12-20 分页筛选、评论与错误处理 无效参数被拒绝;结果稳定分页;服务端日志可定位 Web与权限第10周
11 12-21至12-27 能走通业务的简单页面 页面完成创建到关闭;能从浏览器定位接口错误 Web与权限第11周
12 12-28至2027-01-03 登录和退出 框架Session与密码编码生效;CSRF正确处理 Web与权限第12周
13 2027-01-04至01-10 角色和数据访问边界 用户不能看别人单;工程师不能处理别人任务 Web与权限第13周
14 01-11至01-17 并发派单保护 两请求争同一版本只有一个成功,不互相覆盖 测试部署与求职第14周
15 01-18至01-24 核心测试与缺陷复盘 真实库验证事务和并发;权限/状态/回滚测试可重复 测试部署与求职第15周
16 01-25至01-31 可演示的v1.0 按说明打包运行;5分钟演示;能讲清本人代码 测试部署与求职第16周
17 02-01至02-07 可复现部署和备份恢复 新环境或新配置能启动;测试备份可恢复 测试部署与求职第17周
18 02-08至02-14 一次有证据的排错或优化 同数据同条件比较;线程池实验有结束和失败处理 测试部署与求职第18周
19 02-15至02-21 补齐最弱一项 优先修核心问题;有余力才选一个小扩展 测试部署与求职第19周
20 02-22至02-28 可投递作品与面试表达 一份简历、一次独立小改、两轮模拟面试 测试部署与求职第20周

详细任务见前6周、第7至13周、第14至20周。各周任务按列出顺序有前后依赖,但由你决定安排在哪一天。

每周基础与算法怎么穿插

这些内容使用本周的基础练习时间,不另加一套课。通常每周做2—3个小练习,并复做一个旧题;理解仍不稳时少做题、多解释。

周次 基础主题 小练习 自己判断是否理解
1—2 对象、引用、数组、循环 找最大值与并列最大值;统计字符出现次数 能列出空输入、重复值、边界;解释循环状态
3—4 哈希表、集合去重、基本复杂度 按编号查找;寻找和为目标值的两个数 能比较遍历和Map查找的代价,知道不能重复使用同一元素
5—6 字符串、双指针、二分查找 忽略空格判断回文;有序数组定位 能解释边界推进和终止,不死循环
7—8 链表、栈与队列、HTTP 反转链表;括号匹配;画请求响应 能手推指针变化,并说清URL/方法/状态码
9—10 树遍历、事务与索引 二叉树层序遍历;手工模拟事务失败 知道队列的作用,理解原子性与约束
11—12 浏览器请求、Cookie、Session 用网络面板比较登录前后请求 能说明身份凭证如何发送,区分认证和授权
13—14 线程与进程、共享状态 计数器竞争;两个请求同时抢工单 能说明Java锁和数据库条件更新保护的范围
15—16 回归测试、异常、调用链 给真实缺陷写最小复现与回归测试 能定位失败层,不把所有错误都catch后返回成功
17—18 Linux进程/端口、JVM内存、线程池 找服务日志;解释线程阻塞与CPU计算 能提出基于现象的排查顺序,不只背术语
19—20 综合复习与面试 限时重做典型题;口述一个功能的完整链路 能在不看旧答案时解释思路并写出关键部分

如果二叉树或链表本周明显超时,将练习移到复习时间,不影响已经能独立完成的业务任务。复杂动态规划、图算法和JVM调优放在基础稳定之后,具体以目标岗位笔试需求决定。

五个阶段检查点

检查点 要保存的证据 未通过时怎么处理
第3周 Java核心 正常/边界运行输出、一次独立筛选功能、文件读写说明 缩回一个类、一个集合或一行文件,重做对应练习
第6周 SQL与JDBC 建表SQL、查询结果、回滚实验、参数化查询代码 暂缓框架,先在数据库客户端验证单条SQL
第11周 Web闭环 请求样例、页面演示、接口错误记录 一次只排查一层,先HTTP客户端成功再接页面
第16周 可投递MVP 三角色演示、并发与回滚证据、测试命令、README 保留核心补缺,暂停新增缓存/AI和美化
第20周 求职版本 干净环境启动记录、项目讲解、真实简历、模拟面试反馈 依据实际薄弱项复习,不机械重做全20周

每个检查点都用“能运行、能解释、能独立小改”三个维度看。能运行但解释不清,记为参考完成;解释清楚但不能写,增加短小独立练习。核心缺陷未修不能算验收通过。

从现在到暑期的投递节奏

  • 2026年12月:整理简历草稿、记录项目成果,开始关注接受2028届的渠道和岗位条件。
  • 2027年1—2月:达到MVP后即可匹配投递,不以可选扩展完成作为前提。
  • 2027年3—5月:每周核对岗位、投递、复盘面试问题和修正最薄弱项。
  • 2027年6—7月:落实到岗时间、出勤安排、手续和交接;尚未录用时继续匹配合适岗位。

这只是个人行动计划,不是企业统一招聘日历。春季投递安排提供每周行动与无反馈时的调整方式。

第 01 课 工单对象与封装

这节课开始做设备运维工单平台,为你以 2028 届学生身份申请 2027 年暑期 Java 实习积累项目能力。我们先把一张工单变成 Java 对象,再逐步加入业务规则。

全课预计60—90分钟,可以分两次完成。首次先完成起点问题和 A 步,再继续 B、C;先自己尝试,卡住时记录具体疑问。

先确认三个概念

用约10分钟回答,不用背术语。暂时答不清可以带着问题完成本课。

  1. 把成绩字段设为 private 后,是否就能自动阻止构造器保存 101?为什么?
  2. 构造器与修改成绩的方法,为什么都需要检查 0—100 的范围?
  3. 创建两个不同对象时,普通实例字段为什么可以保存不同的值?如果把字段改成 static,情况会怎样?

自查提示:private 限制从哪里访问,数据合法性依靠校验逻辑;每个对象拥有自己的实例字段,而静态字段属于类。能口述后,还要通过下面的代码实验验证。

工单表示什么

例如“教室投影仪无法开机”是一条报修。以后它会经历报修、派单、处理、验收。今天的 Ticket 只保存两个信息:id 是工单编号,title 是问题标题。

本课最终规则是:编号必须大于 0;标题不能是 null,去掉两端空格后不能为空,长度不能超过 50;编号创建后不提供修改入口,标题可以通过 rename 修改。

在自己选择的工作目录创建或打开 ops-ticket 普通 Java 项目,在其中创建 src\Ticket.java 和 src\TicketDemo.java:前者描述工单,后者放 main 方法运行小例子。先用普通 Java 项目,不需要 Spring 或 Maven。

本课语法兼容 Java 8 和 21。第3周学习 Maven 时统一使用 Java 21,并核对项目 SDK 与终端实际使用的 JDK。

A 步 创建对象并读取信息

预计 25–35 分钟。先实现正常输入的流程,边界校验放到 B 步。

请在 Ticket 中完成:

  • 两个私有字段,类型分别为 long 和 String。
  • 构造器 Ticket(long id, String title),把参数保存到当前对象。
  • getId() 和 getTitle(),读取当前对象的信息。
  • 不提供 setId()。

在 TicketDemo 中创建两张不同工单,并通过 getter 打印它们。

创建时的输入 读取时的预期结果
1001, "教室投影仪无法开机" 编号为 1001,标题为 教室投影仪无法开机
1002, "实验室电脑无法联网" 编号为 1002,标题为 实验室电脑无法联网;第一张工单信息不变

局部提示:字段使用 private,getter 使用 public 并返回对应字段。当参数名与字段名相同时,想一想 this 指向什么。构造器名称与类名一致,没有返回类型。

验收时请解释:为什么创建两张工单后,第一张的标题不会被第二张覆盖?为什么读取标题的方法不应该使用 static?

B 步 保证创建出来的工单有效

预计 15–20 分钟,A 步通过后再做。加入编号和标题校验,不合法时抛出 IllegalArgumentException;合法标题保存去掉两端空格后的值。

输入 预期结果
编号 0 或 -1,标题正常 拒绝创建
编号 1,标题为 null、"" 或 " " 拒绝创建
编号 1,标题为 " 无法联网 " 保存的标题为 无法联网
编号 1,标题为连续 50 个英文字母 创建成功
编号 1,标题为连续 51 个英文字母 拒绝创建

局部提示:先判断是否为 null,再考虑调用 trim()、isEmpty()、length()。当条件不满足时,用 throw new IllegalArgumentException("说明原因") 拒绝非法参数。

本课长度按 String.length() 计算;它不等同于所有 Unicode 字符的实际个数,表情等情况留待后续。测试边界时用英文字母即可。非法输入可先逐次运行验证;未捕获异常会结束本次程序,这是需要观察的结果。

C 步 修改标题时遵守同一套规则

预计 10–15 分钟。增加 rename(String newTitle),让它与构造器复用标题校验,避免两处规则逐渐不同。

操作 预期结果
将标题改为 " 投影仪无信号 " 新标题为 投影仪无信号,编号保持不变
将标题改为空白或超过 50 个英文字母 拒绝修改,原编号和标题不变

局部提示:可以把“检查标题并得到处理后的标题”提取成一个私有方法。先得到合法结果,再修改字段。思考如果先改字段再检查,失败时对象会处于什么状态。

保存哪些学习证据

首次保存三个起点问题的回答、A 步的两个 Java 文件和运行输出,并用一句话写出最不确定的地方。B、C 可以在下一次学习时完成。

完成本课后,按第1周任务与验收检查边界,再核对第1周参考答案。笔记区分“独立完成”“参考提示完成”“仍有疑问”;能解释并独立小改之后,再记录已掌握。

第 1 至 6 周 Java 与数据库

这六周把你已经接触过的对象、异常和文件读写,变成自己能够修改的工单程序,再把数据放入 MySQL。每周以一个能演示的成果为目标。表中的六个任务各约 3 小时,包含阅读、编码、排错和复盘,总计约 18 小时;可以自行选日期,考试周拆成两周。提前完成就复写和查漏,不必把时间填满。

先独立做任务,再按输入和输出验收。一个问题卡住 30 分钟时,先读报错第一行和自己代码对应行,缩成小例子;仍不明白再看本阶段参考答案。答案提供局部示例,不要求依赖聊天才能继续。每周三道自测先口述或写下自己的答案。

创建或打开 ops-ticket 项目,在其中写代码。第 1 周只需 src/Ticket.java 和 src/TicketDemo.java;第 3 周再调整 Maven 目录。各周练习逐步扩大范围,不要提前写完整六张表或全部接口。项目字段总约定见数据与接口约定。

第 1 周 创建一个始终合法的工单对象

日期窗口:2026 年 10 月 12 日至 18 日。前置:会写 main、变量、if 和方法调用;能说明 private 与成绩范围检查的区别。成果:运行 TicketDemo,创建两张工单、读取标题、修改标题,并证明非法输入不会破坏原对象。

任务 约用时 做到什么程度
A 写对象 3 小时 写 private long id、private String title、构造器和 getter;在 Demo 中创建编号 1、2 的对象,逐个打印
B 加校验 3 小时 编号必须大于 0;标题先判断 null,再 trim(),处理后长度为 1 至 50。先校验再赋值
C 修改标题 3 小时 加 rename(String newTitle);构造与修改共用一个私有校验方法;不提供 setId
D 观察执行过程 3 小时 在构造器、校验方法和 rename 各设断点;观察 this、形参、字段;尝试让两个变量指向同一对象
E 基础与保存 3 小时 用循环找整数数组最大值,验证空数组如何处理;在项目目录学习 git status、git diff、一次明确文件的提交
F 独立验收 3 小时 合上答案重写标题校验;运行下面用例,记录一个错误及修复;回答三道自测

对象是某张工单,类是描述它有哪些数据和操作的代码。this.title 是当前对象的标题,参数 title 是调用者刚传进来的值。两者同名不代表它们自动关联。private 把直接修改入口收拢到类内部;入口中的校验才负责阻止非法数据。异常会中断当前正常执行路径;捕获后才能继续执行后面的演示用例。

标题长度本阶段按 Java String.length() 计算,验收先用普通汉字与英文字母;这不是“所有 Unicode 字符都占一个位置”的承诺。trim() 的规则也不要自行换成其他空白处理方式。先保持项目规则统一。

输入或操作 预期结果
new Ticket(1L, " 投影仪无法开机 ") 编号 1,标题为 投影仪无法开机,两端空格已去掉
标题为 null、""、" " 或 51 个 A 抛 IllegalArgumentException,该次创建不成功
标题为 1 个或 50 个 A 创建成功
编号为 0 或 -1 抛 IllegalArgumentException
合法工单调用 rename(" ") 抛异常,原来的标题仍在
创建两张工单后只修改第一张 第二张标题不变

自测:①为什么构造器也要校验,不能只在 rename 校验?②Ticket b = a; b.rename("新标题"); 后 a 的标题是什么,为什么?③为什么不能把 id、title 写成 static?

卡住提示:构造器与类同名,没有返回类型;读取方法需要 return。如果 title = title 没有效果,比较参数与字段的含义。先实现普通输入,再一次加一条边界。阅读只需 Dev.java 的面向对象与异常入口,不必看完全部 Java 教程。

第 2 周 管理多张工单并保存到文件

日期窗口:10 月 19 日至 25 日。前置:第 1 周用例全部通过;会追踪一次异常。成果:控制台可以列出工单、按状态筛选;退出后重新启动,编号、标题、状态和处理人仍能恢复。

任务 约用时 做到什么程度
A 集合操作 3 小时 用 List<Ticket> 保存 3 张工单;循环按编号查找,查不到给明确提示;用 Map<Long, Ticket> 再做一次并比较
B 状态与派单 3 小时 定义四种 TicketStatus;新增工单为 OPEN;加可为空的 Long assigneeId,仅实现 assign(long engineerId):正数编号、只允许 OPEN,成功转 IN_PROGRESS
C 筛选与去重 3 小时 用普通循环筛选状态并生成新列表;用 Set<Long> 检查重复编号;写一个统计数组数字出现次数的小练习
D 写文件 3 小时 用 Properties 把字段变成字符串,再用 UTF-8 Writer 保存;明确文件的绝对位置;保存前先保留上一份练习文件
E 读文件 3 小时 读回字符串、解析编号与枚举、恢复对象;缺文件视为首次启动,坏数据报清字段且停止加载,不能默默当空数据覆盖旧文件
F 重启验收 3 小时 保存后结束进程,再启动;新增编号使用现有最大编号加 1;验证重复编号、坏状态、中文与特殊字符,独立说明转换过程

List 适合按顺序遍历;Map 用编号直接定位工单;Set 记录“某编号是否出现过”。集合里存的是对象引用,并不会自动把数据写入硬盘。枚举将可选状态限制为 OPEN、IN_PROGRESS、RESOLVED、CLOSED;本周只实现前两种之间的派单,处理与验收留到第 9 周。

文件使用现成的 Properties 格式,避免自己设计逗号和换行的转义。约定键为 count、ticket.0.id、ticket.0.title、ticket.0.status、ticket.0.assigneeId,后续记录按下标递增。编号等数字用十进制字符串;未派单的处理人保存为空字符串。这个下标只是文件位置,不是工单编号。库会处理标题里的 =、反斜杠和换行;不要自己按 = 切割文件。Java Properties 文档

读取旧数据需要单独的 restore 工厂方法或仅供存储层使用的构造器;仍校验编号、标题、状态,以及 OPEN 没有处理人、其他状态有正数处理人。不要为了恢复文件开放任意 setStatus。try-with-resources 在正常或异常退出时关闭 Reader/Writer;“关闭”与“捕获异常后假装成功”是两件事。

验收:1、2、3 三张中把 2 派给工程师 20,筛选 OPEN 得到 1、3;重启后仍是同样结果,新工单编号为 4。把文件中的一个状态改成 DONE,启动应指出对应记录不合法并保留原文件;修回去后可加载。标题 A=B\\C 保存读取后完全一致。重复派单抛异常,处理人不改变。

自测:①Map.put 遇到已有编号会怎样,创建工单该怎么处理?②为什么重新启动后不能让编号从 1 开始?③程序打印“保存成功”但重启找不到文件,先查哪两件事?

卡住提示:先只保存、读取一张工单的标题,再加编号、状态和循环。先让内存程序跑通再接文件;本周不实现文件并发写入或自定义二进制序列化。

第 3 周 拆分职责并用 Maven 构建

日期窗口:10 月 26 日至 11 月 1 日。前置:能独立解释第 2 周的对象到文件再回到对象。成果:控制台 v0.1 有固定启动步骤;切换内存与文件存储时,业务校验不需要复制两份。

任务 约用时 做到什么程度
A 统一环境 3 小时 比较 IDEA Project SDK、java -version、javac -version、mvn -v;统一为 Java 21,记录实际路径;首次依赖下载保留网络时间
B 整理 Maven 3 小时 学 pom.xml;把类移到 src/main/java/com/example/opsticket,加对应 package;用 Maven 编译和打包
C 提取接口 3 小时 定义 TicketRepository 的 add、findById、findAll、update;先写内存实现,定义不存在与重复编号的行为
D 拆分业务与交互 3 小时 ConsoleApp 负责输入输出;TicketService 负责用例;构造器接收 Repository;让文件实现遵守同一接口
E 独立小改 3 小时 增加“标题包含关键词”的筛选;用普通循环完成后再读一个 Lambda 示例;练两数之和,先用双循环再用 Map
F 交付与复盘 3 小时 运行同一组用例验证两种存储;记录启动命令、数据文件位置、已知限制;提交 v0.1 代码与说明

接口是调用方与实现方的约定。例如业务只需要“按编号找工单”,无需知道它来自 Map 还是文件。构造器传入依赖,就是创建 Service 时把它需要的 Repository 交给它。这会让第 8 周更换数据库实现时保留业务规则,不是为了多建几个空类。

接口可以采用下面的签名,Optional.empty() 表示不存在。add 遇到同编号应报错;update 找不到原编号也应报错。返回新 List 防止调用方直接增删仓库的集合;其中的 Ticket 仍可能是同一对象,修改后必须显式调用 update,不要依赖内存引用恰好同步的效果。

public interface TicketRepository {
    void add(Ticket ticket);
    Optional<Ticket> findById(long id);
    List<Ticket> findAll();
    void update(Ticket ticket);
}

验收:把 new InMemoryTicketRepository() 换成 new FileTicketRepository(path),创建、查询、改标题和派单结果一致;文件版每次成功修改后保存,重启数据保留。保存失败必须报告失败,不能打印成功。输入不存在的编号 999 显示“工单不存在”;关键词“投影”只返回包含该文字的工单;用 Maven 构建出现 BUILD SUCCESS,打包后从命令行运行同一入口。

Maven 负责按配置取依赖、编译、测试与打包;它不会自动替你写业务。首次联网准备好 Maven、插件和依赖,之后无新增依赖时可尝试离线构建。maven.compiler.release 指定目标 Java 版本;不要只改 IDEA 的字体旁语言级别就认为终端同步了。参考 Maven 入门与目录结构及编译目标版本。

自测:①为什么业务类不应在自己的方法里反复 new FileTicketRepository(...)?②将存储实现换掉后,哪些类应变化,哪些规则应保留?③IDEA 能跑但 Maven 报 release version 21 not supported,你要检查什么?

卡住提示:先保留 TicketDemo 直接调用 Service,不必做复杂菜单。环境不通时先写接口和内存实现;正式进入 Web 前必须解决构建问题。

第 4 周 建立用户 设备与工单三张表

日期窗口:11 月 2 日至 8 日。前置:会区分内存与持久化,能读懂基本方法调用。成果:拥有自己的 SQL 建表文件与样本数据文件,能手写增删改查并观察数据库约束。

任务 约用时 做到什么程度
A 连接与隔离 3 小时 确认 MySQL 服务、账号、端口;查询 SELECT VERSION(), DATABASE();;建立练习库 ops_ticket_dev,使用 utf8mb4
B 表和关系 3 小时 画出“一名用户报多单、一台设备有多单、一单最多一名处理人”;按统一字段建立三张 InnoDB 表
C 数据与约束 3 小时 插入下方样本;验证主键、唯一用户名、唯一设备编码、外键、非空约束;理解 NULL 与 0 的区别
D 基础查询 3 小时 手写查所有、按状态查、按报修人查、按标题关键词查;每个查询先预测结果,再运行核对
E 修改与删除 3 小时 用 WHERE id = ... 改设备位置;练习设备停用;只对自己新增且未被引用的练习行做删除,先 SELECT 确认目标
F 独立验收 3 小时 不看答案写一条 INSERT、两条 SELECT、一条 UPDATE;比较 Java 校验和数据库约束职责;解释所有外键

表是一类数据,行是一条记录,列是字段。主键 id 用于稳定识别记录;名字可以变化,所以不能靠“投影仪”唯一识别设备。外键要求工单引用的用户和设备存在,但它不自动判断用户是不是工程师,也不判断设备是否启用,这些要在业务层检查。数据库约束是额外保障,不替代 Java 的清晰错误提示。

字段固定为 app_user(id, username, password_hash, role, enabled),device(id, code, name, location, enabled),ticket(id, title, description, device_id, reporter_id, assignee_id, status, version, created_at, updated_at)。assignee_id 初始为 SQL NULL,版本号初始 0;创建时间、更新时间用 DATETIME(6),本地学习统一按 Asia/Shanghai 解释,部署时再统一应用与数据库时间设置。完整类型和关键 DDL 在参考答案中。

样本 明确内容
用户 1 reporter_a 为 REPORTER;2 engineer_a 为 ENGINEER;3 admin_a 为 ADMIN;4 reporter_b 为 REPORTER,均启用
设备 1 PJ-001 投影仪;2 AC-001 空调;3 PR-001 打印机,均启用
工单 101 投影仪无法开机,设备 1,报修人 1,OPEN,无处理人
工单 102 空调不制冷,设备 2,报修人 1,IN_PROGRESS,处理人 2
工单 103 投影画面闪烁,设备 1,报修人 4,OPEN,无处理人

密码列只放 NOT_A_LOGIN_HASH 这一显式占位值;本阶段没有登录功能。第 12 周必须通过 PasswordEncoder 生成真正的密码哈希,不能把占位值或明文当作可登录数据。

验收:查 OPEN 返回 101、103;查报修人 1 返回 101、102;查未派单用 IS NULL 返回 101、103。插入重复 PJ-001 失败;引用设备 999 的工单失败;把设备 1 停用后,其历史工单仍可查询。保存自己写的 SQL,不依赖图形工具自动生成全部语句。

自测:①assignee_id = NULL 为什么不是正确查询?②设备名称修改后,为什么不应逐条修改工单里的设备编号?③外键存在,能否保证派给用户 1 的工单合理?

卡住提示:先只建 device 表练 INSERT/SELECT,再建其余表。连不上时区分服务未启动、端口错和账号错;不要重装所有软件。参阅 MySQL 建表,已有 8.0 环境可先练本阶段通用 SQL。

第 5 周 关联 统计 事务与索引

日期窗口:11 月 9 日至 15 日。前置:第 4 周三张表与样本可重复使用。成果:一份工单列表查询、一份设备工单统计,以及提交与回滚的可解释记录。

任务 约用时 做到什么程度
A 关联列表 3 小时 JOIN 设备和报修人;对可能为空的处理人使用 LEFT JOIN;给两个用户关联起不同别名
B 分组统计 3 小时 按状态计数;按设备统计包含零工单的设备;区分 COUNT(*) 和 COUNT(t.id)
C 稳定分页 3 小时 按 created_at DESC, id DESC 排序;每页 2 条,写第一页和第二页;用纸上样本解释同一时间需要 id 兜底
D 事务实验 3 小时 在同一连接执行 START TRANSACTION、UPDATE、ROLLBACK;再实验 COMMIT;记录提交前后另一个连接的可见结果
E 索引实验 3 小时 对“报修人筛选并按时间排序”读 EXPLAIN;建立相应联合索引后比较计划,不用三条数据宣称性能提升
F 复写与验收 3 小时 独立写 LEFT JOIN 统计;补一个字符串或哈希表算法小题;写事务失败时该怎么做,回答自测

JOIN 根据编号把行拼成结果;LEFT JOIN 会保留左侧没有匹配项的行。因此未派单的工单也应出现在列表中。GROUP BY 将同一组放在一起计算。对“所有设备及其工单数”,没有工单的设备仍占结果的一行,应该统计右表非空工单编号,而不是简单数结果行。MySQL JOIN 语法

事务把几个修改当作一个工作单元。只有提交后才确认这些修改;主动回滚会撤销尚未提交的修改。本周用设备位置更新做小实验,下阶段才把工单状态与操作日志一起提交。SQL 报错后不要假设整个事务自动回滚;练习时明确执行 ROLLBACK。建表、改表等 DDL 不拿来演示普通事务回滚。MySQL 提交与回滚

索引像按某种顺序整理的查找入口,会占空间并增加写入维护成本。联合索引 (reporter_id, created_at, id) 对本周查询有合理的使用场景,但优化器仍按成本选计划。三条样本走全表扫描并不等于索引错误。重点是解释 possible_keys、key、rows、Extra,不要编造百分比。

验收:关联列表仍有 3 行,101 和 103 的处理人显示为空或“未派单”;状态统计为 OPEN 2、IN_PROGRESS 1;设备统计为投影仪 2、空调 1、打印机 0。样本时间依次为 09:00、10:00、11:00,第一页编号 103、102,第二页 101。设备 1 位置改为“临时位置”后回滚,恢复原值;改为“新教室”并提交,再连接仍是新值。

自测:①处理人使用 INNER JOIN 为什么可能丢工单?②统计打印机工单数为什么不能直接用 COUNT(*)?③事务中第一条更新成功、第二条失败,为什么仍要显式回滚?

卡住提示:先只关联 ticket 与 device;结果正确再加两个用户别名。事务每次只做一个实验,记录原值,结束时确保 COMMIT 或 ROLLBACK,避免连接一直持有未提交修改。

第 6 周 用 JDBC 连接 Java 与数据库

日期窗口:11 月 16 日至 22 日。前置:能手写插入、关联查询,知道事务提交与回滚。成果:Java 程序创建工单后退出,重新运行仍能查到;能画出浏览器发 HTTP 请求到服务返回 JSON 的过程。

任务 约用时 做到什么程度
A 接入驱动 3 小时 Maven 添加 Connector/J;从环境变量读 URL、用户、密码;用 JDBC 执行 SELECT 1,打印结果 1
B 创建数据 3 小时 写一个创建方法,复用标题校验;固定 SQL 用 ? 占位并绑定参数;数据库生成 id,读取生成编号;不再从最大编号加 1
C 查询数据 3 小时 按 id 查询,循环或判断 ResultSet;把每列映射成 Java 字段,正确处理 SQL NULL 的处理人;查不到返回明确结果
D 资源与失败 3 小时 Connection、PreparedStatement、ResultSet 用 try-with-resources 关闭;记录 SQLState 和原因;验证不存在设备插入失败且没有半成品
E HTTP 过渡 3 小时 读一个 GET 请求和 JSON 响应,区分方法、路径、状态码、请求体;在纸上写出创建与查询接口,不急着搭 Spring
F 重启与独立修改 3 小时 重启后查新编号;独立增加按 OPEN 查询的方法;复习数组/哈希题,回答自测并整理 JDBC 到 MyBatis 的过渡笔记

JDBC 是 Java 操作关系数据库的一套接口,MySQL 驱动负责具体连接和通信。Connection 是这次数据库会话;PreparedStatement 保存 SQL 结构和绑定参数;ResultSet 保存查询结果,游标先位于第一行之前,要先 next() 才读取。? 只绑定数据值,不用来绑定表名、列名或排序方向。

本周短练习可以单独写 JdbcTicketDemo,只做创建和查询。创建方法接受标题、描述、设备编号、报修人编号,返回数据库生成的 long id;进入数据库后编号由数据库生成。不要为了兼容原来带 id 的构造器,让新增工单乱填一个假编号;可以先完成插入,得到 id 后再构造对象。业务时间与状态使用前述约定,SQL 列表写全,不依赖列的物理顺序。

HTTP 把请求与响应带过网络,JSON 是其中一种数据格式。GET /api/tickets/101 读取工单;POST /api/tickets 提交新工单。下一阶段正常创建返回 201,查询成功 200,参数错误 400,不存在 404。最终报修人来自登录身份,本周控制台传编号只是没有登录时的学习接口。HTTP 概览

验收:新增 " O'Reilly投影仪异常 ",数据库保存去掉两端空格的原文字,单引号不破坏 SQL;退出程序后按新 id 查得到。创建时设备为 999,外键拒绝且记录数不增加。查询 999999 无结果;SQL NULL 映射成 Long null,不是编号 0。查 OPEN 在初始样本上返回 101、103,新增 OPEN 工单后也能返回它。

自测:①为什么不把标题直接拼进 SQL 字符串?②为什么 Java 程序结束后数据还在,而第 2 周的纯 Map 不在?③收到 200 和 JSON 是否代表一定查到了想要的工单,还应核对什么?

卡住提示:连接不通时先用同一主机、端口和账号在数据库客户端连接;能连接后再查驱动与依赖。No suitable driver 与 Access denied 原因不同。这里的 JDBC 是理解数据访问的桥梁,不继续扩成另一套正式持久层。参考驱动接入与DriverManager 连接说明。

进入第 7 周的门槛

你能脱离答案完成:一个带校验的对象、一个集合筛选、一次文件往返、一条关联统计 SQL、一次 JDBC 插入与查询,并解释构造器、接口、事务、参数绑定分别解决什么问题。未通过的项最多选两项补练,先完成它们再开始 Web;不用为了赶日期把不会的内容全部积压到 Spring 阶段。

第7至13周 Web接口与登录权限

这七周把你已经会写的 Java 业务和 SQL 接到浏览器上,做出能保存数据、走完工单流程、区分三种用户的 Web 版本。按每周约18小时安排,任务块可以交换到不同日期;遇到考试就顺延,验收没有通过时先补当前周。

先做题和实验,再打开本阶段参考答案。答案包含关键接线代码、请求示例和排错顺序,供你在没有 AI 陪读时继续推进;它不是一套已经替你写完的项目。

本阶段共同约定

  • 主线使用 Java 21、Spring Boot 4.1 系列、MyBatis Starter 4.0 系列。2026年10月12日核对的版本为 Boot 4.1.1、MyBatis Starter 4.0.0;新建时选择兼容的正式补丁版本,记录在 pom.xml 和项目说明中。Java21在 Boot4.1.1 支持范围内;MyBatis Starter4.0 支持 Boot4.0及以上。Boot系统要求、MyBatis兼容表
  • 业务包名示例为 com.example.opsticket,启动类放在该包顶层。业务代码逐步分为 controller、dto、service、mapper、model、security、exception,不必第一天创建全部空目录。
  • 数据库为 ops_ticket_dev。用户1 reporter_a、2 engineer_a、3 admin_a、4 reporter_b;设备1 PJ-001投影仪、2 AC-001空调、3 PR-001打印机。初始工单101为用户1报修设备1的OPEN单,102为用户1报修设备2且指派用户2的IN_PROGRESS单,103为用户4报修设备1的OPEN单。每次验收注明是否已重置这份样本。
  • 第7至11周尚无真实登录,只在本机运行,配置 server.address=127.0.0.1。临时身份由服务器端本地配置选定,写请求也不接受 reporterId、actorId。第12周彻底替换为会话身份。临时身份不属于最终成品,不能算作权限已经完成。
  • 请求与响应统一使用 JSON;框架登录例外,它使用表单编码。创建成功201,查询与状态操作成功200,状态操作返回新状态与版本;登录、退出成功204且无响应体。参数错误400、未登录401、无权访问403、资源不存在404、状态或版本冲突409,意外故障500。字段与边界统一查数据与接口约定。
  • 时间先统一使用 LocalDateTime 加数据库 DATETIME,明确解释为北京时间,响应示例 2026-12-07T14:30:00。数据库会话和应用部署时保持相同约定;该字符串没有时区,不能直接当全球通用时间。后续如改成UTC应一起调整存储、API和展示。

第7周 建立HTTP接口

目标窗口:2026年11月23日至29日。

前置能力: 能用 JDBC 创建与查询工单,知道 JSON 是文本格式,能运行 Maven 工程。若 java -version 与 IDEA、Maven 的 Java 版本不一致,先完成前阶段的环境统一。

本周成果: 一份能够启动的 Boot 工程和三个接口:创建工单、列表查询、按编号查询。创建之后可以再查询得到;本周可保留此前 JDBC 存储实现,第8周再换 MyBatis。

先理解: Controller 像接待窗口,把 HTTP 参数交给 Java 方法;Service 决定业务是否合法;存储层读写数据库。DTO 是“这次交接允许携带的字段清单”。例如创建请求只有标题、描述和设备编号,状态、报修人、版本号由服务端决定。依赖注入就是由 Spring 创建对象,再通过构造器把它需要的对象交给它,和你第3周手动传入存储实现的思路相通。

灵活任务块:

  1. 环境与最小启动,约3小时。 用 Spring Initializr 生成 Maven、Java21、Boot4.1正式版工程,只选 Spring Web 和 Validation;在新目录检查生成结构,再有选择地迁入已有项目,保留 Git 历史,不覆盖旧源码。先运行一个返回 {"message":"ok"} 的 /api/ping,观察启动日志中的端口。
  2. 请求和DTO,约3小时。 设计 CreateTicketRequest(title, description, deviceId) 和 TicketView。读懂 @RestController、@RequestMapping、@PostMapping、@RequestBody、@PathVariable,不要一口气背完所有注解。
  3. 连接业务,约5小时。 Controller 通过构造器取得 Service,再接此前存储接口。创建时验证标题、设备存在且启用;服务器给出 OPEN、报修人1、版本0以及创建时间。GET详情不存在时返回404;暂时把列表限制在演示数据量内。
  4. HTTP实验,约4小时。 保存 requests.http 或 PowerShell 请求脚本;分别发送合法JSON、坏JSON、空标题、未知设备、未知编号。记录请求路径、方法、请求体、状态码和响应体。参数注解的完整整理留到第10周,此时先保证业务校验有效。
  5. 独立修改与复盘,约3小时。 增加列表的 status 查询参数,把参数一路传到存储查询;画出“请求→DTO→Service→存储→响应”的箭头图,口述一次。完成一次只包含本周变化的提交。

验收: 用客户端创建“教学楼投影仪无信号”,拿响应里的编号查询,标题与设备一致;重启后仍存在。GET未知编号404,POST空标题400;请求体自带 status=CLOSED 不能生成关闭工单。能够指着代码指出状态和报修人到底在哪里赋值。

自测: 7.1 为什么不直接把数据库实体当创建请求?7.2 Controller里每次 new TicketService(...) 与构造器注入有什么区别?7.3 请求到达服务端但返回404、400、500,各应先检查哪一层?

卡住时先看端口与完整异常的第一条业务相关堆栈,再看类是否在启动类所在包的子包中。Spring REST入门只读接收请求和返回对象部分。

第8周 用MyBatis连接数据库

目标窗口:2026年11月30日至12月6日。

前置能力: 第7周三条接口可用;能手写工单INSERT、按编号SELECT和设备关联查询。

本周成果: Web接口改由 MyBatis 访问 MySQL。请求地址和业务规则保持一致,JDBC练习代码保留在学习记录中,正式流程只维护一个存储实现。

先理解: JDBC需要你亲自准备SQL、绑定参数、执行并读取结果;MyBatis把这些重复步骤封装起来,但SQL仍由你写。Mapper接口定义“我需要怎样的数据”,注解或XML提供实际SQL。Mapper不自动知道某字段是否允许修改,也不替你决定谁能看工单。数据库行、创建请求、展示结果可以是三个不同类型。

灵活任务块:

  1. 接入配置,约3小时。 添加 org.mybatis.spring.boot:mybatis-spring-boot-starter:4.0.0 与 com.mysql:mysql-connector-j。先用 dependency:tree查看Boot实际管理的驱动版本,核对它与MySQL服务端兼容;仍用8.0练习时可延续前阶段明确支持8.0的Connector/J9.7.0,不无条件追最新版,也不替换原有数据库。其余Boot管理依赖不随意改版本。用环境变量设置URL、用户名和密码,不把密码提交到Git。驱动9.7.0兼容说明
  2. 最小Mapper,约3小时。 给 TicketMapper 加 @Mapper,先实现 findById;用 #{id} 绑定值。字段使用明确列名,配置下划线转驼峰,验证 reporter_id 能进入 reporterId。复杂关联展示用单独DTO并起明确别名。
  3. 创建与查询,约5小时。 实现INSERT与生成主键回填,再实现列表和按状态筛选;增加 GET /api/devices返回设备列表,可按启用状态筛选。若返回实体用可写 id 配合 useGeneratedKeys;本阶段不对不可变record硬套主键回填。运行已有HTTP请求,确认替换存储没有改变行为。
  4. 最小设备维护与排错,约4小时。 实现 POST /api/devices新增设备,以及 PATCH /api/devices/{id}/enabled启用或停用;设备编码唯一,不删除历史引用。用API和SQL交叉查验,再制造并修复一次映射错误,记录排错步骤;管理权限第13周接入。
  5. 理解与复盘,约3小时。 独立补一个按设备编号筛选;指出Controller为什么无需知道数据库密码、Service为什么不拼SQL、Mapper为什么不负责HTTP状态码。提交 pom.xml、配置示例和映射代码。

验收: 创建后响应含真实数据库主键;重启应用后可查询;deviceId、reporterId、可空 assigneeId、version 映射正确。设备新增201,停用200且原有工单仍能查到,停用设备不能新报修。输入带单引号的标题仍作为普通数据保存,不破坏SQL;不会用 ${title} 拼接用户输入。提交内容不含密码。

自测: 8.1 #{title} 与 ${title} 有何区别?8.2 返回了工单但 reporterId 为null,应从哪三处排查?8.3 Mapper是接口没有手写实现,为什么仍能被注入?

提示:先把SQL复制到SQL客户端用具体参数运行,SQL正确再查Java映射。仅阅读MyBatis Starter快速接入和参数与结果映射的相关小节。

第9周 实现业务流转和事务

目标窗口:2026年12月7日至13日。

前置能力: MyBatis创建、查询已通过;能解释一次数据库提交与回滚。

本周成果: assign、resolve、accept、reject四种操作,以及处理结果和操作日志。工单更新与对应记录必须一起成功或一起失败。

先理解: 状态不是随便填写的标签。例如只有OPEN可以派单,派单后是IN_PROGRESS;处理中提交说明变成RESOLVED;验收通过CLOSED,退回则回到IN_PROGRESS。一次操作可能改工单、插处理说明、写日志三张表,事务把它们作为一次整体提交。事务负责完整性,具体允许怎样跳转仍由你判断。

灵活任务块:

  1. 规则表,约2小时。 按项目说明写四行“当前状态→动作→目标状态→必需参数”。派单要求有效工程师编号;处理和退回要求非空说明;关闭不允许继续变更。请求路径统一为 /api/tickets/{id}/动作名。
  2. 日志表与请求,约3小时。 用 ticket_action_log(id,ticket_id,actor_id,action,from_status,to_status,note,created_at) 保存日志;ticket_resolution 保存每次工程师提交,退回后再次处理新增记录。请求含 expectedVersion,assign另含 engineerId,resolve另含 content,reject另含 reason;日志说明仍写入 note列。
  3. 先做派单,约4小时。 Service检查对象存在、当前状态和工程师;Mapper的UPDATE带 id、预期状态、expectedVersion条件,成功时版本加1。检查影响行数,0行抛业务冲突。把更新和日志写入放进一个公开的 @Transactional Service方法。
  4. 完成其他操作,约5小时。 延续相同结构;resolve再保存处理结果,reject保留原处理人和历史处理说明。详情响应增加 actionLogs和 resolutions,按时间与id正序展示历史。暂时由本机服务端配置选择演示操作者,不从JSON取操作者。第12至13周接入真实身份和资源范围;本周不声称已经防越权。
  5. 验证与复盘,约4小时。 正常走完两条路径:直接验收;退回后再次处理再验收。在练习分支临时让日志插入前抛 RuntimeException,实际调用事务方法并查询数据库,确认状态和版本均未改变,再移除故障代码。总结事务注解生效条件。

验收: 两条路径都留下顺序正确的日志;重复派单、重复验收409,日志不多一条。没有 POST /setStatus 任意改状态接口。故障实验中,工单、版本、处理结果和日志一起回滚。第14周再用真正并发请求加固和验证条件更新。

自测: 9.1 已更新工单但写日志失败,为什么不能吞掉异常后返回“失败”?9.2 同类中 this.assign(...) 调用可能让事务失效,原因是什么?9.3 UPDATE返回0行时,为什么不能继续写“派单成功”日志?

提示:优先使用一个数据源,Mapper交给Spring管理,不手动开启另一个SqlSession。Spring事务注解、MyBatis与Spring事务。

第10周 校验参数和分页评论

目标窗口:2026年12月14日至20日。

前置能力: 四种状态操作和日志回滚已验收。

本周成果: 列表有稳定分页与筛选;详情可显示评论;错误返回固定的 code 与 message。用户能知道如何修正输入,开发者能从日志定位意外故障。

先理解: 校验分两类:标题为空这类“单看输入就能判断”的要求交给DTO校验;设备已停用、工单不允许再派单这类“需要数据库现状”的要求放Service。分页也不是从全部数据中随便截几条:先用一致条件计算总数,再用固定排序取当前页,权限接入后总数和列表都要带相同的可见范围。

灵活任务块:

  1. 输入边界,约3小时。 使用 jakarta.validation 的 @NotBlank、@Size、@NotNull、@Positive;Controller加 @Valid。明确标题1至50字符、描述1至2000、评论1至1000;trim去除首尾空白后检查,数据库列长也要容纳这些值。数字用包装类型以区分缺失与0。
  2. 分页筛选,约4小时。 定义 page 从1开始、size默认10且1至100;offset=(page-1)*size用long计算。支持 status、deviceId;排序固定 created_at DESC,id DESC。返回 items,total,page,size,不把客户端提供的排序字符串直接拼进SQL。
  3. 评论,约3小时。 POST /api/tickets/{id}/comments保存非空内容,GET同一路径按时间和id正序分页读取。作者来自服务器身份;工单不存在404。本版本关闭工单仍允许评论,但评论不改变状态。
  4. 错误与日志,约4小时。 @RestControllerAdvice处理校验、找不到、业务冲突;坏JSON和数字类型错误也应400。业务异常给可理解的信息,意外异常返回通用500并在服务器保留堆栈;日志不记录密码和会话Cookie。没有必要把所有异常都变成200。
  5. 边界与复盘,约4小时。 独立造至少12条工单,把size设5检查三页;验证空结果、越界页、负页码、超大size、未知状态和全空格评论。写出一张“请求输入→预期状态码→数据库变化”的记录表。

验收: 固定数据下翻页无重复遗漏;第4页空列表且total仍正确。page=0、size=101、status=INVALID均400;未知工单评论404;空评论400;合法评论201且工单状态不变。相同评论重复发送会新增两条,本周没有承诺所有POST天然幂等,页面下一周降低误点,状态操作则已有冲突保护。

自测: 10.1 只有 @Size(max=50) 能拒绝空标题吗?10.2 count查询漏掉筛选条件会导致什么现象?10.3 为什么“所有异常都返回200和一段提示”会给联调带来问题?

提示:校验注解无效先检查Validation依赖、jakarta导入及 @Valid,不要反复修改正则。Spring MVC校验。

第11周 做一个能够演示的页面

目标窗口:2026年12月21日至27日。

前置能力: HTTP客户端已能走完整流程,分页和错误可预测。先修接口,再做页面。

本周成果: 简单列表、创建表单、详情、评论与操作按钮,以及最小设备管理表单;一个正常业务流程可以从页面完成。页面从Boot同一端口提供,第一版无需Vue、React或单独的前端构建工程。

先理解: HTML决定有哪些输入框和按钮,JavaScript负责点击后发请求、读取响应并修改页面。fetch返回Promise,可以用 await等待结果;HTTP400并不自动使fetch抛网络异常,必须检查 response.ok。同源指协议、主机、端口相同,用相对路径 /api/tickets 可避免把开发地址写死。Fetch官方说明

灵活任务块:

  1. 静态页面,约2小时。 在项目的 src/main/resources/static 中建立 index.html 和 app.js,从 http://localhost:8080/打开。先显示一个标题和按钮,确认浏览器能加载脚本;不通过双击HTML的 file://打开。Boot静态资源说明
  2. 列表和详情,约4小时。 用fetch获取第一页;用 document.createElement 和 textContent显示编号、标题、状态。点击一条加载详情、评论和日志。标题与评论是用户输入,用文本方式显示,不直接塞进 innerHTML。
  3. 创建和流转,约5小时。 表单提交时阻止默认刷新,把字段转换成JSON;成功后刷新列表,失败保留输入并显示提示。操作携带详情里的版本号;409提示刷新后重试,不自动覆盖。再用相同表单方法做设备新增及启停,页面保持简朴。演示角色此时仍是服务器临时身份,不设计能伪造身份的前端下拉框。
  4. 失败反馈,约4小时。 统一请求函数,处理JSON响应和204空响应;请求过程中禁用相关按钮,结束恢复。测试后端关闭、参数错误和重复点击。登录将在下周加入,此时预留401跳登录的处理即可。
  5. 演示与表达,约3小时。 做一段3分钟本地演示并写项目README:业务、已实现功能、启动方式、当前限制。写简历草稿,只写已完成的接口、SQL、事务与排错,不写尚未完成的登录或高并发。

验收: 页面可以创建、查看、评论并展示状态变化;设备可新增、停用,报修只能选择启用设备;错误信息可见且用户输入保留。把标题设为 <b>测试</b>,页面应显示这些文字而不是粗体标签。Network面板能找到请求JSON、响应状态和返回数据。状态操作200时用新版本刷新页面;下周登录退出会返回204,辅助函数应支持空响应。

自测: 11.1 fetch遇到500为什么可能仍进入正常返回分支?11.2 textContent 与 innerHTML 用于评论有什么差别?11.3 按钮被隐藏或禁用,是否就能阻止用户直接调用接口?

提示:页面无反应先开浏览器Console;请求失败再查Network的URL、方法、Content-Type和响应体。CSS到“能读、能点击”即可,余下时间用于业务演示。

第12周 登录退出与会话

目标窗口:2026年12月28日至2027年1月3日。

前置能力: 同源页面可用;能理解浏览器发送请求时会携带Cookie;能看懂Service构造器注入。

本周成果: 三类演示账号能登录与退出,API能识别会话用户;数据库保存密码哈希。客户端不再有改变当前用户编号的入口,服务器移除临时身份实现。

先理解: 认证回答“你是谁”,授权回答“你能做什么”。Session把登录身份留在服务器,浏览器持有会话Cookie;它不是把密码反复传给每个接口。密码哈希由框架编码器生成并匹配,不能通过每次重新encode后直接比较字符串。CSRF令牌帮助服务端验证修改请求的来源意图,它与登录身份是两回事。

灵活任务块:

  1. 账号读取,约3小时。 添加Spring Security依赖。建 UserDetailsService,用MyBatis按唯一username读取 app_user(id,username,password_hash,role,enabled),返回框架的用户信息。role存REPORTER/ENGINEER/ADMIN;配置 .roles(...)时框架添加ROLE_前缀,不要重复添加。
  2. 框架登录,约4小时。 使用 SecurityFilterChain 和 formLogin,POST /api/login提交 username/password表单参数;成功204、凭据错误401。通过PasswordEncoder生成演示哈希,保存完整编码前缀;停用账号不能登录。不要自己写“仅authenticate然后返回成功”的Controller。
  3. CSRF与页面,约4小时。 保留默认CSRF,GET /api/csrf允许匿名获取 headerName/token,响应不缓存。登录及其他POST带该请求头;登录成功和退出成功后重新获取令牌。参考答案提供同源fetch接线示例,先逐句解释再使用。
  4. 身份与退出,约3小时。 GET /api/me只返回当前账号的id、username和role;从 Authentication.getName()回查用户,拒绝不存在或停用用户,绝不返回哈希。框架处理POST /api/logout并结束会话,成功204。修改工单的操作者来自这条服务器身份链。
  5. 浏览器验收与复盘,约4小时。 用普通窗口和无痕窗口建立两个独立会话。记录登录前后Cookie与/api/me变化、错误密码、停用账号、退出后再次查询以及缺失CSRF的结果。

验收: 未登录GET工单401;错误密码且CSRF正确时401;正确登录204,随后/api/me200;退出后GET工单401。已登录但缺失或错误CSRF的POST403,携带新令牌可通过认证继续业务校验。未登录POST也可能先因CSRF缺失返回403,这是安全过滤器先后顺序,不要为追求“所有情况都是401”关闭CSRF。

自测: 12.1 为什么 authenticate 返回成功不等于下一次请求已经登录?12.2 正确密码却收到403,先查什么?12.3 为什么登录成功后,首次创建工单可能因继续使用旧CSRF令牌失败?

本周理解主线即可,不扩展JWT、刷新令牌或单点登录。表单登录、会话持久化、CSRF令牌端点。

第13周 限制资源范围和业务操作

目标窗口:2027年1月4日至10日。

前置能力: 登录退出已通过,包括两份独立会话;业务操作已经从会话获得actorId。

本周成果: 报修人只能查看自己的工单,工程师只能看分配给自己的工单;管理员查看全部并负责派单。权限同时覆盖列表、详情、评论、日志、处理结果和写操作。

先理解: “是工程师”只是角色条件,“这张工单分配给当前工程师”是数据条件。两个都满足才能处理。路径中的工单编号只是用户声称要访问的对象,服务器必须查出它,再核对归属。只限制列表仍可能让人直接改URL查看别人的详情。

灵活任务块:

  1. 写权限矩阵,约2小时。 REPORTER可创建、查看自己的单、评论、验收或退回自己的待验收单;ENGINEER可查看与评论自己的任务、提交处理说明;ADMIN可查看与评论全部、派单和管理设备。管理员不替报修人验收,工程师不能把单指给自己。
  2. 统一身份与规则,约4小时。 将会话身份解析集中在一个 CurrentUser组件,将角色和资源判断集中到Service或专门Policy。每个入口调用同一规则,避免Controller之间复制后漏改;数据不允许时返回403,确实不存在返回404。
  3. 读权限,约4小时。 列表查询与count都带服务端生成的范围:报修人 reporter_id=当前id,工程师 assignee_id=当前id,管理员不限。详情、评论列表、日志和结果先检查工单可见性。前端传来的 reporterId=别人 不能扩大范围。
  4. 写权限,约4小时。 创建强制reporterId=本人,派单仅管理员;resolve同时检查工程师角色、assigneeId和IN_PROGRESS;accept/reject同时检查报修人角色、reporterId和RESOLVED。评论复用可见性判断。设备新增和启停限定ADMIN,设备读取供已登录用户使用。条件UPDATE继续包含状态与版本,涉及操作者的更新再加归属条件。
  5. 越权实验与复盘,约4小时。 用reporter_a读取103,应403;读取列表不含103。用reporter_b验收用户1的单、用另一工程师处理不属于他的单、用报修人调用assign,均403;客户端手填actorId不改变日志作者。整理权限矩阵及验证记录,提交本阶段版本。

验收: 每个“允许”至少测一次,每类“拒绝”至少测一次。成功登录但越权403;本人合法请求参数错误400;本人重复状态操作409。失败操作不改状态、不增加结果和日志。评论、日志、总数中都没有泄露其他人的工单信息;界面隐藏按钮只是体验,HTTP客户端绕过页面仍会被服务端拒绝。

自测: 13.1 hasRole("ENGINEER") 能否独自保证工程师只能处理自己的任务?13.2 为什么列表items过滤了,total却没有过滤,仍然是权限缺陷?13.3 用户在JSON里加 reporterId=3 或 actorId=3,服务端应如何处理?

提示:准备第二名工程师时使用新的虚构账号并保存同样格式的密码哈希;不要通过修改账号角色来冒充另一个会话。课程采用明确403展示越权错误;产品若选择把无权资源也返回404,应全局一致且测试说明。Spring请求授权。

进入下一阶段的门槛

你应能独立演示:报修人创建工单→管理员派单→工程师处理→报修人退回→工程师补充→报修人验收;能解释请求、SQL、事务、Session和权限在哪里生效。

保存三份证据:一张请求链路图,一组可重复执行的HTTP请求,一份包含失败场景的验收记录。第14周开始验证真正并发并补自动测试;没有通过第12至13周权限验收前,不把项目发布到公网。

第14至20周 测试部署与求职准备

这七周把已能运行的设备运维工单平台变成能验证、能交付、能讲清楚的作品。按每周约 15—18 小时安排,表中的小时已经包含阅读、动手和验证,不需要额外再加一套课程。顺序可以移动,但先通过本周验收,再进入依赖它的下一周。考试、春节安排或环境问题导致延期时,先取消第 19 周扩展,把时间还给主线。

这部分接续前十三周:Java 21、Spring Boot 4.1.x、MyBatis Starter 4.0.x、MySQL,登录使用 Spring Security 的 Session 和 CSRF。依赖沿用前面已验证的同一组版本;本阶段不为了追新而升级。独立部署优先使用 MySQL 8.4,本地既有 8.0 可以继续初练。

开发库固定为 ops_ticket_dev,测试库固定为 ops_ticket_test。两者使用分开的连接配置和限定库权限的账号。恢复练习另建空库,不向开发库覆盖导入。本文的代码和命令是局部教学示例,要放入自己前面完成的工程并调整包名、方法签名和文件名,不是一套已经完成的项目源码。

自测先写自己的答案,再看第14至20周自测答案。整个阶段结束后转入2027年春季投递与面试。

第14周 1月11日至17日 并发派单

目标:同一张 OPEN 工单被两次同时派单时,只允许一个请求成功,失败者得到明确冲突。 先确认上周的角色与数据权限已经通过检查。

本周任务 小时 做完留下什么
用两个线程运行简单计数实验,认识共享变量、原子性、start 与 join 3 一段小实验和自己的运行解释
画出两个管理员先查询再更新的交错过程,复现旧写法的覆盖问题 3 六步时序记录,保留失败证据
写条件更新 SQL,检查受影响行数,把状态更新和操作记录放进一个事务 5 派单 Mapper、Service、冲突响应
两个独立连接或两个独立请求并发派单,重复验证并检查数据库 4 成功一次、冲突一次、有效日志一条
复盘事务边界,做两道哈希表或二分题,完成自测 3 三题答案和一条有意义的提交

两个请求都曾经读到 OPEN,并不代表它们更新时仍然有权派单。事务保证几条操作共同成功或失败;“本次修改必须基于我读到的旧状态”还需要写入条件。Java 的 synchronized 可以保护同一个 JVM 内共享的锁对象,却不能独自协调两台应用之间的数据库修改。

下面是 MyBatis XML 中的局部 SQL。Java 参数 expectedVersion 对应数据库字段 version;assigneeId 先验证确实是启用的 ENGINEER,操作者身份来自登录会话。

UPDATE ticket
SET assignee_id = #{assigneeId},
    status = 'IN_PROGRESS',
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE id = #{id}
  AND status = 'OPEN'
  AND version = #{expectedVersion};

更新返回 1 才写入 ASSIGN 操作记录;返回 0 表示这次操作没有获准,不能假装派单成功。本项目先检查是否存在和是否有权限,再把条件更新失败映射为 HTTP 409;页面提示刷新。ticket_action_log 的 actor_id 来自会话,from_status 为 OPEN,to_status 为 IN_PROGRESS。Service 对外事务方法要经过 Spring Bean 调用,不能只在同一个类的内部自调用处贴注解。Spring 事务说明

实验采用测试库中的新工单,记录初始版本。两个工作线程都先拿到同一版本,再用 CountDownLatch 同时放行;各自调用 Service 或分别发出 HTTP 请求。不要用一个 JDBC Connection 给两个线程共享。仅连续点击两次页面,只验证了重复请求,不能证明发生过并发。

验收: 同一初始版本下恰好一次成功;最终状态 IN_PROGRESS,版本只增加 1,处理人是胜出请求选择的人,有一条有效派单日志。没有第二位工程师账号时可以都派给 engineer_a,仍然检查两次结果;需要验证覆盖时,再创建独立测试工程师。连续执行至少五轮,每轮创建新工单,保存结果。一次请求失败并不意味着数据库故障。

卡住时: 两次都成功,先看 WHERE 是否带旧版本和状态;两次都失败,先检查初始状态、版本、角色与 CSRF;日志有两条,检查是否在判定更新结果之前插日志。不要先加入 Redis 锁。

自测: 14.1 为什么 SELECT 后判断 OPEN,再无条件 UPDATE 会出错?14.2 为什么有事务还需要条件更新?14.3 派单已经更新,写操作记录时抛出异常,最终数据库应是什么样?

第15周 1月18日至24日 自动测试与缺陷复盘

目标:把“我试过了”变成可重复运行的行为证据。 保留第 14 周并发实验,本周把它纳入测试或清楚的独立验证脚本。

本周任务 小时 做完留下什么
用 JUnit Jupiter 写三个纯 Java 规则测试,练习 arrange、act、assert 3 正常值、非法值和边界测试
为登录状态、角色和资源归属写接口测试,保留 CSRF 4 REPORTER、ENGINEER、ADMIN 测试矩阵
在真实 MySQL 测试库验证约束、回滚和并发结果 5 可重复的数据库集成测试
从真实开发错误中选一项,先保留失败测试,再修复和回归 3 一份缺陷复盘
连续运行两次测试,核对残留数据,复习事务与 HTTP 状态码 3 测试执行记录和自测答案

单元测试检查某条规则,接口测试检查输入、会话、返回码,数据库集成测试检查真实 SQL 和事务。模拟 Mapper 抛异常可以验证 Service 如何响应,但模拟数据库不能证明 MySQL 回滚正确。测试名称写行为,例如 reporterCannotReadOthersTicket,不要只叫 test1。测试依赖使用所选 Boot 版本管理的 spring-boot-starter-test,Web MVC 和 Security 的测试支持按同版本文档添加,不单独拼装一组旧 JUnit 版本。Boot 测试文档

最少覆盖这八项:有效创建、空标题、报修人越权查询、错误工程师处理、非法状态迁移、重复验收、并发派单、操作记录写入失败后的回滚。登录验证再保留正确密码、错误密码、退出后三条。对 Session 应用,修改请求的测试需要有效 CSRF;缺少 CSRF 的 403 不能证明角色授权做对了。Spring Security 测试 CSRF

先做数据库隔离护栏:测试连接只指向 ops_ticket_test,测试账号无权写开发库;开始测试时查询 SELECT DATABASE() 并断言库名。每个测试创建自己的数据,记录主键,按依赖顺序清理自己创建的记录。不得用“清空所有表”解决残留问题。使用固定样本时,明确它属于测试库,不与正在演示的实例共用。

验证回滚时,真实调用 Spring 管理的 Service:让工单更新实际执行,再让日志写入因测试故障失败,最后重新查询工单与日志。测试本身若带自动回滚事务,可能掩盖业务事务缺失;这项测试不要依赖外层测试事务。真实端口的 HTTP 请求在服务端线程里执行,也不会自动跟随测试线程回滚。Boot 应用测试说明

验收: 测试失败时能看到哪条业务规则破坏;至少一次回滚测试验证真实数据库;测试连续执行两遍都通过;测试后开发库内容未变。回滚故障注入只在测试配置生效,不能留下公开的“模拟失败”业务接口。

卡住时: 全部 403,先分别检查登录与 CSRF;测试绿但页面错,检查是否把真实权限组件全替换成了 mock;第二次失败,检查测试数据是否独立;测试一直等,检查并发放行条件、连接池数量和等待超时。

自测: 15.1 mock Mapper 返回成功,能证明数据库事务正确吗?15.2 测试方法自动回滚,为什么仍可能漏掉 Service 的事务错误?15.3 “别人的工单返回 403”这一测试,要怎样排除其实只是缺少 CSRF?

第16周 1月25日至31日 可以交付的第一版

目标:离开 IDEA 的运行按钮,仍能启动并演示项目。达到本周标准就可以匹配投递,不必等扩展完成。

本周任务 小时 做完留下什么
核对 JDK、Maven Wrapper、依赖与配置,把数据库凭据移出源码 3 可复现版本记录和配置样例
运行测试并打包,在新终端使用 java -jar 启动 4 可执行 jar 和真实启动记录
编写 README、初始化说明与已知限制 3 新读者按步骤运行的文档
准备虚构演示数据,录一次 5—8 分钟讲解 3 演示脚本、录屏或截图
写一页简历草稿,核对每个技术词对应的实现和证据 3 可投递草稿与修订记录

配置和程序应分开:程序描述“需要哪个配置项”,运行环境给出具体数据库地址、用户和密码。提交 application-example.yml 或写好带占位符的说明,真实 .env、日志、备份和密码不提交。Spring 的环境变量可以覆盖配置;变量名必须与你实际使用的配置方式一致。使用标准数据源配置时可设置 SPRING_DATASOURCE_URL、SPRING_DATASOURCE_USERNAME、SPRING_DATASOURCE_PASSWORD,不要同时再维护另一套含义重复的变量。

Windows PowerShell 在项目根目录执行下面的构建示例。ops-ticket-0.0.1-SNAPSHOT.jar 只是示例,先查看 target 中的实际文件名,并确认它是包含启动信息的可执行 jar。

java -version
.\mvnw.cmd -version
.\mvnw.cmd clean verify
Get-ChildItem -LiteralPath .\target -Filter *.jar
java -jar .\target\ops-ticket-0.0.1-SNAPSHOT.jar

上述最后一条会在当前终端持续运行,结束演示用 Ctrl+C 正常停止。不要为了得到“构建成功”默认跳过测试。若 MySQL 集成测试设计成单独 profile,README 必须分别给出普通测试命令和数据库测试命令,并说明实际运行了哪一个。

README 至少包含:项目解决什么问题、三种角色、状态流转、技术版本、JDK 与数据库准备、配置填写、建表和虚构演示数据、测试、构建启动、接口与演示顺序、已知限制。把账号密码的演示规则单独说明:演示账号只用于本地虚构环境,密码仍通过编码器存储,不能把明文直接填进密码哈希列。

推荐演示顺序:reporter_a 创建报修单 → admin_a 派给 engineer_a → 工程师提交处理说明 → 报修人退回 → 再次处理并验收 → 查看操作记录 → 展示 reporter_b 无权读取该单 → 简要解释并发测试。用新的演示工单走流程,保留 101、102、103 基础样本作为查询对照。

验收: 从新终端可以按 README 启动;重启后工单还在;三个角色走通完整业务;测试通过;每句简历描述都能指到代码、SQL、测试或实际测量。部署地址不是这一周的必需品,本机演示可以作为作品展示。

卡住时: no main manifest attribute,检查打包插件与选择的 jar;端口被占用,确认自己已有实例,或指定空闲端口;连接数据库失败,依次检查服务、地址、库名、用户权限和实际生效的配置。

自测: 16.1 同一份 jar 为什么能在不同数据库地址运行?16.2 README 中只有“点击 Run”缺少了什么?16.3 没做性能实验,简历能不能写“高并发、高性能”?

第17周 2月1日至7日 部署与备份恢复

目标:能说明应用在哪里运行、数据放在哪里、出错怎么查,并真正恢复一次数据库。 不需要购买云服务器。Linux 环境可以是已有虚拟机、WSL 或可用的本地环境;电脑资源不足就先完成本机 jar 路线,Linux 操作另记为待练,不冒充已经部署。

本周任务 小时 做完留下什么
在 Linux 练习目录、权限、进程、端口、日志六类操作 3 自己验证过的命令小卡片
二选一:在已有 Linux 环境跑 jar,或完成本机独立运行与配置检查 4 环境信息、启动停止与日志说明
环境允许时学习 Docker Compose;否则用本机数据库加 jar 复现同样业务 4 运行说明和数据目录说明
导出开发库副本,恢复到全新的空库,核对中文和关联记录 4 一份备份及恢复核对记录
模拟一次配置错误并恢复,复习进程、端口与 TCP,完成自测 3 排错记录和本周验收

Linux 常用操作先掌握 pwd、ls、cd、cat、tail -n 100、ps、ss -ltnp。读日志时先定位最新异常与 Caused by,再关联时间、请求和数据库情况。只处理自己项目的进程,不能见到 Java 就全部终止。前台启动适合初次排错;稳定运行与服务管理可以以后再深入。

Docker 的镜像是启动模板,容器是运行实例,数据库持久化卷承载容器外的数据。Compose 里应用访问数据库通常使用服务名,如 db:3306;容器内 localhost 指当前容器。宿主机访问数据库则使用实际映射端口。先学 docker compose config、up -d、ps、logs、stop,不把 down -v 当重启命令;-v 会删除相关卷。Docker Compose down 文档

不安装 Docker 也可以通过本周核心验收:本机独立 MySQL、可执行 jar、外部配置、查看日志、备份恢复、另一个空目录按说明启动。把尚未练习的 Linux 或容器技能在学习记录中标为待完成。开发与学习工具的下载、许可和可用条件以使用时为准;主线不要求订阅 AI 或购买云资源。

备份示例先在项目根目录创建 backups 文件夹,将 your_backup_user 换成自己有相应权限的账号。-p 后不写密码,让客户端交互提示;文件名使用当天日期。

mysqldump -u your_backup_user -p --single-transaction --no-tablespaces --set-gtid-purged=OFF --result-file=backups/ops_ticket_dev_20270201.sql ops_ticket_dev

这里不加 --databases,便于恢复到不同名称的空库。--single-transaction 面向本项目 InnoDB 表;备份时不要执行建表、改表等 DDL。Windows 使用 --result-file 可避免 PowerShell 重定向编码带来的问题。MySQL 备份文档

恢复完整步骤在自测答案中:先核对备份内容,再建立新空库,导入,核对数据并用临时配置启动。不能在尚未确认库名时执行清表、删库或覆盖导入。

验收: 备份文件存在且非空;恢复库有六张业务表;用户、设备、工单、评论、操作记录和处理记录能关联;中文没有乱码;恢复库能支撑一次查询演示。只得到一个 .sql 文件还没通过恢复验收。

自测: 17.1 为什么容器启动了,应用仍可能无法连接 MySQL?17.2 为什么备份成功不等于可以恢复?17.3 docker compose stop 与删除持久化卷,对工单数据意味着什么?

第18周 2月8日至14日 查询性能与线程池

目标:做一次有证据的小优化,理解资源为什么有限。 春节与学校安排可以挪动本周,先做索引实验;线程池或 JVM 内容可以使用第 19 周补齐。

本周任务 小时 做完留下什么
在测试库生成合理分布的几千条虚构工单,写出一个实际列表查询 3 数据数量、分布和查询条件
保存 EXPLAIN,设计一个候选联合索引,在相同条件下前后比较 5 执行计划与测量记录
写有界线程池小实验,观察队列、拒绝、异常与关闭 4 独立实验代码和现象解释
认识 JVM 堆、线程栈、GC,观察一次本机 Java 进程 3 一页概念与实际观察记录
独立解释优化取舍,完成两道链表或栈题和自测 3 优化说明与答案

先优化真实存在的列表查询,如报修人查看自己的某种状态工单。下面只是候选设计,是否有效由数据分布和实际执行计划决定,不要求 MySQL 必须选择它。

EXPLAIN
SELECT id, title, status, created_at
FROM ticket
WHERE reporter_id = 1 AND status = 'OPEN'
ORDER BY created_at DESC, id DESC
LIMIT 20;

CREATE INDEX idx_ticket_reporter_status_time
ON ticket (reporter_id, status, created_at DESC, id DESC);

索引能缩小定位范围或帮助排序,但会增加空间与写入维护成本。不能给每个字段都加一个索引就称作优化。阅读 key、rows、Extra;传统 EXPLAIN 的行数是估计,支持的 SELECT 可以用 EXPLAIN ANALYZE 看真实执行情况,它会实际执行查询。MySQL EXPLAIN 文档

固定数据量、过滤条件、机器和应用版本,先预热,再重复测量,例如各测 10 次,保留中位数和执行计划。只有几十行或耗时接近计时噪声时,如实写“差异不足以判断”,不要生成一个漂亮百分比。不要把 SQL 客户端耗时直接当成整个接口响应时间。

线程池实验使用 corePoolSize=2、maximumPoolSize=2、容量为 2 的 ArrayBlockingQueue 和 AbortPolicy。让前两项任务在门闩等待,再提交另外三项,观察第五项被拒绝;最后释放门闩、等待结果并关闭池。池限制正在执行的任务,队列限制等待任务;任务异常也要通过结果或日志观察。Java 21 ThreadPoolExecutor

JVM 本周只要求分清:对象通常占用堆;线程执行方法需要自己的栈帧;GC 回收不可达对象,并不保证数据库连接等外部资源被及时关闭。可以用 jcmd -l 找到自己的 Java 进程,再按 JDK 帮助检查堆信息。不要为了练习故意把个人电脑内存耗尽。

验收: 一条 SQL 优化有前后证据与局限;能解释线程池拒绝为何发生;实验退出后没有挂住的工作线程;能说出增加索引、线程数、堆大小各自不能解决的至少一个问题。

自测: 18.1 EXPLAIN 显示使用了索引,就能断言查询一定更快吗?18.2 两线程、两队列的池,在任务都不结束时连续提交五项会怎样?18.3 反复创建线程或把所有查询结果永久存到 List,会分别带来什么风险?

第19周 2月15日至21日 补短板或小扩展

目标:把时间花在最影响面试与交付的地方。 核心验收有任何一项未通过,就选补课。已经全部通过,才从统计缓存或故障知识库中选一个;不要求付费模型,不再新开一套项目。

本周任务 小时 做完留下什么
不看答案做一次 SQL、权限、事务和项目复述检查,选一个方向 2 明确本周唯一目标
补课路线修两个薄弱点;扩展路线先写需求与边界 4 两个可验证问题或一页小需求
编码实现并覆盖边界,不改动已验证的主线契约 5 可独立解释的小变更
核心测试回归、异常与停用场景验证 3 验证记录与一条缺陷修复
复习基础和算法,更新 README 与项目讲稿 3 扩展说明或补课验收

补课建议按影响排序:不能手写 JOIN 与 GROUP BY → 先补 SQL;能背事务但回滚实验失败 → 先补 Spring 调用与事务边界;只能照着教程新建接口 → 独立加一个筛选条件;面试一问就绕 → 用自己的代码重讲一次。补课完成同样是有效进度,不因为没有新技术词而降低评价。

统计缓存路线: 只针对管理员的“各状态工单数量”,先用数据库查询给出正确结果,再在单实例内做 30 秒过期的小缓存,或在资源允许时使用本地 Redis。标明更新时间;创建、派单、处理、验收和退回都可能影响统计。明确可以接受最多 30 秒陈旧数据,不缓存报修人私有列表到公共 key。Redis 不可用时回落到数据库,是否继续启用由你验证。

知识库路线: 准备 10—20 条虚构设备故障条目,字段含标题、症状、排查步骤、适用设备类型和来源说明。先完成关键词检索、空关键词、无结果和停用条目处理;匹配排序先简单可解释。页面把结果称为“参考排查步骤”,保留来源,不让文章直接触发派单、关闭或修改设备。模型与 RAG 都是后续可选项,本周不依赖它们。

不论选哪条,先写一个手工核对表:输入、预期结果、实际结果、结论。缓存至少查首次请求、命中、过期、更新后的可接受陈旧期和故障回落;知识库至少查命中、多个结果、无结果、空白输入和停用记录。只展示正常页面不足以验收。

验收: 能用两分钟解释为什么做、怎么验证、有什么代价;核心工单流程和权限测试仍通过;没有把扩展写成未实现的简历成果。如果到周末仍不稳定,保留独立分支或提交记录,演示版继续使用已经通过测试的 v1.0。

卡住时: 缓存数据串用户,检查 key 和访问范围;更新后数量没变,先确认预期陈旧窗口与失效策略;知识库常搜不到,先检查分词或关键词规则,不马上接入模型;补课反复失败,把任务缩到一个 SQL 或一个接口重新验收。

自测: 19.1 报修人 A 的列表可以和报修人 B 共用同一个缓存 key 吗?19.2 缓存上线后发现数字滞后,什么时候属于约定行为,什么时候是错误?19.3 没接模型的关键词知识库,有什么可以验收的价值?

第20周 2月22日至28日 项目表达与模拟面试

目标:你能凭自己的代码和证据说明能力,并能在追问下做一个小修改。 第一轮允许暴露问题,第二轮检查是否真正修复,不追求背一段流利稿子。

本周任务 小时 做完留下什么
冻结可演示版本,核对 README、测试、配置和真实简历内容 3 可投递版本与清单
录制第一次模拟面试,按表达、基础、项目、编码四项评分 3 原始录音或文字记录
修复最高优先级的两个问题,做一次现场限时小改 4 修复证据及小改提交
做第二轮模拟面试和完整演示,测试陌生启动流程 4 前后对照和剩余弱项
筛选岗位、整理投递表,复习两道算法与本周自测 3 下一周投递名单及计划

项目讲解按一个具体请求展开:谁报修,Controller 接收什么,Service 怎样校验角色与业务规则,Mapper 执行哪条 SQL,事务怎样覆盖工单和日志,失败怎样返回。再选择一个难点,比如并发派单,讲出原来的错误、两个请求的交错、修复条件、验证结果和仍有的限制。

5—8 分钟演示可以这样分配:1 分钟业务与角色;2—3 分钟工单流程;1 分钟权限边界;1 分钟并发或回滚证据;1 分钟部署、真实问题与下一步。不要花一半时间读依赖名称。单人学习项目就说个人项目;请同学帮忙测试可以如实说测试协助,不能改写成企业团队交付。

第一次模拟面试用 45 分钟:自我介绍 3 分钟,Java 与集合 10 分钟,SQL 与事务 10 分钟,项目追问 12 分钟,编码或 SQL 10 分钟。独自练习时随机抽问题,用手机录音,回答结束后再看笔记。第二轮换问题顺序,检验是否依赖背诵。

限时小改任选一项:列表增加设备筛选且保留资源权限;限制评论长度并增加边界测试;管理员统计增加一个状态条件。先说影响范围,再修改 DTO、Service/Mapper、测试和文档,40—60 分钟内完成可用的小部分,不要求一次做完全部可能扩展。

验收: 不看稿能讲清三项设计取舍;能展示一条真实排错记录;权限、事务和并发至少各回答一次追问;README 在新目录或另一环境可复现到已完成的程度;简历的每项功能确实存在。未掌握的问题写进待补清单,不填成“精通”。

卡住时: 忘词就画“请求 → Service → SQL → 返回”;遇到不会的问题先说明已知边界,再给出准备怎样验证;面试反馈涉及基础就补一个实验,不整套重学。未完成扩展时,把时间优先留给申请和反馈修补。

自测: 20.1 用一分钟说明你怎样防止两次派单互相覆盖。20.2 面试官问“你项目支持多少并发”,没有正式压测时怎样答?20.3 列表新增设备筛选时,要改哪些地方,怎样证明权限没有退化?

本阶段完成记录

每周结束复制一段到自己的学习记录,不把“看懂”当作“独立完成”。

周次与实际日期:
独立完成:
参考示例后完成:
验收步骤和结果:
失败案例及修复:
测试命令或操作:
仍不能解释的问题:
下周只优先解决的两件事:

网络资料只用于查询补充,课程正文与自测答案可以离线阅读。项目首次下载 JDK、依赖或数据库仍需要可用的软件来源;已装好、依赖已缓存后,可以继续离线编码、测试和复盘。

2027年春季投递与暑期实习准备

你的目标是以 2028 届软件工程本科生身份,争取 2027 年暑期 Java 后端实习。这份安排从完成项目主线后接到暑期,按每周目标保留弹性。你可以在项目达到可投递标准时提前申请,不必等待 20 周全部结束;也不必等缓存、AI 或漂亮页面完成。

下面日期是个人学习和申请计划,不是任何企业的招聘日历或录用承诺。编制计划时参考的岗位资料是 2026 年 10 月的快照,能指导能力准备;2027 年是否开放、接受哪届学生以及出勤要求,必须按投递时的企业正式职位说明确认。

本计划按每天约 3 小时、2028 年毕业安排;最早到岗日期、每周可到岗天数、连续可实习月数仍需结合课表和学校安排填写。学习时间不能自动当作可到岗时间。暑期两个月也不能直接当成满足“连续至少三个月”。先写真实范围,再筛岗位。

什么时候可以开始投递

当你能按下面清单实际演示,就可以开始匹配申请。某一岗位要求明显超出时,判断能否在到岗前补齐,不要把所有岗位都等到完全熟练才看。

  • 三个角色能走通报修、派单、处理、验收和退回。
  • 身份由服务器确定;报修人看不到别人的工单,工程师不能处理别人的任务。
  • MySQL 中数据重启后仍在,派单并发不会覆盖,状态与日志有事务验证。
  • 核心测试能够重复运行,有一份可用的启动 README。
  • 你能解释关键代码,并独立完成一个小修改。
  • 简历只写已经实现的内容,所有成果有自己的代码或证据。

如果到 3 月仍差一两项,先筛选岗位、写简历和补缺;不能声称功能已经完成。把最近两周的时间集中到可投递版本,不继续扩展技术栈。已明确开放、条件匹配且截止较早的机会,可按实际完成情况如实申请。

每周时间怎么分

普通申请周参考 18 小时:岗位核对与定向投递 4 小时,基础与算法 5 小时,项目补缺 4 小时,模拟或正式面试 3 小时,反馈整理 2 小时。正式面试多时,替换模拟与部分项目时间,不要求在原计划上额外加班。

考试周可以缩成 6—9 小时:处理已有面试与重要通知、保留一次基础复习、完成一个小修复。暂停扩展,次周顺延。没有回复的一周也要按计划练习和检查岗位匹配,不能把每天刷新消息当成学习任务。

投递数量按机会质量调整,刚开始每周选择约 3—5 个确认匹配的岗位,之后根据实际岗位数量和反馈增减。这个数量只是个人起步参考,不是必须完成的指标,更不代表投得越多越容易录用。

3月至5月每周目标

每周最后一次学习都做一次复盘:本周有哪些反馈、哪两个薄弱点最影响下一次面试、下周删掉什么低优先级任务。下面每行是本周目标,不需要固定到某天。

周次与日期 本周主要任务 参考预算 周末可检查的产出
第21周 3月1日至7日 完成一页简历;填到岗范围;建立岗位表;核对一批接受2028届或在校生日常实习的岗位;做一轮项目演示 16—18小时 简历v1、3—5条已核对岗位、一次完整演示记录
第22周 3月8日至14日 进行首批定向投递;按岗位调整真实技能描述;复习Java集合、异常、equals与hashCode;手写两道哈希题 16—18小时 投递日期与职位链接、Java错题页、两题独立解答
第23周 3月15日至21日 复习JOIN、GROUP BY、分页与索引;讲一次事务失败回滚;做SQL限时练习;整理首批反馈 16—18小时 三条手写SQL、回滚演示、反馈分类
第24周 3月22日至28日 复习HTTP、Cookie、Session、CSRF和权限;模拟一次项目追问;检查申请状态;补一项接口测试缺口 15—18小时 一张请求链路图、权限测试、下一批岗位清单
第25周 3月29日至4月4日 根据真实反馈修订简历;练习Spring依赖注入、MVC请求处理与MyBatis参数绑定;继续匹配申请 16—18小时 简历v2或保持不变的理由、两分钟框架讲解
第26周 4月5日至11日 复习线程、锁范围、条件更新和数据库事务;重跑并发实验;练习链表、栈队列;参加或模拟一次面试 16—18小时 并发验证记录、两题解答、一份面试复盘
第27周 4月12日至18日 做一次60分钟现场小改;定位一项真实缺陷;复习进程、线程、端口和TCP连接基础;继续核对岗位 15—18小时 小改提交、缺陷复盘、基础问题清单
第28周 4月19日至25日 汇总近四周申请表现;简历无回复先查条件与描述,面试受阻则补具体知识;安排第二轮模拟 15—18小时 一页问题诊断、两项改进、第二轮模拟记录
第29周 4月26日至5月2日 补面试反复出现的最高优先级弱项;复习二分、树与BFS/DFS;核对假期和面试可用时段 15—18小时 一个弱项实验、两题独立复述、可面试时间表
第30周 5月3日至9日 按反馈继续申请;练习项目5分钟和1分钟两种版本;重新核对最早到岗和连续实习时间 15—18小时 两版讲稿、更新后的到岗说明、申请记录
第31周 5月10日至16日 针对已约面试岗位做准备;复习SQL与Java高频错题;若无面试则扩大匹配企业范围和合法渠道 15—18小时 每个重点岗位一页准备卡、错题再答结果
第32周 5月17日至23日 检查项目交付;新终端启动、测试、备份恢复各验证一次;准备向面试官询问的工作内容与培养问题 15—18小时 交付检查记录、真实问题清单、面试反馈
第33周 5月24日至30日 汇总所有进行中流程;确认学校与到岗安排;有结果则准备入职,无结果则启动6月持续申请方案 15—18小时 明确的6月路线、剩余问题与安排

“扩大范围”可以是从几家知名公司扩展到有明确技术工作内容的中小型企业、软件产品团队或企业信息化团队,也可以调整通勤或城市范围;具体选择仍以你的生活条件和真实意愿为准。不要在没有确认时替自己承诺异地搬迁、全周到岗或开学后继续实习。

6月至7月怎样接到入职

时间窗口 已获得合适机会时 仍在寻找时 可检查成果
5月31日至6月13日 与对方确认岗位职责、到岗日期、每周出勤和预计周期;按要求准备材料;安排学校事务 保留每周申请和面试;集中解决最常见的两项反馈;继续寻找明确接受2028届的日常或暑期实习 已确认事项清单,或更新后的申请与改进记录
6月14日至27日 复习Git协作、读项目、日志排错、SQL查询;准备一份“我如何处理陌生任务”的笔记 跟进仍在进行的流程;若主要卡在简历,重写项目具体行为;若卡在笔试,增加限时练习 一次陌生代码阅读练习和一份缺陷说明
6月28日至7月11日 按实际约定到岗;熟悉团队流程、开发环境、测试和代码审阅;记录不懂的问题 根据暑假真实可用时段继续申请;把本机可运行演示保持稳定,减少无关新功能 入职学习记录,或保持可投递状态的作品
7月12日至25日 在指导下完成小任务,先确认验收标准,再改代码、验证并说明影响;每周总结一次 对求职过程做一次完整复盘,调整岗位匹配与表达;必要时寻求学校就业指导或同学帮助 一份真实任务复盘,或下一阶段具体方案
7月26日以后 根据实际实习情况继续学习,规划与开学课表的衔接 继续匹配仍开放机会,同时保持项目、基础和算法练习,不把某个日期未拿到机会等同于全部学习无效 下一阶段安排与已积累成果

入职后的资料、代码和数据按所在团队要求处理。个人简历可写自己实际做过的职责与可披露成果,不能把团队保密代码搬进公开作品。遇到拿不准的工作安排,向负责人确认具体范围再做。

筛一个岗位的固定步骤

先核对门槛,再判断能力匹配。可接受2028届通常比“职位标题带实习”更关键;面向2027届毕业生的校招岗位不能直接当成你的当前目标。

  1. 来源与状态: 优先找企业官方招聘页面、官方发布渠道或学校核验过的信息,保存职位链接与查阅日期,确认仍能申请。
  2. 届别与身份: 是否明确接受2028届、在校本科生或无届别限制的日常实习;模糊时在实际申请沟通中如实说明毕业时间。
  3. 到岗条件: 地点、最早到岗、每周天数、连续月数,逐项与你能做到的范围比较。
  4. 技术要求: 标出必须项和加分项,将Java、Spring、MyBatis、MySQL、测试部署等与你的实际证据对应。
  5. 职责内容: 是否真正包含开发、联调、排错、测试或交付;了解有人指导、代码审阅和可承担任务的情况。
  6. 申请方式: 按正式渠道提交相应材料,保存职位编号和版本;不因为焦虑就夸大能力或隐瞒出勤限制。

为防止耗时失控,第一次核对单个岗位先控制在约15分钟。明显届别不符就标为不匹配,不花一晚准备。条件匹配的重点岗位,再花30—60分钟读业务、整理问题和调整简历描述。

岗位与反馈记录模板

可以复制到自己的笔记里,不必购买表格工具,也不需要自动化服务。不要保存求职网站密码或验证码。

企业与岗位 官方链接或职位编号 查阅日期 届别 地点 出勤与连续月数 技术匹配与缺口 投递日期与简历版本 当前状态 下一步与日期
待填 待填 待填 待核对 待填 待确认 待填 尚未投递 准备中 待填

状态可以使用:待核对、可投递、已投递、笔试、面试、待反馈、结束、不匹配。没有消息时写“待反馈”,不要自己推断已经拒绝或录用。跟进频率按招聘方说明;没有要求时,合理间隔后再进行简短询问,不每天重复催促。

每次面试或笔试后,花20—30分钟写:

日期、企业、岗位与面试阶段:
被问到的问题:
我当时怎么答的:
能够核实的反馈:
我的推测(和反馈分开):
最影响表现的两个问题:
各用什么小实验或练习修复:
下一次如何证明已经补上:

如果没有明确反馈,不把“我不够聪明”或“项目肯定没价值”当结论。比较毕业届别、出勤、岗位方向、简历具体程度、面试答题和编码表现,找到可以验证的下一步。

面试复习按照问题组织

每周从下表选当前最弱的两类。每类至少做到“自己的话解释、一个代码或SQL例子、一个常见错误”,不要只背术语定义。

类别 需要能回答的问题 用本项目提供什么证据
Java对象与异常 private与校验有什么区别?构造器为什么复用校验?检查型与运行时异常怎样处理? Ticket校验、业务异常、错误响应
集合与泛型 List、Set、Map何时使用?equals/hashCode为什么有关联?泛型解决什么问题? 控制台筛选、去重、查找与排序练习
并发 线程和进程有什么区别?共享状态为什么出错?锁能保护多大范围? 双请求派单与有界线程池实验
SQL JOIN为什么会多出行?COUNT与GROUP BY统计什么?分页为什么要稳定排序? 列表、状态统计、created_at加id排序
事务与索引 回滚什么时候生效?事务为什么不自动阻止覆盖?索引怎样取舍? 日志失败回滚、条件更新、EXPLAIN记录
Spring与MyBatis 一次请求经过哪些层?依赖注入有什么用?Mapper怎样绑定参数? 一个实际接口从DTO到SQL的路径
登录与权限 登录和授权有何区别?身份从哪里来?CSRF防什么问题? Session登录、服务端资源权限与测试
网络与系统 HTTP请求和响应包含什么?Cookie放在哪里?端口冲突怎样排查? 浏览器网络面板、启动日志与端口检查
测试与交付 mock能验证什么?真实数据库测试补什么?README如何复现? 核心用例、构建记录和备份恢复

算法每周保留2—4题的有效练习即可,题量可按起点调整。读题后先用例子讲思路,自己写,检查空输入、重复值与边界,最后解释时间和空间复杂度。隔几天关掉答案重写一题,比当晚连续看十个题解更能判断掌握程度。

建议顺序:数组与字符串 → 哈希表 → 双指针 → 链表 → 栈队列 → 二分 → 二叉树 → BFS/DFS。遇到明显超出当前岗位与时间预算的竞赛难题,可以先回到基础题。任何平台的免费范围都可能改变,核心任务可以在本地 IDEA 用自造用例练习,不依赖某个付费题库。

简历和项目介绍怎样写

简历先用一页,内容按教育、实际技能、项目、其他可验证经历组织。教育写2028年预计毕业;技能写自己能解释和使用的部分,避免“精通Java全栈”这样的空泛表述。项目按问题、做法和证据写,不把依赖名称堆成一段。

下面是未来完成后可以改写的模板,现在不能直接当作已完成成果:

设备运维工单平台,个人学习项目,实际时间:____。基于 Java、Spring Boot、MyBatis 和 MySQL 实现报修、派单、处理、验收及退回流程;使用 Session 与服务端资源权限限制不同角色访问。通过状态与版本条件更新防止重复派单,并验证工单与操作记录的事务一致性。编写核心业务、权限和真实数据库测试,提供可复现启动与备份恢复说明。

若真实做过索引实验,再补“在_数据与环境下,对_查询比较前后执行计划和耗时,得到____结果”;没有数据就不补百分比。还没有独立部署到Linux时,不写“熟练Linux部署”,写已完成的本机jar交付即可。

一分钟自我介绍结构:我是谁与毕业时间 → 目标Java实习和真实到岗安排 → 做过的项目及最能说明能力的一个问题 → 希望承担哪类开发任务。练到自然清楚即可,不需要把所有知识点一次说完。

独立学习与排错方法

每遇到问题,先把它变成可复现的小问题:写出输入、预期、实际、错误堆栈和最近改动。先读最靠近原因的异常,再检查配置与代码路径。查资料时带上框架版本和错误关键句,优先阅读对应官方文档;不要遇到一个报错就换一套技术栈。

如果30—45分钟仍无进展:把问题缩到最小示例,暂时回到最后能工作的提交,记录已经排除的原因。可以请同学、老师或技术社区帮助,并提供最小代码与完整错误信息;分享前移除密码和个人信息。当天仍可以完成不依赖该阻塞的复习、SQL练习或文档任务。

每周只保留两个最重要的补缺目标。你的核心成果是自己写出的程序、可复现的验证记录和能解释的理解;课程正文、自测答案与项目设计均提供离线版,可结合官方文档继续学习。

资料索引与排错手册

这份索引用来在自学时快速找到当前任务需要的材料。一次只打开当前主题的章节,优先用项目中的小实验验证理解。入口于2026年10月12日核对;外部网站内容和版本可能继续更新,正文练习不依赖付费课程。

按阶段选择资料

编号 资料 什么时候读什么
R01 Dev.java 学习入口 第1—3周读对象、集合、异常;第14周再读线程主题。英文概念先与本包中文解释对照
R02 Pro Git 中文版 第1周看起步与Git基础;先学status、diff、add、commit、log,后面再学分支
R03 Maven 入门 第3周看pom、标准目录、compile/test/package;不先研究自定义插件
R04 MySQL 教程 第4—5周看连接、建表、查询、多表与聚合;完成自己的SQL而不是通读整本手册
R05 Connector/J 兼容说明 第6周连接前核对JDK、数据库服务端与驱动版本,不把动态最新驱动当作旧服务器必然兼容
R06 Spring REST 入门 第7周观察请求参数如何变成JSON响应,再改成工单接口
R07 Spring Boot 系统要求 创建Web项目时核对正式版本、Java和构建工具要求
R08 MyBatis Spring Boot Starter 第8周看兼容表、Mapper、配置与XML示例
R09 Spring Security 用户名密码认证 第12周理解框架认证、UserDetailsService和密码编码
R10 Spring Security CSRF 第12周按页面形态处理token,并理解登录后刷新token
R11 Spring Boot 测试 第15周使用当前Boot版本配套的测试组件,确认测试职责
R12 Docker Compose 入门 第17周再学服务、网络、数据卷;容器化之前先让jar能启动
R13 Hello 算法 每周基础时间阅读对应数据结构,选择Java示例;不必追求一次读完

资料网站提供阅读入口。需要离线时,使用网站自身提供的下载入口或浏览器保存所需页面;保留出处,不把下载材料当作自己原创内容。此包已包含中文任务解释和自测答案,但没有内嵌第三方整本书或视频。

环境清单

阶段 必须具备 先怎样确认
第1周 JDK、编辑器或IDE 编译运行一个只有main的类;不要用运行README判断Java环境
第3周 Java21、Git、Maven java -version、javac -version、git --version、mvn -v,Java路径一致
第4周 可连接的MySQL练习库 客户端能执行SELECT VERSION();;能创建和查询自己练习库的表
第7周 Web项目依赖和空闲端口 先启动最小接口,再接数据库与页面
第12周 三角色演示账号 密码由框架编码;测试账号只用于本地练习
第17周 可运行jar的环境 本机/本地Linux均可;Docker与云主机不是第一阶段前置条件

工具下载从Temurin JDK、Maven、Git与MySQL各自入口选择。已有可用工具先复用,不重复安装多个版本。教学不要求购买IDE;是否支持某个便捷界面不会改变Java命令行构建的原理。

IDEA中的项目SDK、终端PATH和Maven使用的Java可能不同。终端显示Java8而项目配置写Java21时,先找到实际安装路径,并让本项目明确使用Java21。确认配置后重开终端再检查,不要仅修改语言级别就认为JDK已经切换。

固定版本的方法

在项目README记录Java、Maven、Spring Boot、MyBatis Starter、MySQL服务端和Connector/J版本。Web计划使用Java21、Boot4.1系列和MyBatis Starter4.0系列;以兼容表为依据。只使用正式版本,学习期间不因“出现新版本”频繁升级。

Spring Boot管理的依赖优先跟随其依赖管理清单。若使用本机MySQL8.0练习,仍须核对被选中的驱动能否连接8.0;使用较新驱动不能自动推导为旧服务端兼容。课程JDBC阶段若指定驱动版本,按该阶段说明核对;进入Web阶段再检查实际依赖树。升级另建练习环境,不直接替换其他项目的数据库。

排错顺序

先记下“在哪一步失败”:编辑/编译、程序启动、收到请求、执行SQL、浏览器展示。按层检查比同时改很多配置有效。

现象 优先检查 最小验证
没有Java Class创建选项 src是否被识别为源码目录;项目SDK是否设置 在源码目录新建普通类,确认能够编译
找不到或无法加载主类 类名、包名、编译输出目录、classpath 先运行只有main的TicketDemo
release version 21 not supported Maven实际使用的JDK 对比java -version与mvn -v中的Java版本
UnsupportedClassVersionError 编译版本高于运行版本 用同一个Java21编译、运行
Maven无法下载依赖 网络、仓库响应、版本是否存在、错误日志 先重试一个最小项目;不要随意复制来历不明的仓库配置
文件找不到 运行工作目录与实际Path 打印path.toAbsolutePath(),确认目标文件
中文乱码 源文件、读写流、终端的编码 UTF-8写一行再UTF-8读回,分开判断文件与显示问题
加载后数据翻倍 是否向同一列表重复追加 用两条记录连续加载两次,观察列表长度
数据库连接被拒绝 服务是否启动、主机、端口、网络 数据库客户端用相同主机端口连接
Access denied 用户名、密码、来源主机及权限 用同一账户在客户端连接,不在日志打印密码
表或列不存在 当前数据库、建表脚本、命名 SELECT DATABASE();与SHOW CREATE TABLE ticket;
MyBatis查到但字段为空 列名与属性映射、构造方式、getter 只查id和title两列,确认resultMap或映射策略
Mapper重复注册 扫描范围、多个配置、重复Bean 保留一种明确的扫描方式,阅读启动错误中的Bean名
404 请求路径、方法、Controller扫描包 对照启动映射和最小GET接口
400 JSON格式、Content-Type、字段类型与校验 发送文档中的最小合法JSON
401 是否登录、Cookie是否随请求发送 查看登录响应和下一次请求的Cookie
403 权限是否允许、CSRF是否缺失/过期 GET与POST分开试;检查token与Cookie,不直接关闭防护
页面能看到按钮但接口拒绝 前端展示不代表服务端授权 在HTTP客户端复现,按用户身份检查规则
事务没有回滚 调用是否经Spring代理、是否吞掉异常、多个写操作是否共用事务 在日志写入前故意抛运行时异常,仅在练习库检查两处均未提交
两次派单都成功 SQL是否含状态/版本条件,是否检查影响行数 从相同初始版本分别发送请求并检查最终记录
端口占用 旧程序仍在运行或配置冲突 找到自己的旧实例正常停止,或给练习实例换端口
关闭程序后数据没了 是否仍在用内存实现或容器临时层 确认实际连接的库与持久化位置,重启前后查询同一库

不要用删除数据库、删除数据卷、关闭权限校验或把所有异常忽略作为通用修复手段。先保留复现证据,确认错误原因后只修改必要部分。

看答案后仍不理解

把解释拆成三句话:输入是什么、这一行改变了什么、输出或异常是什么。用两个不同的输入手工执行一遍。比如构造器调用校验方法,是让创建与修改遵守相同规则;private本身不会判断数值范围。

每次看完参考代码,关闭它并改变一项需求:标题上限、筛选条件、状态操作或查询字段。能根据变化调整实现,才算从复制过渡到理解。

验收与记录模板

这些模板用于自己判断进度,也能在简历和面试时提供真实证据。每周复制一份填写即可,不要求使用特定笔记软件。不要把阅读、跟写和独立完成记成同一状态。

每周记录

第__周,实际日期:____至____,实际投入:__小时
本周目标:
本周完成的任务:
独立完成的代码/SQL:
参考答案后完成的部分:
仍不能解释的概念:
正常输入与运行结果:
至少一个边界输入与结果:
Git提交编号或本地版本备份:
本周一个错误及原因:
验收:通过 / 需补课 / 顺延
下周第一个小任务:

20周进度勾选

填写时使用:未开始、已阅读、参考完成、独立完成、验收通过。只有满足该周具体标准才勾选。

  • □ 第01周 工单对象与封装
  • □ 第02周 集合与文件存档
  • □ 第03周 分层与构建
  • □ 第04周 建表与CRUD
  • □ 第05周 查询与事务
  • □ 第06周 JDBC与HTTP
  • □ 第07周 Web接口
  • □ 第08周 MyBatis
  • □ 第09周 状态流转
  • □ 第10周 分页评论与错误处理
  • □ 第11周 页面联调
  • □ 第12周 登录与退出
  • □ 第13周 资源权限
  • □ 第14周 并发派单
  • □ 第15周 核心自动测试
  • □ 第16周 可投递MVP
  • □ 第17周 部署与恢复
  • □ 第18周 排错与性能证据
  • □ 第19周 补缺或扩展
  • □ 第20周 项目表达与简历

功能验收单

项目 填写内容
功能 例如管理员派单
前置数据 工单101为OPEN、version=0;管理员已登录
操作 给工程师2派单,带期望版本0
预期 一次成功;状态IN_PROGRESS;版本递增;操作日志一条
实际 自己填写响应、数据库记录或测试结果
异常场景 重复请求、错误角色、错误工程师、日志插入失败
独立小改 例如新增一个允许的筛选条件或补充参数校验
结论 通过或待修,写出原因

验收只在专用练习库进行。并发实验前记录初始状态,实验后检查结果;不要把前一次实验留下的数据当成新实验起点。

缺陷报告

标题:在什么条件下出现什么错误
环境:Java / Boot / 数据库 / 浏览器版本
前置数据和用户角色:
最小复现步骤:1. ... 2. ... 3. ...
预期结果:
实际结果:
相关错误与日志:
已排除的原因:
根本原因:
修改了什么及为什么:
如何防止再次出现:回归用例/约束/校验
验证结果:

项目README清单

  • □ 说明项目解决的问题、角色和核心流程。
  • □ 列出实际使用的版本和所需工具。
  • □ 有可复制的初始化、启动、测试命令及预期结果。
  • □ 有配置示例,但没有真实密码或密钥。
  • □ 有演示账号的本地初始化方式和虚构数据。
  • □ 说明表结构、核心接口和错误处理。
  • □ 说明权限边界、并发保护与事务一致性。
  • □ 记录真实测试、排错和性能测量条件。
  • □ 说明已知限制及尚未实现的扩展。
  • □ 能在新的练习环境依说明复现。

项目讲解提纲

用5—8分钟讲清:谁需要这个系统;一张工单如何流转;自己实现哪些模块;为什么这样组织代码;一次真实问题如何定位;如何证明权限和并发正确;当前版本的局限。

技术追问时不要背一整段宣传文字。先说具体场景,再说方案与验证;没有实际测量就不写吞吐量,没有真实用户就不写服务人数。

投递记录

日期 公司及岗位链接 是否接受2028届 到岗时间与天数 连续月数 当前状态 反馈与下一步
待填写 待填写 待核对 待核对 待核对 未投递 待填写

状态可用未投递、已投、笔试、面试、等待结果、录用、结束。每周选择一次统一更新,依据实际反馈改简历或补知识,不用每天反复刷新。

设备工单的数据与接口约定

这份约定在第4周以后逐步使用,避免不同阶段对字段和业务含义理解不同。第一周只实现Ticket编号与标题,不需要预先建全部表或接口。它描述计划中的最终约定,具体SQL和代码随课程完成。

角色与状态

角色固定为REPORTER、ENGINEER、ADMIN。REPORTER只能查看和操作自己的工单;ENGINEER只能查看和处理分配给自己的工单;ADMIN可看全部并维护设备、对待派单工单派单。演示系统不提供公开注册,不让前端自行指定当前身份。

OPEN → IN_PROGRESS → RESOLVED → CLOSED
          ↑              │
          └── reject ────┘

assign由管理员触发;resolve由当前工程师触发;accept/reject由原报修人触发。关闭后第一版不再变更。所有状态变更都检查当前状态和权限,最终版本带期望版本以识别并发冲突。评论本身不变更工单状态。

字段约定

Java属性用驼峰,例如reporterId;SQL用下划线,例如reporter_id。编号用Java long/Long、MySQL BIGINT。还没分配的处理人用NULL,不用0冒充用户。标题trim后长度1—50;本课程Java长度按String.length计算,验收长短边界使用英文字母。

表 关键字段 约束与含义
app_user id, username, password_hash, role, enabled username唯一;role为三个角色之一;停用保留历史记录
device id, code, name, location, enabled code唯一;停用后不再接受新报修;已有历史保留
ticket id, title, description, device_id, reporter_id, assignee_id, status, version, created_at, updated_at device/reporter存在;assignee可空;创建状态OPEN、version=0
ticket_comment id, ticket_id, author_id, content, created_at 有访问权限者评论;内容非空;身份取自会话
ticket_action_log id, ticket_id, actor_id, action, from_status, to_status, note, created_at 记录关键状态操作;与状态更新同事务;创建的from_status可空
ticket_resolution id, ticket_id, engineer_id, content, created_at 每次提交处理结果保存一条;验收退回后可以再次提交

先在ops_ticket_dev练习,自动测试使用独立的ops_ticket_test;恢复演练另建ops_ticket_restore。建表脚本分版本保存在项目中,不让自动测试连接自己仍需保留的开发数据。

全课程的基础样本

对象 编号与内容
用户 1 reporter_a、2 engineer_a、3 admin_a、4 reporter_b
设备 1 PJ-001投影仪、2 AC-001空调、3 PR-001打印机
工单101 device_id=1,reporter_id=1,assignee_id=NULL,status=OPEN,version=0
工单102 device_id=2,reporter_id=1,assignee_id=2,status=IN_PROGRESS,version=0
工单103 device_id=1,reporter_id=4,assignee_id=NULL,status=OPEN,version=0

这个样本应查询到3张工单:OPEN有2张,IN_PROGRESS有1张;设备1有2张、设备2有1张、设备3有0张。做聚合时是否显示0条设备,是INNER JOIN与LEFT JOIN练习的区别。

做权限实验可额外创建engineer_b,验证工程师之间不能互相操作。建表阶段的password_hash可以用明确的非登录占位值;第12周必须由PasswordEncoder生成演示密码的哈希后再验证登录。占位值不是可用密码,更不是加密实现。

各实验改变状态后,使用新的测试记录或专用测试库重新准备初始条件。不要依赖在开发库全表删除来重置实验。

请求与响应

请求 主要输入 成功结果 主要规则
POST /api/tickets title, description, deviceId 201,返回工单id与初始状态 reporter取当前用户;设备必须启用
GET /api/tickets page, size, status等 200,返回items与total page从1起,size限制1—100;先限制可见范围再筛选
GET /api/tickets/{id} 路径编号 200,返回详情,含actionLogs与resolutions 先检查资源是否存在及当前用户的访问范围
POST /api/tickets/{id}/assign engineerId, expectedVersion 200,返回新状态与版本 管理员、OPEN、工程师存在且启用
POST /api/tickets/{id}/resolve content, expectedVersion 200,返回新状态与版本 当前工程师、IN_PROGRESS、处理说明非空
POST /api/tickets/{id}/accept expectedVersion 200,返回CLOSED与版本 原报修人、RESOLVED
POST /api/tickets/{id}/reject reason, expectedVersion 200,返回IN_PROGRESS与版本 原报修人、RESOLVED、退回原因非空
POST /api/tickets/{id}/comments content 201,返回评论id 有工单访问权、内容非空
GET /api/tickets/{id}/comments page, size 200,分页返回评论 先检查工单访问权,再按时间和id正序读取
GET /api/devices 可选启用状态 200,设备列表 已登录用户可读;创建报修时只选择启用设备
POST /api/devices code, name, location 201,返回设备id 仅管理员;编码唯一、名称非空
PATCH /api/devices/{id}/enabled enabled 200,返回设备状态 仅管理员;停用保留历史工单

时间显示采用明确时区,列表固定排序,例如created_at DESC, id DESC。创建时间相同也能保持相对顺序;分页时插入新数据仍可能使后续页移动,第一版接受这一限制并记录。

Web版创建请求的description去掉首尾空白后长度为1—2000;评论content为1—1000。标题、描述和评论的请求校验与页面提示保持一致。详情中的日志和处理记录同样受工单访问权限保护。

状态码约定:400输入格式或校验错误;401身份未认证;403无权限或CSRF校验失败;404资源不存在;409当前状态或版本冲突。访问别人工单在本教学项目按403处理;若选择统一404以隐藏资源存在性,须保持一致并调整对应测试。

CSRF校验可能先于认证失败处理:未登录GET受保护API通常401;缺失CSRF的POST可能先403。错误响应不能只靠状态码猜原因,应读取错误体和服务端日志。

认证方案

第12周使用Spring Security框架formLogin、Session Cookie和框架密码编码。静态页面与API同源,避免一开始引入跨域复杂度。登录请求使用表单字段username/password,携带有效CSRF token;登录成功由框架保存会话认证。登录和退出后按课程重新取得token。

API认证失败与禁止访问返回JSON错误;页面可以自行跳转登录页。后端不接受请求体传入的actorId或reporterId作为当前用户身份。前端隐藏按钮只能改善界面,不能替代服务端授权。

并发与事务

派单必须把“判断仍为OPEN且版本相同”和“写入新处理人”放进一次条件UPDATE;检查影响行数为1后写操作记录。受影响行为0表示条件已变化,应返回冲突,不把它当作正常完成。

同一事务覆盖工单更新与操作记录,提交结果才能一起可见。需要持久化处理说明时,处理记录同样参与事务。单独在Java里先查状态再无条件更新,会在并发时留下覆盖窗口;仅在一个JVM里synchronized也无法保护多个进程的数据库写入。

核心SQL与实现练习在各周参考答案中给出,所有实验只针对自己创建的练习数据。

第 1 至 6 周自测答案与关键示例

配合第 1 至 6 周课程使用。先保留自己的尝试、报错和预测结果,再读对应小节。Java 代码块多数是局部片段,不能直接作为独立文件运行;需要放进相应类,并由你补全 import、字段、getter、调用和边界处理。全部示例使用虚构数据。

每周过关不以“复制后运行成功”为标准:合上答案,改变一个输入,先预测结果,再独立解释或修改代码。环境和第三方依赖首次下载通常需要网络;这套材料不要求订阅任何 AI 服务。

第 1 周答案

题 1:为什么构造器也要校验? 创建对象也是输入数据的入口。如果构造器直接保存空标题,非法对象在第一次调用 rename 前已经存在。构造和修改应共用校验规则,保证任何正常完成的创建或修改都留下合法状态。

题 2:两个变量指向同一对象会怎样? Ticket b = a 复制引用,没有复制对象。通过 b.rename("新标题") 修改同一个对象后,a.getTitle() 也返回“新标题”。只有再次 new Ticket(...) 才创建另一张对象。

题 3:为什么不用 static 字段? static 字段属于类,所有实例共享。如果标题是 static,改第一张可能让第二张读取相同的新标题。每张工单自己的数据必须是实例字段。main 是 static,是因为程序入口不需要先创建 Demo 对象。

关键校验可以这样写在 Ticket 类内部。这里的 normalizeTitle 是私有静态工具方法:它只处理参数,不访问任何一张工单的字段,因此与“把 title 字段变成 static”不同。

private long id;
private String title;

public Ticket(long id, String title) {
    if (id <= 0) {
        throw new IllegalArgumentException("编号必须大于0");
    }
    String validTitle = normalizeTitle(title);
    this.id = id;
    this.title = validTitle;
}

private static String normalizeTitle(String value) {
    if (value == null) {
        throw new IllegalArgumentException("标题不能为空");
    }
    String normalized = value.trim();
    if (normalized.isEmpty() || normalized.length() > 50) {
        throw new IllegalArgumentException("标题长度必须为1至50");
    }
    return normalized;
}

public void rename(String newTitle) {
    this.title = normalizeTitle(newTitle);
}

先算赋值右边:校验抛异常时,赋值还没有发生,旧标题保留。getter 由你补上。不要先 this.title = newTitle 再判断,否则异常发生后对象已经被非法输入污染。

下面放在 Demo 的 main 中检查失败修改。每个失败用例单独捕获异常,才能继续验证后面的用例。

Ticket ticket = new Ticket(1L, "  投影仪无法开机  ");
try {
    ticket.rename("   ");
    throw new AssertionError("这里本应拒绝空标题");
} catch (IllegalArgumentException expected) {
    System.out.println(expected.getMessage());
}
System.out.println(ticket.getTitle()); // 投影仪无法开机

常见错误:void Ticket(...) 是普通方法,不是构造器;title = title 只给参数自身赋值;先 trim 再判断 null 会产生 NullPointerException;只判断 isEmpty() 无法排除全空格。检查 50 与 51 的边界,条件应为 > 50,不是 >= 50。

数组复习题:先约定空数组抛异常,再把第一个元素作为当前最大值,循环比较剩下的元素;不要把最大值初始设为 0,否则 [-5,-2,-8] 会错误返回 0。时间复杂度 O(n),额外空间 O(1)。

第 2 周答案

题 1:重复 Map.put 会怎样? 相同键的新值会替换旧值。创建时应先检查 containsKey(id) 并拒绝重复,或使用 putIfAbsent 检查返回值。本阶段是单线程,先检查再放入足够;并发时不能直接推广这个结论。

题 2:为什么不从 1 开始? 文件里已经有旧编号,重新从 1 开始可能覆盖或与旧记录冲突。读取全部记录后,空集合用 1,否则用现有最大 id 加 1。这仅用于单进程练习;数据库阶段改成 AUTO_INCREMENT,后期并发不能用 MAX(id)+1。

题 3:明明保存却找不到,查什么? 先打印保存和读取的绝对路径,判断工作目录是否不同;再检查写入是否真正完成、异常是否被吞掉,且保存成功提示是否放在实际写入成功之后。src 是代码目录,不自动等于运行的工作目录。

状态与派单的最小实现。字段名和方法名放在 Ticket 中,getter 自行补全。

public enum TicketStatus {
    OPEN, IN_PROGRESS, RESOLVED, CLOSED
}
private TicketStatus status = TicketStatus.OPEN;
private Long assigneeId; // null表示还没有处理人

public void assign(long engineerId) {
    if (engineerId <= 0) {
        throw new IllegalArgumentException("工程师编号必须大于0");
    }
    if (status != TicketStatus.OPEN) {
        throw new IllegalStateException("只有待派单工单可以派单");
    }
    assigneeId = engineerId;
    status = TicketStatus.IN_PROGRESS;
}

这里还没有用户库,所以只能校验编号正数,不能证明这个编号真的是启用的工程师。进入 Service 和数据库后增加相应检查。不要将 assign 当作任意修改处理人的 setter。

普通循环筛选示例,tickets 是已有 List。返回新列表,不删除原列表里的其他状态。

List<Ticket> openTickets = new ArrayList<>();
for (Ticket ticket : tickets) {
    if (ticket.getStatus() == TicketStatus.OPEN) {
        openTickets.add(ticket);
    }
}

文件先只练一张,再写循环。以下代码需要 java.nio.file.*、java.nio.charset.StandardCharsets、java.io.*、java.util.Properties;放入声明了 throws IOException 的方法中,或由调用处处理 IOException。

Path path = Paths.get("data", "tickets.properties");
Files.createDirectories(path.toAbsolutePath().getParent());
System.out.println(path.toAbsolutePath());

Properties data = new Properties();
data.setProperty("count", "1");
data.setProperty("ticket.0.id", Long.toString(ticket.getId()));
data.setProperty("ticket.0.title", ticket.getTitle());
data.setProperty("ticket.0.status", ticket.getStatus().name());
data.setProperty("ticket.0.assigneeId",
    ticket.getAssigneeId() == null ? "" : ticket.getAssigneeId().toString());

try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    data.store(writer, "ops-ticket training data");
}
System.out.println("保存成功");

读取先保存到临时 Properties 和临时集合。只有全部通过校验,才替换程序原来的集合。不要读到一半报错,却把旧集合清空了。

Properties loaded = new Properties();
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    loaded.load(reader);
}
String rawId = loaded.getProperty("ticket.0.id");
long id = Long.parseLong(rawId);
String title = loaded.getProperty("ticket.0.title");
TicketStatus status = TicketStatus.valueOf(
    loaded.getProperty("ticket.0.status"));
String rawAssignee = loaded.getProperty("ticket.0.assigneeId");
if (rawAssignee == null) {
    throw new IllegalArgumentException("第0条缺少assigneeId字段");
}
Long assignee = rawAssignee.isEmpty() ? null : Long.valueOf(rawAssignee);

接着把这些值交给专门的恢复入口,不用“创建后反复派单”来恢复旧状态。一个可对照的核心片段:

public static Ticket restore(long id, String title,
        TicketStatus status, Long assigneeId) {
    if (status == null) {
        throw new IllegalArgumentException("状态不能为空");
    }
    if (status == TicketStatus.OPEN && assigneeId != null) {
        throw new IllegalArgumentException("OPEN工单不能已有处理人");
    }
    if (status != TicketStatus.OPEN
            && (assigneeId == null || assigneeId <= 0)) {
        throw new IllegalArgumentException("非OPEN工单必须有正数处理人");
    }
    Ticket ticket = new Ticket(id, title);
    ticket.status = status;
    ticket.assigneeId = assigneeId;
    return ticket;
}

restore 仅用于存储恢复,不开放为用户操作入口。第 3 周拆包后继续收紧调用位置。新增字段时记得同时更新保存与恢复逻辑。

独立扩展:读取 count,校验为非负整数,用 for (int i = 0; i < count; i++) 生成前缀 "ticket." + i + "."。检查每个必填键存在、重复编号、非法数字和未知枚举。缺文件可以返回空集合;已有文件损坏必须报错,不可伪装成首次启动。写入会覆盖指定文件,本阶段只用自己的练习数据,并在试坏数据前复制备份;原子替换与并发写入不作为本周要求。

常见错误:字符串比较用 ==;在 foreach 中直接删除原集合;读状态用中文“待派单”却保存英文 OPEN;漏存处理人;捕获所有异常后返回空列表。Properties 的 Reader/Writer 方式可明确使用 UTF-8,别混用其默认 ISO-8859-1 的字节流方法。Properties 官方 API

第 3 周答案

题 1:为什么不在业务方法里不断 new 文件仓库? 这样业务被具体存储绑住,每次还可能重复加载或得到不同的内存状态。将 Repository 通过构造器传入,业务只调用约定接口,同一个对象的依赖清晰,也方便用内存实现做练习。

题 2:换存储时什么变、什么不变? 启动组装位置改实现,Repository 实现内部改变;标题规范、允许的状态迁移、用户看到的成功或失败含义应保留。若一个实现允许重复编号,另一个拒绝,就没有遵守统一约定。

题 3:release 21 报错查什么? 查看 mvn -v 输出里的 Java 版本与 Java home,并与 java -version、javac -version、IDEA Project SDK 及 Maven Runner JRE 比较。Maven 使用较旧 JDK 时,只把 pom 的 release 改为 21 无法让它编译 Java 21。

构造器传入依赖及改标题的关键代码:

public class TicketService {
    private final TicketRepository repository;

    public TicketService(TicketRepository repository) {
        this.repository = java.util.Objects.requireNonNull(repository);
    }

    public void rename(long id, String title) {
        Ticket ticket = repository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("工单不存在"));
        ticket.rename(title);
        repository.update(ticket);
    }
}

启动处 new TicketService(new InMemoryTicketRepository())。不要在 Service 中读键盘,也不要在 Repository 中决定哪些状态能派单。文件写入的 IOException 可以保留原因包装成存储失败异常,交互层显示失败;不能吞掉异常打印成功。内存版如果直接返回可变 Ticket,字段修改会提前反映在内存,这只是练习实现的限制;仍显式 update,后续用数据库事务处理多步修改的一致性。

最小 Maven pom 示例,文件位于项目根目录。它只承担 Java 编译,不包含 Spring 或测试框架;此时 mvn package 成功也不等于已验证业务行为。

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>ops-ticket</artifactId>
  <version>0.1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
    </plugins>
  </build>
</project>

代码目录与包名对应:src/main/java/com/example/opsticket/ConsoleApp.java 的第一行是 package com.example.opsticket;。在项目根目录分别运行:

mvn -v
mvn package
java -cp target/ops-ticket-0.1.0.jar com.example.opsticket.ConsoleApp

最后一条用于尚无外部运行依赖的第 3 周程序;第 6 周加 JDBC 后可先用 IDEA 带依赖运行,第 7 周用 Spring Boot 的打包方式。若项目有 Maven Wrapper,Windows 对应命令为 ./mvnw.cmd package。首次构建会下载工具或插件,准备完整后 mvn -o package 才可能成功。Maven 入门;Compiler Plugin 版本与要求。

关键词筛选可先按原样匹配:校验关键词非 null,trim 后为空返回全部或报错,选一种并写进说明;对每张工单用 getTitle().contains(keyword),加入新列表。两数之和的 Map 解法:遍历到 x 时先查 target - x 是否已出现,找到才返回两下标,然后再放入 x,避免同一个下标使用两次;[3,3]、目标 6 是必要边界。

常见错误:默认包里的类没有随目录加 package;Optional.get() 查不到会抛异常;仅新建接口但业务仍引用具体类;手改文件后程序还使用启动前的旧缓存;把文件写失败当作成功。Git 提交先看 diff,提交自己本次修改的源文件和配置,不提交 target、数据库密码或机器专属路径。

第 4 周答案

题 1:为什么不用 = NULL? NULL 表示缺失或未知值,与它用普通等号比较得不到“相等为真”。筛选缺失值用 IS NULL,有值用 IS NOT NULL。SQL NULL 不等于字符串 'NULL',也不等于数字 0。

题 2:改设备名为什么不用改工单编号? 工单保存稳定的 device_id,关联时读取设备当前名称。同一设备改名仍是同一设备,id 不变。若未来有“保留报修当时名称”的业务需求,再专门设计快照字段。

题 3:外键能否保证处理人是工程师? 不能。外键仅保证编号引用的用户存在;角色和启用状态还需 Service 校验。用户 1 即使存在,也只是 REPORTER。

以下是三张表的参考结构。仅在自己的空练习库首次运行;重复执行 CREATE TABLE 已存在报错是正常提示,不要为了图省事删除整个数据库。把自己写的版本与它逐列对照。

CREATE DATABASE IF NOT EXISTS ops_ticket_dev
  CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE ops_ticket_dev;

CREATE TABLE app_user (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(64) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(16) NOT NULL,
  enabled BOOLEAN NOT NULL DEFAULT TRUE,
  CONSTRAINT ck_user_role CHECK (role IN ('REPORTER','ENGINEER','ADMIN'))
) ENGINE=InnoDB;

CREATE TABLE device (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  code VARCHAR(64) NOT NULL UNIQUE,
  name VARCHAR(100) NOT NULL,
  location VARCHAR(200) NOT NULL,
  enabled BOOLEAN NOT NULL DEFAULT TRUE
) ENGINE=InnoDB;

CREATE TABLE ticket (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(50) NOT NULL,
  description VARCHAR(2000) NOT NULL DEFAULT '',
  device_id BIGINT NOT NULL,
  reporter_id BIGINT NOT NULL,
  assignee_id BIGINT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
  version BIGINT NOT NULL DEFAULT 0,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),
  CONSTRAINT fk_ticket_device FOREIGN KEY (device_id) REFERENCES device(id),
  CONSTRAINT fk_ticket_reporter FOREIGN KEY (reporter_id) REFERENCES app_user(id),
  CONSTRAINT fk_ticket_assignee FOREIGN KEY (assignee_id) REFERENCES app_user(id),
  CONSTRAINT ck_ticket_title CHECK (CHAR_LENGTH(TRIM(title)) BETWEEN 1 AND 50),
  CONSTRAINT ck_ticket_status CHECK (
    status IN ('OPEN','IN_PROGRESS','RESOLVED','CLOSED')),
  CONSTRAINT ck_ticket_assignment CHECK (
    (status = 'OPEN' AND assignee_id IS NULL) OR
    (status IN ('IN_PROGRESS','RESOLVED','CLOSED') AND assignee_id IS NOT NULL)),
  CONSTRAINT ck_ticket_version CHECK (version >= 0)
) ENGINE=InnoDB;

本结构使用 MySQL 8.0.16 及以上执行的 CHECK 约束;先执行 SELECT VERSION(),确认服务端为 MySQL 8.0.16 及以上或 8.4,再练习这些 SQL。客户端版本不能代表服务端版本。Java 的 trim 与 String.length 是应用层标题规则,数据库这条 CHECK 只做基本补充,不声称两种字符处理完全等价。

种子数据只插入一次。占位哈希不能用于第 12 周的正式登录演示;到时由 PasswordEncoder 生成并更新,且不返回到前端。

INSERT INTO app_user (id,username,password_hash,role,enabled) VALUES
 (1,'reporter_a','NOT_A_LOGIN_HASH','REPORTER',TRUE),
 (2,'engineer_a','NOT_A_LOGIN_HASH','ENGINEER',TRUE),
 (3,'admin_a','NOT_A_LOGIN_HASH','ADMIN',TRUE),
 (4,'reporter_b','NOT_A_LOGIN_HASH','REPORTER',TRUE);

INSERT INTO device (id,code,name,location,enabled) VALUES
 (1,'PJ-001','投影仪','A101',TRUE),
 (2,'AC-001','空调','A102',TRUE),
 (3,'PR-001','打印机','办公室',TRUE);

INSERT INTO ticket
 (id,title,description,device_id,reporter_id,assignee_id,status,version,created_at,updated_at)
VALUES
 (101,'投影仪无法开机','按电源键无响应',1,1,NULL,'OPEN',0,
  '2026-11-02 09:00:00','2026-11-02 09:00:00'),
 (102,'空调不制冷','运行十分钟仍无冷风',2,1,2,'IN_PROGRESS',0,
  '2026-11-02 10:00:00','2026-11-02 10:00:00'),
 (103,'投影画面闪烁','更换输入源仍闪烁',1,4,NULL,'OPEN',0,
  '2026-11-02 11:00:00','2026-11-02 11:00:00');

基础 SQL 参考。更新前的 SELECT 是帮助自己确认范围,不能替代事务或并发保护。

SELECT id,title FROM ticket WHERE status='OPEN' ORDER BY id;
SELECT id,title FROM ticket WHERE reporter_id=1 ORDER BY id;
SELECT id,title FROM ticket WHERE assignee_id IS NULL ORDER BY id;
SELECT id,title FROM ticket WHERE title LIKE '%投影%' ORDER BY id;

SELECT id,name,location FROM device WHERE id=1;
UPDATE device SET location='A103' WHERE id=1;
UPDATE device SET enabled=FALSE WHERE id=1;
SELECT id,device_id FROM ticket WHERE device_id=1;

验收后为了后续创建演示,重新启用设备 1:UPDATE device SET enabled=TRUE WHERE id=1;。练习删除另建一台没有工单引用的设备,并按确定的 id 删除;被工单引用的设备应保留记录并停用,外键不应改为级联删除工单。

常见错误:先插工单再插用户与设备;用中文顿号代替逗号;列名拼错;漏写 WHERE 修改了所有行;把布尔值、NULL 和字符串混用。Duplicate entry 先找冲突的唯一键;Cannot add or update a child row 先检查引用编号是否存在。数据库规范读 MySQL 建表,遇到错误按错误信息查相应小节。

第 5 周答案

题 1:INNER JOIN 处理人为什么丢行? 未派单工单的 assignee_id 是 NULL,没有匹配用户。INNER JOIN 只保留匹配成功的行,因此 101、103 被过滤掉。LEFT JOIN 保留工单,处理人相关列为 NULL。

题 2:为什么不 COUNT(*)? 打印机没有工单,LEFT JOIN 仍保留一行,COUNT(*) 得到 1;COUNT(t.id) 只数非空工单编号,得到 0。统计前还要检查其他关联是否把工单复制成多行。

题 3:失败后为什么显式回滚? 某条 SQL 失败通常不代表前面所有成功语句都已回滚;仍可能保留在未提交事务里。业务要求全成功或全失败时,异常路径主动 ROLLBACK,正常路径才 COMMIT,并释放连接。不要在失败后继续提交部分结果。

关联列表:

SELECT t.id,t.title,d.name AS device_name,
       r.username AS reporter_name,
       a.username AS assignee_name,t.status
FROM ticket t
JOIN device d ON d.id=t.device_id
JOIN app_user r ON r.id=t.reporter_id
LEFT JOIN app_user a ON a.id=t.assignee_id
ORDER BY t.id;

初始结果依次为:101 投影仪 reporter_a 无处理人 OPEN;102 空调 reporter_a engineer_a IN_PROGRESS;103 投影仪 reporter_b 无处理人 OPEN。

SELECT status,COUNT(*) AS total
FROM ticket GROUP BY status ORDER BY status;

SELECT d.id,d.name,COUNT(t.id) AS ticket_count
FROM device d
LEFT JOIN ticket t ON t.device_id=d.id
GROUP BY d.id,d.name
ORDER BY d.id;

SELECT id,title FROM ticket
ORDER BY created_at DESC,id DESC LIMIT 2 OFFSET 0;
SELECT id,title FROM ticket
ORDER BY created_at DESC,id DESC LIMIT 2 OFFSET 2;

状态统计只输出实际出现的状态,当前没有 RESOLVED、CLOSED 行,不代表错误。设备统计为 1 投影仪 2,2 空调 1,3 打印机 0。如果只统计 OPEN 且仍要求保留零工单设备,把 t.status='OPEN' 放在 LEFT JOIN 的 ON 条件内,而不是 WHERE 中。

事务实验在同一数据库连接逐条执行,先记下设备 1 当前位置;ROLLBACK 后恢复该原值,不必与教材初始值完全相同。

SELECT location FROM device WHERE id=1;
START TRANSACTION;
UPDATE device SET location='临时位置' WHERE id=1;
SELECT location FROM device WHERE id=1; -- 当前连接看到临时位置
ROLLBACK;
SELECT location FROM device WHERE id=1; -- 恢复实验前的位置

START TRANSACTION;
UPDATE device SET location='新教室' WHERE id=1;
COMMIT;

第二连接使用普通自动提交模式做查询,不提前开启长事务,分别观察第一连接提交前后。否则第二连接可能因事务隔离下的旧快照继续看到旧结果。此处只验证最小提交/回滚,不一次混入所有隔离级别。

EXPLAIN SELECT id,title,created_at FROM ticket
WHERE reporter_id=1
ORDER BY created_at DESC,id DESC LIMIT 20;

CREATE INDEX idx_ticket_reporter_created_id
  ON ticket(reporter_id,created_at,id);

EXPLAIN SELECT id,title,created_at FROM ticket
WHERE reporter_id=1
ORDER BY created_at DESC,id DESC LIMIT 20;

索引只创建一次。EXPLAIN 的 possible_keys 是候选,key 是实际选择,rows 是估算;不能把估算行数当成真实测量耗时。小表无明显差异是可接受结果,第 18 周用相同数据规模与条件做更完整性能实验。

常见错误:两个 app_user 没有起别名;JOIN 漏掉 ON 导致结果数量异常;将处理人条件放 WHERE 意外排掉未派单行;GROUP BY 只分组一列却随意查询不确定的其他列;认为建好索引一定快;事务开始后忘记结束。参考 JOIN及事务语句。

第 6 周答案

题 1:为什么不拼标题到 SQL? 标题可能包含单引号等字符,拼接会改变 SQL 结构或制造注入风险。固定语句使用占位符绑定值,让输入作为数据处理。参数绑定不替代标题业务校验,也不能自动保证权限。

题 2:为什么退出后数据库数据还在? 已提交数据由数据库持久化管理,不只存在 Java 进程内存中。Map 只在进程内;第 2 周能恢复是因为额外写了文件。不要把 commit 与 Java 对象仍在混为一谈。

题 3:200 和 JSON 是否够了? 还要核对响应内容、id、字段、当前身份与可见范围是否符合请求。200 只表达服务端报告成功,不自动证明业务实现正确;比如错误返回了另一张工单也仍可能带 200。

第 6 周独立 JDBC 练习在 pom 的 project 内添加 dependencies。示例固定 Connector/J 9.7.0,便于兼容既有 MySQL 8.0 与 8.4;进入 Spring Boot 后优先使用该 Boot BOM 管理的驱动,并核对数据库兼容范围。此版本为学习基线,不要求永远锁死。9.7.0 官方发布说明明确支持 MySQL Server 8.0 及以上;官方 Maven 坐标为下列 groupId 与 artifactId。

<dependencies>
  <dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.7.0</version>
  </dependency>
</dependencies>

在 IDEA 运行配置中设置 OPS_DB_URL、OPS_DB_USERNAME、OPS_DB_PASSWORD。URL 的基础形式为 jdbc:mysql://localhost:3306/ops_ticket_dev,主机、端口以你的实际环境为准。不要把真实密码写到源码或提交到 Git。连接设置不通时按具体认证与连接错误排查,不先复制一串关闭安全检查的参数。

最小连接与查询示例可放入 JdbcTicketDemo.main,声明 throws SQLException 用于观察首个实验错误。实际交互入口再增加清晰错误处理。requiredEnv 帮助在缺配置时立即指出变量名。

private static String requiredEnv(String key) {
    String value = System.getenv(key);
    if (value == null || value.isBlank()) {
        throw new IllegalStateException("缺少环境变量:" + key);
    }
    return value;
}

private static Connection openConnection() throws SQLException {
    return DriverManager.getConnection(
        requiredEnv("OPS_DB_URL"),
        requiredEnv("OPS_DB_USERNAME"),
        requiredEnv("OPS_DB_PASSWORD"));
}
try (Connection connection = openConnection();
     PreparedStatement statement = connection.prepareStatement("SELECT 1");
     ResultSet result = statement.executeQuery()) {
    if (result.next()) {
        System.out.println(result.getInt(1)); // 1
    }
}

需要导入 java.sql.Connection、DriverManager、PreparedStatement、ResultSet、SQLException,或学习阶段使用 import java.sql.*;。添加依赖后让 Maven 同步完成,不同时手动粘贴多份驱动 JAR。

创建方法核心示例如下。normalizedTitle 表示已经用第 1 周规则处理过的标题;方法调用者还应检查描述长度、用户与设备是否启用。这里展示参数绑定和生成编号,不是完整 Service。

static long insertTicket(String normalizedTitle, String description,
        long deviceId, long reporterId) throws SQLException {
    String sql = "INSERT INTO ticket "
        + "(title,description,device_id,reporter_id,status,version) "
        + "VALUES (?,?,?,?, 'OPEN',0)";
    try (Connection connection = openConnection();
         PreparedStatement statement = connection.prepareStatement(
             sql, Statement.RETURN_GENERATED_KEYS)) {
        statement.setString(1, normalizedTitle);
        statement.setString(2, description);
        statement.setLong(3, deviceId);
        statement.setLong(4, reporterId);
        if (statement.executeUpdate() != 1) {
            throw new SQLException("创建工单受影响行数不是1");
        }
        try (ResultSet keys = statement.getGeneratedKeys()) {
            if (!keys.next()) {
                throw new SQLException("没有取得生成的工单编号");
            }
            return keys.getLong(1);
        }
    }
}

此单语句演示使用新 JDBC 连接默认的自动提交。插入已提交却在读取生成键时异常,不应简单认为数据库一定没有新增;先按实际数据库状态排查,不要盲目重复提交。多步业务在第 9 周显式放入事务。数据库自增编号可能有空洞,不把它当连续计数器。

按编号查询时先绑定编号再执行,处理 ResultSet 的关键部分:

String sql = "SELECT id,title,assignee_id,status FROM ticket WHERE id=?";
try (Connection connection = openConnection();
     PreparedStatement statement = connection.prepareStatement(sql)) {
    statement.setLong(1, 101L);
    try (ResultSet result = statement.executeQuery()) {
        if (result.next()) {
            Long assignee = result.getObject("assignee_id", Long.class);
            TicketStatus status = TicketStatus.valueOf(result.getString("status"));
            System.out.println(result.getLong("id") + " | "
                + result.getString("title") + " | " + assignee + " | " + status);
        } else {
            System.out.println("工单不存在");
        }
    }
}

getLong 返回基本类型,SQL NULL 时要配合 wasNull();这里用 getObject(..., Long.class) 保留 null。参数从 1 编号,不是从 0。executeQuery 用于返回结果集的查询;executeUpdate 用于插入、更新、删除,返回受影响行数。驱动基本用法可查 DriverManager 官方示例。

把一次请求与响应画成文字也合格:

浏览器
  → GET /api/tickets/101
后端:检查身份和数据范围 → 查询数据库 → 组织响应
  → HTTP 200
  → Content-Type: application/json
  → {"id":101,"title":"投影仪无法开机","status":"OPEN"}
浏览器读取 JSON 并显示标题

这里是第 7 周将要实现的草图,第 6 周不声称已经有 HTTP 服务。JSON 属性名和字符串用双引号;数字可以不加引号。读取用 GET,创建用 POST;请求地址不是数据库连接 URL。HTTP 概览

报错或现象 先检查
No suitable driver Maven 依赖是否下载、IDEA 是否刷新、运行 classpath 是否带驱动、URL 是否以 jdbc:mysql 开头
Communications link failure 服务是否在运行,主机与端口是否正确;同配置的数据库客户端能否连接
Access denied 账号、密码、允许连接的主机与该库权限;不要把 SQL 语法当成原因
No operations allowed after connection closed 是否离开了 try 块后才使用连接或 ResultSet
Before start of result set 读取列前有没有先执行 result.next()
Parameter index out of range 占位符数量与 setXxx 下标是否对应,下标从 1 开始
中文乱码 源码、终端显示、表字符集、连接与实际存储逐层确认;不要用重复转码掩盖问题
查不到刚插入的记录 是否同一数据库、同一编号,事务是否提交;打印库名和编号,不要打印密码

最后独立小改:写 SELECT id,title,status FROM ticket WHERE status=? ORDER BY id,绑定 TicketStatus.OPEN.name(),循环读取所有行。能够不照抄完成这一小改,并解释每一层数据流,才是准备好进入 Spring Boot 的证据。

示例核验范围

提取第 1 周局部逻辑、补充测试壳后,用现有 JDK 8 编译运行,14 项检查通过。其他 SQL、JDBC 片段为教学参考,未连接数据库运行;这不是已经验收的完整项目。

第7至13周自测答案与关键实现提示

对应Web与权限课程。先完成自己的解答,再逐项对照;答案不要求逐字背诵。能把解释用于一个新例子,比记住术语更重要。

下列代码分为可以单独运行的最小示例和必须嵌入项目的局部示例,均明确标注。业务类、Mapper与Service应由你按课程逐步完成;本资料不是一个已构建、已联调的完整应用,也不替代你运行验收。

第7周答案和最小接口

7.1 创建请求为何不直接用数据库实体? 数据库实体含id、reporterId、assigneeId、status、version等服务端字段,客户端没有修改它们的权利。专门的DTO只接收本次操作允许传入的字段,Service再构造合法对象。即便JSON附带 status=CLOSED,也不能把它复制成最终状态;可拒绝未知字段或忽略未知字段,业务结果都不能被它改变。

7.2 手动new与构造器注入的区别? 手动new本身没有错,第3周就是这样组装对象的。但在Controller里到处new,会把业务和存储实现选择散落到多个入口,且自己new的Service通常不受Spring事务代理管理。构造器注入让对象依赖显式可见,由容器统一组装,也便于测试时传入替身。它不会自动让业务逻辑正确。

7.3 404、400、500分别先查什么? 404先核对URL、HTTP方法、Controller映射以及资源编号;400先看JSON格式、字段类型、约束和请求Content-Type;500先看服务器异常堆栈与导致失败的数据库或业务调用。连接被拒绝甚至没有HTTP响应时,应先查进程和端口,不能归类为500。

Boot工程依赖参考

在生成的Boot4.1工程里检查依赖。Web MVC使用 spring-boot-starter-webmvc,Validation使用 spring-boot-starter-validation;第12周才加 spring-boot-starter-security。这些依赖使用 org.springframework.boot groupId,版本跟随Boot父POM。Boot4官方已把旧 spring-boot-starter-web 标为由webmvc替代,不把旧教程整份POM复制过来。Boot依赖与Starter

Boot主类位于顶层包,以下是可放进已生成Boot工程的完整最小Controller,业务接口仍需自己实现。文件名为 PingController.java,位于 com.example.opsticket.controller 包。

package com.example.opsticket.controller;

import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PingController {
    @GetMapping("/api/ping")
    public Map<String, String> ping() {
        return Map.of("message", "ok");
    }
}

进入包含 pom.xml 与 mvnw.cmd 的工程根目录后运行:

.\mvnw.cmd -v
.\mvnw.cmd spring-boot:run

第一个命令显示Java21;第二个应保持运行,另开终端发送请求。退出用原终端Ctrl+C。依赖首次下载需要网络,后续离线构建还要求插件与依赖已缓存。

以下请求用于第7至11周本地尚未登录的开发阶段;第12周后使用后文Session与CSRF方法,不能继续发送匿名写请求。

$ticketBody = @{
    title = '教学楼投影仪无信号'
    description = '通电后指示灯亮,但无法显示输入画面'
    deviceId = 1
} | ConvertTo-Json

$createdTicket = Invoke-RestMethod -Uri 'http://localhost:8080/api/tickets' -Method Post -ContentType 'application/json; charset=utf-8' -Body ([System.Text.Encoding]::UTF8.GetBytes($ticketBody))
$createdTicket
Invoke-RestMethod -Uri ('http://localhost:8080/api/tickets/' + $createdTicket.id)

POST应201,响应至少含新id、title、deviceId、status和version;客户端取得的新id再用于下一次请求,不假定永远是104。

排错顺序: 端口8080被占用时先查占用进程,或明确改 server.port=8081并同步请求地址;找不到Controller时查包层级;返回JSON为空时查getter或DTO定义;不要加一堆 @ComponentScan 来掩盖目录错误。

第8周答案和Mapper

8.1 #{}与${}的区别? #{title}把内容作为绑定参数,单引号只是内容;${title}先把文本拼入SQL,改变SQL结构的风险由此产生。值用 #{}。列名和排序方向不能当普通值绑定,应在服务端白名单中选择固定SQL,本项目直接固定排序即可。

8.2 reporterId为什么为null? 先查SELECT是否选出 reporter_id且源数据非null;再查列名到属性名的映射或别名;最后查Java属性名、setter、类型是否一致。不是每个null都意味着数据库没数据。assigneeId初始允许null,应使用 Long,避免把“尚未派单”和编号0混在一起。

8.3 接口为何能注入? MyBatis与Spring集成会扫描标记的Mapper,为接口生成代理对象,方法调用由代理定位映射SQL并执行。你不必手写实现类,但必须保证映射名、参数名、SQL和结果类型一致。具体扫描方式参见MyBatis Starter说明。

配置与版本

以下为 application.properties 局部配置,URL、账号与密码从环境读取;在IDEA的运行配置里填写 OPS_DB_URL、OPS_DB_USERNAME、OPS_DB_PASSWORD,不要把实际密码写进共享截图或Git。URL示例为 jdbc:mysql://localhost:3306/ops_ticket_dev?connectionTimeZone=Asia/Shanghai,实际端口以你的数据库为准。

server.address=127.0.0.1
spring.datasource.url=${OPS_DB_URL}
spring.datasource.username=${OPS_DB_USERNAME}
spring.datasource.password=${OPS_DB_PASSWORD}
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.mapper-locations=classpath:mapper/*.xml

若全用注解SQL,可以先不设mapper-locations。注解与XML可按方法混用,但同一方法不要定义两份SQL。

.\mvnw.cmd dependency:tree '-Dincludes=com.mysql:mysql-connector-j'

核对这条命令显示的实际驱动。沿用MySQL8.0时,Connector/J9.7.0官方说明支持8.0及以上;必要时在该依赖显式固定9.7.0,并把原因写入README。采用新驱动前查看其服务端要求,不把驱动版本和MySQL服务端版本当成必须相同的数字。独立8.4部署环境仍单独验收。Connector/J9.7.0发布说明

Mapper局部示例

前置:你已编写普通JavaBean TicketRow,具有数据库字段对应属性、无参构造器、getter/setter;其中id用 Long且可以回填。下面是接口中的两个方法,不是完整Mapper文件。注解来自 org.apache.ibatis.annotations,TicketRow来自你自己的model包。

@Select("""
    SELECT id, title, description, device_id, reporter_id,
           assignee_id, status, version, created_at, updated_at
    FROM ticket
    WHERE id = #{id}
    """)
TicketRow findById(@Param("id") long id);

@Insert("""
    INSERT INTO ticket
      (title, description, device_id, reporter_id, assignee_id,
       status, version, created_at, updated_at)
    VALUES
      (#{title}, #{description}, #{deviceId}, #{reporterId}, NULL,
       'OPEN', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
    """)
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(TicketRow ticket);

Service先填写经过校验的title、description、deviceId以及服务器身份的reporterId,调用insert之后从 ticket.getId()读取生成编号,再查详情返回。不要把SQL的受影响行数当工单id;返回1只说明插入了一行。MyBatis生成键与参数映射

常见排错: Invalid bound statement查Mapper XML的namespace是否是接口完整类名、id是否等于方法名、XML是否打包进resources;Parameter ... not found查 @Param 名与SQL一致;Access denied查账号、密码、主机与授权;Communications link failure先查数据库服务与端口;Unknown database查URL库名;这些问题不能靠换成root账号一概解决。

最小设备接口提示

GET /api/devices?enabled=true供报修表单选择启用设备;管理员查看全部时可省略enabled条件。POST /api/devices只接收code、name、location,trim后检查code与name非空,数据库以唯一约束保证code不重复,成功返回201与id。PATCH /api/devices/{id}/enabled只接收布尔enabled,用 Boolean配合 @NotNull区分缺失与false。

下面是绑定参数的SQL逻辑,转成MyBatis时把问号改成相应 #{属性名};列表应明确字段,启用条件按是否传入选择。

SELECT id, code, name, location, enabled FROM device ORDER BY id;
INSERT INTO device (code, name, location, enabled) VALUES (?, ?, ?, TRUE);
UPDATE device SET enabled = ? WHERE id = ?;

PATCH先查设备是否存在,不存在404;已经是目标状态可直接返回200和当前数据,不把“重复停用”当工单状态冲突。停用只改enabled,历史工单的device_id不变。创建工单时重新检查设备是否启用,不能仅依赖页面下拉框。重复编码映射409并提示换一个code,其他数据库异常不能一概说成“编码重复”。第13周将两个写接口限制ADMIN,读接口供已登录用户使用。

第9周答案和事务边界

9.1 为什么不能吞异常? 事务方法如果捕获运行时异常后正常返回,框架可能把它视为一次成功调用并提交已做的修改。应让异常越过事务方法,由外层异常处理映射为HTTP错误;或明确标记回滚,但初学阶段采用前一种。不要 catch(Exception) 后只打印一行并返回false。

9.2 this.assign有什么问题? Spring常通过代理在方法前后开始和结束事务。同一个对象内部直接调用自己,绕过外部代理,注解可能不生效。把事务边界放在Controller通过注入对象调用的公开Service方法上;不要new这个Service,不把事务写在只会内部调用的小工具方法上。Spring事务代理说明

9.3 为什么0行不能继续记成功日志? WHERE条件未匹配意味着工单已变更、状态不对或不存在。没有成功修改,就没有派单成功这一业务事实。先区分不存在404、无权403,再把状态或版本不匹配作为409;抛异常使本次事务不产生成功记录。

业务规则答案

操作 操作者 原状态 新状态 必填内容
assign ADMIN OPEN IN_PROGRESS engineerId、expectedVersion
resolve 本单ENGINEER IN_PROGRESS RESOLVED content、expectedVersion
accept 本单REPORTER RESOLVED CLOSED expectedVersion
reject 本单REPORTER RESOLVED IN_PROGRESS reason、expectedVersion

权限在第13周正式接入,此表从一开始就作为最终目标。退回不清除assigneeId;再次处理新增resolution,旧记录保留。关闭后可评论但不能继续变更状态,这是本项目选择的业务规则。

下面是Mapper方法局部示例。影响行数检查和事务必须在Service完成。

@Update("""
    UPDATE ticket
    SET assignee_id = #{engineerId}, status = 'IN_PROGRESS',
        version = version + 1, updated_at = CURRENT_TIMESTAMP
    WHERE id = #{id} AND status = 'OPEN' AND version = #{expectedVersion}
    """)
int assign(@Param("id") long id,
           @Param("engineerId") long engineerId,
           @Param("expectedVersion") long expectedVersion);

下面是Service方法的实现次序,不是可直接编译的完整类。其中 currentUser、permission、Mapper、DTO和业务异常由你按相应周次定义;第9周使用服务器本地身份,第13周换为会话与权限实现。

公开的@Transactional方法 assign(id, command)
  1. 取服务器当前操作者,检查其为ADMIN
  2. 查工单,不存在则404
  3. 查目标工程师,要求存在、启用、角色为ENGINEER
  4. 校验OPEN与expectedVersion
  5. 执行带状态和版本条件的UPDATE
  6. 影响行数不是1则抛ConflictException(继承RuntimeException)
  7. INSERT ticket_action_log
       (ticket_id,actor_id,action,from_status,to_status,note,created_at)
       写入(id,操作者id,'ASSIGN','OPEN','IN_PROGRESS',说明,当前时间)
  8. 正常结束提交;异常越过方法时回滚

resolve同一事务内更新工单、插入 ticket_resolution、写日志;accept/reject更新和写日志。MyBatis使用Spring管理的同一DataSource和事务,别在某个Mapper旁边额外 DriverManager.getConnection另开连接。MyBatis事务说明

回滚实验的证据: 先记录工单的status/version与日志、结果条数;使更新之后且日志插入之前出现运行时异常;调用真实HTTP接口;再用新的SQL连接查询,前后完全一致才算通过。仅看到500,或仅靠Mock对象证明“抛了异常”,都不能证明数据库回滚。

第10周答案和分页校验

10.1 @Size能否拒绝空标题? 不能单独完成。@Size(max=50)允许长度0,也通常把null视为有效;用 @NotBlank拒绝null、空字符串与纯空白,再加 @Size(max=50)限制上限。必须有Validation依赖并触发校验。

10.2 count漏条件会怎样? items可能只有2条符合条件的工单,total却显示12,于是页面出现多余页码;接入权限后还可能泄露别人的工单数量。列表与count复用相同WHERE条件,仅排序与LIMIT有所不同。

10.3 为什么不能所有错误都200? 浏览器请求工具、监控和调用方都依赖状态码区分成功与失败。200会使失败看起来像成功,页面可能清空用户输入或刷新列表,排错也更困难。状态码负责大类,code/message负责具体含义。

DTO最小示例

以下是完整DTO类,依赖Boot Validation starter,文件名 CreateTicketRequest.java。Java21支持record;它自动保存构造参数并提供 title() 等访问方法,适合短小的请求类型。不会record时可用普通类写相同字段和getter,不影响业务设计。

package com.example.opsticket.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public record CreateTicketRequest(
        @NotBlank @Size(max = 50) String title,
        @NotBlank @Size(max = 2000) String description,
        @NotNull @Positive Long deviceId) {
    public CreateTicketRequest {
        title = title == null ? null : title.trim();
        description = description == null ? null : description.trim();
    }
}

Controller方法参数应为 @Valid @RequestBody CreateTicketRequest request;两个注解分别来自 jakarta.validation.Valid 和 org.springframework.web.bind.annotation.RequestBody。带 @Size的长度采用Java字符串长度规则,数据库和应用校验不要各自假定不同“字符数”。Spring MVC校验

分页先采用显式校验也完全可以:

// Controller收到page与size后,交给Service前执行的局部代码。
if (page < 1 || size < 1 || size > 100) {
    throw new IllegalArgumentException("page至少为1,size必须在1到100之间");
}
long offset = ((long) page - 1) * size;

注意先把page转long再乘,别等int已经溢出之后才转。请求 page=abc 会在类型转换阶段失败,也应映射400。

XML分页片段

以下是Mapper XML中的局部SQL片段,假定Mapper方法有 @Param("status")、@Param("deviceId")、@Param("offset")、@Param("size"),status先在Java侧校验为允许的枚举值或null。第13周要把权限范围也加入这一公用where。

<sql id="ticketFilters">
  <where>
    <if test="status != null">
      AND status = #{status}
    </if>
    <if test="deviceId != null">
      AND device_id = #{deviceId}
    </if>
  </where>
</sql>

<select id="count" resultType="long">
  SELECT COUNT(*) FROM ticket
  <include refid="ticketFilters"/>
</select>

<select id="findPage" resultType="com.example.opsticket.model.TicketRow">
  SELECT id, title, description, device_id, reporter_id, assignee_id,
         status, version, created_at, updated_at
  FROM ticket
  <include refid="ticketFilters"/>
  ORDER BY created_at DESC, id DESC
  LIMIT #{size} OFFSET #{offset}
</select>

固定样本、固定筛选条件下,total=12、size=5时三页数量为5、5、2,第4页0。实时新增删除时offset分页可能移动边界,这不是固定排序就能彻底解决的问题;本阶段先记录这个限制,暂不引入游标分页。

异常处理清单: DTO校验失败 MethodArgumentNotValidException;直接方法参数约束可能是 HandlerMethodValidationException;坏JSON HttpMessageNotReadableException;查询参数类型不符 MethodArgumentTypeMismatchException。这些转400;你的NotFoundException转404,ConflictException转409。进入第12周后,安全过滤器的401/403用Security处理器输出,不能指望MVC的Advice包办所有过滤器异常。

业务异常可以用 { "code":"TICKET_CONFLICT", "message":"工单状态已变化,请刷新后重试" }。意外异常不要把SQL语句、密码、数据库地址或完整堆栈原样返回页面。

第11周答案和页面请求

11.1 fetch为何没有因500抛异常? fetch主要用拒绝Promise表示网络等请求失败;拿到HTTP500响应仍属于收到了响应。代码必须主动看 response.ok 或 response.status。Fetch用法

11.2 评论为什么用textContent? textContent把 <b> 当普通文字;innerHTML让浏览器把它解释成HTML,用户输入可能改变页面甚至执行不应执行的内容。本项目评论就是纯文本,直接用textContent最符合需求。

11.3 隐藏按钮能否保护接口? 不能。用户能用HTTP客户端发送相同请求,甚至编辑浏览器里的页面。按钮控制只为了让人不误操作,服务端仍要验证身份、角色、归属、状态与版本。

以下是第11周app.js的局部辅助函数,供未接登录时使用;第12周应由后文带CSRF的函数替代。

async function requestJson(url, method = "GET", data) {
  const headers = { Accept: "application/json" };
  const options = { method, headers, credentials: "same-origin" };
  if (data !== undefined) {
    headers["Content-Type"] = "application/json";
    options.body = JSON.stringify(data);
  }
  const response = await fetch(url, options);
  const text = await response.text();
  let result = null;
  if (text) {
    try { result = JSON.parse(text); }
    catch { throw new Error("服务器返回格式异常,请检查Network响应"); }
  }
  if (!response.ok) {
    throw new Error(result?.message ?? `请求失败:${response.status}`);
  }
  return result;
}

先读文本并判断是否为空,因而204不会直接执行 response.json()。页面事件处理应 try/catch/finally:try发请求并更新页面,catch展示消息,finally恢复按钮。禁用按钮能降低双击概率,但仍须保留后端条件更新。

在HTML放 <ul id="tickets"></ul> 后,可用下面的列表渲染片段。假定接口返回 {items,total,page,size}。

const page = await requestJson("/api/tickets?page=1&size=10");
const list = document.getElementById("tickets");
list.replaceChildren();
for (const ticket of page.items) {
  const item = document.createElement("li");
  item.textContent = `${ticket.id} ${ticket.title} ${ticket.status}`;
  list.append(item);
}

若直接使用顶层await,脚本标签要写 <script type="module" src="/app.js"></script>;也可把片段包进async函数再调用。数字输入框的value是字符串,提交deviceId前转换并检查,如 Number(input.value) 及 Number.isInteger。

排错记录示例: “页面提示JSON解析失败→第12周退出接口的Network响应显示204空体→代码无条件response.json→改为先检查空响应→退出成功且页面跳回登录”。这是可供练习的排错场景;只有你实际遇到并记录后,才把它作为个人经历用于面试。

第12周答案和Session登录接线

12.1 authenticate成功为何不代表下一次也登录? authenticate只完成本次凭据验证并返回认证对象。认证上下文还必须通过框架机制保存,下次请求才能从会话恢复。主线使用框架formLogin,由过滤器执行认证、会话处理和上下文保存;不要自写仅调用AuthenticationManager的“假登录成功”接口。认证持久化

12.2 正确密码却403先查什么? 查看请求是否包含当前会话对应的CSRF头,Cookie是否随请求发送,登录参数是否用表单编码而非JSON。缺少CSRF可能在验证密码之前被拒绝,所以改密码、删除Security依赖都不是对应修复。

12.3 登录后旧CSRF为何失效? 框架会在成功登录和退出时清除旧令牌;浏览器应重新GET /api/csrf并使用新的返回值。这也是为什么不能把令牌永久写死在JavaScript中。CSRF令牌生命周期

账号读取的连接点

前置:编写 AppUserRow普通JavaBean,属性为id、username、passwordHash、role、enabled;enabled用boolean并提供 isEnabled()。AppUserMapper.findByUsername采用 #{username},返回用户行或null:

SELECT id, username, password_hash, role, enabled
FROM app_user
WHERE username = ?;

这里的问号表示绑定参数;MyBatis实际映射写 #{username}。username有唯一约束。下面是放入配置类的局部Bean方法,需要导入你自己的Mapper,以及框架的Bean、UserDetailsService、UsernameNotFoundException、User:

@Bean
UserDetailsService userDetailsService(AppUserMapper users) {
    return username -> {
        AppUserRow row = users.findByUsername(username);
        if (row == null) {
            throw new UsernameNotFoundException("账号或密码错误");
        }
        return User.withUsername(row.getUsername())
                .password(row.getPasswordHash())
                .roles(row.getRole())
                .disabled(!row.isEnabled())
                .build();
    };
}

User为 org.springframework.security.core.userdetails.User,不要导入自己的同名用户实体。数据库role只允许REPORTER、ENGINEER、ADMIN;UserDetailsService负责读取,PasswordEncoder负责核对密码,调用顺序由框架认证提供器组织。UserDetailsService、认证提供器

Security配置模板

这是一个完整配置类模板,前提是已加入Security依赖、上面的UserDetailsService Bean已由另一个配置类提供,并且登录页、CSRF端点及业务Controller存在。它只完成认证接线;第13周Service里的数据权限仍需自己写。若项目已有SecurityFilterChain,要修改现有配置,不叠加第二份不明顺序的配置。

package com.example.opsticket.security;

import java.io.IOException;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfException;

@Configuration
public class SecurityConfig {
    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    SecurityFilterChain securityFilterChain(
            HttpSecurity http, UserDetailsService users,
            PasswordEncoder passwords) throws Exception {
        var provider = new DaoAuthenticationProvider(users);
        provider.setPasswordEncoder(passwords);
        http.authenticationProvider(provider);
        http.csrf(Customizer.withDefaults());
        http.requestCache(cache -> cache.disable());
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/index.html", "/login.html",
                        "/app.js", "/auth.js", "/style.css", "/favicon.ico",
                        "/api/csrf", "/error").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll());
        http.formLogin(form -> form
                .loginPage("/login.html")
                .loginProcessingUrl("/api/login")
                .successHandler((request, response, auth) -> response.setStatus(204))
                .failureHandler((request, response, error) -> writeError(response, 401,
                        "{\"code\":\"LOGIN_FAILED\",\"message\":\"账号或密码错误\"}"))
                .permitAll());
        http.logout(logout -> logout
                .logoutUrl("/api/logout")
                .deleteCookies("JSESSIONID")
                .logoutSuccessHandler((request, response, auth) -> response.setStatus(204))
                .permitAll());
        http.exceptionHandling(errors -> errors
                .authenticationEntryPoint((request, response, error) ->
                        writeError(response, 401,
                                "{\"code\":\"UNAUTHENTICATED\",\"message\":\"请先登录\"}"))
                .accessDeniedHandler((request, response, error) -> {
                    String body = error instanceof CsrfException
                            ? "{\"code\":\"CSRF_INVALID\",\"message\":\"请求令牌失效,请刷新页面\"}"
                            : "{\"code\":\"FORBIDDEN\",\"message\":\"没有操作权限\"}";
                    writeError(response, 403, body);
                }));
        return http.build();
    }

    private static void writeError(HttpServletResponse response, int status,
                                   String fixedJson) throws IOException {
        response.setStatus(status);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(fixedJson);
    }
}

该例只写固定JSON常量,没有拼接用户内容;动态对象应交给JSON序列化器。Boot4的JSON包与旧教程可能不同,不为一个错误响应照搬旧ObjectMapper依赖。DaoAuthenticationProvider(UserDetailsService)构造器及 setPasswordEncoder已按当前API核对。认证提供器API

框架处理POST /api/login与POST /api/logout,不要再为这两个地址写同名Controller。第13周如增加HTTP级角色匹配,应放在 /api/** 的宽泛规则前面;具体工单归属仍在Service检查。

CSRF端点模板

以下是完整Controller模板,继续使用默认会话CSRF存储,并通过参数取得框架已经处理的令牌,不自行替换令牌编码方式。

package com.example.opsticket.controller;

import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CsrfController {
    public record TokenView(String headerName, String token) { }

    @GetMapping("/api/csrf")
    public ResponseEntity<TokenView> token(CsrfToken csrf) {
        var view = new TokenView(csrf.getHeaderName(), csrf.getToken());
        return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(view);
    }
}

同源页面的登录与写请求

以下是auth.js辅助函数示例,登录页面需要自行提供输入框,并在提交事件中调用login;它替代第11周没有CSRF的写请求方法。令牌只存在内存里,页面每次加载可重新获取。Cookie由浏览器管理,不尝试用JavaScript读取HttpOnly会话Cookie。

let csrfInfo;

async function readResponse(response) {
  const text = await response.text();
  let data = null;
  if (text) {
    try { data = JSON.parse(text); }
    catch { throw new Error(`响应格式异常:${response.status}`); }
  }
  if (!response.ok) {
    const error = new Error(data?.message ?? `请求失败:${response.status}`);
    error.status = response.status;
    error.code = data?.code;
    throw error;
  }
  return data;
}

async function refreshCsrf() {
  const response = await fetch("/api/csrf", {
    credentials: "same-origin", cache: "no-store"
  });
  csrfInfo = await readResponse(response);
}

async function login(username, password) {
  await refreshCsrf();
  const response = await fetch("/api/login", {
    method: "POST",
    credentials: "same-origin",
    headers: { [csrfInfo.headerName]: csrfInfo.token },
    body: new URLSearchParams({ username, password })
  });
  await readResponse(response);
  await refreshCsrf();
}

async function apiJson(url, method = "GET", data) {
  const headers = { Accept: "application/json" };
  const options = { method, credentials: "same-origin", headers };
  const modifies = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
  if (modifies) {
    if (!csrfInfo) await refreshCsrf();
    headers[csrfInfo.headerName] = csrfInfo.token;
  }
  if (data !== undefined) {
    headers["Content-Type"] = "application/json";
    options.body = JSON.stringify(data);
  }
  return readResponse(await fetch(url, options));
}

async function logout() {
  await apiJson("/api/logout", "POST");
  csrfInfo = undefined;
  await refreshCsrf();
}

登录成功后可跳回主页;新页面重新调用refreshCsrf即可。页面遇401提示重新登录;遇403区分 CSRF_INVALID 与业务无权;遇409提示数据已变更。不要对失败POST无限自动重发,避免重复创建和评论。CORS不在本阶段引入,也不添加允许任意来源携带凭据的配置。

密码和当前用户

生成演示密码哈希时,在已有依赖的本地Java练习或测试中调用 PasswordEncoderFactories.createDelegatingPasswordEncoder().encode(演示密码),把返回的完整字符串存入password_hash,列长建议255。实际校验用 matches(原始密码,数据库哈希);两次encode结果不同是正常现象。演示账号只用于学习库,密码不复用个人真实账号。密码存储说明

/api/me和Service的身份解析遵循同一条链:Controller接收框架的Authentication→取 getName()→MyBatis查当前账号→检查存在且enabled→得到id与固定role。返回前专门构造 {id,username,role},不返回AppUserRow整个对象。第一版禁止更改username,并在每次业务请求回查账号是否启用,避免把登录时的资料永久视作最新。

这一做法使停用账号在业务层被拒绝,但“登录会话立即被集中注销”属于另一项功能,不能声称已经实现。角色和权限最终以本次服务端解析结果为准。

最短人工验收顺序: 匿名GET工单401→GET令牌200→带令牌和正确表单密码登录204→重新GET令牌200→GET当前用户200→带令牌创建201→不带令牌创建403→带令牌退出204→GET工单401。登录和退出后CSRF可能建立新的匿名会话;存在Cookie本身并不代表已登录。

常见排错: 登录302通常是没有配置JSON/204成功处理;登录200但响应是HTML可能是fetch跟随了跳转;一直401查password_hash前缀、UserDetailsService、enabled、Cookie;GET成功POST403查CSRF;IDE报 javax.servlet 找不到,当前代码应使用 jakarta.servlet;不要为修一个包名降级整套框架。

第13周答案和权限条件

13.1 工程师角色够不够? 不够。同为ENGINEER的两个人也不能处理对方的任务;必须再比较数据库中的assigneeId和服务器当前用户id,且状态须为IN_PROGRESS、版本匹配。报修人验收同理。

13.2 total为何也要过滤? total是对数据库内容的统计,也会透露“有多少我看不到的记录”,并使分页不准确。列表、count、导出、统计、评论、日志都属于数据出口,应遵循同一可见范围。

13.3 JSON伪造身份怎么处理? 创建DTO里没有reporterId,操作DTO里没有actorId;忽略或拒绝这些额外字段,不让它们参与身份决定。真正的reporterId、日志actorId来自会话绑定账号。URL里传来的id也只是目标工单编号,不是访问许可。

权限矩阵参考

功能 REPORTER ENGINEER ADMIN
创建 仅以本人身份创建 不允许 不允许
列表与详情 本人报修单 分配给本人 全部
评论与日志读取 可见工单 可见工单 全部
发评论 可见工单 可见工单 全部
派单 不允许 不允许 OPEN单
提交处理 不允许 本人IN_PROGRESS单 不允许
验收或退回 本人RESOLVED单 不允许 不允许
设备新增与停用 不允许 不允许 允许

设备启用列表可供已登录用户查看,以便选择报修设备;停用设备不允许新报修,但历史工单仍显示原设备信息。管理员“权限多”不代表跳过既定流程。

下面是可见性判断局部逻辑,假定actor为服务器解析的当前用户,ticket为数据库工单,二者id类型按示例为long与可空Long,role为字符串:

boolean visible = switch (actor.role()) {
    case "ADMIN" -> true;
    case "REPORTER" -> ticket.getReporterId() == actor.id();
    case "ENGINEER" -> java.util.Objects.equals(ticket.getAssigneeId(), actor.id());
    default -> false;
};
if (!visible) {
    throw new ForbiddenException("没有访问这张工单的权限");
}

这里 ForbiddenException是你自己定义并由Advice映射403的运行时异常;不要让它落入通用500处理。比较两个Long对象时不能直接用 ==判断数值;示例reporterId与long比较会拆箱,但仍要求数据库非空约束,实际代码也可统一使用Objects.equals。

列表不能“先查全量再用Java删除不可见的几条”,那样分页和total容易错误。把服务端构造的范围一起传给Mapper。下面是加入第10周公用WHERE的XML片段,role与currentUserId必须是服务器计算结果,不能绑定前端同名查询参数:

<choose>
  <when test="role == 'ADMIN'">
    AND 1 = 1
  </when>
  <when test="role == 'REPORTER'">
    AND reporter_id = #{currentUserId}
  </when>
  <when test="role == 'ENGINEER'">
    AND assignee_id = #{currentUserId}
  </when>
  <otherwise>
    AND 1 = 0
  </otherwise>
</choose>

没有角色的请求应在更前面被拒绝;默认零行仍是防止意外扩大范围的最后一道约束。添加带表别名的JOIN后,相应列都加同样别名,避免同名列歧义。

resolve的更新除状态和版本外还带 AND assignee_id=#{currentUserId};accept/reject带 AND reporter_id=#{currentUserId}。角色检查不能省略,例如ADMIN不能仅因恰巧某字段等于自己的id就代为验收。

本周独立验收用例

在原始样本上用不同会话完成:

请求 会话 预期
GET /api/tickets/101 reporter_a 200
GET /api/tickets/103 reporter_a 403
GET /api/tickets?page=1&size=10 reporter_a 只有101和102,total=2
GET /api/tickets?page=1&size=10 engineer_a 只有102,total=1
GET /api/tickets/103/comments reporter_a 403,不能只因“评论为空”返回200
POST /api/tickets/101/assign reporter_a CSRF有效时403
POST /api/tickets/101/assign admin_a 工程师2且版本正确时200,返回新状态和版本
POST /api/tickets/102/resolve 第二名工程师 CSRF有效时403,无数据变化
POST /api/tickets/102/resolve engineer_a content与版本正确时200,返回新状态和版本
POST /api/tickets/102/accept reporter_b CSRF有效时403
POST /api/tickets/102/accept reporter_a 当前版本正确时200,再次操作409

版本必须从当前详情响应读取,不能一直用初始0。核对日志actorId分别为3、2、1,不能由请求体伪造。测试403时必须先使CSRF正确,否则测试到的是令牌拒绝,不能证明资源权限有效。

权限拒绝、状态冲突和数据库回滚都应有可观察证据;到第15周再把这些用例转换成自动测试。能在不看参考答案时独立增加一个“仅本人可读取”的子资源接口,才算理解了这周的核心。

第14至20周 自测答案与排错示例

先完成第14至20周课程中的尝试,再核对答案。这里提供判断依据、局部实现和复现实验;包名、Java 方法签名及实际 jar 名称要与你自己的工程对应。读完以后关掉答案,用自己的工程重新做一次,才记为通过。

所有修改数据库的实验只在 ops_ticket_test、自己创建的新测试记录或专用恢复库中进行。测试账号不应拥有 ops_ticket_dev 的写权限。禁止为了重复实验清空开发库,或把删除 Docker 数据卷当成重置步骤。

第14周 并发与事务

14.1 先查询再无条件更新为什么出错

因为查询到的是某个时刻的结果,不是对未来修改的独占许可。下面是可能发生的顺序:

顺序 管理员请求 A 管理员请求 B
1 查询到 OPEN、version=0
2 也查询到 OPEN、version=0
3 派给工程师甲并更新
4 无条件派给工程师乙,覆盖 A 的结果

if (ticket.getStatus() == OPEN) 只检查 Java 对象中那份旧值。修复要让数据库在实际修改时再次检查状态和期望版本:

UPDATE ticket
SET assignee_id = ?, status = 'IN_PROGRESS',
    version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'OPEN' AND version = ?;

三个 ? 分别绑定处理人、工单编号、用户本次操作基于的版本。MyBatis 可改为命名参数,不能拼接用户输入到 SQL。只有返回影响行数 1 才代表修改成功。

可以先在两个 MySQL 客户端窗口验证。准备一张新的 OPEN 测试工单并记住编号与版本;A 开始事务后执行条件更新但暂不提交;B 开始事务并对同一旧版本执行条件更新,通常会等待;A 提交后 B 继续,条件已不满足,影响行数为 0。B 回滚结束实验。每次 UPDATE 后立即看客户端影响行数或执行 SELECT ROW_COUNT()。此实验直接操作数据库,只验证并发更新机制;应用权限和日志事务还要通过应用接口验证。

14.2 有事务为什么还要条件更新

事务用于让“修改工单、写日志”等操作形成一个整体;它不会自动替你决定“两位管理员谁的派单有效”。若 SQL 只按 id 更新,后来的事务仍可能在前者提交后覆盖处理人。条件更新把业务前提写进同一条 SQL,避免检查和修改之间的空隙。

版本号还能表达“你操作的是旧页面中的工单”。对 OPEN 派单来说状态条件已经能阻止第二次派单,保留版本条件则让后续其他修改也能沿用一致的冲突约定。本项目并不要求为此引入分布式锁。

局部 Service 结构如下,类名和调用方法需要对应自己的实现:

@Transactional
public void assign(long ticketId, long engineerId, long expectedVersion,
                   CurrentUser actor) {
    requireAdmin(actor);
    requireEnabledEngineer(engineerId);
    requireTicketExists(ticketId);
    int changed = ticketMapper.assignIfOpen(
            ticketId, engineerId, expectedVersion);
    if (changed != 1) {
        throw new ConflictException("工单已变化,请刷新后重试");
    }
    actionLogMapper.insertAssign(ticketId, actor.id(), engineerId);
}

CurrentUser、校验方法和 ConflictException 都是项目自己的类型或方法;ConflictException 可继承 RuntimeException。把它映射为 409。此处 actor 必须从服务器认证上下文取得,不能由客户端提交一个管理员编号冒充。状态和日志由同一个事务管理器管理、使用同一数据源参与事务。Spring 声明式事务

14.3 日志写失败后数据库应是什么样

整个业务事务应回滚:工单保持原状态、原处理人和原版本,没有这次派单日志。如果初始值为 OPEN、assignee_id 为 NULL、version 为 0,失败后仍应是这些值。

默认声明式事务对未被吞掉的运行时异常进行回滚;如果是检查型异常,要明确配置适当的回滚规则。不要捕获异常后只打印一句话、继续正常返回;不要让日志写入进入单独提交的事务。事务入口应由另一个组件调用 Spring 管理的 Service Bean,而不是 new Service(),也不是在同一个类内部依赖自调用来启动事务。

诊断顺序: 先确认调用的是 Spring Bean;再看异常是否逃离事务方法;再看两个 Mapper 是否用同一数据源;最后检查日志方法是否另开事务、更新是否自动提交。不要仅凭注解存在就认定事务一定生效。

第15周 自动测试

15.1 mock 成功能否证明数据库事务正确

不能。mock 返回的是你预设的行为,没有真实执行 SQL,也没有数据库锁、约束和提交过程。它可以证明 Service 遇到 Mapper 返回 0 时抛出冲突,却不能证明两个真实事务竞争时最终只成功一个。

最小 JUnit 示例只说明断言用法,不与项目类名绑定:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TitleRuleTest {
    private String normalizeTitle(String title) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("标题不能为空");
        }
        return title.strip();
    }

    @Test
    void blankTitleIsRejected() {
        assertThrows(IllegalArgumentException.class,
                () -> normalizeTitle("   "));
    }

    @Test
    void surroundingSpacesAreRemoved() {
        assertEquals("投影仪无法开机", normalizeTitle(" 投影仪无法开机 "));
    }
}

在正式项目中,应直接测实际领域方法或 Service,避免把业务规则复制到测试类再测复制品。上面只适合第一次学习 JUnit 时运行,之后删除重复规则,用真实实现替换。

数据库集成测试至少实际调用 MyBatis 和 MySQL:创建测试用户、设备与 OPEN 工单;调用真实 Service;重新查询 ticket 和 ticket_action_log;核对状态、版本和日志。并发测试需要两条独立事务路径,测试前数据必须已经提交,不能被外层未提交的准备事务挡住。对每个等待和 Future 设置超时,失败时也释放门闩并关闭线程池。

15.2 测试自动回滚为什么会掩盖业务错误

假设 Service 忘写事务,但测试方法自己开了事务。Service 的数据库操作可能加入测试事务,测试结束时一起被回滚,于是你误以为业务本来就会回滚。真实 HTTP 请求没有这个测试外壳,可能只完成了工单修改而没写日志。

验证步骤要让业务事务独立承担责任:

  1. 测试方法本身不加自动回滚事务;使用仅能访问 ops_ticket_test 的配置和账号。
  2. 创建并提交一张新的 OPEN 工单,记录初始值。
  3. 在测试配置中让“写操作记录”这一步抛出运行时异常,但工单 Mapper 和数据库连接仍真实工作。可以使用仅测试生效的日志写入组件,或对实际日志组件做故障注入;不替换工单 Mapper。
  4. 调用 Spring 管理的派单 Service,断言确实抛出了预期异常。
  5. 在业务调用结束后开启新的查询,核对工单与日志;不能只检查内存里的旧对象。
  6. 清理这项测试创建的记录,不清空整个库。

步骤 3 没有执行真实日志 SQL,也能检查真实工单更新是否被异常回滚;若要验证数据库约束引起的失败,可在测试专用日志写入路径中故意写入违反已有非空约束的值。两种方式都必须在测试环境隔离,不能加一个对外的“故障开关”接口。

可用下面的查询核对。:id 是说明中的参数占位符,实际通过 PreparedStatement、Mapper 参数或数据库客户端变量绑定,不能直接把这段冒号语法当普通 MySQL SQL 执行。

SELECT status, assignee_id, version FROM ticket WHERE id = :id;
SELECT COUNT(*) FROM ticket_action_log
WHERE ticket_id = :id AND action = 'ASSIGN';

真实端口的接口测试运行在服务端线程中,测试方法自动回滚不会自动覆盖服务器事务,因此数据清理也要单独安排。Spring Boot 应用测试

15.3 怎样确认 403 是权限拒绝

先分清请求:普通 GET 查询不要求 CSRF token,所以越权读取的 GET 测试主要确认已登录、账号角色正确、工单存在且属于另一个报修人。写入接口如 POST,需要同时提供有效登录身份和有效 CSRF token,再验证角色或资源权限。

例如使用 Spring Security 的 MockMvc 测试辅助时,局部示例为:

mockMvc.perform(post("/api/tickets/{id}/resolve", ticketId)
        .with(user(actualOtherEngineerPrincipal))
        .with(csrf())
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"content\":\"已更换线缆\",\"expectedVersion\":0}"))
    .andExpect(status().isForbidden());

actualOtherEngineerPrincipal 应是应用能识别用户编号的真实 UserDetails 实现,且是另一个 ENGINEER;只写 user("name").roles("ENGINEER") 可能没有项目需要的 id。测试预先创建状态为 IN_PROGRESS、版本为 0、分配给其他工程师的工单;请求按接口约定提供 content 和 expectedVersion,避免因请求字段错误先触发 400,无法检验权限。user 与 csrf 的静态方法来自 Spring Security Test 支持。CSRF 测试说明

再补一个对照:正确处理工程师、相同有效请求应成功。最后补缺少 CSRF token 被拒绝的独立用例。三个结果对应三条不同规则,不能混成一个用例。如果项目对不可见资源统一返回 404,就按既有接口契约断言 404,并检查不存在信息泄露,不要为了教材字面数字更改已经明确的策略。

本周诊断: 所有请求都 403 先看安全过滤链;只有部分身份失败再看数据权限;事务测试偶尔卡住先看准备数据是否提交、等待是否有超时;第二次跑失败先看测试主键或唯一用户名是否重复。

第16周 打包与交付

16.1 同一份 jar 为什么能连接不同地址

代码从配置读取数据库地址,地址不写死在 Java 类里。不同环境提供不同配置,同一业务代码就能使用相应数据库。示例使用标准 Spring 数据源属性:

spring:
  datasource:
    url: ${SPRING_DATASOURCE_URL}
    username: ${SPRING_DATASOURCE_USERNAME}
    password: ${SPRING_DATASOURCE_PASSWORD}

也可以直接利用 Spring 对标准环境变量的映射;上面的显式占位符更方便初学者看出依赖。填写方式在本机按前期选择的配置方案保持一致,密码不要写入公开源码、终端截图或提交记录。改变地址并不会自动替你建表、搬数据或适配不同数据库产品。

16.2 只有点击 Run 的说明缺少什么

它没有解释新机器需要什么版本的 JDK、怎样准备数据库和表、配置从哪里来、依赖怎样构建、怎样测试、怎样从 jar 运行,以及失败时看哪里。按下面的最小目录核对 README:

项目目标和业务规则
已验证的 Java / Boot / MyBatis / MySQL 版本
准备数据库和独立账号
填写外部配置
建表与演示数据
运行测试与构建
运行 jar、正常停止、查看日志
角色演示和主要接口
已知限制与排错

“在新目录复现”要从可交付的源码、脚本和配置样例开始,不能偷偷依赖原来 IDEA 里的本地设置。首次下载依赖需要网络;要离线演示,就提前确认依赖与 jar 已准备好。

16.3 没有实验能不能写高并发高性能

不能把未测量的能力写成已证实的结果。可以写已经实现的具体行为,例如:

使用状态与版本条件更新防止重复派单;在真实 MySQL 上验证两个相同期望版本的请求只成功一次,并通过事务保持工单修改与操作记录一致。

这是功能和一致性描述,不等于系统吞吐量结论。做过性能实验以后,写明测试环境、数据量、请求方式和结果,避免省略上下文。没有真实公司用户,就写个人学习项目,不能写“服务数万用户”。

本周诊断: IDEA 能跑而 jar 不行,优先比较 JDK、配置和打包产物;只有自己的电脑能跑,核查绝对路径与环境变量;新终端缺少配置,检查变量是否只在旧终端或 IDE 生效。

第17周 部署与恢复

17.1 容器运行为什么不代表数据库可用

“容器进程存在”只是一个状态。MySQL 可能还在初始化,也可能因目录权限、密码、端口或数据版本不匹配而无法服务。应用可能把 localhost 错当成另一个容器的地址,或者数据库还没就绪就尝试连接。

排查顺序:

  1. docker compose ps 看容器状态与端口。
  2. docker compose logs --tail 100 db 看数据库服务日志,db 改成自己的服务名。
  3. 检查应用配置中的主机、端口、库名与账号;容器之间用数据库服务名和内部端口。
  4. 使用实际配置尝试连接,区别连接拒绝、认证失败和找不到数据库。
  5. 数据库可用后再看应用日志;必要时设置数据库健康检查与应用连接重试。

没有 Docker 时同样适用:先确认 MySQL 服务,再检查地址、认证、库表,最后查应用。不要反复重装所有工具来掩盖一个错误端口。

17.2 为什么还要实际恢复

文件可能为空、不完整、字符编码不正确,或依赖没有一起导出。你还可能没有恢复权限、选错目标库、漏掉关联数据。恢复测试让“有文件”变成“这份文件确实能重新形成可用数据”。

以下是本项目六张 InnoDB 表的恢复练习,不覆盖已有库。示例假定备份由课程中不带 --databases 的命令生成,当前目录有 backups 子目录。

  1. 停止向学习数据写入,记下源库每张表记录数及两条含中文的工单;备份完成后先确认文件非空。
  2. 打开备份文本,确认没有 CREATE DATABASE、USE ops_ticket_dev 或其他切换到源库的语句。如果有,停止导入,回到课程备份命令重新生成,不要盲目改几个词继续。
  3. 使用有建库权限的本地管理账号创建一个全新库,示例 SQL 如下。若库已存在,换一个新名称;不要删除同名库。
CREATE DATABASE ops_ticket_restore_20270201
CHARACTER SET utf8mb4;
  1. 确认恢复账号只对这个恢复库有本次恢复所需权限。恢复 SQL 通常含建表和插入语句,可能包含 DROP TABLE IF EXISTS;因此目标必须是新空库,不能因为“名称看起来差不多”就复用旧库。
  2. 在项目根目录运行下面的交互式客户端命令,账号名替换为实际恢复账号:
mysql -u your_restore_user -p ops_ticket_restore_20270201
  1. 进入 mysql> 后,先核对当前库为空,再从当前终端工作目录导入:
SELECT DATABASE();
SHOW TABLES;
SOURCE backups/ops_ticket_dev_20270201.sql;
SHOW TABLES;

SOURCE 是 mysql 客户端命令,文件路径指客户端机器上的文件。相对路径不可用时,改用自己确认过的绝对路径与正斜杠;不要把 Linux 的 < file.sql 重定向直接复制到 Windows PowerShell。

  1. 比较 app_user、device、ticket、ticket_comment、ticket_action_log、ticket_resolution 的行数,检查中文标题、密码哈希和时间字段;用 JOIN 检查工单引用的用户和设备都存在。
  2. 用临时外部配置把本机应用连接到恢复库,执行一次查询和登录演示。结束后恢复原配置,保留验证记录,不要求删除恢复库。

例如检查恢复库中的工单关联是否缺失:

SELECT t.id
FROM ticket t
LEFT JOIN device d ON d.id = t.device_id
LEFT JOIN app_user u ON u.id = t.reporter_id
WHERE d.id IS NULL OR u.id IS NULL;

结果应为空,但空结果只说明这两种关联未发现缺失,不代表所有恢复验证都通过。备份文件包含学习账号的密码哈希,也应作为私有文件保存。MySQL 备份工具说明

17.3 停止容器与删除卷有什么区别

docker compose stop 停止运行,保留容器及持久化数据,之后可以再启动。数据库使用命名卷时,正常停止和启动不应使工单消失。删除承载 MySQL 数据的卷会删除其中数据,之后启动可能得到一个全新数据库。

不要用 docker compose down -v 演示“安全重启”;其中 -v 会删除 Compose 管理的相关卷。即使只执行不带 -v 的 down,也应事先知道数据到底存在命名卷、绑定目录还是容器可写层,不能凭命令名称判断数据一定保留。Docker Compose down

本周诊断: 重启数据丢失先查持久化位置;恢复乱码先查导出和客户端字符集;导入权限不足就核对恢复账号权限,不要改成面向所有库的高权限账号后忘记收回;备份看似成功仍要检查退出结果和文件内容。

第18周 索引线程池与 JVM

18.1 使用索引就一定更快吗

不一定。索引可能只排除很少数据,还需要大量回表;表很小时扫描全表反而更简单;排序、返回字段、统计信息和缓存状态也会影响结果。key 有值只说明选择了某个访问路径,不能代替实际测量。

本项目的候选索引把相等条件 reporter_id、status 放前面,再按查询排序设计时间和 id 列。它适合特定查询,不保证对“按设备统计”也有帮助。优化记录可使用下面的模板,数值必须自己测量:

数据量和分布:____条工单,OPEN约____%,指定报修人约____条
查询 SQL 和参数:____
环境与数据库版本:____
索引前执行计划及10次耗时:____
索引后执行计划及10次耗时:____
是否排除预热、是否相同返回结果:____
结论与局限:____

传统 EXPLAIN 的 rows 是估算;EXPLAIN ANALYZE 会执行支持的查询并提供实际执行信息,所以仅对自己理解成本的 SELECT 使用。没有明显差异时保留这个结论,同样体现测量能力。MySQL EXPLAIN

18.2 两线程两队列提交五项会怎样

在前两项任务都持续等待、尚未完成的前提下:任务 1、2 占用两个工作线程;任务 3、4 进入队列;任务 5 因线程与队列都满,被 AbortPolicy 拒绝并抛 RejectedExecutionException。

下面是一段独立 Java 21 实验,不接入工单业务。它使用门闩固定观察条件,不依赖“机器正好比较慢”。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class PoolDemo {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch release = new CountDownLatch(1);
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2, 2, 0, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(2),
                new ThreadPoolExecutor.AbortPolicy());
        try {
            for (int i = 1; i <= 5; i++) {
                final int taskId = i;
                try {
                    pool.execute(() -> {
                        try {
                            release.await();
                            System.out.println("完成任务 " + taskId);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    });
                } catch (RejectedExecutionException e) {
                    System.out.println("被拒绝的任务 " + taskId);
                }
            }
        } finally {
            release.countDown();
            pool.shutdown();
            if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
                pool.shutdownNow();
            }
        }
    }
}

预期第五项被拒绝,其余四项完成;四个完成信息之间的顺序不固定。这里使用 execute,若改成 submit 并在任务中抛异常,需要查看 Future.get() 的异常结果,不能只看提交方法有没有抛异常。线程池大小也必须考虑数据库连接和 CPU 等下游资源。Java 21 线程池文档

18.3 无限线程和永久 List 分别有什么风险

反复创建大量线程会增加线程栈、调度和系统资源开销,严重时无法再创建线程。把所有查询结果一直保存在某个长期存活的 List 中,会让这些对象仍可达,GC 无法回收,堆使用量持续增长。

GC 不是“内存不够时自动把没用的数据都猜出来删除”。程序还持有引用,GC 通常就必须认为对象可能被使用。数据库连接、文件等外部资源应按生命周期关闭,也不能等 GC 替你处理。应限制分页大小、缓存容量和线程数,并观察真实资源使用。

本周诊断: 索引没被选不代表索引创建失败,先看查询和数据分布;线程池程序一直不退出,先看任务是否结束、门闩是否放行、池是否关闭;内存升高不必立即调堆,先查是否无界集合或无界排队。

第19周 缓存与知识库

19.1 两个报修人能共用列表缓存吗

不能直接共用。列表结果受当前登录用户、角色、筛选条件和分页影响。若 A 的结果写入一个公共 key,B 读取它,就可能看见 A 的私有工单。鉴权也不能因为“有缓存”而跳过。

这也是本课程只推荐先缓存管理员统计的原因:范围比较明确,容易验证。若以后缓存私有列表,key 至少需要表达正确的用户与查询范围,例如下面只是结构示意:

ticket:list:reporter:1:status:OPEN:device:all:page:1:size:20

还需要考虑角色变更、数据变更和失效策略。包含用户 id 只是必要设计的一部分,并不自动让整个缓存方案安全正确。

19.2 数字滞后什么时候是约定行为

如果功能明确允许“最多 30 秒陈旧”,页面显示更新时间,缓存过期后能重新加载,那么 10 秒前的数据可能符合约定。如果已超过约定窗口仍不更新,或者把旧数据用于必须立即准确的派单判断,就是错误。

本项目派单是否合法必须由数据库条件更新决定,不能从统计缓存判断。缓存统计只辅助展示。测试至少包含以下步骤:查询并记录初始数量 → 创建一张工单 → 在约定窗口内读取 → 过期后再读取并核对数据库。若采用主动失效,还应检查每一种会改变状态数量的操作是否触发失效。

缓存服务不可用时可回退数据库,但也要观察请求量是否在本机能承受的范围。个人项目先保证正确性,不用为了一个统计查询立即搭建复杂集群。

19.3 没接模型的知识库有什么价值

它仍能把常见故障整理成可查询资料,减少重复查找;可以验证文章维护、检索、分页、输入处理和来源展示。比如输入“投影 黑屏”,返回两条含关键词的条目并说明匹配依据,找不到就明确显示无结果。

自测数据至少包括:命中一条、命中多条、没有匹配、空白输入、停用文章不展示。不要把关键词匹配叫成语义检索,也不要把简单数据库搜索写成 RAG。以后接模型时才考虑检索结果怎样进入上下文、引用怎样验证和无依据时怎样回答。

本周诊断: 先核对原始 SQL 结果,再看缓存;先核对关键词规则,再谈模型;扩展导致主线不稳定时回到最后通过测试的版本,保留扩展代码与问题记录。

第20周 面试与独立修改

20.1 一分钟解释防止重复派单

参考表达,必须用自己的实现替换细节:

两个管理员可能同时读到工单处于 OPEN,直接按 id 更新会产生覆盖。我把工单 id、OPEN 状态和页面读到的版本一起放进 UPDATE 条件,成功后版本加一,并检查影响行数。只有一个请求更新一行,另一个返回零行时转成 409,提醒刷新。工单修改和操作记录在同一个事务里。我用两个独立请求携带同一初始版本验证,最终只派单一次,版本只加一,日志只有一条。

追问“为什么不用 Java 锁”时说明 JVM 内锁的范围;追问“日志失败怎么办”时说明回滚验证;追问“版本能防止所有重复提交吗”时说明这是本次派单的状态与并发策略,不等于所有接口都自动幂等。

20.2 没压测怎样回答并发能力

如实回答范围:

目前做了并发正确性验证,两个请求竞争同一张工单只成功一个,但没有完成系统容量压测,所以我不能给出吞吐量上限。我做过的查询实验是……。如果要测容量,我会先固定机器、数据量、请求比例和并发模型,观察响应时间、错误率、CPU、数据库连接与慢查询,再逐步增加负载。

不要把“两个请求的并发冲突测试”说成“支持高并发”。不了解的指标可以说还没测,再解释已经验证的部分。

20.3 新增设备筛选要改什么

先确认接口参数如 deviceId 的含义和可选性,再沿请求路径检查:

  1. DTO 或请求参数接收设备编号;校验非法编号、分页大小等输入。
  2. Service 仍从登录身份确定可见范围。设备过滤只是增加条件,不能替代报修人或处理人限制。
  3. Mapper 的列表和总数查询都增加同样的设备条件,保持分页统计一致。
  4. 索引是否需要调整,用实际查询决定;没有测量就先保留简单实现。
  5. 增加接口测试,覆盖自己的设备工单、他人的同设备工单、无结果和不传参数;更新接口说明与页面。

报修人查询的核心意思应当是:

WHERE reporter_id = :currentUserId
  AND device_id = :deviceId

可选状态等条件继续用 AND 组合,不能误写成 reporter_id = ... OR device_id = ...,否则可能把同设备的他人工单泄露出来。这里的冒号仍是参数说明,MyBatis 实现时换成绑定参数。

用既有样本验证尤其直接:reporter_a 是用户 1,reporter_b 是用户 4;101 和 103 都关联设备 1,但报修人不同。用户 1 按设备 1 筛选时能看到 101,不应该因为设备相同就看到 103;管理员在合法权限下可以看见两者。

本周诊断: 讲不清就顺着一条真实请求解释;现场小改先画影响范围再动手;总数和列表不一致先查两个 SQL 条件;问题没做过就承认范围,不编测量结果。

自评分方法

每题给自己 0—2 分:0 分是答不出或结论错误,1 分是看答案后能解释,2 分是不看答案能解释并在项目中验证。每周 6 分满分,至少 4 分且关键验收通过再继续;14、15 周的并发和事务即使其他题得分高,也必须亲自验证。自评分帮助安排补课,不是招聘通过率预测。