从“给逝者打电话”热点拆解语音信箱原理与Spring Boot实现

从“给逝者打电话”热点拆解语音信箱原理与Spring Boot实现 最近一个关于“俄罗斯酒吧”的话题又传开了网上流传着一种说法说俄罗斯有一家酒吧不卖酒却放着一部老式电话顾客可以在里面给已经去世的人“打电话”。很多视频把它渲染成了灵异故事昏暗的灯光、复古话筒、听筒里传来“另一个世界”的声音配上“泪目”“破防”的文案吸引大量转发。但从通信技术从业者的角度看这类热点大概率不是通灵而是一套“呼叫转移 语音信箱 自动应答 数字纪念”的产品组合。把玄幻外壳剥掉里面是电信网里再普通不过的流程一个号码无人接听呼叫被转接到语音平台平台播放提示音然后完成录音保存。对开发者来说这件事真正值得关注的点不在于酒吧是否真实存在而在于它背后的整条语音链路。本文会做三件事拆解从“拨号”到“语音留言”的技术原理用 Spring Boot 搭建一个最小可运行的数字纪念语音信箱 Demo讲清真实产品落地时必须考虑的隐私、授权和安全边界。如果你在做呼叫中心、IVR、号码路由、数字遗产或情感陪伴类应用这篇文章值得读完。1. 这个热点背后真正值得技术人关注的是什么先说判断这类“给逝者打电话”的服务多数是仪式感产品不是真的接通了另一个世界。它通常由一个虚拟号码和一套自动语音流程构成用户拨号后线路并不寻找真实被叫而是直接进入预设的语音信箱系统播放一段话然后开始录音。所谓“接通逝者”的体验其实是产品设计出来的情绪节奏。这件事被当成奇闻传播是因为大多数人不知道电话网里有一个专门负责“替你接电话”的组件也就是语音信箱Voicemail。只要被叫号码在运营商侧或企业侧配置了无应答转移呼叫就会在振铃超时后被“路由”到语音平台。平台再按照脚本播放提示音调用录音模块最后把音频文件落盘或上传到对象存储。对开发者的启发在于这个看似神秘的场景本质是一个标准的工程问题——号码接入、呼叫控制、录音存储、授权播放。真正难的不是“打电话”本身而是如何让一段声音被体面、安全、长期地保存并且在合规前提下允许授权家属反复聆听。所以这篇文章的读者画像很清晰做通信业务、呼叫中心、IVR 系统的后端开发者正在设计数字遗产、情感陪伴、纪念空间类产品的产品经理和技术负责人对 SIP、VoIP、云通信感兴趣想理解语音信箱内部原理的学生或工程师。如果你不在这些范围内可以把这篇文章当作一次“破除神秘感”的通信技术科普再顺手带走一套可运行的代码Demo。2. 电话系统如何把“无人接听”变成“一段留言”要理解“给逝者打电话”的体验必须先理解语音信箱在电话网络中的位置。正常情况下一次呼叫要经过这些环节主叫终端发起呼叫电信交换网把呼叫路由到被叫号码所在网络被叫终端开始振铃如果被叫无人接听、主动拒接或处于忙线状态交换网络就会触发呼叫转移规则呼叫被转移到一个专门的语音信箱平台Voicemail Server平台播放“您拨打的用户暂时无法接听请在‘嘟’声后留言”等提示音电话进入录音状态主叫挂断或录音超时后文件被保存并生成索引记录。在传统运营商网络里这些环节由交换机和语音信箱系统协同完成。而在企业级场景里更多使用 Asterisk、FreeSWITCH 这类软交换或者直接使用云通信平台提供的电话回调能力。这里有几个容易混淆的术语先列清楚术语通俗解释在语音信箱流程中的作用PSTN传统公共交换电话网络负责电话线路接入和基础呼叫路由呼叫转移把呼叫从原被叫号码改送到另一个号码或平台无人接听时把电话引到语音平台IVR交互式语音应答自动播放菜单并接收按键播放提示语、控制录音开始和结束Voicemail语音留言信箱完成录音、存储、查询、播放RTP实时传输协议承载音频数据流承载主叫和平台之间的语音媒体流把这套机制映射到“纪念电话亭”场景就会发现产品流程并不复杂用户拿起电话拨号线路应答后播放一段背景音乐或思念寄语随后进入录音最后挂断保存。整个过程没有真正的“另一端通话”只有预先编排好的语音脚本。再往深一层看语音信箱内部其实是一个状态机。一个常规留言会话会经历这些状态IDLE空闲等待呼叫进入RINGING呼叫进入但尚未应答PROMPT_PLAYING播放问候语RECORDING录音中SAVING用户挂断或超时保存录音文件COMPLETED留言保存完成生成可播放记录PLAYED被授权用户听过一次或多次。理解这个状态机很重要。因为后面写 Demo 时我用 HTTP 接口模拟的就是从 RECORDING 到 COMPLETED 再到 PLAYED 的过程。真实系统区别只是把“RINGING”和“PROMPT_PLAYING”交给运营商或软交换处理业务侧只接收回调。3. “给逝者打电话”类产品拆解五个核心模块如果我们要在真实业务中做一款类似产品可以拆成五个模块。每个模块都有对应的技术选型和工程难点。第一个模块是呼叫入口。用户需要一个可拨打的号码可能是运营商提供的虚拟号码、固话号码也可能是 400 号码。在 App 内则可以直接使用 VoIP 能力发起呼叫。这里的难点是号码资质和线路稳定性。生产环境要接入真实线路必须有运营商资源和相应的通信业务资质个人开发者不要贸然做电话外呼或转接。第二个模块是信令与媒体控制。这一层负责接听电话、播放提示音、启动录音、检测挂断等。常见方案是用 FreeSWITCH 编写拨号计划或者使用云通信平台配置呼叫回调。工程上需要关注振铃时长、超时处理、媒体协商和并发线路数。第三个模块是存储与索引。录音文件通常不能直接存到服务器本地而是上传到对象存储数据库只保存音频文件的 Key 和留言元数据。为了后续检索和字幕展示还可以把录音交给 ASR 语音转写服务生成文本索引。这里要特别注意音频文件的加密和备份策略。第四个模块是授权访问。不是所有人都能听留言只有通过认证的家属才能查看列表和播放录音。真实上线时建议使用短期凭证、访问码、二次人脸认证等组合方案而不是裸奔式的永久 URL。第五个模块是展示与仪式感。用户最终是通过 Web、App 还是线下实体话机播放留言决定了 API 设计和技术选型。如果场景是线下电话亭还需要嵌入式播放器和防误触设计。这五个模块中真正决定产品成败的是最后一个模块和授权访问。用户不会关心你的信令是 SIP 还是 HTTP 模拟他们只关心这段留言是否清晰、播放是否流畅、访问是否体面。因此在设计系统时不要只盯着通话功能还要把权限、数据生命周期和产品情绪体验纳入架构。4. 环境准备把 Demo 跑起来需要什么文章给出的示例代码是一个纯粹的业务逻辑层 Demo不会真的占用电话线路也不会产生通信费用。它用 HTTP 接口模拟了语音网关回调目的是让读者在本地完整跑通“留言—列表—播放”闭环。建议环境如下JDK 17 或更高版本Maven 3.6Spring Boot 3.x任意的 REST 客户端或 curl操作系统不限Windows、macOS、Linux 均可。如果你之前没有安装过 Java 和 Maven可以先用 IDE如 IntelliJ IDEA直接导入 Maven 项目IDE 会自动下载依赖。本文所有命令都基于命令行方便在服务器环境复用。另外说明一下如果读者真的想接真实电话线需要的条件会多很多包括具备资质的号码资源、SIP 中继、媒体服务器、RTP 网络策略、备案和合规评估。这部分超出本文范围我不会展开避免误导。本节只保证 Demo 能跑。5. Spring Boot 实现最小数字纪念语音信箱为了让文章代码完整可跑我会实现一个极简版本的数字纪念语音信箱系统。它包含三个接口POST /api/memorial/{memorialId}/messages模拟来电者留言GET /api/memorial/{memorialId}/messages家属查看留言列表POST /api/memorial/{memorialId}/messages/{messageId}/play授权后播放一条留言。该项目的核心思路是用memorialId区分不同纪念空间用X-Access-Code请求头作简单授权用内存存储模拟数据库和对象存储。真实项目中这层需要替换为数据库和云对象存储。5.1 项目结构memorial-voicemail-demo/ ├── pom.xml └── src/main/java/com/example/memorial/ ├── MemorialApplication.java ├── controller/ │ └── MessageController.java ├── model/ │ └── MemorialMessage.java └── repository/ └── MessageStore.java5.2 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 version3.2.4/version relativePath/ /parent groupIdcom.example/groupId artifactIdmemorial-voicemail-demo/artifactId version1.0.0/version properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project这里只引入了 Spring Web 和 Validation 两个基础依赖不涉及第三方云厂商 SDK避免环境不通。5.3 配置文件文件路径src/main/resources/application.ymlserver: port: 8080 app: memorial: # 家属访问码实际项目应放到配置中心或环境变量不要明文提交仓库 access-code: 20240601 # 未接来电多久后转语音信箱单位秒。真实线路由软交换控制 no-answer-timeout: 30 # 单条留言最长时间单位秒 max-message-seconds: 180 # 录音文件存放目录演示环境使用本地临时目录 storage-dir: ./storage配置项都做了注释。在真实项目中access-code建议改成密钥或 JWT 校验storage-dir应替换为对象存储的 Bucket 名称。5.4 核心代码实现文件路径src/main/java/com/example/memorial/MemorialApplication.javapackage com.example.memorial; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class MemorialApplication { public static void main(String[] args) { SpringApplication.run(MemorialApplication.class, args); } }文件路径src/main/java/com/example/memorial/model/MemorialMessage.javapackage com.example.memorial.model; import java.time.LocalDateTime; public record MemorialMessage( String messageId, String callerNumber, String content, String audioKey, LocalDateTime createdAt, int playCount, boolean archived ) { public MemorialMessage withPlayed() { return new MemorialMessage( messageId, callerNumber, content, audioKey, createdAt, playCount 1, archived ); } }这个 record 是留言的不可变对象。每次“播放”都通过withPlayed()返回新实例播放次数加一。使用 record 可以减少样板代码也更符合 Java 17 的编程习惯。文件路径src/main/java/com/example/memorial/repository/MessageStore.javapackage com.example.memorial.repository; import com.example.memorial.model.MemorialMessage; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; Component public class MessageStore { private final MapString, ListMemorialMessage messagesByMemorial new ConcurrentHashMap(); public ListMemorialMessage list(String memorialId) { return messagesByMemorial.getOrDefault(memorialId, List.of()); } public MemorialMessage add(String memorialId, String callerNumber, String content, String audioKey) { MemorialMessage message new MemorialMessage( UUID.randomUUID().toString(), callerNumber, content, audioKey, LocalDateTime.now(), 0, false ); messagesByMemorial .computeIfAbsent(memorialId, k - new CopyOnWriteArrayList()) .add(message); return message; } public MemorialMessage findById(String memorialId, String messageId) { return list(memorialId).stream() .filter(m - m.messageId().equals(messageId)) .findFirst() .orElse(null); } public MemorialMessage markPlayed(String memorialId, String messageId) { ListMemorialMessage list messagesByMemorial.get(memorialId); if (list null) { return null; } for (int i 0; i list.size(); i) { MemorialMessage item list.get(i); if (item.messageId().equals(messageId)) { MemorialMessage updated item.withPlayed(); list.set(i, updated); return updated; } } return null; } }这里用ConcurrentHashMap加CopyOnWriteArrayList保证并发安全。生产环境要替换为数据库和对象存储并发控制逻辑也要重新设计。文件路径src/main/java/com/example/memorial/controller/MessageController.javapackage com.example.memorial.controller; import com.example.memorial.model.MemorialMessage; import com.example.memorial.repository.MessageStore; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; RestController RequestMapping(/api/memorial) public class MessageController { private final MessageStore messageStore; private final String accessCode; public MessageController( MessageStore messageStore, Value(${app.memorial.access-code}) String accessCode) { this.messageStore messageStore; this.accessCode accessCode; } /** * 模拟“来电者留言”。 * 真实环境中这个接口由语音网关或 IVR 平台回调触发 * content 可以来自 ASR 转写文本audioKey 指向对象存储中的录音文件 key。 */ PostMapping(/{memorialId}/messages) public ResponseEntityMemorialMessage leaveMessage( PathVariable String memorialId, RequestParam String callerNumber, RequestParam(required false) String content, RequestParam(required false) String audioKey) { MemorialMessage saved messageStore.add( memorialId, callerNumber, content null ? : content, audioKey null ? : audioKey ); return ResponseEntity.ok(saved); } /** * 家属查看留言列表。 * 真实项目建议使用短期 token而不是固定的 access-code。 */ GetMapping(/{memorialId}/messages) public ResponseEntity? listMessages( PathVariable String memorialId, RequestHeader(X-Access-Code) String accessCode) { if (!this.accessCode.equals(accessCode)) { return ResponseEntity.status(403).body(Map.of(error, invalid access code)); } ListMemorialMessage messages messageStore.list(memorialId); return ResponseEntity.ok(messages); } /** * 模拟播放一条留言。 * 真实项目中这里应返回加密的录音文件流并记录播放水印日志 * 避免录音被下载后二次传播。 */ PostMapping(/{memorialId}/messages/{messageId}/play) public ResponseEntity? playMessage( PathVariable String memorialId, PathVariable String messageId, RequestHeader(X-Access-Code) String accessCode) { if (!this.accessCode.equals(accessCode)) { return ResponseEntity.status(403).body(Map.of(error, invalid access code)); } MemorialMessage message messageStore.findById(memorialId, messageId); if (message null) { return ResponseEntity.status(404).body(Map.of(error, message not found)); } MemorialMessage updated messageStore.markPlayed(memorialId, messageId); return ResponseEntity.ok(Map.of( messageId, updated.messageId(), content, updated.content(), audioKey, updated.audioKey(), playCount, updated.playCount() )); } }这段代码有三个关键设计leaveMessage模拟语音网关回调。当电话线路检测到无人接听后平台会自动调用这个接口保存留言。Demo 里由我们手动调用真实系统中这是自动触发的。listMessages是授权读取。通过请求头X-Access-Code校验访问者身份避免任何人都能拉取留言列表。playMessage会更新播放次数。这一点在真实系统中非常重要可以用来做家属访问审计也可以实现“听完一次后提示是否永久保存”的产品逻辑。5.5 代码逻辑说明整体流程是用户来电留言系统生成一条MemorialMessage家属带访问码查询列表家属点击播放系统校验权限后返回留言内容并增加播放次数。在真实项目中content字段可以是语音转写文本audioKey是对象存储地址playMessage接口会改为返回预签名 URL 或直接返回音频流。通过把“文本内容”和“音频文件 Key”分开这个 Demo 可以平滑升级到完整版语音系统。6. 运行与验证用 curl 模拟一次完整留言闭环代码写完后先在项目根目录启动 Spring Boot 应用mvn spring-boot:run如果 Maven 未安装可以用项目已有的 Maven Wrapper./mvnw spring-boot:run。启动成功后控制台会看到Started MemorialApplication日志。第一个命令模拟一位来电者留言。curl -X POST http://localhost:8080/api/memorial/grandpa001/messages \ -H Content-Type: application/x-www-form-urlencoded \ -d callerNumber10086 \ --data-urlencode content爷爷我们都很好就是很想你。预期返回{ messageId: dc3f95ab-8b2a-4c41-b5f3-8b2a7f3c0f21, callerNumber: 10086, content: 爷爷我们都很好就是很想你。, audioKey: , createdAt: 2025-01-01T12:00:00, playCount: 0, archived: false }第二个命令家属查看留言列表。curl -X GET http://localhost:8080/api/memorial/grandpa001/messages \ -H X-Access-Code: 20240601预期返回一个 JSON 数组数组里包含刚才创建的留言对象。如果请求头里的访问码错误接口会返回 403 和{error:invalid access code}。第三个命令播放一条留言验证playCount增加。curl -X POST http://localhost:8080/api/memorial/grandpa001/messages/dc3f95ab-8b2a-4c41-b5f3-8b2a7f3c0f21/play \ -H X-Access-Code: 20240601预期输出中playCount变成 1。这里要注意命令里的messageId需要替换成第一条留言接口返回的真实 ID。判断 Demo 是否成功的标准有三个留言接口返回了messageId列表接口能看到记录播放接口的playCount从 0 变成 1。如果失败先不要查代码先按第 7 章的顺序排查环境问题。7. 常见问题与排查思路问题现象可能原因排查方式解决方案启动时报Port 8080 was already in use端口被其他进程占用执行lsof -i:8080或 netstat -anofindstr 8080 查看占用进程调用列表接口返回 403请求头中的X-Access-Code与配置不一致对比application.yml中的app.memorial.access-code和请求头值统一访问码或改用环境变量注入播放接口返回 404messageId不对或memorialId不属于当前空间先调用列表接口复制真实的messageId再播放使用列表接口返回的 ID中文内容变成乱码终端编码或 curl 参数编码问题检查终端字符集是否为 UTF-8使用--data-urlencode传中文不要手动拼 URL编译报错jakarta.servlet找不到Java 版本低于 17或 Maven 依赖未刷新执行java -version检查 JDK升级 JDK 17并重新mvn clean install除了 Demo 阶段的问题真实语音链路里还有几个高频故障点提前说明SIP 注册失败检查 SIP 账号、密码、服务器地址和端口运营商可能会屏蔽特定地区的 IP 或端口。振铃超时设置不合理如果no-answer-timeout设置太长用户会等很久才听到语音信箱欢迎语体验差。录音文件静音可能是媒体流没有正确协商需要使用 RTP 调试工具抓包确认音频流向。回调接口超时语音网关调用业务系统接口时业务系统响应过慢会导致留言状态丢失建议回调接口设计成异步处理。遇到真实线路问题时排查顺序一般是从网络层到协议层再到应用层先确认 SIP 包收发正常再确认 RTP 媒体流是否到达最后确认业务回调是否完成。8. 安全与伦理边界比代码更重要的东西写这类产品技术难度并不是最高难点在安全和伦理。首先必须明确一个底线这不是“通灵”也不是“连接另一个世界”。如果有人以此名义收费称可以提供“接通逝者的神秘服务”那就是商业噱头甚至诈骗。开发者和产品运营方必须诚实不能利用技术渲染迷信来收割情感。从法律角度看逝者的语音、影像和文字属于数字遗产的一部分。真实上线前需要确认以下事项是否获得逝者生前的明确授权例如遗嘱中对数字遗物处置的说明是否获得全部直系亲属或法定继承人的一致同意是否对未成年人、非婚生子女、重组家庭等复杂亲属关系做了确认是否明确告知家属录音的保存期限、销毁方式和数据管理者信息。从数据安全角度看录音属于高度敏感的个人信息。建议做到录音文件加密存储传输过程使用 HTTPS/TLS播放链接使用短期预签名 URL不暴露永久地址留言列表接口使用临时 Token 而不是固定访问码对播放行为做审计日志记录谁在什么时间听了哪条留言设置留言过期策略支持家属主动删除。从接口安全角度看Demo 里用固定X-Access-Code只是教学演示。生产环境必须做更严格的认证比如家属实名认证后下发短期凭证失败次数限制防止暴力猜访问码使用设备指纹或短信二次验证对接口做限流防止被爬虫批量拉取。另外要特别注意不要用 AI 语音克隆伪造逝者声音进行“实时对话回复”除非有充分的生前授权、防腐机制和家属知情同意。用逝者形象和声音创造“数字复活”很容易滑向伦理争议甚至会触犯个人信息保护法的相关规定。做这类产品技术能力再强也要把人的尊严放在第一位。9. 总结与下一步学习路线这篇文章从“俄罗斯酒吧电话亭”的话题切入实际上讲清楚了三个问题语音信箱在电话网络中的位置和状态流转一个最小数字纪念语音信箱的工程实现方式真实产品落地的安全与伦理约束。如果你之前没接触过通信业务最大的收获应该是理解了“给逝者打电话”服务的本质它并不是超自然现象而是呼叫转移、自动应答、录音存储和授权播放的组合。这类场景很容易被包装成奇闻但拆开来看每一步都有明确的技术组件。跑通 Demo 之后建议按以下方向继续深入学习 Asterisk 或 FreeSWITCH理解真实世界中的拨号计划、SIP 中继和媒体流处理学习对象存储的预签名 URL 机制把留言播放地址做成短期有效学习 ASR 语音转写把录音转成文字方便家属搜索和回顾学习数字遗产和隐私合规相关法规特别是个人信息保护和数据留存期限要求。如果未来真要做一款数字纪念产品我的实际建议是先把代码跑通再把权限设计和数据加密做好最后再考虑仪式感。代码跑通只是第一步更难的是让家属信任你不会滥用录音。每一段留言背后都是一个真实的人做这类系统工程师应该比做普通业务多一份敬畏。