ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot+Vue农产品直卖平台:Java毕设前后端全栈落地指南

Spring Boot+Vue农产品直卖平台:Java毕设前后端全栈落地指南 简介一个基于SpringBootVue的农产品直卖平台Java毕业设计项目面向本科、专科毕业设计、Java课程设计与期末大作业场景。项目采用前后端分离架构前端以Vue、JavaScript、HTML/CSS构建后端由SpringBoot提供接口代码含注释新手也能快速上手系统围绕农产品直卖场景设计具备前台展示与后台管理两套入口功能完善、管理便捷经过严格调试后运行稳定整体界面简洁具备较高实用价值。全套资源共809个文件、约32.6MB包含Java后端代码、Vue前端页面、JS/CSS/HTML静态资源与SQL数据库脚本其中Java文件对应业务逻辑与接口Vue及JS/CSS构成前端交互页面SQL脚本用于初始化数据另附安装、运行、构建等脚本源码与数据库均打包在内。部署环境建议为IDEA、MySql5.7、Navicat、Tomcat7/8及Maven包内启动脚本可降低搭建难度若部署中遇到问题也可向作者咨询减少踩坑成本。目前已有660人学习浏览适合需要完整可运行毕设方案的读者直接参考。1. 农产品直卖平台一个能讲清楚前后端全栈的Java毕设选题“基于Spring Boot Vue的农产品直卖平台”这个标题核心就一件事去掉中间商让农户把货直接挂在平台上卖消费者下单后由农户发货。很多人第一反应是“又是个电商CRUD”但真正动手做完会发现它把Java后端、Vue前端、MySQL数据库三块硬技术全部串了起来而且业务链路完整、演示效果好答辩时从需求讲到部署至少能撑二十分钟。这个选题最适合两类人一是时间紧、需要快速交出一个能跑且能讲清楚的毕业设计二是刚学完Spring Boot和Vue基础想通过一个完整项目把前后端打通。我下面按数据库→后端→前端→联调部署的顺序把每一步的落地做法和必然要踩的坑一次讲清。2. 数据库设计先行用户、商品、订单三张核心表怎么建模2.1 权限模型把买家、卖家、管理员拆进同一张用户表农产品直卖平台里至少有三种角色买菜的消费者、上架农产品的农户卖家、维护平台的管理员。很多毕设习惯按角色建三张表再把登录逻辑写三遍这是最常见的翻车点。我一般只建一张user表加一个role字段用 int 表示1买家、2卖家、3管理员。理由很直接三种角色都要走同一套登录注册流程共用同一份用户名和密码校验逻辑拆成多张表除了让 join 变多没有任何业务收益。字段类型说明idbigint主键自增usernamevarchar(50)登录名唯一索引passwordvarchar(100)BCrypt 加密后的密码串nicknamevarchar(50)昵称phonevarchar(20)手机号avatarvarchar(255)头像图片路径roletinyint1买家 2卖家 3管理员create_timedatetime注册时间update_timedatetime更新时间这个表设计里有两个容易被问到的点。password 字段长度特意留了 100因为 BCrypt 每次加密生成的串长度是 60 位左右varchar(50) 会直接存不进去这是初版最容易翻车的地方。role 用 tinyint 而不是字符串一是省空间二是后续加角色比如“配送员”不需要改表结构只扩展枚举值。如果你的数据库用的 MySQL顺手给 username 加上唯一索引注册接口里查重就少一次全表扫描。后端实体类里对角色我一般用枚举类而不是在 Controller 里硬编码数字。登录成功后返回给前端的 userInfo 里带上 role前端路由守卫用这个值判断该跳转到买家首页还是卖家工作台这部分后面第 4 章会展开。2.2 直卖业务的主链路商品表、订单表与状态字段平台最核心的链路是“卖家上架商品→买家浏览下单→卖家发货→买家确认收货”中间穿插订单状态流转和支付模拟。商品表和订单表的字段设计直接决定了后期代码好不好写。先看商品表product的核心字段字段类型说明idbigint商品idseller_idbigint所属卖家关联user.idnamevarchar(100)商品名称categoryvarchar(50)分类如蔬菜/水果/粮油pricedecimal(10,2)单价单位元stockint库存imagevarchar(255)主图路径descriptiontext图文详情statustinyint1上架 0下架create_timedatetime上架时间price 用 decimal(10,2) 是条血泪经验很多新手用 double 存价格最后在订单金额计算时出现 0.10.2 不等于 0.3 的问题。Java 里 BigDecimal 配合数据库 decimal 才是正路这个点答辩时被问到“金额精度怎么处理”可以直接答上来。订单表orders比商品表复杂一些因为订单要关联买家、卖家、商品三个维度还要记录成交快照字段类型说明idbigint订单idorder_novarchar(32)订单号业务展示用buyer_idbigint买家idseller_idbigint卖家idproduct_idbigint商品idproduct_namevarchar(100)商品名称快照product_imagevarchar(255)商品图片快照pricedecimal(10,2)成交单价快照quantityint购买数量total_amountdecimal(10,2)总金额statustinyint0待支付 1已支付 2已发货 3已收货 4已取消create_timedatetime下单时间pay_timedatetime支付时间这里重点说下为什么订单表里要冗余 product_name、product_image 和 price 这三个快照字段。因为卖家之后完全可能修改商品名、把商品下架甚至改价如果不做快照买家查看历史订单时商品信息会跟着变甚至产生“订单金额和商品当前价格不一致”的 bug。用冗余字段把下单那一刻的卖相固定下来这是电商订单设计里的标准做法答辩时能讲出这一层说明你是真理解业务而不是抄表的。2.3 建表SQL与初始化数据用Navicat落地一份可直接跑的脚本上面三张表就是平台的最小集。我习惯把建表语句维护成一个db.sql文件用 Navicat 或者命令行执行都行。下面是一份可直接执行的初始化脚本MySQL 8.x 和 5.7 都兼容-- 用户表 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, nickname varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role tinyint NOT NULL DEFAULT 1 COMMENT 1买家 2卖家 3管理员, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表 CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, seller_id bigint NOT NULL, name varchar(100) NOT NULL, category varchar(50) DEFAULT NULL, price decimal(10,2) NOT NULL, stock int NOT NULL DEFAULT 0, image varchar(255) DEFAULT NULL, description text, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller (seller_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, buyer_id bigint NOT NULL, seller_id bigint NOT NULL, product_id bigint NOT NULL, product_name varchar(100) NOT NULL, product_image varchar(255) DEFAULT NULL, price decimal(10,2) NOT NULL, quantity int NOT NULL DEFAULT 1, total_amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已收货 4已取消, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_buyer (buyer_id), KEY idx_seller (seller_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;执行时在 Navicat 里新建一个farm_market数据库字符集选 utf8mb4然后运行整个脚本即可。注意order是 MySQL 的保留字所以这里表名用了orders如果你执意要叫order每次查询都必须加反引号这是个容易忽略的细节统一用orders能省掉一堆麻烦。这套建表语句有几个参数值得展开。DEFAULT CURRENT_TIMESTAMP让 create_time 不用在 Java 代码里手动 set插入时留空MySQL 自动填当前时间ON UPDATE CURRENT_TIMESTAMP加在 update_time 上任何 update 语句都会自动刷新更新时间省去手写new Date()。后续需求要改表结构比如给商品表加一个“产地”字段直接用ALTER TABLE product ADD COLUMN origin varchar(100) DEFAULT NULL COMMENT 产地不用删表重建MySQL 对已有数据是安全的这也是数据库增删改查里最常用的一个操作。3. Spring Boot后端REST API、JWT鉴权与文件上传的最小实现3.1 项目结构与依赖为什么选择MyBatis-Plus后端我习惯用 Spring Initializr 生成基础工程Java 版本选 8 或 11 都行。考虑到毕业设计的部署环境和答辩老师的机器Java 8 的兼容性最好。依赖方面除了 spring-boot-starter-web真正核心的是 MyBatis-Plus、MySQL 驱动和 JWT 工具。为什么用 MyBatis-Plus 而不是原生 MyBatis 或者 JPA理由很实在直卖平台的单表增删改查占据 80% 的接口量MyBatis-Plus 的 BaseMapper 直接提供了 insert、selectById、updateById、deleteById 这些现成方法不用写 XML 文件分页查询用内置的分页插件一行代码搞定。JPA 的复杂查询手感很怪很多用 JPA 写到后期的人又把 JdbcTemplate 拿出来混着用维护成本反而高。pom.xml 里的关键依赖大概是这样dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency注意 jjwt 从 0.9.x 升到 0.11.x 之后依赖被拆成了 api、impl、jackson 三个包只引一个会在运行时报 ClassNotFoundException。版本号这里写的是我常用的一组你用更新的 0.12.x 也可以但三个包版本必须保持一致这也是配置 springboot 依赖时最容易踩的坑。项目内部分层就是标准的三层架构controller 接收参数、service 处理业务、mapper 直接操作数据库。再加一个 config 包放拦截器和配置类一个 common 包放统一返回结果 Result 和异常处理。不要在这个规模的项目里硬上 DDD 或者 CQRS毕业设计讲不清楚反而扣分。3.2 application.yml配置数据源、端口与MyBatis映射springboot 配置里最容易出错的就是数据源对应关系。一份能跑的配置长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/farm_market?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root 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: autourl 后面的serverTimezoneAsia/Shanghai必须写否则连 MySQL 8 会报时区错误。jackson 的 date-format 和 time-zone 是为了解决前后端之间时间格式不一致的问题先把概念的坑埋下第 5 章会展开讲。map-underscore-to-camel-case让数据库的 create_time 自动映射到实体的 createTime不用自己写 ResultMap。log-impl配成 StdOutImpl 会在控制台打印执行 SQL联调时排查问题非常有用。3.3 JWT登录与拦截器三分钟给接口加一层身份校验登录接口逻辑很简单拿着 username 和 password 去 user 表查用 BCrypt 校验密码成功后签发 token 返回给前端。前端把 token 存 localStorage之后请求头带 Authorization 即可。关键是后端要有一个拦截器统一校验而不是在每个接口里重复写解析 token 的代码。先写一个 JwtUtilComponent public class JwtUtil { private final String secret farm-market-secret-key-please-change-in-prod; private final long expire 7 * 24 * 60 * 60 * 1000; // 7天 public String generateToken(Long userId, String username, Integer role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }这段代码里 secret 是签名密钥生产环境必须放到配置中心或环境变量里直接写在类里只是毕设阶段的省事写法。expire 设置成 7 天前端一星期内不用重新登录比每次都弹登录框体验好很多。generateToken 把 userId 和 role 放到 claim 里后续接口里取“当前登录用户是谁”不再查库直接用拦截器放进去的请求属性。接着写拦截器Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals(OPTIONS)) { return true; } String token request.getHeader(Authorization); if (token null || token.isBlank()) { response.setStatus(401); return false; } try { Claims claims jwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }OPTIONS 请求要直接放行否则前端跨域预检会被拦这是前后端分离联调时的经典翻车点。拦截器只校验 token 是否有效具体到某个接口是不是管理员才能操作由业务代码里从 request 取 role 自己判断。这样拆的好处是拦截器逻辑简单稳定新增接口时不用改拦截器。注意拦截器返回 401 时前端响应拦截器会统一跳转登录页这是一种约定。如果你想让后端返回 JSON 而不是空 body可以在 response 里写一段结果集再返回但要注意排除公开接口。在 WebMvcConfigurer 里注册拦截器时记得排除登录、注册、商品列表这些公开接口registry.addInterceptor(authInterceptor) .addPathPatterns(/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/product/list, /api/product/detail/**);3.4 商品增删改查接口Controller-Service-Mapper全链路有了上面的基础写一个商品分页查询接口就是顺理成章的事这里展示最小实现覆盖三层RestController RequestMapping(/api/product) public class ProductController { Autowired private ProductService productService; GetMapping(/list) public Result page(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) String category) { return Result.success(productService.pageProducts(page, size, category)); } }Service public class ProductServiceImpl implements ProductService { Autowired private ProductMapper productMapper; Override public IPageProduct pageProducts(int page, int size, String category) { PageProduct pageParam new Page(page, size); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(Product::getStatus, 1); if (category ! null !category.isEmpty()) { wrapper.eq(Product::getCategory, category); } wrapper.orderByDesc(Product::getCreateTime); return productMapper.selectPage(pageParam, wrapper); } }这段代码的关键在 LambdaQueryWrapper。eq(Product::getStatus, 1)表示 status 等于 1只显示上架商品category 为空时不加条件orderByDesc按创建时间倒序让新上架的排前面。MyBatis-Plus 会把 Lambda 表达式安全地转成 SQL 字段名不会出现字符串拼条件时写错列名的低级错误。page 和 size 参数直接传给 Page 对象分页插件自动拼 LIMIT 语句返回的 IPage 里包含 total、records 等数据前端拿 records 渲染列表、拿 total 算总页数。新增和修改商品同理。新增时注意 seller_id 要从登录态里取不能信任前端传过来的值修改时用 updateById但要校验 status、stock 这些字段否则前端漏传一个字段就可能把库存清零。这里建议在 Service 层先按 id 查出旧数据把前端传来的非空字段逐个更新而不是直接把整个对象覆盖上去。4. Vue前端从环境搭建到页面渲染的完整路径4.1 用Vite搭项目node版本、目录规划与代理配置前端部分我不再用 vue-cli直接用 Vite。Vite 启动速度快、配置简单对 Vue 3 的支持是天然的。这里有个环境前提Node.js 版本要 16 以上版本太低会直接报错。装完 Node 后在项目目录下执行npm create vitelatest farm-ui -- --template vue cd farm-ui npm install npm install axios vue-router4 pinia element-plus npm run devnpm create vite会自动生成一个 Vue 3 Vite 的最小工程--template vue指定 Vue 模板不加的话会进入交互式选择。我一般会在 src 下建几个目录api 放接口请求router 放路由配置views 放页面组件components 放可复用组件utils 放 axios 实例封装。这种目录规划不是强迫症而是当后端写了十几个接口后没有合理分类的 src 目录会迅速变成垃圾桶。Vite 的另一个关键配置是开发环境的代理。后端接口跑在 localhost:8080前端 dev server 跑在 5173直接请求一定跨域。在 vite.config.js 里加代理export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置之后前端请求 /api/product/list 时Vite dev server 会把请求转发到后端浏览器层面看不到跨域前端代码里也不用把 localhost:8080 写死。这是开发环境的正确解法比后端写一堆 CORS 配置让它硬通过更干净。4.2 路由与导航守卫登录态是怎么在页面间流转的vue-router 4 在 Vue 3 里的用法和旧版本略有不同。routes 数组里配置页面路径和组件的对应关系meta 里标记是否需要登录核心是导航守卫里读 localStorage 里的 token 和用户信息没有登录就弹回登录页。import { createRouter, createWebHistory } from vue-router import Home from ../views/Home.vue import Login from ../views/Login.vue import ProductDetail from ../views/ProductDetail.vue import Cart from ../views/Cart.vue const routes [ { path: /, name: home, component: Home }, { path: /login, name: login, component: Login }, { path: /product/:id, name: product-detail, component: ProductDetail }, { path: /cart, name: cart, component: Cart, meta: { requiresAuth: true } } ] const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } }) export default router这段导航守卫要理解两个点。meta.requiresAuth是给路由打标记购物车、个人中心这种需要登录的页面加上它守卫里只要校验 token 是否存在即可不用在每个组件里写“未登录跳转”的重复逻辑。localStorage 里存 token 是毕设阶段的常规做法生产环境一般用 httpOnly Cookie 更安全但要处理 CSRF复杂度上升不少作为毕设讲清楚用 localStorage 方案的取舍就够了。4.3 Axios封装与Element Plus表格把后端商品数据渲染出来axios 实例要做两件基础的事请求拦截器统一在请求头加 token响应拦截器统一处理 401 跳登录页。代码放在 utils/request.js 里import axios from axios import router from ../router 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) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default requestbaseURL 设置成 /api配合刚才 Vite 的代理实际上请求的就是http://localhost:8080/api/product/list。请求拦截器在每次请求发出前自动带上 Authorization后端拦截器再统一解析。这个设计让业务代码里调用接口时完全不用关心 token写起来很干净。响应拦截器直接返回response.data而不是整个 response这样页面里拿到的就是后端 Result 结构少一层解包。商品列表页用 Element Plus 的 el-table 展示数据template el-table :dataproducts v-loadingloading el-table-column propid labelID width80 / el-table-column propname label商品名称 / el-table-column propcategory label分类 width100 / el-table-column propprice label单价 width120 / el-table-column propstock label库存 width100 / el-table-column label操作 width150 template #default{ row } el-button typeprimary link clickgoDetail(row.id)查看/el-button /template /el-table-column /el-table /template script setup import { ref, onMounted } from vue import request from ../utils/request import { useRouter } from vue-router const products ref([]) const loading ref(false) const router useRouter() const fetchProducts async () { loading.value true try { const res await request.get(/product/list, { params: { page: 1, size: 10 } }) products.value res.data.records } finally { loading.value false } } const goDetail (id) { router.push(/product/${id}) } onMounted(fetchProducts) /scriptel-table-column 里嵌套了 template 并用了作用域插槽#default{ row }这是 Element Plus 表格自定义列的标准写法row 就是当前行的数据对象。这是 Vue 3 里v-slot的简写语法如果看到别人代码里写slot-scope{ row }那是 Vue 2 的旧语法在 Vue 3 里已经废弃。request.get返回的 res 已经被拦截器处理成 response.data所以直接访问res.data.records就能拿到后端 IPage 里的列表数据。加载态用 v-loading 指令控制接口没回来时表格区域显示 loading 动画体验会好很多。5. 联调与部署避坑跨域、图片、时间格式与打包的5个常见问题5.1 跨域与Cookie开发环境代理和生产环境网关现象前端 npm run dev 打开页面调用后端接口报 CORS error浏览器 console 显示 Access-Control-Allow-Origin 缺失。原因前端跑 5173后端跑 8080端口不同属于跨域。浏览器会先发 OPTIONS 预检请求后端没有正确处理就报错。解决开发环境用上一章里的 Vite proxy不要在后端乱加 CrossOrigin 或者全局 CORS 配置。因为一旦加了全局 CORS开发环境是通了打包部署后如果前端资源和接口同源CORS 配置反而变成多余的限制。生产环境用 Nginx 把 / 指向前端静态文件、/api 指向后端服务或者把所有静态资源放 Spring Boot 的 resources/static 下统一走 8080压根不存在跨域问题。跨域是浏览器的行为不是后端接口的问题用代理绕开它比“解决”它省事得多。5.2 图片上传后404静态资源映射与路径存储现象上传商品图片返回了一个路径 /upload/xxx.jpg但浏览器访问这个地址 404。原因Spring Boot 默认只映射 static、public、resources 这几个目录。如果把文件写到项目根目录的 upload 文件夹而项目里没有配置对应的资源映射Tomcat 找不到这个路径。解决在 WebMvcConfigurer 里手动加一个资源映射把磁盘上的 upload 目录映射到 /upload/** 访问路径上Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) File.separator upload File.separator; registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }配置之后前端访问 /upload/xxx.jpg 就会从项目根目录的 upload 文件夹里找文件。注意addResourceLocations的file:前缀不能丢否则会被当成 classpath 路径解析。存储到数据库的 image 字段应该写相对路径 /upload/xxx.jpg而不是完整的http://localhost:8080/upload/xxx.jpg这样换服务器或换端口后前端图片地址不用跟着改。5.3 时间字段“少了8小时”JSON序列化与MySQL时区现象订单列表里的 createTime 显示比数据库里少了 8 个小时或者直接变成一串数字时间戳。原因两个点叠加。一是 JDBC 连接串里没配置 serverTimezoneMySQL 8 默认用 UTC 时区二是 Spring Boot 默认用 Jackson 序列化时间不指定格式时输出的是时间戳或者 ISO 格式。两个问题一起出现时前端看到的就是“数据库时间减 8 小时”。解决按两步走。JDBC URL 加上serverTimezoneAsia/Shanghai和useUnicodetruecharacterEncodingutf8保证驱动读出来的时间就是北京时间application.yml 里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这样后端返回给前端的时间就是2025-06-01 14:30:00这种格式前端直接展示。如果实体里还有 Timestamp 类型的字段可以在字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)双保险。这个问题最讨厌的地方是它不会报错只有对数据时才发现时间对不上所以要提前在 yml 里配好不要等答辩前才排查。5.4 把Vue打包放进Spring Boot单工程部署的两种做法现象前后端都开发完了部署时不知道把 Vite 打包后的 dist 目录放哪直接双击 dist/index.html 打开后白屏接口也全 404。原因Vite 打包出的文件是源码级 JS/CSS/HTML必须通过 HTTP 服务器访问双击本地文件方式用的是 file:// 协议浏览器会拦截模块加载。另外 dist/index.html 里的资源路径默认是/assets/xxx.js放在子路径下会找不到。解决两种常见的部署方式。第一种是 Nginx 部署dist 目录指给 root/api 请求反向代理到 Spring Boot 的 8080这种方式前后端分离清晰适合以后要加负载均衡的情况。第二种是把 dist 拷贝到 Spring Boot 的src/main/resources/static下然后重新执行打包命令项目会打成一个带前端页面的 fat jarjava -jar就能跑。毕设答辩用第二种最省心因为一台电脑、一个进程就演示完整个系统。执行命令时注意一个细节前端 build 之前把 vite.config.js 里的base设为./否则打包后资源路径以绝对路径/开头放在 Spring Boot 静态目录下会找不到资源。命令如下# 前端打包 npm run build # 把 dist 目录里的内容复制到后端 cp -r dist/* ../farm-server/src/main/resources/static/ # 后端打包并启动 cd ../farm-server mvn clean package -DskipTests java -jar target/farm-server.jar如果只后端启动成功但页面白屏先检查 static 目录下有没有 index.html再检查 dist 里引用的 JS 路径是不是相对路径。这个顺序排查能解决九成部署问题。5.5 答辩高频追问这张订单表为什么这么设计现象答辩老师盯着订单表问为什么既有 total_amount 又有 price为什么订单和商品不直接外键关联。原因很多同学没有理解快照字段的意义设计表时照着教程抄被追问就卡壳。解决提前把下面几个问题想清楚被问到时能答出“为什么”。为什么有 product_name 和 price 快照因为商品信息会变订单需要保持下单那一刻的原始状态这是电商领域的一致性要求。为什么订单表没有设置外键约束因为毕设规模小用代码维护关联关系更直观业务上删除订单也不需要级联商品外键在这个场景里只会让插入变慢、逻辑变僵。为什么密码不存明文因为数据库一旦泄露用户明文密码会被直接利用BCrypt 是带盐的哈希算法即使两个账号密码相同存储的哈希串也不同。这一条是最能体现设计能力的地方比背一百行代码都管用。6. 把平台做得更完整订单状态机、模拟支付与验收自测6.1 订单状态机用一个整数字段管理完整流转订单状态从 0 到 4 的五档流转不要散落在代码里把状态迁移收敛到 Service 层。比如取消订单只在“待支付”状态允许发货只在“已支付”状态允许。在 Service 方法开头先查订单、比对状态、再更新而不是直接 updateById能拦截掉“已取消订单被重复发货”这种低级事故。当前状态允许动作下一个状态0 待支付支付/取消1 已支付 / 4 已取消1 已支付发货2 已发货2 已发货确认收货3 已收货6.2 模拟支付与订单超时定时任务与事务边界毕设接入真实支付不现实常见做法是写一个“模拟支付”接口把订单状态从 0 改成 1。别忘了订单超过 30 分钟未支付要自动关闭用 Spring 的Scheduled定时扫表是个简单可靠的路子。注意事务边界先查出需要关闭的订单 id 集合再逐条更新不要在一个大事务里扫表加更新锁竞争会把数据库拖垮。6.3 验收自测清单从注册到发货的端到端验证最后给一份我每次做这种项目都会跑一遍的清单注册两个账号一个买家一个卖家卖家登录后创建 3 款商品并上架买家浏览列表、查看详情、下单卖家发货买家确认收货。每个环节截图存档答辩演示时直接用。我个人项目做完习惯跑三遍完整流程第二遍成心乱点看会不会产生脏数据——第一次跑从来没一次通过。这也算是这些年最实在的教训自测越狠答辩越稳。希望帮到你。本文还有配套的精品资源点击获取
返回列表