ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于SpringBoot3+Vue3的校园旧书漂流交易系统开发实战

基于SpringBoot3+Vue3的校园旧书漂流交易系统开发实战 手头在做校园类 Web 项目最常见的一种需求就是把“学生手里的闲置旧书”转起来。单纯做二手交易大家已经见得太多了但加上“漂流”的概念就多了一层校园互助和循环利用的味道。这篇文章会完整拆解一个前后端分离的校园旧书漂流交易系统技术栈是 Java SpringBoot3 Vue.js3 MySQL。我会从需求拆解、数据库设计、后端接口、前端页面、联调排错到工程建议尽量讲成一套能照着做的最小可运行闭环。读者可以是正在做课程设计的在校生也可以是打算把 SpringBoot3 Vue3 练成完整项目经验的初级开发者。文章不会只丢代码还会解释每一步为什么这么设计以及运行过程中常见的坑。1. 项目背景与功能拆分1.1 校园旧书漂流的业务场景校园里每学期考完试总有一批教材、考研书、英语复习资料被闲置。书本身没有坏但堆在宿舍占空间扔了又可惜。传统做法是二手群发消息、跳蚤市场摆摊信息很分散交易没有保障。“漂流”比“二手交易”多了一层意思一本书可以被一个学生阅读完再转给下一个人图书的状态和流转记录可以追溯。从系统实现来说这个业务闭环大概是学生注册登录进入系统。看到在架图书列表搜索书名或分类。发布自己想漂出去的旧书。想读书的人提交“接漂”订单。原书主确认后线下完成交付。系统记录这本书从谁到谁的漂流轨迹。新书主可以继续让书漂流形成循环。这种模型本质上仍然是一个C2C交易系统只是弱化了“支付”环节重点完成了商品管理、订单管理和状态流转。1.2 功能模块划分按照上面的业务流程系统可以拆成下面的模块模块功能点说明用户模块注册、登录、个人信息身份是普通学生可以不引入权限框架图书模块发布、列表、详情、搜索、下架包含封面图、分类、原价、漂流价格订单模块提交接漂、确认交付、取消订单描述书从 A 到 B 的状态流转漂流轨迹模块查看一本书的历史主人记录旧书的漂流路线评论模块图书评价、用户评价增强校园信任可选做在这里我选择把“交易”简化成“发布 接漂 确认”不做支付也不做物流。这样项目复杂度更适合教学演示也保留了扩展点以后接入微信支付或校园卡支付订单模块已经预留了状态位。1.3 为什么选 SpringBoot3 Vue3 MySQL这套组合在目前的校园项目和中小型系统里非常常见。SpringBoot3 是基于 JDK17 的新一代框架。它把 WEB、数据库、参数校验等能力都自动装配好了开发体验比传统 SSM 工程轻松很多。需要留意的是SpringBoot3 里面原来的 javax 包名变成了 jakarta所以很多老教程的代码无法直接复制运行这也是我们选择写新版本项目的原因之一。Vue3 配合 Vite 和组合式 API开发效率高组件化思路清晰。尤其适合做图书卡片、订单状态标签这类需要大量组件复用的页面。MySQL 则是关系型数据的默认选择稳定、易用。2. 环境准备2.1 JDK 与开发工具准备SpringBoot3 强制要求 JDK17 及以上。建议直接安装 JDK17 LTS 版本。安装完成后检查环境java -version如果看到如下输出说明 JDK 可用java version 17.0.10 2024-01-16 LTS Java(TM) SE Runtime Environment (build 17.0.108-LTS-45) Java HotSpot(TM) 64-Bit Server VM (build 17.0.108-LTS-45, mixed mode, sharing)开发工具我推荐 IDEA社区版也够用。因为 SpringBoot3 对配置提示做得很好application.yml里写配置项时能自动提示能帮初学者避免很多拼写错误。2.2 MySQL 安装与初始化数据库可以选择 MySQL 8.0 或以上版本。安装完成后建议使用 MySQL 自带的命令行或 Navicat 工具执行建库脚本。先创建数据库CREATE DATABASE IF NOT EXISTS book_drift DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;为什么要用 utf8mb4MySQL 的 utf8 最多存 3 个字节遇到表情符号或一些生僻字会报错。utf8mb4 才是完整的 UTF-8 编码适合存储图书介绍中可能出现的中文标点和特殊字符。然后创建一个专用账号避免所有服务都用 root 连接生产或共享开发库CREATE USER book_userlocalhost IDENTIFIED BY 你的密码; GRANT SELECT, INSERT, UPDATE, DELETE ON book_drift.* TO book_userlocalhost; FLUSH PRIVILEGES;这里只授予了业务必要权限没有给 DDL 权限。这是一个很好的安全习惯但在开发阶段如果希望 MyBatis 自动管理表结构也可以另配一个具备 ALTER/CREATE 权限的账号。本文使用纯 SQL 初始化表结构所以最小权限足够。2.3 前端 Node 环境Vue3 Vite 项目需要 Node.js。建议使用长周期支持版本例如 Node 18 或 Node 20。检查命令node -v npm -v如果你所在网络环境下载 npm 依赖慢可以切换为国内镜像源npm config set registry https://registry.npmmirror.com注意镜像源只影响依赖下载速度不影响项目最终运行效果。3. 数据库设计3.1 核心实体关系一个比较清晰的最小设计需要四张核心表用户表、图书表、订单表、漂流轨迹表。用户与图书一对多一个用户能发布多本图书。图书与订单一对多一本书可以有多条订单记录但同一时间只能有一个在途订单。订单与漂流轨迹订单完成后生成一条漂流轨迹记录。关系图用文字描述就是user 1 --- n book user 1 --- n orders book 1 --- n orders book 1 --- n drift_record3.2 核心建表 SQL实际项目里如果要方便查询会增加 create_time、update_time 这些通用字段。下面重点给出三张核心表用户表字段相对简单篇幅原因不再展开。图书表CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, title VARCHAR(100) NOT NULL COMMENT 书名, author VARCHAR(50) DEFAULT COMMENT 作者, isbn VARCHAR(30) DEFAULT COMMENT ISBN号, category VARCHAR(50) DEFAULT COMMENT 分类, original_price DECIMAL(10,2) DEFAULT 0.00 COMMENT 原价, price DECIMAL(10,2) NOT NULL COMMENT 漂流价, description TEXT COMMENT 图书描述, cover_url VARCHAR(255) DEFAULT COMMENT 封面图地址, owner_id BIGINT NOT NULL COMMENT 发布人ID, status TINYINT DEFAULT 0 COMMENT 0在架 1已预约 2已漂出 3已下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT旧书表;说一下 status 字段的设计。很多初学者会直接设计成“0 表示上架、1 表示下架”但旧书漂流场景里一本书在被确认书主前后存在中间状态“已预约”。如果不加这个状态两个人同时接漂同一本时就容易出现数据冲突。所以这里选择0 在架可被搜索、可被提交接漂请求。1 已预约有同学下单等待书主确认。2 已漂出书主已经确认图书离开当前主人。3 已下架书主主动下架或者图书被删除。订单表CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号, book_id BIGINT NOT NULL COMMENT 图书ID, seller_id BIGINT NOT NULL COMMENT 书主ID, buyer_id BIGINT NOT NULL COMMENT 接漂人ID, status TINYINT DEFAULT 0 COMMENT 0待确认 1已确认 2已完成 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_book_id (book_id), KEY idx_seller_id (seller_id), KEY idx_buyer_id (buyer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT漂流传单;为什么不直接使用自增 ID 当订单号因为订单号往往会暴露业务量而且还可能被外部用户猜测遍历。用一个固定长度的业务订单号更适合对外展示。生成方式可以是用时间戳加随机数后续接入支付平台时也方便对账。漂流轨迹表CREATE TABLE drift_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, from_user_id BIGINT DEFAULT NULL COMMENT 上一个漂主首漂为空, to_user_id BIGINT NOT NULL COMMENT 当前新书主, order_id BIGINT NOT NULL COMMENT 关联订单, message VARCHAR(255) DEFAULT COMMENT 漂流寄语, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT旧书漂流轨迹;有了这张表进入图书详情页时就能按时间倒序展示陈同学2024年3月 → 李同学2024年9月 → 王同学当前书主这个功能虽然简单但特别有“漂流”的仪式感也让普通二手书交易系统在演示时更有故事性。3.3 状态机与业务约束订单和图书的状态变更要联动。为了防止并发问题这里有一个非常关键的点当一本书被预约时不能只判断“看起来是在架”而应该在 UPDATE 语句里把状态作为条件。例如UPDATE book SET status 1 WHERE id #{bookId} AND status 0;如果这条语句影响行数为 1说明抢“预约”成功如果影响行数为 0说明图书已经被别人预约或下架了。这种写法叫乐观锁思路比先查再改更安全。后续在代码实现里一定要体现这一点。4. SpringBoot3 后端工程搭建4.1 初始化项目结构推荐的后端包结构是这样src/main/java/com/campus/drift/ ├── BookDriftApplication.java ├── common/ │ └── Result.java ├── controller/ │ ├── BookController.java │ └── OrderController.java ├── entity/ │ ├── Book.java │ └── Orders.java ├── mapper/ │ ├── BookMapper.java │ └── OrdersMapper.java └── service/ ├── BookService.java └── BookServiceImpl.java这里采用了按技术分层的方式。对于中小型业务系统这种分层足够清晰。当业务继续膨胀后再按“用户域”“图书域”“订单域”拆分模块也不迟。4.2 引入依赖项目的 Maven 依赖核心内容如下使用 SpringBoot3 时MyBatis 需要引入适配 SpringBoot3 的独立 starterparent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里需要注意两点。第一mysql-connector-j 是新版本的驱动坐标老教程里的 mysql-connector-java 已经被官方合并并改名。使用 Boot3 时提供方管理了版本号不需要显式写版本号。第二mybatis-plus 必须使用 spring-boot3 专用的 starter 坐标如果写成mybatis-plus-boot-starter在 Boot3 中可能因为 javax 与 jakarta 包名差异导致启动报错。4.3 配置文件在src/main/resources/application.yml中配置数据源和 MyBatisserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/book_drift?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: book_user password: 你的密码 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0在 url 上加上 serverTimezone是为了避免 MySQL 与 JDBC 驱动之间时区不一致产生的时间偏移问题。同时开启了 MyBatis 的驼峰映射那么数据库字段owner_id就能直接映射到实体属性ownerId。4.4 实体类与 Mapper编写一个实体类 Bookpackage com.campus.drift.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; Data TableName(book) public class Book { TableId(type IdType.AUTO) private Long id; private String title; private String author; private String isbn; private String category; private BigDecimal originalPrice; private BigDecimal price; private String description; private String coverUrl; private Long ownerId; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }实体中的 BigDecimal 用来表示价格。价格字段使用 double 会产生精度误差MONEY 类型在 Java 中用 BigDecimal 接收是最稳妥的方式。Mapper 接口只需要继承 BaseMapperpackage com.campus.drift.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.campus.drift.entity.Book; import org.apache.ibatis.annotations.Mapper; Mapper public interface BookMapper extends BaseMapperBook { }BaseMapper 提供了 insert、selectById、updateById 等通用方法。很多简单 SQL 就不再需要写在 XML 文件里。4.5 业务层与抢购式更新在 BookService 中我们要实现几个核心能力发布图书、分页查询列表、查看详情、预约图书、确认漂流、下架。下面重点讨论“预约图书”这个动作。它涉及两张表的更新订单表插入一条待确认记录同时图书状态从 0 变成 1。先写订单实体的创建逻辑package com.campus.drift.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.campus.drift.entity.Book; import com.campus.drift.entity.Orders; import com.campus.drift.mapper.BookMapper; import com.campus.drift.mapper.OrdersMapper; import com.campus.drift.service.BookService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.concurrent.ThreadLocalRandom; Service public class BookServiceImpl implements BookService { private final BookMapper bookMapper; private final OrdersMapper ordersMapper; public BookServiceImpl(BookMapper bookMapper, OrdersMapper ordersMapper) { this.bookMapper bookMapper; this.ordersMapper ordersMapper; } Override Transactional(rollbackFor Exception.class) public Long reserveBook(Long bookId, Long buyerId) { Book book bookMapper.selectById(bookId); if (book null) { throw new RuntimeException(图书不存在); } int updated bookMapper.update(null, new LambdaUpdateWrapperBook() .eq(Book::getId, bookId) .eq(Book::getStatus, 0) .set(Book::getStatus, 1)); if (updated 0) { throw new RuntimeException(这本书已被预约或已下架); } Orders orders new Orders(); orders.setOrderNo(generateOrderNo()); orders.setBookId(bookId); orders.setSellerId(book.getOwnerId()); orders.setBuyerId(buyerId); orders.setStatus(0); ordersMapper.insert(orders); return orders.getId(); } private String generateOrderNo() { return DRF LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)) ThreadLocalRandom.current().nextInt(1000, 9999); } }这里的事务注解非常关键。如果先插入订单后更新图书而不是在一个事务内完成一旦第二步失败订单表里就会出现一条“幽灵订单”。所以从设计上应该把下单、扣库存这类跨表写操作放进同一个事务。LambdaUpdateWrapper 中的 set 和 eq 会自动拼接 SQL最终生成的语句就是我们前面设计的“条件更新”。这种写法可以避免并发时两个用户同时预约同一本书的问题。然后写确认漂流的逻辑Override Transactional(rollbackFor Exception.class) public void confirmDrift(Long orderId) { Orders orders ordersMapper.selectById(orderId); if (orders null || orders.getStatus() ! 0) { throw new RuntimeException(订单状态不正确); } orders.setStatus(1); ordersMapper.updateById(orders); Book book bookMapper.selectById(orders.getBookId()); book.setStatus(2); book.setOwnerId(orders.getBuyerId()); bookMapper.updateById(book); driftRecordMapper.insertDrift(book.getId(), orders.getSellerId(), orders.getBuyerId()); }确认漂流后图书的 owner_id 会变成接漂人。这个过程实现了“旧书流转”。后续新书主再把这本书设置为在架这本书就可以继续漂向下一站。4.6 Controller 层Controller 层要尽量薄只负责接收参数和返回结果。统一返回结构可以先行定义package com.campus.drift.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }图书相关接口package com.campus.drift.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.campus.drift.common.Result; import com.campus.drift.entity.Book; import com.campus.drift.service.BookService; import org.springframework.web.bind.annotation.*; import java.util.List; RestController RequestMapping(/api/book) public class BookController { private final BookService bookService; public BookController(BookService bookService) { this.bookService bookService; } GetMapping(/list) public ResultPageBook list(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) String keyword) { return Result.success(bookService.pageBooks(page, size, keyword)); } GetMapping(/{id}) public ResultBook detail(PathVariable Long id) { return Result.success(bookService.getWithDetail(id)); } PostMapping(/publish) public ResultLong publish(RequestBody Book book) { book.setOwnerId(getCurrentUserId()); book.setStatus(0); bookService.publish(book); return Result.success(book.getId()); } PostMapping(/{bookId}/reserve) public ResultLong reserve(PathVariable Long bookId) { return Result.success(bookService.reserveBook(bookId, getCurrentUserId())); } private Long getCurrentUserId() { return 1L; } }getCurrentUserId 目前是写死的实际项目中需要结合登录态。在引入 Spring Security 或者 JWT 之前可以从请求头、Session 等上下文里解析。示例先占位即可联调时前端不传用户ID也能保证流程跑通。这确实是个简化方案。真实项目里不能这么写否则任何人都能操作他人图书。这篇文章后面会专门提醒安全边界。4.7 后端启动验证启动 SpringBoot 应用后会在控制台看到 Tomcat started on port(s): 8080。可以使用 curl 命令验证接口curl http://localhost:8080/api/book/list?page1size10如果数据库里还没有数据返回如下结构也是正常的{ code: 200, message: success, data: { records: [], total: 0, size: 10, current: 1 } }这说明应用、数据库、MyBatis 链路已经通了。5. Vue3 前端工程实现5.1 初始化项目结构前端建议直接用 Vite 创建npm create vitelatest book-drift-ui -- --template vue cd book-drift-ui npm install npm install axios element-plus这里选 Element Plus 是因为它在校园管理后台、课程设计中特别常见表格、表单、弹窗组件齐全能给项目节省很多样式时间。完成后端联调时先整理一下需要的文件src/ ├── api/ │ ├── request.js │ └── book.js ├── views/ │ ├── HomeView.vue │ ├── PublishBook.vue │ ├── BookDetail.vue │ └── OrderCenter.vue ├── router/ │ └── index.js └── App.vue5.2 封装请求工具前端所有请求都走同一个 axios 实例方便统一处理错误码// src/api/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request5.3 配置 Vite 代理开发阶段前端运行在 5173 端口后端运行在 8080 端口。直接让前端请求http://localhost:8080会触发跨域问题最省事的办法不是后端加 CORS而是在 Vite 配置代理// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样浏览器访问/api/book/list时Vite 开发服务器会把请求转发给后端服务器之间没有跨域限制。所以开发环境不需要在后端写一个恐怖的allow-origin*全局过滤器。5.4 首页图书卡片列表Vue3 组合式 API 的写法可以这样组织!-- src/views/HomeView.vue -- template div classbook-container el-input v-modelkeyword placeholder输入书名或作者搜索 clearable stylewidth: 300px; margin-bottom: 20px keyup.enterloadBooks / el-button typeprimary clickloadBooks搜索/el-button div classbook-grid el-card v-forbook in bookList :keybook.id classbook-card clickgoDetail(book.id) el-image :srcbook.coverUrl || defaultCover fitcover stylewidth: 100%; height: 180px / h3{{ book.title }}/h3 p{{ book.author }}/p p classprice漂流价{{ book.price }}/p el-tag v-ifbook.status 0 typesuccess可接漂/el-tag el-tag v-else typeinfo已暂离书架/el-tag /el-card /div /div /template script setup import { ref, onMounted } from vue import { useRouter } from vue-router import { getBookList } from ../api/book const keyword ref() const bookList ref([]) const defaultCover https://example.com/default-cover.png const router useRouter() const loadBooks async () { const data await getBookList({ page: 1, size: 20, keyword: keyword.value }) bookList.value data.records || [] } const goDetail (id) { router.push(/book/${id}) } onMounted(loadBooks) /script注意封面图的默认值。实际开发中很多学生没有自己的图片服务器可以先把图片上传到本地目录或者使用临时图床但要注意外部图床可能随时失效因此代码中预留了 defaultCover。5.5 发布图书表单发布页比较适合用 Element Plus 的 Form 组件!-- src/views/PublishBook.vue -- template el-form refformRef :modelform label-width90px el-form-item label书名 proptitle el-input v-modelform.title placeholder请输入书名 / /el-form-item el-form-item label作者 propauthor el-input v-modelform.author / /el-form-item el-form-item label分类 el-select v-modelform.category placeholder选择分类 el-option label教材教辅 value教材教辅 / el-option label文学小说 value文学小说 / el-option label考研英语 value考研英语 / el-option label计算机 value计算机 / /el-select /el-form-item el-form-item label原价 el-input-number v-modelform.originalPrice :min0 :precision2 / /el-form-item el-form-item label漂流价 el-input-number v-modelform.price :min0.01 :precision2 / /el-form-item el-form-item label图书描述 el-input v-modelform.description typetextarea rows4 placeholder简单介绍一下书的品相、有没有笔记、适合什么人群 / /el-form-item el-button typeprimary clickpublish发布漂流/el-button /el-form /template script setup import { reactive } from vue import { ElMessage } from element-plus import { publishBook } from ../api/book const form reactive({ title: , author: , category: , originalPrice: 0, price: 0, description: , coverUrl: }) const publish async () { if (!form.title || form.price 0) { ElMessage.warning(请填写书名和漂流价) return } await publishBook({ ...form }) ElMessage.success(发布成功等待爱书的同学来接漂吧) } /script表单里的 el-input-number 返回的是 number 类型不需要再手动做字符串转数字。5.6 图书详情与漂流轨迹展示详情页不仅展示图书内容还要展示历史漂流路线。漂流轨迹是一个一维的时间线非常适合用垂直排列来展示。template div v-ifbook h2{{ book.title }}/h2 p作者{{ book.author }} 分类{{ book.category }}/p p状态el-tag{{ statusText }}/el-tag/p p漂流价{{ book.price }}/p el-button v-ifbook.status 0 typeprimary clickreserveBook 我要接漂 /el-button el-divider漂流足迹/el-divider el-timeline el-timeline-item v-forrecord in driftRecords :keyrecord.id :timestamprecord.createTime {{ record.message }} /el-timeline-item /el-timeline /div /template这段模板体现了旧书漂流和普通电商的区别用户看到的不只一个商品价格还看到 “前任书主” 写给下一位同学的寄语。这个信息维度能让系统看起来不只是一个删减版电商。6. 常见问题与排查思路这一部分整理我在部署和联调这类系统时最常见的几类问题按现象分类给出排查建议。问题现象常见原因解决思路SpringBoot 启动报 ClassNotFound javax.* 相关错误引入了基于旧版 Servlet 规范的依赖检查依赖是否适用于 SpringBoot3MyBatis 需要引入 mybatis-plus-spring-boot3-starter启动报数据库连接失败MySQL 未启动、账号权限不足、url 写错先用命令行或 Navicat 手动测试连接中文字段存储乱码数据库不是 utf8mb4在建表前统一修改库和表字符集前端请求 404后端接口路径和前端请求路径不一致打开浏览器 Network查看实际响应路径前端请求 500 且控制台提示 Unknown column实体字段映射失败确认表字段与实体属性是否符合驼峰映射规则同一本书被多个用户预约成功没有在更新时带状态条件改成条件 UPDATEVite 页面能打开但接口一直 404代理配置没有生效或顺序不对检查 vite.config.js 中 proxy 路径前缀是否与请求一致这里单独把数据库连接失败的排查顺序列出来确认 MySQL 服务是否启动。Windows 上检查服务列表Linux 上可以执行 systemctl status mysqld 或 service mysql status。检查密码是否写错。MySQL 8 默认使用 caching_sha2_password 认证很老版本的 JDBC 驱动可能连不上但本文使用 mysql-connector-j 后通常不存在此问题。检查账号权限。比如账号只能从 localhost 登录但 Java 应用跑了远程服务器。如果本地跑项目一般问题不大。检查防火墙和端口。如果 MySQL 在云服务器需要看安全组是否放行 3306。如果你刚接触 SpringBoot3 和 MySQL还有一个非常值得注意的问题不要把业务数据表都扔到 root 用户下。虽然自己学习时无所谓但如果项目要放服务器演示至少应该为每个业务系统建独立账号。这样可以缩小数据库被误操作时的爆炸半径。7. 工程化最佳实践与安全建议7.1 表结构设计方面所有业务表保留 create_time 和 update_time。这样可以排查数据异常也可以在前端直接展示图书的上架时间。如果没有数据库自动管理的机制建议在 MyBatis 插入和更新时手动填充或在建表时直接采用 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP。另外像 Orders 这种核心表要重点做好索引一个用户查询自己发布的订单走 seller_id create_time。查询自己接漂的订单走 buyer_id create_time。查询某本书的漂流传单走 book_id。索引不是越多越好但对高频查询条件的列建索引收益非常明显。7.2 业务安全边界上面的示例代码为了聚焦流程在 Controller 层直接写死了当前用户。真实项目不能这么干。改造方向是引入登录机制比如 JWT 或 Spring Session然后通过拦截器解析用户身份。图书状态更新时要校验当前操作人是不是这本书的主人。例如图书下架接口必须校验 owner_id确认漂流接口必须校验当前用户是 seller_id。如果没有这些校验别人只要知道订单号就能帮你确认订单属于严重越权问题。这类问题的排查思路是对所有写操作都问一句“当前用户身份是什么”并在代码最前面做鉴权判断。7.3 事务与并发订单核心链路必须增加事务。当一个方法里对多张表执行写操作时使用Transactional(rollbackFor Exception.class)rollbackFor 指定发生任何异常都回滚。如果不写这个参数默认只回滚 RuntimeException编译期异常导致数据库状态不一致时不容易察觉。并发场景下优先考虑数据库原子更新不要用“先 select 判断再 update”的方式因为两个请求同时读到 status0最后就会产生两条预约订单。正确的做法是我的示例代码里演示的条件更新写法让数据库来决定谁能抢到这一单。7.4 代码分层约束Controller 不要直接写 SQL 查询Service 不要堆砌大量工具方法Entity 和 VO 尽量分离。前端展示的字段可能包含一些不适合直接返回的字段比如用户手机号、内部备注等。用 VO 类做返回裁剪可以避免把敏感字段暴露给前端。7.5 日志与可观测性使用 SLF4J 日志在关键业务链路打点。一个可以借鉴的规范是进入方法时打印入参关键ID例如“用户 1234 准备预约图书 88”。下单成功后打印订单号。捕获异常后打印完整堆栈并记录业务上下文。日志级别建议开发时用 debug生产环境用 info避免大量无效输出拖垮磁盘。7.6 备份与变更数据库表结构变更和业务代码发布是两个不同频率的动作。开发阶段可以用脚本重建表但生产或演示数据不要随意 DROP。安全的做法是表结构变更前先导出备份数据量再小也要保持这个习惯。MySQL 导出备份命令mysqldump -u book_user -p book_drift book_drift_backup.sql恢复命令mysql -u book_user -p book_drift book_drift_backup.sql这里想特别强调一个原则任何涉及删除表、清空表、批量更新数据的操作都应该先备份并确认影响范围在测试环境验证无误后再执行。8. 项目扩展建议与后续学习方向这套校园旧书漂流交易系统跑通后可以继续往几个方向扩展难度由低到高排列一是增加图片上传能力。目前封面图是 URL 字符串本地项目可以开发一个简单的上传接口把图片保存到服务器目录或对象存储中。二是引入登录鉴权框架。与前端登录页面配合把写死的 getCurrentUserId 替换为 Token 解析出来的真实用户 ID。三是增加订单超时处理。如果预约后书主长时间不确认需要允许用户取消或系统自动释放图书状态。这时可以使用定时任务也可以在下单时记录过期时间查询时判断。四是消息通知。当书主发布的书被预约时可以发一封站内信或邮件提醒对方确认。把通知表引入后可以继续扩展成完整的站内消息模块。五是考虑性能优化。当图书表数据量增长后列表查询可能变慢这时可以考虑引入 Redis 做热点图书缓存但这属于进阶优化不是前期必须做的事。如果你做这个系统是为了课程设计或毕业设计建议先确保最核心的“发布—搜索—预约—确认—图书漂流轨迹”这条主链路是完整的而不是把大量时间花在花哨页面和装饰性功能上。一个能流畅跑通主流程、状态流转没有冲突的项目比一个界面好看但数据容易错乱的项目更有说服力。旧书漂流系统的代码量并不算大但如果能在项目里体现出数据库设计层面的状态机思路、条件更新防并发思路、事务处理思路、接口层安全边界思路就已经能区别于普通“增删改查”项目了。希望这篇文章能帮你把系统从“能运行”提升到“设计合理、能讲清楚”。
返回列表