
简介一份面向计算机专业学生、Java开发者及毕业设计作者的博物馆文创产品在线销售系统完整资源包。系统基于Spring Boot 3.4.1与Vue3搭建后端采用Java实现安全身份验证、商品管理、购物车、订单生成与支付、物流跟踪等业务流程前端以Vue3组件化页面提供商品分类筛选、用户账户管理与在线选购体验适合学习前后端分离架构和电商系统设计。资源包共344个文件、约1.63MB包含194个Java后端源码、72个Vue前端组件、7份SQL数据库脚本另有js/ts脚本、yml及properties配置、Markdown文档、首页及订单页面等目录结构清晰便于按模块对照研读。随包附带需求分析、系统设计、接口文档和操作手册能帮助理解用户表、订单表等数据库设计以及订单支付与查询的接口调用逻辑。当前已有99人学习下载适合需要完整项目案例的初学者和准备毕设答辩的学生参考。1. 博物馆文创在线销售系统Spring Boot 3.4.1 与 Vue3 这套组合到底在做什么每年毕业设计选型电商类系统都是最稳的赛道之一但“稳”不等于“旧”。用 Spring Boot 3.4.1 提供后端接口、Vue3 搭前端页面、MySQL 存业务数据的博物馆文化创意产品在线销售系统本质是把文创电商这个垂直场景和主流的前后端分离架构组合起来前台有商品检索、分类筛选、购物车、下单支付、订单查询后台有商品维护、库存管理、订单处理和素材上传。它能解决两个真实痛点一是给学生一套结构清晰、能演示、能写进简历的完整项目二是给博物馆或景区提供一个可以二次开发的文创商城原型。适合正在选题的毕业生也适合想快速验证文创销售流程的开发者。这套源码加数据库加文档的组合难点其实不在功能多而在版本新引入的适配问题——下文会逐层拆开。2. 技术选型与工程初始化Spring Boot 3.4.1、Vue3、MyBatis-Plus 的版本匹配与项目骨架2.1 为什么选这套组合版本边界先看清楚Spring Boot 3.4.1 是 2024 年底发布的稳定版本它和 2.x 的最大区别是底层基于 Spring Framework 6并且把 Java EE 的命名空间从 javax 整体迁移到了 jakarta。这意味着很多老的教程和旧的依赖不能再直接抄比如 3.4.1 要求 JDK 17 起步、Maven 坐标里的 mysql-connector-java 变成了 com.mysql:mysql-connector-j、javax.servlet.* 全部换成 jakarta.servlet.*。选它做毕业设计好处是技术栈足够新答辩时可以讲清楚“为什么不用 Spring Boot 2.x”代价是踩坑成本比 2.x 高第三方库必须选适配 Boot 3 的版本这一点在第 5 章会专门展开。Vue3 这边相对简单。现在官方脚手架默认就是 Vue3 Vite用 vue3 商城这类关键词能找到不少参考项目但很多项目要么没有接入真实接口要么还停留在 Options API。建议直接上手script setup组合式 API配合 Pinia 做状态管理、Element Plus 做商城 UI这套组合在后端 Spring Boot 的接口下联调是最顺的。前端工程只需要保证 Node.js 18Vite 会自动处理依赖的版本兼容不需要手工配 webpack。数据库层面文创商品和普通电商没有本质区别MySQL 8.0 足够。真正需要设计的是文创商品往往有“限量”“编号”“材质”这类字段订单又需要记录下单时的商品快照所以表结构要刻意区分“商品主档”和“订单明细快照”而不是在订单表里直接关联商品表。这一点决定了整个系统的数据质量第 3 章会给出完整建表语句。2.2 初始化后端工程JDK 17、Maven 依赖与 application.yml 配置后端工程我一般用 IDEA 的 Spring Initializr 创建Group 填 com.museumArtifact 填 museum-mallJava 版本选 17。如果网络不方便也可以直接手写 pom.xml。核心依赖就四个spring-boot-starter-web、spring-boot-starter-validation、mybatis-plus-spring-boot3-starter 和 mysql-connector-j登录认证再加一个 jjwt。注意 MyBatis-Plus 必须要用带spring-boot3的 starter老的mybatis-plus-boot-starter在 Boot 3 下会直接起不来这是第一个常见的使用差异。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.4.1/version relativePath/ /parent 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 dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency /dependencies参数说明spring-boot-starter-parent 统一管理依赖版本所以 web 和 validation 不需要写 versionMyBatis-Plus 3.5.7 是适配 Spring Boot 3 的稳定版本如果后续发现启动异常优先在 3.x 范围内升小版本mysql-connector-j 是 MySQL 官方新的 artifactId旧坐标在 Boot 3 下仍能解析但不推荐。jjwt 0.11.5 三个包api、impl、jackson要一起引入只引 api 会在运行时抛 ClassNotFoundException。写好后配置 application.yml重点是时区、编码和 MyBatis-Plus 的驼峰映射。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/museum_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id这里最容易出问题的参数是allowPublicKeyRetrievaltrueMySQL 8 用 caching_sha2_password 认证时JDBC 第一次连接需要拿公钥不加这个参数会报 Public Key Retrieval is not allowed。serverTimezone不设的话时间字段在你本机 8 小时时差下会错乱订单时间比实际慢 8 小时是毕设答辩时很常见的问题。id-type: assign_id表示主键由 MyBatis-Plus 生成雪花 ID而不是依赖数据库自增这样在分布式的场景下更好迁移后面讲订单唯一性时还会用到。2.3 初始化前端工程Vite 创建 Vue3 商城项目与目录规划前端工程用 Vite 脚手架创建命令是npm create vitelatest museum-front -- --template vue。创建完成后安装三个关键依赖vue-router 4、pinia、element-plus。Element Plus 如果不需要按需加载直接在 main.js 里全量引入即可毕设项目不必纠结打包体积。npm create vitelatest museum-front -- --template vue cd museum-front npm install npm install vue-router4 pinia element-plus axios--template vue生成的是 Vue3 组合式 API 骨架不是 Vue2 模板vue-router 必须是 4.x3.x 是给 Vue2 用的装错版本后路由会静默不跳转这种问题排查起来很费时间。目录我一般按功能拆src/api 放接口请求src/router 放路由配置src/stores 放 Piniasrc/views 下面再分前台页面和后台管理页面。初始目录里自动生成的 HelloWorld.vue 和 components 里的演示组件可以直接删掉。src/ api/ # http.js 各业务模块接口 router/ # 路由表 守卫 stores/ # user.js cart.js views/ # home / product / cart / order / admin layout/ # 用户端和后台的公共布局目录分层的作用是让“源代码”部分有清晰边界后端按 controller/service/mapper/entity 分层前端按 api/stores/views 分层答辩讲代码时可以直接按这个层次从请求讲到数据落库避免被问“这段代码在哪一层”时支支吾吾。工程初始化的常见翻车点有两个。一是 Node 版本过低Vite 5 以上要求 Node 18启动时报 error:0308010C 就是版本太低。二是 npm 源的问题安装 Element Plus 时若网络差建议先切到国内镜像源再执行npm install不要在 install 中途 CtrlC容易留下损坏的 node_modules。到这一步前后端两个空壳工程已经能分别启动接下来先做数据库设计再写后端接口联调时才不会两头乱。3. 数据库设计与后端核心模块从文创商品表到订单库存扣减3.1 数据库设计九张表如何支撑文创电商闭环文创商城不追求复杂营销核心闭环是“用户逛 → 加购 → 下单 → 支付 → 发货 → 收货”。我用九张表覆盖这个流程account用户、category分类、product商品、cart_item购物车、order_master订单主表、order_item订单明细、banner首页轮播、address收货地址、material文创素材库存文物编号和纹样信息。CREATE TABLE product ( id BIGINT PRIMARY KEY COMMENT 雪花ID, name VARCHAR(100) NOT NULL COMMENT 商品名称, subtitle VARCHAR(200) DEFAULT COMMENT 副标题/卖点, category_id BIGINT NOT NULL COMMENT 分类ID, heritage_no VARCHAR(50) DEFAULT COMMENT 文物编号如M001-青瓷, material_desc VARCHAR(255) DEFAULT COMMENT 材质工艺说明, cover_url VARCHAR(255) DEFAULT COMMENT 封面图URL, price DECIMAL(10,2) NOT NULL COMMENT 售价, original_price DECIMAL(10,2) DEFAULT NULL COMMENT 划线价, stock INT NOT NULL DEFAULT 0 COMMENT 可售库存, sales INT NOT NULL DEFAULT 0 COMMENT 销量, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 文创商品表;设计要点商品和分类用 category_id 关联不要用字符串存分类名否则后面改分类名要写一堆 UPDATEheritage_no 和 material_desc 是文创商品的特色字段答辩时可以解释为“非遗数字化素材关联”stock、sales 是商品维度的统计值订单维度由 order_item 单独记录。所有金额用 DECIMAL禁止用 DOUBLE否则累计对账时会有一长串小数。购物车表 cart_item 记录 user_id、product_id、quantity 和 checked 字段check 字段表示勾选状态订单主表 order_master 保存订单号、用户、总金额、支付状态和订单状态。订单明细 order_item 一定要冗余商品名称、封面、单价和数量这样商品后来改名或删除订单详情依然能按当时的快照展示这是电商系统的基本功。CREATE TABLE order_item ( id BIGINT PRIMARY KEY, order_id BIGINT NOT NULL COMMENT 订单主表ID, product_id BIGINT NOT NULL COMMENT 商品ID不回查, product_name VARCHAR(100) NOT NULL COMMENT 下单时商品名快照, product_cover VARCHAR(255) DEFAULT COMMENT 下单时封面快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时单价快照, quantity INT NOT NULL DEFAULT 1 COMMENT 数量 ) COMMENT 订单明细快照表;这里要特别提醒order_item 不设外键约束。外键在教学演示里有意义但在实际开发和毕设答辩中反而会被问“为什么不用外键”这类问题然后露出破绽。常见做法是业务层保证一致性不建物理外键理由是高并发写入时外键会放大锁竞争。3.2 MyBatis-Plus 实体与 Mapper数据库增删改查的最小落地实体类用 Lombok 简化 getter/setter注意字段命名规则数据库是下划线实体是驼峰打开 map-underscore-to-camel-case 后 MyBatis-Plus 会自动映射。TableName 指定表名TableId 指定主键策略和 yml 里的 assign_id 呼应。Data TableName(product) public class Product { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private String subtitle; private Long categoryId; private String heritageNo; private String materialDesc; private String coverUrl; private BigDecimal price; private BigDecimal originalPrice; private Integer stock; private Integer sales; private Integer status; }逻辑说明TableId 的 ASSIGN_ID 表示由 MyBatis-Plus 生成雪花 ID而不是数据库自增这和全局配置保持一致BigDecimal 对应数据库 DECIMAL不能换成 Double。Data 来自 Lombok编译期生成 getter/setter代码看起来干净但注意 Lombok 需要 IDEA 插件支持否则看着像编译报错。Mapper 层继承 BaseMapper 之后单表的增删改查已经全部具备不需要写一行 SQL。这是 MyBatis-Plus 提升开发速度最明显的地方但第 3.3 节的扣库存逻辑必须写 SQL因为 UPDATE 语句里“stock stock - 1”这种操作不能被乐观锁插件替代。public interface ProductMapper extends BaseMapperProduct { Select(SELECT * FROM product WHERE id #{id} FOR UPDATE) Product selectByIdForUpdate(Param(id) Long id); Update(UPDATE product SET stock stock - #{quantity}, sales sales #{quantity} WHERE id #{id} AND stock #{quantity}) int deductStock(Param(id) Long id, Param(quantity) Integer quantity); }这里的 selectByIdForUpdate 用的是悲观锁FOR UPDATE 会把商品这一行锁住事务提交前其他事务只能等待deductStock 返回影响行数如果 stock 不够条件不成立影响行数为 0业务层据此抛异常。两条 SQL 一锁一扣配合 Transactional 就能保证不超卖这是整个后端代码里最核心的几行。3.3 下单与库存扣减并发安全与订单状态机订单服务是系统的大脑。流程是校验购物车记录 → 锁商品行 → 扣库存 → 生成订单主表 → 生成订单明细 → 清除购物车项 → 返回订单号。整个流程必须在一个事务内完成任何一步失败都要回滚库存。Service RequiredArgsConstructor public class OrderServiceImpl implements OrderService { private final ProductMapper productMapper; private final OrderMasterMapper orderMasterMapper; private final OrderItemMapper orderItemMapper; private final CartItemMapper cartItemMapper; Override Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, Long cartItemId) { CartItem cartItem cartItemMapper.selectById(cartItemId); if (cartItem null) { throw new BizException(购物车记录不存在); } Product product productMapper.selectByIdForUpdate(cartItem.getProductId()); if (product null || product.getStatus() ! 1) { throw new BizException(商品已下架); } if (product.getStock() cartItem.getQuantity()) { throw new BizException(库存不足仅剩 product.getStock() 件); } int rows productMapper.deductStock(product.getId(), cartItem.getQuantity()); if (rows 0) { throw new BizException(扣减失败请重新下单); } OrderMaster order new OrderMaster(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(product.getPrice() .multiply(BigDecimal.valueOf(cartItem.getQuantity()))); order.setOrderStatus(0); orderMasterMapper.insert(order); OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setProductCover(product.getCoverUrl()); item.setPrice(product.getPrice()); item.setQuantity(cartItem.getQuantity()); orderItemMapper.insert(item); cartItemMapper.deleteById(cartItemId); return order.getId(); } }逻辑说明Transactional(rollbackFor Exception.class) 让任何 RuntimeException 和检查异常都能触发回滚默认行为只回滚运行时异常先查后扣查和扣之间用 FOR UPDATE 锁住商品行避免两个用户同时读到剩余 1 件然后都下单deductStock 的 WHERE 条件里再次带上 stock quantity是一个双保险防止未来改动时漏掉锁。参数说明generateOrderNo 一般用“yyyyMMddHHmmss 6 位随机数”或者直接用雪花 ID 当订单号totalAmount 用 product.getPrice() 乘数量不要用前端传的金额前端金额只做展示后端的金额永远以数据库里的单价为准。BizException 是自己定义的运行时异常配合全局异常处理器返回统一 JSON。RestController RequestMapping(/api/product) RequiredArgsConstructor public class ProductController { private final ProductService productService; GetMapping(/list) public ResultPageProduct list(RequestParam(defaultValue 1) Long page, RequestParam(defaultValue 12) Long size, RequestParam(required false) Long categoryId) { PageProduct result productService.page( new Page(page, size), new LambdaQueryWrapperProduct() .eq(categoryId ! null, Product::getCategoryId, categoryId) .eq(Product::getStatus, 1) .orderByDesc(Product::getSales)); return Result.ok(result); } }Controller 统一返回 Result 包装对象code/message/data 三段式分页参数用 defaultValue 给默认值避免前端漏传导致空指针。这里 page 和 size 是 Long 类型前端 axios 传参时如果拼接成字符串也没关系Spring 会自动转换。4. 前端 Vue3 实现路径商品列表、购物车与结算页的组件化拆解4.1 搭建前端基础设施Axios 封装、Pinia 与路由守卫前后端分开跑时前端访问后端必须处理跨域。最省事的方案不是在后端开 CORS而是用 Vite 的代理浏览器只请求相对路径/api/xxxdev server 把它转发到http://localhost:8080。这样浏览器看到的请求是同源的跨域问题在开发阶段基本绝迹。// 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 } } } })changeOrigin: true会把请求头里的 Host 改成目标地址这样后端日志里看到的来源是前端服务如果你把 host 设成 0.0.0.0手机和电脑在同一局域网时可以直接用http://本机IP:5173访问答辩现场用手机演示会比电脑投屏更灵活。注意生产环境部署时 Vite 代理不生效需要 nginx 配置同样的转发规则这个后面第 6 章再讲。Axios 封装要做两件事请求拦截器自动带 token响应拦截器统一处理 code 和错误提示避免每个页面重复写 try/catch。// src/api/http.js import axios from axios import { ElMessage } from element-plus const http axios.create({ baseURL: /api, timeout: 8000 }) http.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) http.interceptors.response.use(res { const body res.data if (body.code ! 200) { ElMessage.error(body.message || 请求失败) return Promise.reject(new Error(body.message)) } return body.data }, err { if (err.response?.status 401) { localStorage.removeItem(token) window.location.href /login } ElMessage.error(err.response?.data?.message || 网络异常) return Promise.reject(err) })这里把响应直接返回body.data页面里拿到的就是干净的业务数据不用每处都写.data.data。401 时统一清 token 跳登录这是 JWT 过期后最常见的处理路径。timeout 设 8000 毫秒文创商品详情里有大图时后端接口若慢用户不至于等太久才看到报错。路由守卫用 Pinia 里的 user store 判断是否登录。受保护页面是“购物车、结算、订单列表、后台管理”未登录时直接跳转登录页并带上 redirect 参数登录成功后回跳。// src/router/index.js router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })逻辑说明userStore.token 在用户刷新页面时会重新从 localStorage 读一次所以放在 Pinia 里的 token 初始化逻辑要先判断有值才赋值不能刷新就丢。后台管理页面建议再套一层 meta.role 判断只有 roleadmin 的用户能进入演示时可以现场演示普通用户进后台被拦截的效果。4.2 商品列表与详情Vue3 响应式数据的正确打开方式商品列表页是 Vue3 学习中最容易出问题的场景。很多人按 Vue2 的习惯把列表放进 reactive 对象然后用解构赋值取出数组结果 push 之后页面不更新这就是所谓的“响应式丢失”。script setup import { ref, onMounted } from vue import { http } from /api/http const productList ref([]) // 用 ref 存数组 const categoryId ref(0) const loading ref(false) async function loadProducts() { loading.value true try { const data await http.get(/product/list, { params: { categoryId: categoryId.value || undefined } }) productList.value data.records // 直接整体赋值 } finally { loading.value false } } onMounted(loadProducts) /scriptref在 template 里会自动解包所以页面上写productList而不是productList.value在 script 里操作数组要写productList.value ...。整体赋值最直观也避免了“修改了某个对象属性但页面不刷新”的问题。如果坚持用 reactive不要解构它而是const state reactive({ list: [] })然后state.list.push(...)。页面模板上用 Element Plus 的卡片栅格el-row 和 el-col 拉商品布局el-card 放封面、价格和“加入购物车”按钮。价格用¥{{ item.price.toFixed(2) }}格式化注意 price 在 JSON 里是数字直接 toFixed 不会出错。图片懒加载用 el-image 的 lazy 属性文创商品图多能明显降低首屏压力。商品详情页要传 id路由配置成/product/:id页面里用route.params.id作为字符串接收。后端主键是雪花 ID超过 JavaScript 的 Number 安全范围这里就涉及到第 5 章讲到的精度问题前端拿到的 id 会变成类似1812333300000的科学计数形状。解决方式是后端把 Long 序列化为 String前端把 id 当字符串传给接口。4.3 购物车与结算跨组件状态管理与订单提交购物车状态放在 Pinia 里因为商品列表页加购、头部导航栏显示数量、结算页勾选操作都要读写它放组件 props 会层层传递非常痛苦。定义一个 cart store。// src/stores/cart.js import { defineStore } from pinia import { ref, computed } from vue import { http } from /api/http export const useCartStore defineStore(cart, () { const items ref([]) const totalCount computed(() items.value.reduce((sum, item) sum item.quantity, 0) ) const checkedTotal computed(() items.value .filter(item item.checked) .reduce((sum, item) sum item.price * item.quantity, 0) ) async function loadCart() { items.value await http.get(/cart/list) } async function addItem(productId, quantity 1) { await http.post(/cart/add, { productId, quantity }) items.value await http.get(/cart/list) } return { items, totalCount, checkedTotal, loadCart, addItem } })这里用 computed 派生出总数和勾选金额模板里直接绑定 totalCount 就可以实时更新。注意后端购物车接口返回的商品价格要和商品表最新价一致因此 loadCart 时去后端重新拉一次而不是拿加购时的价格缓存。加购之后重新请求列表是简化做法代价是多一次请求但数据一致性最好。结算页提交订单的代码要处理两个细节一是把选中的 cartItemId 数组传给后端后端按主键批量锁定二是提交按钮防重复点击用submitting.value true先禁用按钮接口返回后再恢复防止用户在慢网络下连点导致重复下单。async function submitOrder() { const checkedIds cartStore.items .filter(item item.checked) .map(item item.id) if (checkedIds.length 0) { ElMessage.warning(请先勾选商品) return } submitting.value true try { const orderId await http.post(/order/create, { cartItemIds: checkedIds }) ElMessage.success(下单成功) router.push(/order/result?orderId${orderId}) } finally { submitting.value false } }下单接口只接收 cartItemIds 数组价格、数量全部走后端查库计算这是防止“改前端价格下单”的最基本手段答辩时一定会被问“如果前端把金额改成 0.01 怎么办”这样实现可以直接回答——后端不信任前端金额。5. 避坑清单Spring Boot 3.4.1 与 Vue3 联调时的五个经典翻车现场5.1 javax 包找不到Spring Boot 3 改名的连锁反应现象启动后端报java.lang.ClassNotFoundException: javax.servlet.Filter或者编译时拦截器相关 import 全部标红。原因Spring Boot 3.4.1 基于 Jakarta EE 9所有 Java EE 类从javax.*迁移到jakarta.*。老教程里的javax.servlet、javax.validation在 Boot 3 下根本不存在第三方老插件也因为编译时引用了 javax 而无法工作。解决自研代码全部换成jakarta.servlet.*、jakarta.validation.*第三方库必须使用适配 Boot 3 的版本典型的就是 MyBatis-Plus 要用mybatis-plus-spring-boot3-starter。如果某天引入某个老工具类一直报 javax先去查它有没有发布 Boot 3 适配版而不是手动改源码。5.2 CORS 跨域与凭证冲突现象Vue3 页面请求后端控制台报Access to XMLHttpRequest ... has been blocked by CORS policy加了 withCredentials 后又报cannot set allowedOrigin to *。原因浏览器跨域策略在后端没有返回正确的 CORS 头如果前端请求带凭证cookie 或 Authorization 头后端allowedOrigins(http://localhost:5173).allowCredentials(true)不能与allowedOrigins(*)混用。常见的错误写法是把 allowedOriginPatterns 和 allowCredentials 一起用还报错或者反过来。解决开发阶段直接用 Vite 代理第 4.1 节浏览器请求全走相对路径跨域根本不存在如果一定要后端开 CORS用以下配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }参数说明addAllowedOrigin 必须写明确的前端地址不能用*因为 allowCredentials(true) 时会拒绝通配来源allowedMethod 和 allowedHeader 在带 token 时比较宽松直接用*问题不大。项目部署后把 5173 换成实际的前端域名即可。5.3 雪花 ID 精度丢失前端拿到的订单号后几位全是 0现象订单列表页点击详情跳转后的订单号变成1812333000000后面几位是 0接口根本查不到这个订单有时数字看起来正常但和数据库里的真实 ID 对不上。原因MyBatis-Plus 默认以雪花算法生成 19 位 Long 主键而 JavaScript 的 Number 类型只能精确表示 2 的 53 次方以内的整数大约 16 位。19 位数字超过安全范围JSON 解析时后面几位被置 0 或四舍五入。解决后端在做 JSON 序列化时把 Long 转成 String。常见做法是注册一个 Jackson 定制器让所有 Long 字段输出时变成字符串。Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }这是 Spring Boot 3 里推荐的写法规避了老教程中直接 new ObjectMapper 覆盖配置的问题。加了之后前端的 productId、orderId 都以字符串形式出现route.params.id 取到的也是字符串接口层不需要刻意转换MyBatis 会自动把字符串数字写成 SQL 的 BIGINT 值。5.4 路由刷新 404history 模式需要后端配合现象在首页点进商品详情一切正常但只要 F5 刷新/product/123页面就变成 404尤其 Spring Boot 打包后前端 dist 放进 static 目录这种现象更常见。原因Vue3 开启 history 路由后URL 路径是真实的路由地址但服务器上并没有对应的物理文件。dev 阶段 Vite dev server 自己做了 fallback所以没事打包后由后端静态资源服务器处理后端收到/product/123找不到对应 controller 就返回 404。解决让后端把所有非 /api 的 GET 请求转发到 index.html。Controller public class SpaForwardController { RequestMapping(value /{path:[^\\.]*}, method RequestMethod.GET) public String forward() { return forward:/index.html; } }注意花括号里的正则[^\\.]*表示路径不含点号这样*.js、*.css、*.png这些静态资源请求不会被错误转发后端接口路径以 /api 开头也走不到这个 Controller。生产环境如果用 nginx等价配置是try_files $uri $uri/ /index.html;效果相同。5.5 响应式丢失reactive 解构后页面不更新的玄学现象从后端拉回列表后console.log里有数据页面上就是空或者第一次有数据筛选一次后列表不刷新。检查了好几遍代码逻辑没有错最后发现是响应式对象被解构了。原因Vue3 的 reactive 返回的是 Proxy 对象直接解构会把原始值赋给新变量原始值本身不是响应式的。const { list } reactive({ list: [] })之后list 已经脱离 Proxy 代理后续修改自然不触发视图更新。这种问题在 Vue3 学习中出现的频率很高属于典型的“看着像 bug 其实是 API 用法”翻车。解决数组优先用 ref 存整体赋值对象需要响应式就不要解构全程state.list.push(...)如果必须在模板里解构用 toRefs 包裹后再解构。我的习惯是列表一律 ref表单对象一律 reactive通过命名区分这两种用法避免把两者搅在一起。这五条是毕设联调阶段我见过最多的问题。框架版本越新第三方的适配速度越跟得上但命名空间、跨域、精度这些基础问题不会自己消失遇到奇怪的报错先按“版本兼容 → 序列化 → 路由 → 响应式”的顺序排查。6. 验收与答辩演示三轮验证让毕设从能跑变成经得起问6.1 功能验证清单与数据准备拿到“源代码数据库文档”后第一步不是写功能而是把演示路径先走通。我一般分三层验收数据层面准备有说服力的素材——分类至少四类瓷器、书画、青铜、非遗手作每类三到五个商品其中单独留一件库存为 1 的商品业务层面走“注册登录 → 浏览列表 → 加入购物车 → 批量结算 → 模拟支付 → 后台发货 → 订单状态流转”全链路代码层面重点盯三处创建订单事务里的 FOR UPDATE 是否生效、JWT 拦截器是否放行了登录和商品列表接口、Long 转 String 是否作用到了分页响应。6.2 打包部署与演示脚本答辩演示分两种形态本地 IDEA 跑带断点讲解和打包跑单端口访问。本地跑最简单前端 npm run dev、后端启动类直接 Run打包则把前端产物放到后端 static 目录后端打成单个 jar。# 前端构建后把 dist/ 下文件复制到 src/main/resources/static/ npm run build # 后端 mvn clean package -DskipTests java -jar target/museum-mall-0.0.1.jar演示时固定顺序先展示项目分层和数据库表再打开数据库工具把库存字段亮出来走一遍用户端全流程到支付一步用假支付接口改状态切到后台发货最后开两个浏览器同时下单那件库存为 1 的商品演示一个成功一个返回“库存不足”。答辩被问库存问题时逐行讲第 3.3 节的事务和 FOR UPDATE比背十页文档都管用。我最初做这类项目时也堆过功能但真正把订单事务和响应式数据讲清楚后答辩反而更稳。如果你时间紧优先保证登录、商品列表、购物车、下单、库存扣减这条闭环其余模块做成简化版本也能通过。希望帮到你。本文还有配套的精品资源点击获取