拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot实战:构建语音纪念信箱系统

“给死去的人打电话”这个说法第一次看到时更像都市传闻俄罗斯有一家酒吧不卖酒顾客被带进一个昏暗房间拿起电话听筒就能“联系已故的人”。这段网络故事是否属实很难考证但把“听不到、摸不着的声音”保存下来并在某个纪念日重新回听是数字产品完全可以做到的事情。这篇文章把这个标题背后的情感需求拆成一个可落地项目语音纪念信箱。系统允许家属把亲人生前留下的语音上传、归档授权指定成员回放也可以设置纪念时间由系统按计划拨打电话并播放语音。项目用 Spring Boot 实现涉及文件存储、权限校验、任务调度和呼叫服务抽象适合想练习完整业务流程的开发者。1. 从标题到需求先明确“给死去的人打电话”的真实边界1.1 传闻里的需求本质酒吧传闻里的“电话”之所以打动人是因为它把“想再听一次声音”这个朴素愿望包装成了一个有仪式感的动作拿起听筒拨号等待接通。抛开通灵叙事这个需求至少包含三个环节语音内容必须被提前留存。只有被授权的家人才能访问。在希望听到的时间把语音以容易触达的方式播放出来比如电话、网页播放器、智能音箱。这就是“语音纪念信箱”的产品雏形。它的技术本质是“受控访问的声音档案”而不是真的与逝者建立实时通信链路。技术工作要做的是把不可验证的传闻翻译成可测试、可扩展的系统设计。1.2 能做与不能做的技术边界写代码之前先把边界说清楚避免产品方向走偏。能力是否可实现技术方式播放已保存的真实语音可以文件存储 流式播放指定纪念日自动呼叫家人可以定时任务 电话呼叫服务实时与逝者双向对话不可以现有技术无法验证意识留存或通信用 AI 合成逝者声音有条件需要明确授权、严格合规和风险提示在教程项目中我默认只处理“真实语音回放”和“定时电话回拨”两个能力。AI 声音合成涉及肖像权、声音权和诈骗风险不适合作为入门项目默认功能。1.3 把想法拆成四个技术模块一个最小可运行的语音纪念信箱可以拆成四个模块语音档案管理上传录音、保存文件、保存元数据。家属授权访问只有同一家属分组内的用户能播放语音。定时回拨计划用户选择语音和拨打时间后台任务扫描并触发呼叫。呼叫服务抽象本地用 Mock 实现生产环境替换为真实通信服务商。有了清晰模块划分后续代码才不会越写越乱。2. 系统设计与环境准备2.1 技术选型与版本说明教程使用 Java 和 Spring Boot原因有两个国内开发者群体大排查资料多Spring Boot 对 Web、JPA、定时任务支持完整适合做业务闭环。示例版本如下组件版本或类型用途JDK1.8 或 11运行环境Spring Boot2.7.xWeb 框架Spring Data JPA随 Boot 管理数据访问MySQL5.7 或 8.0元数据存储H2可选本地快速验证MinIO最新稳定版文件存储可直接用本地目录替代FFmpeg4.x获取音频时长、转码如果原始环境没有明确安装版本落地前要先确认依赖版本与 Spring Boot 2.7 的兼容性。2.2 创建 Spring Boot 工程推荐用 Spring Initializr 生成基础工程直接勾选 Spring Web、Spring Data JPA、MySQL Driver、Lombok。也可以使用 Maven 手工创建核心 pom 依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency /dependenciespom 中的核心点是JPA 负责元数据持久化commons-io 简化文件复制文件存储先用本地磁盘不引入 MinIO减少环境复杂度。生产环境可以再替换为对象存储。2.3 项目目录结构一个清晰的包结构对后续扩展很重要src/main/java/com/example/voicememory/ ├── VoiceMemoryApplication.java ├── controller/ │ ├── ArchiveController.java │ └── CallPlanController.java ├── entity/ │ ├── VoiceArchive.java │ └── CallPlan.java ├── repository/ │ ├── VoiceArchiveRepository.java │ └── CallPlanRepository.java ├── service/ │ ├── ArchiveService.java │ ├── CallPlanService.java │ └── CallProvider.java └── provider/ └── MockCallProvider.java本地文件建议放在工程外的数据目录比如~/voice-memory-data/。不要存到src/main/resources下否则打包发布时容易丢失。2.4 数据库表设计语音纪念信箱需要两张核心表语音档案表和回拨计划表。CREATE TABLE voice_archive ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 语音标题, family_group VARCHAR(64) NOT NULL COMMENT 家属分组标识, file_path VARCHAR(255) NOT NULL COMMENT 文件存储路径, duration_seconds INT DEFAULT 0 COMMENT 音频时长, created_at DATETIME NOT NULL, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE call_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, archive_id BIGINT NOT NULL COMMENT 语音档案ID, target_mobile VARCHAR(32) NOT NULL COMMENT 目标手机号, call_time DATETIME NOT NULL COMMENT 计划拨打时间, status VARCHAR(16) DEFAULT PENDING COMMENT PENDING/SUCCESS/FAILED, retry_count INT DEFAULT 0 COMMENT 已重试次数, created_at DATETIME NOT NULL, INDEX idx_call_time_status (call_time, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表设计的关键是family_group字段。它决定了谁能访问这条语音。简单方案是同一个家属分组的用户共享权限避免给每条语音单独维护一份权限表。更复杂的方案可以用单独的权限表支持细粒度授权。call_plan表使用索引(call_time, status)保证定时任务扫描时快速定位到期且未处理的计划。3. 核心功能实现3.1 语音档案实体先用 JPA 实体映射刚才的表。package com.example.voicememory.entity; import lombok.Data; import javax.persistence.*; import java.time.LocalDateTime; Data Entity Table(name voice_archive) public class VoiceArchive { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; Column(name family_group) private String familyGroup; Column(name file_path) private String filePath; Column(name duration_seconds) private Integer durationSeconds; Column(name created_at) private LocalDateTime createdAt; private Integer deleted; PrePersist public void prePersist() { if (createdAt null) { createdAt LocalDateTime.now(); } if (deleted null) { deleted 0; } } }PrePersist是为了在插入前自动填充时间字段和逻辑删除标记避免业务代码重复写这两行赋值。3.2 语音上传接口上传接口接收 MultipartFile需要完成校验、存储、获取音频时长、写入数据库四步。package com.example.voicememory.controller; import com.example.voicememory.entity.VoiceArchive; import com.example.voicememory.service.ArchiveService; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; RestController RequestMapping(/api/archives) public class ArchiveController { private final ArchiveService archiveService; public ArchiveController(ArchiveService archiveService) { this.archiveService archiveService; } PostMapping public VoiceArchive upload(RequestParam(file) MultipartFile file, RequestParam(title) String title, RequestParam(familyGroup) String familyGroup) { return archiveService.upload(file, title, familyGroup); } }上传服务是核心逻辑所在。package com.example.voicememory.service; import com.example.voicememory.entity.VoiceArchive; import com.example.voicememory.repository.VoiceArchiveRepository; import org.apache.commons.io.FilenameUtils; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.List; import java.util.UUID; Service public class ArchiveService { private static final ListString ALLOWED_EXTENSIONS Arrays.asList(mp3, wav, m4a); private static final long MAX_FILE_SIZE 50L * 1024 * 1024; private final VoiceArchiveRepository archiveRepository; public ArchiveService(VoiceArchiveRepository archiveRepository) { this.archiveRepository archiveRepository; } public VoiceArchive upload(MultipartFile file, String title, String familyGroup) { String ext FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(); if (!ALLOWED_EXTENSIONS.contains(ext)) { throw new IllegalArgumentException(不支持的音频格式: ext); } if (file.getSize() MAX_FILE_SIZE) { throw new IllegalArgumentException(文件大小超过 50MB 限制); } String storageRoot System.getProperty(user.home) /voice-memory-data/; String filename UUID.randomUUID().toString() . ext; try { Files.createDirectories(Paths.get(storageRoot)); Path target Paths.get(storageRoot filename); file.transferTo(target.toFile()); VoiceArchive archive new VoiceArchive(); archive.setTitle(title); archive.setFamilyGroup(familyGroup); archive.setFilePath(target.toString()); archive.setDurationSeconds(readDuration(target.toFile())); return archiveRepository.save(archive); } catch (IOException e) { throw new RuntimeException(文件保存失败, e); } } private int readDuration(File audioFile) { // 使用 FFprobe 读取时长例如返回 125 return 125; } }这里有一个需要改动的地方readDuration方法在示例中直接返回 125真实项目需要通过 FFprobe 命令读取音频信息。下面给出一个更完整的实现思路import java.io.BufferedReader; import java.io.InputStreamReader; private int readDuration(File audioFile) { try { ProcessBuilder pb new ProcessBuilder( ffprobe, -v, error, -show_entries, formatduration, -of, defaultnoprint_wrappers1:nokey1, audioFile.getAbsolutePath()); Process process pb.start(); BufferedReader reader new BufferedReader(new InputStreamReader(process.getInputStream())); String line reader.readLine(); if (line ! null !line.isEmpty()) { return (int) Math.round(Double.parseDouble(line)); } } catch (Exception e) { // 读取时长失败时不阻塞上传默认填 0 } return 0; }FFprobe 是 FFmpeg 套件里的命令。如果操作系统没有安装 FFmpeg这个逻辑会进入 catch 分支。生产环境建议在文件上传完成后异步转码和读取时长不要在主线程中阻塞太久。3.3 授权回放接口回放接口不能只做文件下载。如果任何人拿到语音 ID 就能播放产品在隐私上就是失败的。接口校验逻辑根据 ID 查询语音档案。校验请求方所在的家属分组与语音档案的 familyGroup 是否一致。一致则返回文件流否则返回 403。GetMapping(/{id}/stream) public ResponseEntityResource stream(PathVariable Long id, RequestHeader(X-Family-Group) String familyGroup) { VoiceArchive archive archiveRepository.findByIdAndDeleted(id, 0) .orElseThrow(() - new RuntimeException(语音不存在)); if (!archive.getFamilyGroup().equals(familyGroup)) { return ResponseEntity.status(403).build(); } try { Path path Paths.get(archive.getFilePath()); Resource resource new FileSystemResource(path); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(audio/mpeg)) .body(resource); } catch (Exception e) { return ResponseEntity.status(404).build(); } }这里使用X-Family-Group请求头做权限判断只是教学演示。真实系统应该使用登录态、Token 或者 JWT从会话中解析用户归属不能完全相信前端传过来的分组名。存档的file_path是服务器本地路径直接返回给前端存在路径泄露风险。更稳妥的做法是只返回id和音频元数据播放时通过这个流式接口读取。3.4 呼叫抽象与定时任务“定时给家人打电话播放语音”是项目的仪式感所在。但真实呼叫服务的接入方式各不相同先定义一个统一接口package com.example.voicememory.service; import com.example.voicememory.entity.CallPlan; public interface CallProvider { CallResult callAndPlay(CallPlan plan, String audioUrl); }接口里只需要两个输入回拨计划和可访问的音频地址。具体怎么呼叫、怎么播报语音由实现类决定。本地开发时可以用 Mock 实现只打印日志避免真实话费package com.example.voicememory.provider; import com.example.voicememory.entity.CallPlan; import com.example.voicememory.service.CallProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; Component public class MockCallProvider implements CallProvider { private static final Logger log LoggerFactory.getLogger(MockCallProvider.class); Override public CallResult callAndPlay(CallPlan plan, String audioUrl) { log.info(模拟拨打手机号: {}, 播放语音ID: {}, 音频地址: {}, plan.getTargetMobile(), plan.getArchiveId(), audioUrl); // 模拟 60% 成功率方便测试失败重试 boolean success Math.random() 0.4; return new CallResult(success, success ? OK : MOCK_FAIL); } }3.5 定时扫描回拨计划Spring Boot 开启定时任务只需要在启动类上加EnableScheduling。package com.example.voicememory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; EnableScheduling SpringBootApplication public class VoiceMemoryApplication { public static void main(String[] args) { SpringApplication.run(VoiceMemoryApplication.class, args); } }定时任务每分钟扫描一次到期计划调用呼叫服务package com.example.voicememory.service; import com.example.voicememory.entity.CallPlan; import com.example.voicememory.entity.VoiceArchive; import com.example.voicememory.repository.CallPlanRepository; import com.example.voicememory.repository.VoiceArchiveRepository; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; Component public class CallPlanScanner { private static final int MAX_RETRY 3; private final CallPlanRepository callPlanRepository; private final VoiceArchiveRepository archiveRepository; private final CallProvider callProvider; public CallPlanScanner(CallPlanRepository callPlanRepository, VoiceArchiveRepository archiveRepository, CallProvider callProvider) { this.callPlanRepository callPlanRepository; this.archiveRepository archiveRepository; this.callProvider callProvider; } Scheduled(cron 0 * * * * *) public void scan() { ListCallPlan pendingPlans callPlanRepository .findByCallTimeLessThanEqualAndStatus(LocalDateTime.now(), PENDING); for (CallPlan plan : pendingPlans) { VoiceArchive archive archiveRepository.findById(plan.getArchiveId()).orElse(null); if (archive null) { plan.setStatus(FAILED); callPlanRepository.save(plan); continue; } if (plan.getRetryCount() MAX_RETRY) { plan.setStatus(FAILED); callPlanRepository.save(plan); continue; } CallResult result callProvider.callAndPlay(plan, http://localhost:8080/api/archives/ archive.getId() /stream); if (result.isSuccess()) { plan.setStatus(SUCCESS); } else { plan.setRetryCount(plan.getRetryCount() 1); } callPlanRepository.save(plan); } } }这个定时任务的判断顺序值得注意先检查语音档案是否存在。再检查是否超过最大重试次数。最后才真正呼叫。原因是避免对已删除语音或超限任务重复发起无意义呼叫。4. 运行验证从 Mock 呼叫跑通完整链路4.1 启动依赖组件本地跑通只需要 MySQL 和 FFmpeg。# 创建数据库 mysql -uroot -p -e CREATE DATABASE voice_memory DEFAULT CHARACTER SET utf8mb4; # 修改 application.yml # 启动应用 mvn spring-boot:runapplication.yml 最小配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/voice_memory?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true servlet: multipart: max-file-size: 50MB max-request-size: 60MBddl-auto: update适合开发阶段快速建表。生产环境建议改成validate或使用 Flyway 管理表结构避免 JPA 自动修改生产表。4.2 上传语音并创建回拨计划使用 curl 上传语音curl -X POST http://localhost:8080/api/archives \ -F file/home/user/audio/voice.mp3 \ -F title外婆的叮嘱 \ -F familyGroupfam_001正常响应会包含档案 ID{ id: 1, title: 外婆的叮嘱, familyGroup: fam_001, filePath: /home/user/voice-memory-data/xxxx.mp3, durationSeconds: 125, createdAt: 2025-01-01T10:00:00, deleted: 0 }然后创建一条回拨计划时间为当前时间之后一分钟curl -X POST http://localhost:8080/api/call-plans \ -H Content-Type: application/json \ -d { archiveId: 1, targetMobile: 13800000000, callTime: 2025-01-01T10:02:00 }4.3 验证定时任务触发等待一分钟在应用日志中可以看到 Mock 呼叫输出模拟拨打手机号: 13800000000, 播放语音ID: 1, 音频地址: http://localhost:8080/api/archives/1/stream查询数据库确认状态SELECT id, archive_id, target_mobile, status, retry_count FROM call_plan;如果 retry_count 累加超过 3状态会变成 FAILED如果 Mock 随机成功状态会变成 SUCCESS。这组设计能帮助验证失败重试规则。5. 常见问题与排查链路5.1 典型问题速查表问题现象常见原因检查方式处理建议上传返回 500存储目录不存在或没有写权限查看日志检查 user.home 路径启动时创建目录或把存储路径配置到有权限的目录上传文件格式被拒绝扩展名不在允许列表打印文件名和扩展名前端校验文件类型后端再次校验不要只信任前端播放返回 403家属分组不一致对比 play 请求头与数据库 family_group登录后从会话读取分组不使用外部传入参数定时任务没有触发启动类缺少 EnableScheduling查看启动日志是否有 scheduled 线程在启动类补注解呼叫全部失败Mock 随机失败或网络问题查看日志中 MOCK_FAIL本地调高成功率生产使用真实服务商并处理回调FFprobe 命令找不到未安装 FFmpeg命令行执行 ffprobe -version安装 FFmpeg或抽离为独立转码服务5.2 排查顺序建议遇到问题不要先改代码按下面顺序定位确认输入是否正确上传的文件类型、大小、字段名是否和后端一致。确认路径存储路径、文件路径、数据库记录路径是否存在。确认依赖Spring Boot、JPA、MySQL 版本是否匹配。确认配置生效application.yml 是否被正确加载环境变量是否覆盖了配置。确认权限目录写权限、数据库权限、接口鉴权逻辑。看异常栈优先搜索日志中的 “Caused by”快速定位根因。验证环境限制本地 FFmpeg、端口、网络是否正常。其中最容易忽略的是“请求参数和实体字段不一致”。比如前端传了familyGroup后端实体字段是family_group如果使用 Lombok 生成 getter/setter字段名不一致会在 JSON 反序列化时静默丢失最终数据库写入空值。排查时可以先打印 Controller 接收到的参数。6. 生产环境落地与最佳实践6.1 权限、加密与审计语音是高度敏感的隐私数据。生产环境至少要处理三件事用登录态和 RBAC 权限模型替代请求头传分组。对上传文件做访问控制不能把文件直接放在 Nginx 静态目录下。保存调用日志记录“谁在什么时间访问了哪条语音”便于审计和溯源。文件存储建议从本地磁盘迁移到对象存储并生成短期有效的预签名 URL。播放接口返回的不是固定地址而是有时效的临时链接降低链接泄露后的风险。6.2 转码、失败重试与调用回调真实呼叫服务与 Mock 最大的区别是异步回调。发起呼叫成功后服务商会回调一个状态通知这时候要单独处理场景处理方式呼叫已接通播放语音文件用户未接听记录失败按策略重试或放弃呼叫成功播放完成标记 SUCCESS音视频文件无法访问检查存储桶权限和音频格式不要在主线程里等待完整呼叫结果。正确流程是定时任务只负责“发起呼叫”真实结果通过回调接口异步更新数据库。6.3 产品伦理与合规边界“给死去的人打电话”这类产品容易触碰伦理问题。落地时必须明确语音一定是亲人自愿留下的真实录音不能伪造。使用 AI 合成逝者声音必须获得本人和家属的明确授权并设置显著提示。不要把产品包装成“真的能和逝者通话”这既不符合事实也可能引发纠纷和信任危机。提供删除通道家属可以随时申请删除语音档案。这些规则不是产品经理一个人的事开发者在表设计、接口参数和页面文案上都要留出余地。6.4 发布前检查清单上线前至少按这份清单过一遍存储路径可配置不依赖代码包内目录。文件上传有格式、大小双重校验。所有语音访问都有鉴权匿名请求被拒绝。定时任务失败有重试上限不会死循环。呼叫服务使用异步回调不阻塞主流程。数据库建表脚本已入库不使用 ddl-autoupdate。日志不输出完整语音文件路径。法律文本中写明语音保存范围、删除规则和用户授权协议。已准备数据备份与恢复演练方案。生产环境关闭调试接口和 Mock 呼叫实现。语音纪念产品真正的技术难点不是“能不能打通电话”而是如何在十年后还能稳定、安全地把一段声音交给正确的人。项目做到这一步比复述一则酒吧传闻有价值得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门