尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Spring Boot构建有声书播放器:音频流、文件上传与进度记录

基于Spring Boot构建有声书播放器:音频流、文件上传与进度记录 1. 项目背景与需求分析1.1 为什么做有声书播放器有声书是音频内容领域的重要形态相比传统阅读它解放了用户的双眼适合通勤、运动、睡前等场景。很多听书类 App 的核心功能并不复杂主要围绕“音频文件管理”“播放控制”“进度记录”三个维度展开。如果后端能提供一个稳定的接口层前端就能快速实现播放、续播、倍速、列表切换等能力。本文将围绕一个有声书播放器项目从零搭建基于 Spring Boot 的后端服务并配合一个轻量级 Web 页面完成播放闭环。你可以把它当作课设项目、开源 Demo 或自有听书产品的前身。项目不依赖第三方付费服务音频文件采用本地存储代码整体通俗易读适合 Java Web 初学者和想快速搭建内容管理系统的开发者。1.2 核心功能拆解在开始写代码之前我们先明确功能边界。一个可用的有声书播放器至少需要包含以下模块功能模块说明音频上传管理员上传音频文件支持 mp3、m4a、wav 等常见格式有声书管理为书籍维护标题、作者、封面、简介、总章节数章节列表一本书包含多个章节每个章节关联一个音频文件播放接口返回音频文件流支持 Range 请求实现拖动进度条进度记录记录用户听到的章节和秒数支持续播倍速播放前端通过 HTML5 Audio 实现倍速后端只需要返回文件流即可项目分为后端和前端两个部分后端提供 REST API 和静态资源访问前端使用原生 HTML JavaScript 完成播放器界面。这里采用前后端同项目部署的方式降低学习成本方便本地验证。2. 环境准备与项目初始化2.1 技术栈选择整体技术选型如下JDK 1.8 或以上版本。Spring Boot 2.7.x 系列稳定且资料丰富。Maven 3.6 作为构建工具。数据库使用 SQLite 或 MySQL。为了减少环境配置成本本文以 SQLite 为例适合本地演示生产环境建议替换为 MySQL。前端使用原生 HTML JavaScript Audio 标签不需要 Node.js 环境。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。如果你本机已经安装了 MySQL也可以直接把spring.sql.init相关配置改成 MySQL 驱动。2.2 创建 Spring Boot 项目推荐使用 Spring Initializr 创建项目也可以在 IDEA 中直接选择 Spring Initializr。项目坐标如下groupIdcom.example/groupId artifactIdaudiobook-player/artifactId version1.0.0/version项目名称可以自定义本文使用audiobook-player。创建完成后需要手动添加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 artifactIdaudiobook-player/artifactId version1.0.0/version nameaudiobook-player/name description有声书播放器项目/description properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.xerial/groupId artifactIdsqlite-jdbc/artifactId version3.44.1.0/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 /project2.3 项目目录结构建议按照下面的目录结构组织代码尽量让 controller、service、mapper、entity 分层清晰。audiobook-player/ ├── pom.xml ├── src/main/java/com/example/audiobook/ │ ├── AudiobookApplication.java │ ├── controller/ │ │ ├── BookController.java │ │ ├── AudioController.java │ │ └── ProgressController.java │ ├── service/ │ │ ├── BookService.java │ │ ├── AudioStorageService.java │ │ └── ProgressService.java │ ├── mapper/ │ │ ├── BookMapper.java │ │ └── ProgressMapper.java │ ├── entity/ │ │ ├── Book.java │ │ ├── Chapter.java │ │ └── Progress.java │ └── config/ │ └── WebConfig.java ├── src/main/resources/ │ ├── application.yml │ ├── schema.sql │ ├── static/ │ │ ├── index.html │ │ └── player.js │ └── audio/ └── src/main/java/com/example/audiobook/后续示例代码所在包如果后续项目变大可以继续细分dto、vo、exception等包。当前阶段保持简洁重点是把功能跑通。3. 数据库设计与实体建模3.1 数据表设计有声书系统的核心表主要有三张书籍表、章节表、播放进度表。章节可以拆成独立表也可以直接把音频地址存在书籍表中。考虑到一本书通常有几十个章节建议拆表方便后续扩展章节排序、试听、上下架等功能。本章使用 SQLite 数据库初始化脚本放在src/main/resources/schema.sqlSpring Boot 启动时会自动执行。CREATE TABLE IF NOT EXISTS book ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), cover_url VARCHAR(500), description TEXT, total_chapters INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS chapter ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, chapter_index INTEGER NOT NULL, title VARCHAR(200) NOT NULL, audio_url VARCHAR(500) NOT NULL, duration INTEGER DEFAULT 0, FOREIGN KEY (book_id) REFERENCES book(id) ); CREATE TABLE IF NOT EXISTS listen_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR(64) NOT NULL, book_id INTEGER NOT NULL, chapter_id INTEGER NOT NULL, position_seconds INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, book_id) );字段说明book表保存书籍的基本信息total_chapters是冗余字段用于列表展示。chapter表记录章节顺序和音频地址。duration表示音频总时长可由前端上传时回传也可以后端解析。listen_progress记录每个用户每本书的收听位置。因为本文没有登录系统user_id可以用浏览器生成的随机 ID 代替。3.2 实体类实现在entity包下创建三个实体类。这里使用 Lombok 的Data注解简化 getter/setter 代码。// 文件路径src/main/java/com/example/audiobook/entity/Book.java package com.example.audiobook.entity; import lombok.Data; Data public class Book { private Integer id; private String title; private String author; private String coverUrl; private String description; private Integer totalChapters; private String createdAt; }// 文件路径src/main/java/com/example/audiobook/entity/Chapter.java package com.example.audiobook.entity; import lombok.Data; Data public class Chapter { private Integer id; private Integer bookId; private Integer chapterIndex; private String title; private String audioUrl; private Integer duration; }// 文件路径src/main/java/com/example/audiobook/entity/Progress.java package com.example.audiobook.entity; import lombok.Data; Data public class Progress { private Integer id; private String userId; private Integer bookId; private Integer chapterId; private Integer positionSeconds; private String updatedAt; }4. 后端核心接口开发4.1 音频文件存储与访问音频文件需要存放在一个可访问的目录中。为了简化部署在application.yml中配置一个自定义的音频目录然后通过 Spring Boot 静态资源映射暴露出去。# 文件路径src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:sqlite:audiobook.db driver-class-name: org.sqlite.JDBC sql: init: mode: always schema-locations: classpath:schema.sql servlet: multipart: max-file-size: 200MB max-request-size: 200MB audio: storage-path: ./audio-data配置类中需要把本地目录映射成 URL 访问路径。这里有两个核心点一是允许跨域方便本地开发调试二是把/audio/**路径映射到本地文件夹。// 文件路径src/main/java/com/example/audiobook/config/WebConfig.java package com.example.audiobook.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.io.File; Configuration public class WebConfig implements WebMvcConfigurer { Value(${audio.storage-path}) private String audioStoragePath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location new File(audioStoragePath).getAbsolutePath(); registry.addResourceHandler(/audio/**) .addResourceLocations(file: location File.separator); } Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }4.2 有声书列表与章节查询创建BookService和BookController对外提供书籍列表、书籍详情、章节列表三个接口。由于数据库操作比较简单这里直接使用 JdbcTemplate。// 文件路径src/main/java/com/example/audiobook/mapper/BookMapper.java package com.example.audiobook.mapper; import com.example.audiobook.entity.Book; import com.example.audiobook.entity.Chapter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; Repository public class BookMapper { Autowired private JdbcTemplate jdbcTemplate; private RowMapperBook bookRowMapper (rs, rowNum) - { Book book new Book(); book.setId(rs.getInt(id)); book.setTitle(rs.getString(title)); book.setAuthor(rs.getString(author)); book.setCoverUrl(rs.getString(cover_url)); book.setDescription(rs.getString(description)); book.setTotalChapters(rs.getInt(total_chapters)); book.setCreatedAt(rs.getString(created_at)); return book; }; private RowMapperChapter chapterRowMapper (rs, rowNum) - { Chapter chapter new Chapter(); chapter.setId(rs.getInt(id)); chapter.setBookId(rs.getInt(book_id)); chapter.setChapterIndex(rs.getInt(chapter_index)); chapter.setTitle(rs.getString(title)); chapter.setAudioUrl(rs.getString(audio_url)); chapter.setDuration(rs.getInt(duration)); return chapter; }; public ListBook findAllBooks() { String sql SELECT * FROM book ORDER BY created_at DESC; return jdbcTemplate.query(sql, bookRowMapper); } public Book findBookById(int id) { String sql SELECT * FROM book WHERE id ?; return jdbcTemplate.query(sql, bookRowMapper, id) .stream().findFirst().orElse(null); } public ListChapter findChaptersByBookId(int bookId) { String sql SELECT * FROM chapter WHERE book_id ? ORDER BY chapter_index ASC; return jdbcTemplate.query(sql, chapterRowMapper, bookId); } public int insertBook(Book book) { String sql INSERT INTO book(title, author, cover_url, description, total_chapters) VALUES(?,?,?,?,?); return jdbcTemplate.update(sql, book.getTitle(), book.getAuthor(), book.getCoverUrl(), book.getDescription(), book.getTotalChapters()); } public int insertChapter(Chapter chapter) { String sql INSERT INTO chapter(book_id, chapter_index, title, audio_url, duration) VALUES(?,?,?,?,?); return jdbcTemplate.update(sql, chapter.getBookId(), chapter.getChapterIndex(), chapter.getTitle(), chapter.getAudioUrl(), chapter.getDuration()); } }在BookService中补充查询逻辑并暴露给 Controller。// 文件路径src/main/java/com/example/audiobook/service/BookService.java package com.example.audiobook.service; import com.example.audiobook.entity.Book; import com.example.audiobook.entity.Chapter; import com.example.audiobook.mapper.BookMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; Service public class BookService { Autowired private BookMapper bookMapper; public ListBook listBooks() { return bookMapper.findAllBooks(); } public Book getBook(int bookId) { return bookMapper.findBookById(bookId); } public ListChapter listChapters(int bookId) { return bookMapper.findChaptersByBookId(bookId); } }4.3 播放接口支持 Range 请求实现音频流播放时很多读者会直接使用静态资源映射这在小型项目中足够用。但如果需要统计播放次数、做权限控制或者后续切到云存储建议单独写一个播放接口。Java Web 支持Range请求头用来实现音频拖动播放。浏览器Audio标签默认会按 Range 方式请求资源。如果后端不处理 Range 头某些浏览器可能出现无法拖动进度条的问题。// 文件路径src/main/java/com/example/audiobook/controller/AudioController.java package com.example.audiobook.controller; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRange; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.util.List; RestController public class AudioController { Value(${audio.storage-path}) private String audioStoragePath; GetMapping(/api/play) public ResponseEntityResource play(String file, HttpServletRequest request) { File audioFile new File(audioStoragePath, file); if (!audioFile.exists()) { return ResponseEntity.notFound().build(); } long fileLength audioFile.length(); String rangeHeader request.getHeader(Range); if (rangeHeader null) { return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(fileLength) .body(new FileSystemResource(audioFile)); } ListHttpRange ranges HttpRange.parseRanges(rangeHeader); if (ranges.size() ! 1) { return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build(); } HttpRange range ranges.get(0); long start range.getRangeStart(fileLength); long end range.getRangeEnd(fileLength); if (start fileLength) { return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build(); } long contentLength end - start 1; return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / fileLength) .contentType(MediaType.APPLICATION_OCTET_STREAM) .contentLength(contentLength) .body(new FileSystemResource(audioFile) { Override public long contentLength() { return contentLength; } }); } }4.4 播放进度记录接口进度记录是实现“续播”功能的关键。前端每隔一定时间例如每 5 秒上报当前播放位置后端保存用户在某本书上的最新进度。这里要注意的是如果每次上报都写数据库会产生大量写入。建议前端在切章、暂停、关闭页面时上报一次后端再做 upsert 操作。// 文件路径src/main/java/com/example/audiobook/mapper/ProgressMapper.java package com.example.audiobook.mapper; import com.example.audiobook.entity.Progress; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; Repository public class ProgressMapper { Autowired private JdbcTemplate jdbcTemplate; public Progress findByUserAndBook(String userId, int bookId) { String sql SELECT * FROM listen_progress WHERE user_id ? AND book_id ?; return jdbcTemplate.query(sql, rs - { if (rs.next()) { Progress progress new Progress(); progress.setId(rs.getInt(id)); progress.setUserId(rs.getString(user_id)); progress.setBookId(rs.getInt(book_id)); progress.setChapterId(rs.getInt(chapter_id)); progress.setPositionSeconds(rs.getInt(position_seconds)); progress.setUpdatedAt(rs.getString(updated_at)); return progress; } return null; }, userId, bookId); } public int upsert(Progress progress) { String sql INSERT INTO listen_progress(user_id, book_id, chapter_id, position_seconds) VALUES(?,?,?,?) ON CONFLICT(user_id, book_id) DO UPDATE SET chapter_idexcluded.chapter_id, position_secondsexcluded.position_seconds, updated_atCURRENT_TIMESTAMP; return jdbcTemplate.update(sql, progress.getUserId(), progress.getBookId(), progress.getChapterId(), progress.getPositionSeconds()); } }// 文件路径src/main/java/com/example/audiobook/controller/ProgressController.java package com.example.audiobook.controller; import com.example.audiobook.entity.Progress; import com.example.audiobook.mapper.ProgressMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/progress) public class ProgressController { Autowired private ProgressMapper progressMapper; GetMapping public Progress getProgress(RequestParam String userId, RequestParam int bookId) { return progressMapper.findByUserAndBook(userId, bookId); } PostMapping public String saveProgress(RequestBody Progress progress) { progressMapper.upsert(progress); return success; } }4.5 文件上传接口上传接口由管理员调用实际生产环境需要加权限校验。这里示例简单实现接收MultipartFile保存到本地音频目录并把书籍信息和章节信息写入数据库。// 文件路径src/main/java/com/example/audiobook/controller/BookController.java package com.example.audiobook.controller; import com.example.audiobook.entity.Book; import com.example.audiobook.entity.Chapter; import com.example.audiobook.service.BookService; import com.example.audiobook.mapper.BookMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.util.List; import java.util.UUID; RestController RequestMapping(/api/book) public class BookController { Autowired private BookService bookService; Autowired private BookMapper bookMapper; Value(${audio.storage-path}) private String audioStoragePath; GetMapping(/list) public ListBook list() { return bookService.listBooks(); } GetMapping(/{bookId}) public Book detail(PathVariable int bookId) { return bookService.getBook(bookId); } GetMapping(/{bookId}/chapters) public ListChapter chapters(PathVariable int bookId) { return bookService.listChapters(bookId); } PostMapping(/create) public String createBook(RequestBody Book book) { bookMapper.insertBook(book); return success; } PostMapping(/upload-audio) public String uploadAudio(RequestParam(file) MultipartFile file, RequestParam int bookId, RequestParam int chapterIndex, RequestParam String chapterTitle) throws IOException { File dir new File(audioStoragePath); if (!dir.exists()) { dir.mkdirs(); } String originalFilename file.getOriginalFilename(); String ext ; if (originalFilename ! null originalFilename.contains(.)) { ext originalFilename.substring(originalFilename.lastIndexOf(.)); } String newFilename UUID.randomUUID().toString().replace(-, ) ext; File dest new File(dir, newFilename); file.transferTo(dest); // 简化处理实际应通过事务保证数据一致性 Chapter chapter new Chapter(); chapter.setBookId(bookId); chapter.setChapterIndex(chapterIndex); chapter.setTitle(chapterTitle); chapter.setAudioUrl(/api/play?file newFilename); chapter.setDuration(0); bookMapper.insertChapter(chapter); return newFilename; } }为了避免手写 SQL 时的拼写混乱这里给出了完整的 SQL 初始化脚本。接下来进入前端部分先把页面和脚本补齐让整个项目真正跑起来。5. 前端页面实现5.1 播放器页面布局在src/main/resources/static/index.html中实现一个简单的播放器页面。页面主要包含书籍列表、章节列表、音频播放器、进度保存按钮和当前播放信息。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title有声书播放器/title style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; background: #f7f8fa; color: #333; } h1 { text-align: center; } .container { display: grid; gap: 24px; } .card { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); } .book-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } .book-item { border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px; cursor: pointer; } .book-item:hover { border-color: #4a90d9; background: #f0f6ff; } .chapter-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; } .player-wrap { margin-top: 20px; } audio { width: 100%; } .tips { padding: 12px; background: #fffbea; border-radius: 8px; border: 1px solid #ffe1a8; font-size: 14px; } /style /head body h1 有声书播放器实战/h1 div classcontainer section classcard h2书籍列表/h2 div idbookList classbook-list加载中.../div /section section classcard h2章节列表/h2 div idchapterList请先选择一本书/div /section section classcard player-wrap h2播放器/h2 div idcurrentChapter未选择章节/div audio idaudioPlayer controls/audio div stylemargin-top: 12px; button onclicksaveProgress()保存播放进度/button button onclickrestoreProgress()续播上次进度/button /div div classtips stylemargin-top:12px; 提示点击章节后自动加载音频播放过程中可以保存进度再次进入页面后点击“续播上次进度”即可回到原来的位置。 /div /section /div script srcplayer.js/script /body /html5.2 JavaScript 播放逻辑player.js负责请求书籍列表、渲染章节、播放音频、上报进度。为了模拟用户身份这里使用本地存储生成一个固定 ID。// 文件路径src/main/resources/static/player.js let currentBookId null; let currentChapterId null; let userId localStorage.getItem(userId); if (!userId) { userId user_ Date.now() _ Math.floor(Math.random() * 10000); localStorage.setItem(userId, userId); } const bookListEl document.getElementById(bookList); const chapterListEl document.getElementById(chapterList); const audioPlayer document.getElementById(audioPlayer); async function loadBooks() { const res await fetch(/api/book/list); const books await res.json(); renderBookList(books); } function renderBookList(books) { if (!books.length) { bookListEl.innerHTML 当前没有书籍请先通过后端接口创建书籍和上传音频。; return; } bookListEl.innerHTML ; books.forEach(book { const div document.createElement(div); div.className book-item; div.innerHTML strong${book.title}/strongbr span stylefont-size: 13px; color: #777;${book.author || 未知作者}/spanbr span stylefont-size: 12px; color: #aaa;共 ${book.totalChapters || 0} 章/span ; div.onclick () loadChapters(book.id); bookListEl.appendChild(div); }); } async function loadChapters(bookId) { currentBookId bookId; const res await fetch(/api/book/${bookId}/chapters); const chapters await res.json(); renderChapters(chapters); } function renderChapters(chapters) { if (!chapters.length) { chapterListEl.innerHTML 本书暂无章节。; return; } chapterListEl.innerHTML ; chapters.forEach((chapter, index) { const div document.createElement(div); div.className chapter-item; div.innerHTML ${index 1}. ${chapter.title}; div.onclick () playChapter(chapter); chapterListEl.appendChild(div); }); } function playChapter(chapter) { currentChapterId chapter.id; document.getElementById(currentChapter).textContent chapter.title; audioPlayer.src chapter.audioUrl; audioPlayer.play().catch(() { console.log(浏览器阻止了自动播放请手动点击播放按钮); }); } async function saveProgress() { if (!currentBookId || !currentChapterId) { alert(请先选择章节); return; } const position Math.floor(audioPlayer.currentTime || 0); const body { userId: userId, bookId: currentBookId, chapterId: currentChapterId, positionSeconds: position }; const res await fetch(/api/progress, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify(body) }); if (res.ok) { alert(进度已保存 position 秒); } else { alert(保存失败); } } async function restoreProgress() { if (!currentBookId) { alert(请先选择一本书); return; } const res await fetch(/api/progress?userId${userId}bookId${currentBookId}); const data await res.json(); if (data) { currentChapterId data.chapterId; const chapterRes await fetch(/api/book/${currentBookId}/chapters); const chapters await chapterRes.json(); const target chapters.find(c c.id data.chapterId); if (target) { playChapter(target); setTimeout(() { audioPlayer.currentTime data.positionSeconds || 0; }, 300); } } else { alert(没有找到续播记录); } } // 开始加载 loadBooks();6. 运行与验证6.1 启动后端服务在项目根目录执行mvn spring-boot:run如果使用 IDEA可以直接运行AudiobookApplication主类。启动成功后控制台日志会出现Started AudiobookApplication默认端口是8080。访问http://localhost:8080/能够看到播放器页面。此时页面显示“当前没有书籍”说明数据库是空的。6.2 准备测试数据为了快速验证功能可以先通过接口创建一本书再上传一个音频文件。假设项目根目录下有一个test.mp3文件。第一步创建书籍curl -X POST http://localhost:8080/api/book/create \ -H Content-Type: application/json \ -d {title:JVM 内存模型入门,author:张三,description:讲解 Java 内存模型和 JVM 调优基础,totalChapters:2}第二步上传音频curl -X POST http://localhost:8080/api/book/upload-audio \ -F filetest.mp3 \ -F bookId1 \ -F chapterIndex1 \ -F chapterTitle第一章运行时数据区上传成功后会在./audio-data目录下生成一个 UUID 文件名。6.3 页面验证流程刷新首页看到书籍列表。点击书籍卡片加载章节列表。点击章节浏览器播放音频。播放几秒后点击“保存播放进度”。刷新页面重新选择该书点击“续播上次进度”音频会跳转到上次保存的位置。同时可以按 F12 打开开发者工具观察 Network 面板中的/api/play请求。可以发现音频文件是以 206 状态返回的这是 Range 请求生效的标志。7. 常见问题与排查思路7.1 音频无法播放问题现象常见原因解决思路点击章节后没有任何反应音频文件不存在或路径错误检查./audio-data目录中的文件确认audio_url拼接正确播放报 404文件上传失败或目录无权限确认audio.storage-path指向的目录存在且应用有写入权限浏览器提示不支持音频格式音频编码格式不是浏览器支持的格式使用格式工厂等工具转成 mp3 编码的音频7.2 点击进度条出现跳动如果后端没有处理 Range 请求音频还是能播放但拖动进度条时可能重新从头播放。这是因为浏览器需要把请求头中的Range值传给服务器服务器必须返回对应字节范围的内容。本文第四章实现了 Range 支持如果你的版本没有这段代码可以回到AudioController中补充。7.3 上传大文件报错Spring Boot 默认限制上传文件大小为 1MB项目在application.yml中已经调大到了 200MB。如果实际音频文件超过这个值需要根据磁盘空间调整spring: servlet: multipart: max-file-size: 1GB max-request-size: 1GB7.4 数据库连接报错SQLite 在某些环境下容易出现 “No suitable driver found” 异常。检查sqlite-jdbc依赖是否导入以及driver-class-name是否写成了org.sqlite.JDBC。如果使用 MySQL需要把 URL 改成jdbc:mysql://localhost:3306/audiobook?useUnicodetruecharacterEncodingutf8。7.5 自动播放被浏览器拦截现代浏览器不允许在页面加载后未经用户交互直接播放有声内容。代码中调用了play()但如果没有用户点击进行配合控制台会提示 “play() failed because the user didnt interact with the document first”。解决方案是将播放动作绑定到按钮或章节列表的点击事件上。8. 工程化建议与扩展方向8.1 后端开发建议当前项目为了降低学习成本把 Controller 和事务控制写得比较简单。如果未来要上线下面几点值得优先完善上传音频和写入数据库应该放在同一个事务中避免出现文件存了但记录没写入的情况。可以改用Transactional注解或使用TransactionTemplate手动控制事务边界。音频文件存储路径不要硬编码应该通过配置中心或环境变量管理。不同环境使用不同的目录或云存储 Bucket。用户进度接口需要做参数校验至少校验userId、bookId、chapterId、positionSeconds是否大于等于 0。如果并发较大会频繁更新进度表建议把 position 的写入放到单独队列或者仅在切章和离开页面时提交。8.2 前端体验优化现在的页面只是一个最小可运行版本。真实听书应用中还可以增加以下功能音频倍速播放控件HTML5audio.playbackRate设置一个范围 0.5 到 2.0 的滑块。章节自动连播当前章节播放结束后自动播放下一章。播放列表记忆用户刷新后自动恢复当前音频和播放状态。深色模式减少夜间听书的视觉刺激。服务端返回有声书封面图页面封面做懒加载。8.3 扩展为云存储方案本地存储适合学习和小规模部署但生产环境更推荐对象存储。切换方案时只需要把AudioController的播放逻辑改为跳转到云存储访问地址或从云存储读取文件流业务层的数据表结构不需要大改。8.4 安全与权限提醒如果要对外提供上传功能必须增加身份验证和管理员授权。建议使用 Spring Security 或者简单的 Token 校验。生产环境对音频目录要设置只读权限避免被上传恶意可执行文件。同时所有上传文件都应该校验文件扩展名与 MIME 类型防止脚本文件伪装成音频。JDK 自带Files.probeContentType可以辅助判断但最终以音频解析结果为准。8.5 后续学习路线跑通这个项目后你可以继续尝试以下方向使用 MyBatis-Plus 或 Spring Data JPA 替换 JdbcTemplate减少手写 SQL 的重复代码。引入 Redis 缓存书籍列表降低数据库压力。使用 Redis Stream 或消息队列处理用户的播放记录上报。将前端升级为 Vue 3 或 React 单页应用与后端 API 分离部署。使用 Nginx 反向代理静态资源和音频文件让项目更接近真实部署。有声书播放器虽然看起来简单但涉及文件上传、大文件传输、断点续播、进度持久化等常见业务难点。如果你能把这个项目完整跑通并尝试修改其中一两个模块对 Java 后端开发的理解会明显加深。欢迎大家动手实践有问题可以在评论区交流。
返回列表