跳转到正文
← 返回学习板块
Java 实习自学 · 设备运维工单平台

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

第1—6周自测解释及Java、文件读写、建表和JDBC局部示例。

完整学习目录 · 14 篇
本篇目录

配合第 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 driverMaven 依赖是否下载、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 片段为教学参考,未连接数据库运行;这不是已经验收的完整项目。

← 浏览全部课程回到顶部 ↑