
如果你看过荒岛求生类的综艺大概对“给好友打电话证明身份”的环节不陌生。阿乐拨通火男的电话说“请说出我的三个口头禅”火男在电话那头来一句“小女子年芳28”气氛一下子活跃起来。综艺里这是笑点但放到软件开发视角这个场景其实非常典型一个系统保存联系人的身份信息通过某个口令口头禅验证通话对象身份并把每一次呼叫行为记录下来。把“口头禅”抽象成标签、“打电话”抽象成接口调用就是一个很标准的通讯录管理系统。本文将围绕这个场景从零搭建一个“荒岛求生通讯录”Spring Boot 实战项目包含联系人管理、口头禅标签维护、模拟呼叫验证、呼叫日志查询等完整功能。文章会提供可复制的代码、完整的表结构设计以及常见报错排查方案适合有一定 Java 基础、想通过一个小项目把 Spring Boot MyBatis-Plus MySQL 串起来的读者。1. 场景背景从综艺梗到一个可落地的通讯录项目1.1 业务场景还原原始场景中阿乐在“荒岛”上通过电话联系好友好友需要准确说出阿乐的口头禅才能证明“你是你”。这个环节看起来简单背后却包含几个关键业务动作提前维护好友信息包括姓名、电话、备注。为每个好友维护多条“口令”也就是口头禅。发起呼叫时需要展示一条口头禅用于身份验证。呼叫结束后要留存记录方便追溯“谁在什么时候验证过”。这不是综艺独有的需求。在实际系统中类似模型非常常见客服系统工单校验。账号安全找回时的密保问答。门禁系统的动态口令。好友互助平台的“灵魂认证”。所以本文用“荒岛求生”这个趣味场景作为壳核心是带你实现一套结构清晰、可扩展的通讯录 标签验证系统。1.2 项目要解决的核心问题这个小项目要解决的核心问题可以拆成四个联系人数据如何持久化一个联系人多条口头禅如何建表呼叫时如何随机选择一条口头禅如何记录呼叫历史方便后续排查为了讲清楚这些问题我会采用 Spring Boot MyBatis-Plus MySQL 这套国内开发中最常见的组合。MyBatis-Plus 可以大幅减少 SQL 编写量让我们更专注于业务逻辑本身。2. 功能拆解与整体技术设计2.1 功能模块拆分根据上面的分析项目拆成以下功能模块模块功能说明关键接口联系人管理新增联系人、按姓名或电话搜索POST /api/contacts、GET /api/contacts/search口头禅管理新增联系人时批量绑定口头禅查询口头禅列表GET /api/contacts/{id}/slogans呼叫验证随机取联系人一条口头禅模拟呼叫验证POST /api/calls/{contactId}/dial呼叫日志查询最近呼叫记录便于追溯GET /api/calls/recent2.2 技术选型JDK 1.8Spring Boot 2.7.18MyBatis-Plus 3.5.3.1MySQL 8.xMaven 3.6IDEA 或任意你习惯的 IDEPostman 或 curl 做接口测试如果你使用的 Spring Boot 版本是 3.x注意javax包名要改成jakarta同时 MyBatis-Plus 也需要使用对应的mybatis-plus-spring-boot3-starter。本文以 Spring Boot 2.7 为例这是目前企业生产环境中存量最大的版本组合之一。2.3 项目结构规划survivor-contacts/ ├── pom.xml └── src/main/ ├── java/com/example/survivor/ │ ├── SurvivorApplication.java │ ├── common/ │ │ └── ApiResult.java │ ├── controller/ │ │ ├── ContactController.java │ │ └── CallController.java │ ├── entity/ │ │ ├── Contact.java │ │ ├── Slogan.java │ │ └── CallLog.java │ ├── mapper/ │ │ ├── ContactMapper.java │ │ ├── SloganMapper.java │ │ └── CallLogMapper.java │ └── service/ │ ├── ContactService.java │ └── CallService.java └── resources/ ├── application.yml └── schema.sql这个结构遵循常见的 Controller → Service → Mapper 分层业务简单但层次完整适合作为初学者模板。3. 环境准备与版本说明3.1 基础环境在开始之前请确保本机已经安装以下环境软件版本建议用途JDK1.8 或 11编译运行 Java 代码Maven3.6 以上依赖管理MySQL5.7 或 8.x数据持久化IDEA2021 以上开发调试Postman任意版本接口测试版本需要根据你的项目实际情况调整。如果你的电脑上只有 JDK 17可以直接改用 Spring Boot 3.x本文重点演示配置思路和代码结构版本差异不影响整体理解。3.2 确认 MySQL 服务安装完 MySQL 后至少确认两件事MySQL 服务已经启动。知道 root 账号的密码并且允许本机连接。如果 MySQL 安装在远程服务器需要把application.yml中的连接地址改成对应 IP同时确认服务器的 3306 端口对当前机器开放。4. 数据库设计与初始化4.1 表结构设计数据库设计是整个项目的地基。这里设计三张表contact联系人主表。slogan口头禅表和联系人是一对多关系。call_log呼叫记录表。为什么不把口头禅直接存到contact表的一个字段里因为“三个口头禅”意味着每条联系人对应多条记录如果存在一个字段里后续抽取、增删、统计都会非常痛苦。拆成一张子表用contact_id关联是最自然的建模方式。call_log冗余了contact_name字段。正常情况下姓名可以通过contact_id关联查询得到但呼叫记录属于历史流水冗余一个快照字段可以避免联系人改名后历史记录失真这是流水表设计的常见做法。4.2 建库建表脚本新建文件src/main/resources/schema.sql内容如下CREATE DATABASE IF NOT EXISTS survivor_contacts DEFAULT CHARACTER SET utf8mb4; USE survivor_contacts; DROP TABLE IF EXISTS call_log; DROP TABLE IF EXISTS slogan; DROP TABLE IF EXISTS contact; CREATE TABLE contact ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 联系人姓名, phone VARCHAR(20) NOT NULL COMMENT 手机号, gender VARCHAR(10) DEFAULT 未知 COMMENT 性别, remark VARCHAR(255) DEFAULT COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT联系人表; CREATE TABLE slogan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contact_id BIGINT NOT NULL COMMENT 联系人ID, content VARCHAR(255) NOT NULL COMMENT 口头禅内容, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, KEY idx_contact_id (contact_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT口头禅表; CREATE TABLE call_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contact_id BIGINT NOT NULL COMMENT 联系人ID, contact_name VARCHAR(50) DEFAULT COMMENT 联系人名快照, slogan_used VARCHAR(255) DEFAULT COMMENT 本次验证使用的口头禅, call_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 呼叫时间, result VARCHAR(100) DEFAULT COMMENT 验证结果 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT呼叫记录表;这里统一使用utf8mb4字符集保证“小女子年芳28”这类带符号的文本能正常存储。InnoDB引擎支持事务后续新增联系人和绑定口头禅时需要事务保证一致性。4.3 插入演示数据为了后续接口测试方便可以插入两个联系人和口头禅数据INSERT INTO contact (name, phone, gender, remark) VALUES (阿乐, 13800000001, 男, 综艺好友), (火男, 13800000002, 男, 口头禅达人); INSERT INTO slogan (contact_id, content) VALUES (1, 小女子年芳28~), (1, 稳住我们能赢), (1, 干饭第一名), (2, 小女子年芳28~), (2, wow wow wow);阿乐有三条口头禅火男有两条。这样后面测试随机抽取时可以看到不同结果。5. 工程搭建与核心代码实现5.1 创建工程并编写 pom.xml在 IDEA 中新建一个 Spring Initializr 项目或者直接手动创建 Maven 工程。pom.xml内容如下?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdsurvivor-contacts/artifactId version0.0.1-SNAPSHOT/version namesurvivor-contacts/name description荒岛求生通讯录与口头禅验证系统/description properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project依赖说明spring-boot-starter-web提供 Web 能力和内嵌 Tomcat。mybatis-plus-boot-starterMyBatis 增强工具单表 CRUD 不用手写 SQL。mysql-connector-javaMySQL 驱动。lombok简化实体类的 getter/setter。5.2 编写 application.yml在src/main/resources/application.yml中写入server: port: 8080 spring: application: name: survivor-contacts datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/survivor_contacts?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case开启后数据库字段create_time会自动映射到 Java 属性createTime不用手写繁琐的TableField。log-impl让控制台打印 SQL方便初学者看 MyBatis-Plus 实际执行的语句。如果你的 MySQL 密码不是root改成自己的密码即可。修改密码后需要重启应用才能生效。5.3 实体类定义创建entity包编写三个实体类。// 文件路径src/main/java/com/example/survivor/entity/Contact.java package com.example.survivor.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(contact) public class Contact { TableId(type IdType.AUTO) private Long id; private String name; private String phone; private String gender; private String remark; private LocalDateTime createTime; }// 文件路径src/main/java/com/example/survivor/entity/Slogan.java package com.example.survivor.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(slogan) public class Slogan { TableId(type IdType.AUTO) private Long id; private Long contactId; private String content; private LocalDateTime createTime; }// 文件路径src/main/java/com/example/survivor/entity/CallLog.java package com.example.survivor.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(call_log) public class CallLog { TableId(type IdType.AUTO) private Long id; private Long contactId; private String contactName; private String sloganUsed; private LocalDateTime callTime; private String result; }Data是 Lombok 提供的注解编译后自动生成 getter、setter、toString 等方法。如果不喜欢 Lombok也可以手动补齐这些方法业务代码不变。5.4 Mapper 层MyBatis-Plus的BaseMapper内置了大量单表方法因此 Mapper 接口非常简洁。// 文件路径src/main/java/com/example/survivor/mapper/ContactMapper.java package com.example.survivor.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.survivor.entity.Contact; import org.apache.ibatis.annotations.Mapper; Mapper public interface ContactMapper extends BaseMapperContact { }// 文件路径src/main/java/com/example/survivor/mapper/SloganMapper.java package com.example.survivor.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.survivor.entity.Slogan; import org.apache.ibatis.annotations.Mapper; Mapper public interface SloganMapper extends BaseMapperSlogan { }// 文件路径src/main/java/com/example/survivor/mapper/CallLogMapper.java package com.example.survivor.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.survivor.entity.CallLog; import org.apache.ibatis.annotations.Mapper; Mapper public interface CallLogMapper extends BaseMapperCallLog { }5.5 Service 层Service 层承载核心业务逻辑。ContactService负责联系人和口头禅的维护使用Transactional保证新增联系人时联系人和口头禅要么都成功要么都失败。// 文件路径src/main/java/com/example/survivor/service/ContactService.java package com.example.survivor.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.survivor.entity.Contact; import com.example.survivor.entity.Slogan; import com.example.survivor.mapper.ContactMapper; import com.example.survivor.mapper.SloganMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; Service public class ContactService { Autowired private ContactMapper contactMapper; Autowired private SloganMapper sloganMapper; Transactional(rollbackFor Exception.class) public void addContact(Contact contact, ListString slogans) { contactMapper.insert(contact); if (slogans ! null) { for (String content : slogans) { Slogan slogan new Slogan(); slogan.setContactId(contact.getId()); slogan.setContent(content); sloganMapper.insert(slogan); } } } public ListContact search(String keyword) { LambdaQueryWrapperContact wrapper new LambdaQueryWrapper(); wrapper.like(Contact::getName, keyword) .or().like(Contact::getPhone, keyword) .orderByDesc(Contact::getCreateTime); return contactMapper.selectList(wrapper); } public ListSlogan listSlogans(Long contactId) { LambdaQueryWrapperSlogan wrapper new LambdaQueryWrapper(); wrapper.eq(Slogan::getContactId, contactId); return sloganMapper.selectList(wrapper); } public Contact getById(Long id) { return contactMapper.selectById(id); } }CallService负责模拟呼叫、随机取口头禅、写呼叫日志。// 文件路径src/main/java/com/example/survivor/service/CallService.java package com.example.survivor.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.survivor.entity.CallLog; import com.example.survivor.entity.Contact; import com.example.survivor.entity.Slogan; import com.example.survivor.mapper.CallLogMapper; import com.example.survivor.mapper.SloganMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; import java.util.Random; Service public class CallService { Autowired private ContactService contactService; Autowired private SloganMapper sloganMapper; Autowired private CallLogMapper callLogMapper; private final Random random new Random(); public String callAndVerify(Long contactId) { Contact contact contactService.getById(contactId); if (contact null) { throw new IllegalArgumentException(联系人不存在: contactId); } ListSlogan slogans contactService.listSlogans(contactId); if (slogans null || slogans.isEmpty()) { throw new IllegalStateException(该联系人还没有设置口头禅请先补全验证口令); } Slogan slogan slogans.get(random.nextInt(slogans.size())); String result 验证通过身份确认 contact.getName(); CallLog log new CallLog(); log.setContactId(contactId); log.setContactName(contact.getName()); log.setSloganUsed(slogan.getContent()); log.setResult(result); callLogMapper.insert(log); return slogan.getContent() —— result; } public ListCallLog recentLogs(int limit) { LambdaQueryWrapperCallLog wrapper new LambdaQueryWrapper(); wrapper.orderByDesc(CallLog::getCallTime); wrapper.last(LIMIT limit); return callLogMapper.selectList(wrapper); } }注意这里对“联系人不存在”和“口头禅为空”做了区分。前者属于资源不存在返回 404后者属于业务状态不对返回 400。这种细分在真实项目中能显著降低沟通成本。5.6 Controller 层Controller 只负责参数接收和结果包装不写业务逻辑。// 文件路径src/main/java/com/example/survivor/controller/ContactController.java package com.example.survivor.controller; import com.example.survivor.common.ApiResult; import com.example.survivor.entity.Contact; import com.example.survivor.entity.Slogan; import com.example.survivor.service.ContactService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; RestController RequestMapping(/api/contacts) public class ContactController { Autowired private ContactService contactService; PostMapping public ApiResultContact add(RequestBody MapString, Object body) { Contact contact new Contact(); contact.setName((String) body.get(name)); contact.setPhone((String) body.get(phone)); contact.setGender((String) body.get(gender)); contact.setRemark((String) body.get(remark)); SuppressWarnings(unchecked) ListString slogans (ListString) body.get(slogans); contactService.addContact(contact, slogans); return ApiResult.success(contact); } GetMapping(/search) public ApiResultListContact search(RequestParam String keyword) { return ApiResult.success(contactService.search(keyword)); } GetMapping(/{id}/slogans) public ApiResultListSlogan listSlogans(PathVariable Long id) { return ApiResult.success(contactService.listSlogans(id)); } }// 文件路径src/main/java/com/example/survivor/controller/CallController.java package com.example.survivor.controller; import com.example.survivor.common.ApiResult; import com.example.survivor.entity.CallLog; import com.example.survivor.service.CallService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/api/calls) public class CallController { Autowired private CallService callService; PostMapping(/{contactId}/dial) public ApiResultString dial(PathVariable Long contactId) { try { String message callService.callAndVerify(contactId); return ApiResult.success(message); } catch (IllegalArgumentException e) { return ApiResult.error(404, e.getMessage()); } catch (IllegalStateException e) { return ApiResult.error(400, e.getMessage()); } } GetMapping(/recent) public ApiResultListCallLog recent(RequestParam(defaultValue 10) int limit) { return ApiResult.success(callService.recentLogs(limit)); } }Controller 中直接使用MapString, Object接收请求体是演示项目里减少 DTO 类的快捷写法。真实项目中更推荐定义专门的ContactRequest对象配合Valid做参数校验。5.7 统一返回结构与启动类为了方便前端解析这里定义一个统一的接口返回包装类。// 文件路径src/main/java/com/example/survivor/common/ApiResult.java package com.example.survivor.common; import lombok.Data; Data public class ApiResultT { private int code; private String message; private T data; public static T ApiResultT success(T data) { ApiResultT result new ApiResult(); result.setCode(200); result.setMessage(ok); result.setData(data); return result; } public static T ApiResultT error(int code, String message) { ApiResultT result new ApiResult(); result.setCode(code); result.setMessage(message); return result; } }最后是启动类。// 文件路径src/main/java/com/example/survivor/SurvivorApplication.java package com.example.survivor; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class SurvivorApplication { public static void main(String[] args) { SpringApplication.run(SurvivorApplication.class, args); } }6. 运行与接口验证6.1 启动项目在 IDEA 中直接运行SurvivorApplication也可以使用 Maven 命令mvn spring-boot:run启动成功后控制台会打印 Tomcat 启动端口默认是 8080。看到类似下面的信息说明启动成功Started SurvivorApplication in 2.53 seconds (JVM running for 3.05)6.2 添加联系人使用 Postman 发送 POST 请求POST http://localhost:8080/api/contacts Content-Type: application/json请求体{ name: 阿乐, phone: 13800000001, gender: 男, remark: 综艺好友, slogans: [ 小女子年芳28~, 稳住我们能赢, 干饭第一名 ] }预期返回{ code: 200, message: ok, data: { id: 1, name: 阿乐, phone: 13800000001, gender: 男, remark: 综艺好友, createTime: 2025-01-01T12:00:00 } }因为contact表里已经手动插入了演示数据如果重复添加阿乐会产生多条同名记录这在实际项目中要通过唯一约束或前端去重来规避。6.3 搜索联系人GET http://localhost:8080/api/contacts/search?keyword阿乐预期返回阿乐的信息列表。如果按手机号搜索传入13800000001也能查到。这个接口内部使用的是LIKE查询关键词越长匹配越精确。线上环境如果联系人数据量大建议再加一个limit参数避免一次返回过多数据。6.4 呼叫验证发起模拟呼叫POST http://localhost:8080/api/calls/1/dial预期返回可能类似{ code: 200, message: ok, data: 稳住我们能赢 —— 验证通过身份确认阿乐 }因为每次随机取一条口头禅再次调用可能返回“小女子年芳28~”或“干饭第一名”。这个随机性正好模拟了“好友说出口头禅”的不确定性。如果访问一个不存在的联系人 ID比如 999预期返回{ code: 404, message: 联系人不存在: 999, data: null }如果联系人没有配置口头禅预期返回 400。6.5 查看呼叫记录GET http://localhost:8080/api/calls/recent?limit10预期返回最近 10 条呼叫记录按时间倒序排列。每次调用/dial都会产生一条记录记录里保存了联系人名、口头禅内容和验证结果。7. 常见问题与排查思路实际开发中最容易遇到的问题集中在环境、编码、依赖和事务四个方面。下面整理成表格方便快速定位。问题现象常见原因解决思路启动报Access denied for user rootlocalhost数据库密码错误检查application.yml的password配置启动报Unknown database survivor_contacts数据库未创建先执行schema.sql中的CREATE DATABASE语句控制台中文乱码数据库字符集不是 utf8mb4建库时指定DEFAULT CHARACTER SET utf8mb4接口返回 404请求路径或方法不对对照 Controller 的RequestMapping检查 URL新增联系人报“口头禅为空”请求体里slogans不是数组确认 JSON 格式slogans应为[xxx,yyy]数据库字段create_time查出来为 null未开启下划线转驼峰在application.yml配置map-underscore-to-camel-case: truedial接口偶尔返回不同口头禅这是正常随机行为随机抽取由Random实现多调用几次即可事务不生效类内部方法互相调用确认Transactional加在 Service 类入口方法上7.1 数据库连不上的排查步骤如果启动工程时报数据库连接错误按以下顺序排查确认 MySQL 服务是否启动。在命令行执行mysql -uroot -p验证账号密码。执行SHOW DATABASES;查看是否存在survivor_contacts。检查application.yml的url中数据库名是否拼写正确。如果 MySQL 是 8.x确认驱动版本是mysql-connector-java8.x不要用 5.x 的驱动连 8.x 数据库。7.2 口头禅随机抽取为空callAndVerify方法要求联系人至少有一条口头禅。如果新增联系人时没有传slogans字段或者传了空数组后续呼叫就会返回 400。建议在新增联系人接口中增加校验例如if (slogans null || slogans.isEmpty()) { throw new IllegalArgumentException(口头禅不能为空); }这样在数据入口处就把问题拦截掉而不是等到呼叫验证时才暴露。8. 最佳实践与工程建议8.1 数据校验不能省略演示代码为了简洁没有使用Valid和 DTO。真实项目中联系人姓名、手机号、口头禅内容都需要做非空和长度校验。可以定义一个ContactRequest类并使用NotBlank、Size等注解然后在 Controller 参数上使用Valid。手机号建议做格式校验至少校验 11 位数字。口头禅不要允许空字符串。8.2 事务边界要清晰新增联系人时“插入联系人”和“批量插入口头禅”两个操作要在一个事务里完成。如果口头禅插入一半失败联系人主表数据也不能保留否则会留下没有口头禅的“孤儿数据”。ContactService中的addContact已经用Transactional(rollbackFor Exception.class)做了处理。注意rollbackFor必须指定为Exception.class否则 RuntimeException 之外的异常不会触发回滚。8.3 日志记录要完整呼叫验证属于敏感操作建议在CallService中增加日志输出log.info(呼叫联系人 id{}, name{}, slogan{}, contact.getId(), contact.getName(), slogan.getContent());如果后续要排查“谁在什么时候验证了什么”只靠call_log表还不够完整的操作日志能帮助快速还原问题过程。8.4 随机抽取注意线程安全Random实例本身是线程安全的但在高并发场景下java.util.Random的性能不如ThreadLocalRandom。如果这个项目未来要支撑大量并发呼叫建议改成int index ThreadLocalRandom.current().nextInt(slogans.size());8.5 删除联系人要级联处理当前代码没有实现删除联系人的功能。如果后续增加DELETE /api/contacts/{id}一定要记得同时删除该联系人下的口头禅和相关的呼叫记录否则会出现脏数据。可以在数据库层面设置外键级联删除也可以在 Service 层手动调用SloganMapper.delete。8.6 接口幂等性设计“呼叫验证”这个动作在真实系统中需要做幂等控制。比如用户连续点击两次拨号系统不应该生成两条内容完全一样的验证记录。简单做法是前端限制按钮点击频率后端可以结合contactId slogan 时间窗口做去重。8.7 安全性提醒这类系统如果上线至少要考虑三点接口需要鉴权不能允许任何人随意调用/dial。口头禅属于隐私信息查询接口需要控制权限。call_log表属于流水数据长期运行后体积会增长需要定期归档或清理。9. 总结与下一步学习方向本文从一个“荒岛求生给好友打电话”的趣味场景出发完成了联系人管理、口头禅标签维护、模拟呼叫验证、呼叫日志查询的完整功能。整套代码基于 Spring Boot MyBatis-Plus MySQL 实现覆盖了工程搭建、建表、实体类、Service、Controller 和接口验证的全过程。如果你完整跟着代码走了一遍现在应该能理解一对多数据关系如何通过两张表建模。MyBatis-Plus 如何简化单表 CRUD。Transactional什么时候需要生效。统一返回结构如何帮助前后端对齐协议。呼叫记录为什么要冗余联系人名字段。下一步可以在这个项目基础上继续扩展增加删除联系人功能并处理级联数据。使用 Spring Security 或 Sa-Token 增加登录鉴权。把随机口头禅改为前端输入口令后端校验是否匹配。对接真实的语音呼叫服务商把模拟呼叫替换成真实电话通知。代码已经完整贴出建议你本地建库后直接跑一遍然后尝试把口头禅改成自己朋友的真实口头禅这样印象会更深。如果卡在哪一步欢迎带着报错信息留言交流。