前两个月有个朋友问我做一个图书商城管理系统用什么组合最不容易翻车我当时给的答案是 SpringBoot Vue MyBatis MySQL。这个组合在2025年依然是图书电子商务网站管理系统里最常见的技术选型学习曲线相对平缓网上资料多遇到问题容易搜到而且它覆盖了一个电商系统的完整闭环用户、图书、购物车、订单、库存、后台管理。这篇文章我打算把这套系统从业务拆解、数据库设计、后端实现、前端联调到上线部署的完整思路讲一遍。无论是刚入行的全栈开发还是准备拿系统作毕业设计、想基于源码二次开发的同学都可以按这条链路走。1. 图书购物闭环不只靠 CRUD业务模块和技术选型很多人一看到“图书商城管理系统”下意识觉得无非就是对图书表做增删改查。真按这个思路做下去做到订单和库存的时候就会卡住。图书商城本质上是一个小型的电商系统业务闭环是用户浏览图书 - 加入购物车 - 下订单 - 扣库存 - 管理订单 - 后台补货上架。它比普通的管理系统多出来的核心部分是订单流转和库存一致性这两块才决定系统能不能真正“卖书”。1.1 图书商城的功能范围远比“图书管理”大前台用户端需要提供图书分类浏览、搜索、按价格/销量排序图书详情页包含封面、作者、出版社、ISBN、定价、库存状态购物车管理加购、修改数量、删除、批量结算下单流程选择收货地址、提交订单、在线或模拟支付个人中心订单列表、订单详情、取消订单、确认收货、图书评论登录注册JWT Token 维持会话状态后台管理端需要提供图书分类管理新增、编辑、删除、上下架分类图书管理添加图书、编辑信息、设置价格和折扣、上传封面、管理库存订单管理查看订单、按状态筛选、发货操作、取消异常订单用户管理用户列表、禁用/启用账号评论管理审核、删除违规评论这里最容易忽略的是“图书上下架状态”。一个电商系统如果图书下架了用户端不能购买但历史订单里还是要保留图书名称和价格快照。这个设计会直接影响后面的表结构所以一开始就要把“当前商品信息”和“订单快照信息”区分开而不是在订单明细里只放一个 book_id等查订单时再去 join 图书表。这个点后面我会再细说。1.2 为什么是 SpringBoot Vue MyBatis MySQL这套组合在图书商城场景下有四个很现实的好处。Spring Boot 负责把后端工程“胶水化”。它用 starter 机制把 Spring MVC、事务、Jackson、连接池、打包部署这些底层配置都自动装配好了。你的主要精力可以放在业务接口和数据处理上而不是写一堆 XML 配置。做图书商城这种业务链路偏完整的系统Spring Boot 能让你很快把用户、图书、订单这些模块搭起来。Vue 负责前端页面和交互。图书商城的前台页面和后台管理页面差异很大Vue 的组件化能力可以把公共部分抽出来比如头部导航、图书卡片、分页组件、上传图片组件。用 Vue Router 做页面跳转用 Pinia 或 Vuex 管理登录状态和购物车整个前端结构会比较清晰不用像传统 JSP 那样把页面逻辑混在一起。MyBatis 负责 SQL 控制。图书商城的查询场景很典型搜索图书、分页、按分类和价格过滤、订单状态统计这些都需要写 SQL。MyBatis 的 XML Mapper 可以让你精确控制每一条 SQL不像 JPA 那样在一些复杂查询里生成低效语句。它的动态 SQL 能根据条件拼 where配合foreach批量插入也很方便这对订单明细的批量写入很关键。MySQL 是数据落地的基础。图书商城的核心数据包括图书信息、库存、订单、用户都是强事务、强一致性要求的数据MySQL 的 InnoDB 引擎支持事务和行级锁正好满足下单扣库存这种场景。加索引之后几万到几十万条图书数据的查询性能完全够用没必要一上来就上重型中间件。1.3 版本选型别让“版本太高”变成第一个坑网上很多教程是 Spring Boot 2.x JDK8 的组合但你真去官网新建项目现在默认生成的是 Spring Boot 3.x需要 JDK17。很多初学者在这一步直接懵了为什么按教程写javax.servlet报错为什么 MyBatis starter 引入后起不来这是因为 Spring Boot 3.x 把javax改成了jakarta不少第三方库的版本也要跟着升级。我的建议是先看清楚你拿到的源码或教程是基于哪个版本再决定是不是要升。如果只是想复现一个图书商城项目用下面这套组合比较稳组件推荐版本说明JDK8 或 17Spring Boot 3.x 必须 172.x 用 8 即可Spring Boot2.7.18 或 3.2.x2.7.18 是 2.x 最后一个版本兼容 JDK8Vue2.7 或 3.4 Vite新项目建议 Vue3Vue Router 4PiniaMyBatis Spring Boot Starter2.3.2 或 3.0.3版本必须和 Spring Boot 匹配MySQL8.0字符集用 utf8mb4Node.js18 LTS 以上Vue3 和 Vite 都要求较高的 Node 版本如果你拿到的源码是 Spring Boot 2.7 JDK8就不必强行升级到 3.x。图书商城这种业务复杂度下2.7 和 3.x 的实际差距不大。真正影响项目能不能跑起来的是 Spring Boot、MyBatis starter、MySQL 连接驱动三者版本是否匹配。比如 MySQL Connector/J 5.x 连 MySQL 8 就容易出现认证插件不支持的问题直接用com.mysql.cj.jdbc.Driver而不是老的com.mysql.jdbc.Driver。2. 图书、SKU、库存与订单状态数据库建模里的几个关键决策数据库设计是这套系统的地基。图书商城不需要设计得太“大”但几个关键点不能错金额字段类型、订单状态、库存扣减方式、订单明细快照。我把表结构按核心、交易、辅助三类来规划实际建表时可以按这个思路落地。2.1 核心表结构与字段规划核心表包括用户表、分类表、图书表、购物车表、收货地址表交易表包括订单主表、订单明细表辅助表包括评论表、轮播图表等。这里最关键的是图书表和订单明细表。图书表我习惯这样设计CREATE TABLE book ( book_id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, book_name VARCHAR(120) NOT NULL, author VARCHAR(80), publisher VARCHAR(120), isbn VARCHAR(32), price DECIMAL(10,2) NOT NULL, discount_price DECIMAL(10,2), stock INT NOT NULL DEFAULT 0, sales INT NOT NULL DEFAULT 0, cover_url VARCHAR(255), detail TEXT, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category_id (category_id), KEY idx_isbn (isbn), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;图书价格用DECIMAL(10,2)不要用FLOAT或DOUBLE。浮点数在计算金额时会有精度问题比如 19.99 在二进制里无法精确表示多次运算会出现 0.0000001 之类的误差这在订单金额对比时非常恶心。DECIMAL(10,2)是以字符串形势存储的十进制数专门解决这种问题。订单明细表必须做“商品快照”CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, book_id BIGINT NOT NULL, book_name VARCHAR(120) NOT NULL, cover_url VARCHAR(255), price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么订单明细里要冗余book_name、cover_url、price因为图书信息可能会被后台修改甚至被删除。如果订单明细只存 book_id用户查历史订单时再去 join 图书表一旦书名变了或者书删了订单历史就会显示乱掉。电商系统里这叫“订单快照”目的是保证下单那一刻看到的商品信息永远不会变。购物车表我建议单独建和订单表区别开。购物车是用户还没确认购买的临时数据订单表是已经生成交易的数据。如果混在一起接口会变得非常别扭。购物车表字段大致是id、user_id、book_id、quantity、checked、create_time、update_time唯一索引可以建在(user_id, book_id)上防止同一本书反复插入多条记录。2.2 订单状态机与库存扣减订单状态是整个交易链路的核心。表里用一个TINYINT字段order_status表示状态值含义说明0待付款用户已提交订单但未支付1已付款支付成功等待商家发货2已发货商家已发货等待用户确认3已完成用户确认收货或自动确认订单结束4已取消用户主动取消或超时未支付自动取消状态流转要限制住不能从“待付款”直接跳到“已完成”。后台管理员发货时要判断当前状态必须是“已付款”否则提示“订单状态已变更”。最简单的方式是在 Service 层写一层状态校验或者直接在 UPDATE 语句里带条件UPDATE orders SET order_status 2, deliver_time NOW() WHERE order_id #{orderId} AND order_status 1如果 UPDATE 影响行数为 0说明当前订单状态不是“已付款”直接抛异常不让它继续执行。这种“条件更新”比先查询再判断更安全也减少一次网络往返。库存扣减同样要用条件更新。真正下单的时候不应该先 SELECT 出库存数量到 Java 代码里判断够不够然后再 UPDATE。因为并发情况下很可能出现多个请求同时查出库存5然后一起把库存扣成负数。正确写法是UPDATE book SET stock stock - #{quantity}, sales sales #{quantity} WHERE book_id #{bookId} AND stock #{quantity}这句 SQL 的意思是只有在当前库存大于等于购买数量时库存才减少。MySQL 执行 UPDATE 时会对匹配的行加排他锁所以多个请求同时走到这里会排队执行第二个请求执行时 stock 已经变小条件不满足UPDATE 返回 0。拿到返回 0 就说明库存不足事务直接回滚。2.3 索引设计先照顾高频查询图书商城的高频查询有几种用户端按分类浏览、搜索关键字、按价格排序后台按订单状态筛选订单和按图书名称搜索。大多数查询表的数据量不会太大但索引还是要有否则后台订单管理翻页时会明显变慢。订单表我一般会给order_status和create_time建联合索引因为后台最常干的事就是“按状态查某一批订单”超时取消订单的定时任务也会按create_time扫。购物车表对user_id建普通索引订单明细表对order_id建普通索引这些都是最基础的查询路径。不建议给图书的detail或用户表的password建索引没有实际查询价值。搜索功能如果只是图书表里几万条数据直接LIKE CONCAT(%, #{keyword}, %)就行不用上 Elasticsearch。真正数据量大了再考虑全文索引或者搜索引擎不是现在该做的事。3. SpringBoot MyBatis 后端落地Mapper 配置和事务的那些细节后端部分围绕 SpringBoot MyBatis 展开的内容其实很固定工程分层、MyBatis 配置、动态 SQL、事务控制。但正是因为“固定”很多人容易照着教程写完之后一启动就报错。3.1 Spring Boot 工程分层与 MyBatis 配置图书商城的后端工程我习惯按包名分层controller接收前端请求参数校验返回统一结果service业务逻辑事务边界在这里mapperMyBatis 接口entity数据库表对应的实体dto接口出入参对象config跨域、Jackson、拦截器等配置MyBatis 的配置核心是application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/book_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password mybatis: # Mapper XML 文件位置 mapper-locations: classpath:/mapper/*.xml type-aliases-package: com.example.bookmall.entity configuration: # 下划线字段自动映射为驼峰属性 map-underscore-to-camel-case: true # 开发阶段打印 SQL生产环境去掉 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl三个最容易踩的地方都是在这些配置里map-underscore-to-camel-case不写导致book_name字段查出来后无法自动映射到bookName返回给前端全是 null。这个配置加上之后MyBatis 会自动把下划线转驼峰。mapper-locations配置错了会出现Invalid bound statement (not started)或者Mapper method not found。如果你的 XML 文件放在src/main/resources/mapper/下面配置就写classpath:/mapper/*.xml。如果你把 XML 和 Mapper 接口放在同一个包下那需要额外配置resources把你接口所在包里的.xml一起打包否则 Maven 默认不会把src/main/java下的 XML 文件打包进去。serverTimezoneAsia/Shanghai不加连 MySQL 8 时经常会报时区错误或者查出来的时间差 8 个小时。这个问题在第五部分还会详细说。3.2 动态 SQL 和 #{} / ${} 的取舍图书列表页最大的特点就是查询条件多分类、关键字、价格区间、排序方式、上下架状态。用动态 SQL 是最合理的做法每个条件都可以为空有值才拼到 where 里。select idselectBookPage resultTypecom.example.bookmall.entity.Book SELECT book_id, book_name, author, isbn, price, discount_price, stock, cover_url, status FROM book where if testkeyword ! null and keyword ! AND (book_name LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %) OR isbn LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND category_id #{categoryId} /if if teststatus ! null AND status #{status} /if /where ORDER BY ${orderBy} /select这里的 ORDER BY 用了${orderBy}因为#{}是占位符不能出现在 ORDER BY 后面。如果你的查询参数传到 SQL 里#{}会被编译成一个?占位再通过预编译参数传进去可以防 SQL 注入。而${}是直接字符串拼接风险很高。所以我的原则是值一律用#{}只有排序字段、表名这类无法用占位符的地方才用${}。而且${}里的值不能直接取前端传过来的原始字符串要在 Service 层做白名单校验比如String[] allowedOrder {price_desc, price_asc, sales_desc, create_time_desc}; if (!Arrays.asList(allowedOrder).contains(orderBy)) { throw new BusinessException(非法的排序参数); }然后根据白名单映射成真正安全的 SQL 片段再传给 Mapper。这样既满足了动态排序需求又不会让你被ORDER BY注入攻击。3.3 下单模块的事务与锁订单创建的后端方法一定要加事务而且要指定rollbackFor Exception.classTransactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 生成订单号比如时间戳加随机数 // 2. 插入订单主表状态为待付款 // 3. 循环校验购物车项执行库存扣减 SQL // 4. 批量插入订单明细 // 5. 清除已购买的购物车项 return orderId; }为什么必须写rollbackFor Exception.class因为 Spring 默认只对RuntimeException和Error回滚如果你在 Service 里抛了一个自定义的 checked exception事务不会自动回滚库存可能扣了一半订单主表却插入成功数据就歪了。多写两个字符能避免这类隐蔽问题。订单明细的批量插入用 MyBatis 的foreach是很自然的写法insert idbatchInsertOrderItems INSERT INTO order_item (order_id, book_id, book_name, cover_url, price, quantity, total_amount) VALUES foreach collectionlist itemitem separator, (#{item.orderId}, #{item.bookId}, #{item.bookName}, #{item.coverUrl}, #{item.price}, #{item.quantity}, #{item.totalAmount}) /foreach /insert如果一张订单里有几十个明细一条 insert 语句也能接受。要是未来订单量大了可以考虑在 JDBC 连接参数里加上rewriteBatchedStatementstrue配合 MyBatis 的ExecutorType.BATCH做真正的批量更新性能会好很多。图书商城这个规模现阶段用上面的foreach足够。事务里还有一个细节是事务边界不要开太大。不要把一个单纯的“查询图书列表”接口加上Transactional因为查询不需要事务。也不要在这类接口里循环调用远程接口或者做大量耗时的计算否则数据库连接会一直被占着。看到“connection is not available, request timed out”这种报错时第一反应不是调大连接池而是检查是不是有人在事务里做了不该做的慢操作。4. Vue 前端把图书商城跑起来路由、状态管理与后台页面前端这块我倾向于把“前台购书流程”和“后台管理页面”分开来想。前台的核心是图书展示、购物车、下单后台的核心是表格、表单、权限。两者复用的是同一个后端接口体系但页面组织方式和状态管理方式有差别。4.1 前端工程搭建与 Axios 请求封装如果你是新起项目直接 Vue3 Vite Vue Router 4 Pinia Element Plus 这一套。如果你手里是老的 Vue2 源码也不用强升Vue2.7 最后维护版一样能用。真正需要留意的不是版本新旧而是组件库和框架要匹配Vue2 配 Element UIVue3 配 Element Plus 或 Ant Design Vue混着用会报各种兼容性问题。前端工程初始化完之后第一件事不是写页面而是封装 Axios 请求实例。import axios from axios import { Message } from element-ui const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { // 登录过期跳转登录页 } Message.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default request这里有两个容易被忽略的地方。第一baseURL如果写成/api开发阶段就需要用前端代理把它转发到后端。第二响应拦截器里直接返回response.data可以让页面调用时少写一层res.data.data但这个约定要在项目里统一不然后端返回结构一变所有页面都要跟着改。后端接口返回结构建议统一成一个对象{ code: 200, message: success, data: ... }。前端拦截器拿到 code 不是 200 时统一弹错误提示。这样业务代码里不用到处写 try catch只需要处理正常分支。4.2 前台核心流程和状态管理图书商城前台页面的核心链路是图书列表 - 图书详情 - 加购物车 - 购物车结算 - 提交订单 - 支付 - 查看订单。购物车数据放哪里是一个常见选择。简单项目可以直接放 Pinia 和 localStorage的好处是不登录也能加购适合演示二维码。但如果你已经设计了后端 cart_item 表那就以后端购物车为准前端只负责调用接口这样用户换设备购物车也不会丢。我的建议是想省事就放本地想贴近真实电商就放后端。源码项目通常两种方式都有。下单这个动作必须是后端逻辑前端不能只把购物车数据存着然后自己算总价。前端提交订单时应该传购物车项的 id 列表或 book_id quantity 列表由后端重新从数据库读取价格、校验库存。前端传过来的价格永远不能直接信任否则改一下请求参数就能用 0.01 元买书。前台的订单状态展示前端只需要根据后端返回的orderStatus字段渲染对应的按钮0 显示“去支付/取消订单”1 显示“等待发货”2 显示“确认收货”3 显示“已完成”。按钮点击后调用对应接口然后刷新订单详情。4.3 后台管理嵌套路由、权限与上传后台管理页面用嵌套路由是标准姿势。/admin是一个带侧边栏布局的父路由里面每个子路由对应一个功能页面。const routes [ { path: /, component: Home }, { path: /book/:id, component: BookDetail }, { path: /cart, component: Cart, meta: { requiresAuth: true } }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, role: ADMIN }, children: [ { path: books, component: AdminBookList }, { path: orders, component: AdminOrderList }, { path: categories, component: AdminCategoryList }, { path: users, component: AdminUserList } ] } ]然后在前置守卫里做页面跳转控制router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role localStorage.getItem(role) ! to.meta.role) { next(/403) } else { next() } })有一点要提醒前端路由守卫只是用户体验层面的拦截真正防盗的是后端接口权限。前端把 role 改成 ADMIN 并不能访问管理员接口后端必须在每个管理接口里校验当前用户的角色两边都做了这个系统才算安全。图书管理后台必然要处理封面上传。最简单的办法是后端提供一个/api/upload接口接收 MultipartFile保存到服务器某个 upload 目录返回文件访问路径。前端用 el-upload 组件上传上传成功后把返回的 URL 塞到图书表coverUrl字段里。文件名不要用用户原始文件名最好用 UUID 重命名避免中文名乱码和路径问题。4.4 前端代理与多环境配置开发阶段最烦的就是跨域。前后端分离项目前端跑 5173后端跑 8080直接请求后端接口必然跨域。最简单的方式是在 Vite 配置里加代理export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/book/list会被转发到http://localhost:8080/api/book/list浏览器看到的请求是同源的规避了开发环境的跨域问题。生产环境一般由 Nginx 统一代理/api路径到后端服务前端不需要关心具体 IP 和端口。还要注意Vue 项目里环境变量最好不要写死。开发环境用.env.development生产环境用.env.production里面分别定义VITE_API_BASE。这样项目换个域名、换台服务器部署时不用去代码里找字符串。5. 联调和部署阶段最常翻车的 5 个地方图书商城这种项目写代码可能只占一半时间联调和部署会吃掉另一半。以下这几个坑都是我在实际项目里反复见过的每一个都能耗掉你一整晚。5.1 跨域问题不是只加一个注解就完事很多教程告诉你后端 Controller 加一个CrossOrigin就能解决跨域但实际项目里你会遇到加了注解仍报错、登录接口跨域失败、前后端联调时发不出带 Cookie 的请求等等问题。更稳的做法是搞一个全局 CORS 配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果你用了 Spring Security跨域配置要放在 Security 的过滤器链之前否则预检请求会被拦截。推荐的做法是开发环境前端用 Vite 代理后端不需要放开跨域生产环境用 Nginx 反向代理让前后端同域。这两个方案都比在后端全局放开跨域更干净。5.2 MySQL 时区、连接与本地安装的常见坑图书商城只要涉及到订单时间就绕不开 MySQL 的时区问题。MySQL 8 连接 URL 里必须显式指定serverTimezoneAsia/Shanghai否则连接时会报 “The server time zone value” 异常。这只是第一步。第二步是 Spring Boot 返回给前端的 JSON 时间格式。如果你用的是 LocalDateTime而项目里没有配置 Jackson 时间格式前端拿到的往往是一个数组或者带 T 的字符串显示出来非常难看。可以在application.yml里统一spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai第三步是 Windows 本地装 MySQL。热词里有一堆“mysql安装教程”“mysql安装配置教程”说明这是高频问题。常见症状是服务启动失败这时候先去事件查看器看日志或者用命令行看错误码。多半是 3306 端口被占用或者 my.ini 里的目录写错或者服务名重复。记得安装时选utf8mb4作为默认字符集排序规则选utf8mb4_general_ci或utf8mb4_0900_ai_ci都可以。MySQL 8 默认认证插件是caching_sha2_password如果老的 Java 驱动连不上要么升级驱动要么在创建用户时指定mysql_native_password。5.3 MyBatis 启动报错与无法打印 SQL 的排查“Invalid bound statement (not found)”是 MyBatis 项目里出现频率最高的报错之一。排查链路基本是固定的看 XML 的 namespace 是否和 Mapper 接口全限定名一致看select的 id 是否和接口方法名一致看 XML 文件是否被 Maven 打包到了 classes 目录看 application.yml 里mapper-locations路径是否匹配如果上述都没问题大概率是接口方法参数比较多XML 里没有加Param注解。比如两个参数bookId和userIdXML 里写#{bookId}但接口方法没写Param(bookId)MyBatis 会报参数找不到。一个更直观的排查方式是让 MyBatis 把真正要执行的 SQL 打印出来。在application.yml里配置log-impl: org.apache.ibatis.logging.stdout.StdOutImpl后控制台能看到带?占位的 SQL 和参数列表。也有人喜欢用 MyBatis Log 这类 IDEA 插件把日志里的?替换成真实参数值直接查看 can 执行语句。IDEA 社区版里装某些 MyBatis 插件时会提示需要com.intellij.database这其实是社区版没有内置数据库工具解决方案要么用旗舰版要么直接看控制台日志不必为了一个插件专门折腾。5.4 Long 类型主键返回到前端后精度丢失这是一个非常隐蔽的坑。Java 的 Long 主键到前端 JavaScript 里一旦超过Number.MAX_SAFE_INTEGER精度就会丢失。图书商城的订单 id、部分业务 id 是雪花 ID 或时间戳生成的 Long 类型如果直接在 JSON 里返回前端拿到的末几位会变成 0。解决办法是把 Long 类型序列化为 String。可以在字段上加JsonSerialize(using ToStringSerializer.class)也可以全局配置Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder - builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }配置之后前端查询订单详情时就不会出现 id 对不上的问题。这个坑不踩到真的很难想到因为本地测试数据量小时Long 的位数可能还没超过安全范围一旦时间戳生成 ID就很容易触发。5.5 Spring Boot 版本与 JDK、Docker 部署的匹配你永远不知道用户会用什么环境来跑你的项目。有人拿到源码后直接用 JDK8 跑 Spring Boot 3项目启动直接报 “UnsupportedClassVersionError”。所以部署前先确认版本组合Spring Boot 2.7 用 JDK8Spring Boot 3.x 用 JDK17。用 Docker 部署 Spring Boot 项目时先把项目打包成 jar再写一个简单的 DockerfileFROM openjdk:8-jre-alpine WORKDIR /app COPY target/book-mall.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]在 Windows 上用 Docker Desktop 跑这个镜像时注意别把本地 8080 端口占用了。前端打包后会生成 dist 目录用 Nginx 托管再把/api反向代理到后端容器前后端就正式联通了。如果本地网络环境有问题镜像拉不下来那就先用本机直接跑别在 Docker 上浪费时间先把业务逻辑调通部署是最后一步。6. 从“能跑通”到“敢上线”安全、并发与性能收尾很多图书商城项目在本地能跑页面点起来也流畅但放到服务器上就各种问题。核心原因不是功能代码写错了而是安全、并发和性能上的细节没补齐。6.1 登录认证、密码存储与接口防刷登录是图书商城的第一个入口也是最容易被攻破的地方。密码绝对不能明文存储也不能用 MD5 简单哈希因为 MD5 撞库太容易了。用 Spring Security 自带的 BCryptPasswordEncoder或者独立的 BCrypt 工具类。JWT 登录的思路是用户登录成功后后端签发一个 token包含用户 id、用户名、角色并给 token 设置过期时间。前端把 token 存到 localStorage每次请求在 Authorization 头里带回。后端用拦截器解析 token把当前用户信息放入 ThreadLocal。这个机制在图书商城场景下完全够用。接口防刷要注意的是登录接口和短信接口这类接口很容易被脚本暴力刷。最简单的方式是给接口加一个基于 IP 和用户维度的短时间访问频率限制。如果你没有引入 Redis也可以先在内存里做一个简单的计数器但多实例部署后就会失效。商用项目上 Redis 固定窗口或滑动窗口是常规操作。6.2 库存超卖的兜底机制我在第三部分已经讲了用条件更新解决超卖这里还要补一个兜底如果下单后用户不支付库存什么时候还回去常见做法是“下单减库存”也就是提交订单时直接扣减库存如果用户 30 分钟内不支付系统自动取消订单并把库存加回来。这个过程可以用