首页 / 资讯中心 / 文章详情

SpringBoot + Vue3 二手交易平台开发实战:架构设计与踩坑复盘

SpringBoot + Vue3 二手交易平台开发实战:架构设计与踩坑复盘 ★ FEATURED ARTICLE
这个项目是我年前帮一位做校园创业的同学落地的二手物品交易平台前后端从零搭到部署整个过程踩了不少坑也沉淀了不少可复用的经验。写这篇东西不是单纯展示源码而是把架构设计、数据库建模、接口链路和联调细节讲透让拿到这套SpringBoot Vue3 MyBatis MySQL源码的人能真正跑起来、改得动而不是对着一个陌生工程干瞪眼。整套系统采用前后端分离架构后端Java SpringBoot提供REST API前端Vue3负责页面渲染和交互MyBatis作为持久层框架操作MySQL数据库。如果你最近在找类似技术栈的课程设计、毕设或者小规模商用项目的参考实现这篇文章应该能帮你省下不少弯路。1. 业务梳理与技术选型二手交易系统到底要做什么1.1 核心业务链路与功能边界二手交易平台和普通电商最大的区别在于商品的唯一性。新电商卖的是标准化SKU库存是个数字扣减就好但二手商品每件都是孤品一件商品被下单后其他买家就不能再拍。这个一物一单的约束贯穿整个数据库设计和后端接口开发也是我在这类项目里最看重的地方。围绕这个核心我把系统拆成了几个模块用户模块注册、登录、个人信息维护、密码修改。登录采用JWT无状态鉴权服务端不存Session。商品模块发布、编辑、上下架、详情、多条件分页搜索。图片走本地存储数据库记录访问路径。交易模块下单、订单列表、确认收货、取消订单。状态流转有严格约束。互动模块收藏、留言咨询方便买卖双方沟通。管理后台商品审核、用户管理、分类管理、数据看板。这七个模块对应三十多张表和五十多个接口基本覆盖了一个小型交易系统的全貌。对初学者来说这个体量刚好能练透CRUD之外的东西——事务、鉴权、状态机、分页、前后端交互又不至于复杂到看不懂。1.2 为什么是SpringBoot Vue3 MyBatis这套组合有朋友问我为什么不直接用MyBatis-Plus或者换Spring Data JPA我简单回答一下选型的逻辑。SpringBoot是当前Java后端的事实标准自动配置、Starter机制、内嵌Tomcat让项目从零到能跑只需要几分钟。Vue3的组合式API配合Vite开发体验比Vue2舒服太多响应式数据逻辑更内聚对前后端分离项目来说编写效率和维护性都有明显优势。至于MyBatis市面上确实有争议。有人在它和JPA之间反复横跳。我的看法是交易系统的SQL往往需要精确控制尤其是多条件动态查询、复杂的连表统计、分页查询MyBatis的XML映射文件写出的SQL是可见的、可控的方便DBA审查也方便后期针对慢查询做优化。这一点在写商品多条件过滤和订单报表统计时感受特别深。MyBatis的这些特性也让它在面试里频繁成为考察点这个项目可谓一举两得既能复习技术又能出实际成果。MySQL则是在数据库选型里最稳妥的选择。开源的InnoDB引擎支持事务和行级锁对二手交易这类读写比不极端、数据量在千万以内的系统绰绰有余。与PostgreSQL对比MySQL在Java生态的适配度、云数据库的支持度、中文技术社区资料丰富度上都有明显优势。如果你只有一台2核4G的学生服务器MySQL也是资源占用最友好的方案。2. 数据库设计与建表细节一张订单表的前世今生2.1 核心表结构与字段命名规范先看最关键的三张表用户表、商品表、订单表。表名统一用业务名复数形式字段名统一用snake_case主键都叫id。下面是简化的表结构省略了通用字段。用户表users字段类型说明idBIGINT UNSIGNED AUTO_INCREMENT主键usernameVARCHAR(50) UNIQUE NOT NULL用户名唯一索引passwordVARCHAR(100) NOT NULLBCrypt加密后的密码哈希nicknameVARCHAR(50)昵称avatarVARCHAR(255)头像路径phoneVARCHAR(20)手机号statusTINYINT DEFAULT 11表示正常0封禁created_atDATETIME DEFAULT CURRENT_TIMESTAMP注册时间密码字段特别注意长度至少设到100。BCrypt哈希结果是60个字符加上盐前缀用VARCHAR(100)留足冗余别用VARCHAR(32)不然数据会截断。这个坑我一个朋友踩过一次注册成功但登录永远校验失败排查了两小时发现是字段长度不够。商品表goods字段类型说明idBIGINT UNSIGNED商品IDuser_idBIGINT UNSIGNED发布者IDtitleVARCHAR(100)商品标题descriptionTEXT商品描述priceDECIMAL(10,2)价格注意用DECIMAL不用FLOAToriginal_priceDECIMAL(10,2)原价或购买价category_idINT分类IDcover_imageVARCHAR(255)封面图路径image_listTEXT/JSON多图路径逗号分隔或JSON数组statusTINYINT1在售 2下架 3已售出 4待审核view_countINT DEFAULT 0浏览量created_atDATETIME发布时间价格这块我必须多说一句电商系统的价格字段一律用DECIMAL(10,2)禁止用FLOAT或DOUBLE。浮点数在计算机里是近似存储算账的时候会出现0.1 0.2 0.30000000000000004这种诡异结果虽然用BigDecimal可以在Java层面纠正但数据库层面直接存十进制小数才是最干净的方案。订单表orders字段类型说明idBIGINT UNSIGNED主键order_noVARCHAR(32) UNIQUE业务订单号唯一goods_idBIGINT UNSIGNED商品IDbuyer_idBIGINT UNSIGNED买家IDseller_idBIGINT UNSIGNED卖家IDamountDECIMAL(10,2)成交金额statusTINYINT1待付款 2待发货 3待收货 4已完成 5已取消created_atDATETIME下单时间paid_atDATETIME支付时间completed_atDATETIME完成时间订单号order_no我采用时间戳加随机数生成yyyyMMddHHmmss 6位随机数业务量不大时碰撞概率可忽略。后来优化加了一个Redis自增序号保证高并发下也不会重复不过当前这套源码里用的还是纯Java生成方式原因后面讲Redis部署的时候会说。2.2 索引设计与数据一致性约束索引设计遵循最左前缀法则。商品列表页最常见的查询是WHERE category_id ? AND status 1 ORDER BY created_at DESC所以建了一个联合索引(category_id, status, created_at)三个字段按等值、等值、排序的顺序排列查询效率最好的情况下只用到一个索引扫描。订单表查询场景通常是我的买入列表和我的卖出列表分别在buyer_id和seller_id上建单列索引。不要建(status, buyer_id)这样的联合索引因为买家ID限制性强用它过滤完结果集已经很小再加状态排序只在索引上扫一遍再回表取数据即可。唯一约束方面order_no设置了唯一索引。这就意味着创建订单时如果重复数据库会直接报Duplicate Entry错误业务层捕获到后重新生成订单号即可。这种数据库兜底 业务补偿的方式比纯靠代码判断可靠得多。关于商品库存容易踩一个认识误区二手商品没有库存概念。goods表里加一个sold_flag标记位而不用stock字段。下单时执行UPDATE goods SET status 3 WHERE id ? AND status 1如果影响行数为0说明商品已经被别人拍走或已下架接口直接返回商品已失效。这条更新语句是乐观锁的经典用法。它的好处是原子操作不需要在应用层加锁也不需要悲观锁把整张表锁住。在高并发场景下这条SQL会在数据库引擎层面做行级锁判断不会出现超卖——或者说不会出现两个买家同时买到同一件二手商品。3. SpringBoot后端分层与登录鉴权实践3.1 项目包结构的分层逻辑后端工程我按标准的Controller-Service-Mapper三层分包同时增加config、common、dto、entity、vo几层。完整的结构看起来是这样的com.bootpf ├── controller # 接口层只做参数接收和结果封装 ├── service # 业务层事务、权限、核心业务逻辑 ├── mapper # MyBatis接口层对应XML映射 ├── entity # 数据库实体类 ├── vo # 视图对象向前端输出 ├── dto # 数据传输对象接收前端参数 ├── config # 配置类如WebMvcConfig、CorsConfig ├── common # 统一返回结果、异常处理、常量 └── interceptor # 拦截器如JWT拦截器在这个结构里我的经验是Controller层一直是所有接口的总入口Service层的核心价值是事务边界管理和业务校验。比如下单逻辑Service层的某个方法会标注Transactional保证商品状态变更和订单创建同时成功或同时失败。这里的逻辑一般不会放在Controller或Mapper里这是三层分层的核心约束。代码生成方面我用了MyBatis Generator依赖进行初步实体类生成从数据库表反转出实体、Mapper接口、XML映射。生成后的代码适合作为脚手架复杂的查询SQL根据业务手写扩展不建议完全依赖自动生成毕竟它只能生成单表简单CRUD多表连表、动态查询仍然需要自己动手加工。3.2 JWT无状态登录的完整链路登录模块采用JWT实现无状态鉴权这套流程在很多SpringBoot面试题中也是高频考点原理其实不难但完整链路要理清。用户提交用户名密码后后端做三件事查库、比对、发token。密码比对用BCryptPasswordEncoder.matches()方法完成它能把未加密的密码和数据库里的BCrypt哈希对比返回布尔值。校验通过后生成JWT包含用户ID和过期时间用HMAC密钥签名之后返回给前端。String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();前端拿到token后存在localStorage或pinia仓库里之后每次请求在拦截器里加上Authorization: Bearer token。后端通过拦截器解析token拿到当前用户ID放入ThreadLocal或request attribute中业务层就能随时取到谁在操作。这个机制避免了传统Session的服务器内存占用和跨域Session共享问题。但要注意一个细节JWT一旦签发在过期之前无法主动失效。所以用户改密码或后台封禁账号后旧token在短时间内还能访问受保护接口。解决方案有两种引入Redis黑名单或者把JWT过期时间设为较短如2小时配合refreshToken刷新的策略。当前源码里用的是7天固定有效期对校园内部小规模使用够用商用请参考短期token加刷新token方案。拦截器需要注意放行路径配置。登录、注册、商品列表、商品详情这些公共接口不能拦。我一开始把所有接口都拦了导致前端页面完全打不开调试的时候才发现登录页面都进不去那还登录个什么……registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/goods/list, /api/goods/detail/**);3.3 商品发布上传与静态资源映射商品发布的前端逻辑是先调图片上传接口拿到图片URL后跟其他字段一并提交到创建商品接口。这里我特意拆成两个接口而不是在创建商品时用multipart/form-data混传因为图片上传本身可能耗时较长拆开之后可以实现上传图片即时预览、商品信息最后提交的交互效果。图片上传的核心代码如下把MultipartFile保存到本地的upload/目录生成UUID文件名防止冲突同时返回可访问的URL路径String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID().toString().replace(-, ) ext; File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(uploadDir / filename)); return /upload/ filename;文件存储我用的是本地磁盘而非OSS对象存储。校园内部项目追求简单可行本地目录配合Nginx静态映射完全够用。但有几个前置条件必须做校验文件大小不能超过5MB限制文件类型只能是jpg/png/webp等常见图片格式重命名文件。我见过直接把用户上传的原文件名存库的后来发现两个不同目录下同名文件互相覆盖图片全丢了损失惨重。静态资源映射在SpringBoot里也需要配置。如果你开发期运行后端访问localhost:8080/upload/xxx.jpg要能直接显示图片就必须在WebMvcConfigurer里重写addResourceHandlersregistry.addResourceHandler(/upload/**) .addResourceLocation(file: uploadDir /);这里要注意file:开头的路径是绝对路径。如果你用的是相对路径upload/在Windows和Linux下的解析差异巨大Windows下可能会出现盘符组合错误部署到服务器时务必用绝对路径。4. MyBatis使用细节XML映射、动态SQL、插件与缓存4.1 Mapper接口与XML映射文件如何配合MyBatis在项目里最常见的用法是Mapper接口方法对应XML里的SQL。接口定义看起来是Java方法实际执行时通过动态代理找到同名XML命名空间下的语句。这层关系要理清不然会出现接口方法写了但报Statement not found的错误。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.bootpf.mapper.GoodsMapper select idselectGoodsList resultTypemap SELECT id, title, price, cover_image, view_count FROM goods WHERE status 1 if testcategoryId ! null AND category_id #{categoryId} /if ORDER BY created_at DESC /select /mapper在application.yml中需要配置mapper映射文件路径和实体别名包mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.bootpf.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置强烈建议打开。数据库字段created_at自动映射为Java实体里的createdAt省去一大堆ResultMap手写别名。调试阶段把StdOutImpl打开控制台会打印完整SQL和参数排查问题效率翻倍。但上线前记得关掉或换成logback适配否则每个查询都往控制台刷日志影响性能也污染日志文件。4.2 动态SQL处理多条件商品筛选商品列表页的筛选条件包括分类、价格区间、商品状态、关键字搜索。如果为每个条件组合写一个SQL数量会爆炸。MyBatis动态SQL就是解决这个问题的if、where、set、foreach几个标签是最常用的实际使用频率非常高。以商品列表为例select idselectGoodsByCondition resultTypecom.bootpf.vo.GoodsVO SELECT g.id, g.title, g.price, g.cover_image, g.view_count, u.nickname AS seller_name FROM goods g LEFT JOIN users u ON g.user_id u.id where g.status 1 if testcategoryId ! null and categoryId ! 0 AND g.category_id #{categoryId} /if if testminPrice ! null AND g.price gt; #{minPrice} /if if testmaxPrice ! null AND g.price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND (g.title LIKE CONCAT(%, #{keyword}, %) OR g.description LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY g.created_at DESC /select这里的核心点在于gt;和lt;的转义。XML中不能直接写大于号小于号解析XML会报错。要么转义要么用![CDATA[]]包裹。我第一次写直接报TypeMismatch控制台提示元素类型必须后跟属性规范网上搜了一堆才想起来是XML原生规则。where标签很聪明。当内部所有条件都为真时它会自动去掉多余的AND避免生成WHERE AND status 1这种荒谬SQL。如果内部条件全不满足则不拼接WHERE子句。这种容错机制省掉了大量人为判断。4.3 分页插件的使用姿势与total取值坑分页查询我用了PageHelper它通过MyBatis拦截器在SQL执行前自动拼接LIMIT子句用法很轻量。关键流程是PageHelper.startPage(pageNum, pageSize); ListGoodsVO list goodsMapper.selectGoodsByCondition(query); PageInfoGoodsVO pageInfo new PageInfo(list);PageHelper.startPage(pageNum, pageSize)后面跟着的第一条查询语句会被拦截并进行分页再后面的查询就不受影响了。注意这个静态方法的调用位置和线程模型pageNum和pageSize存在ThreadLocal里执行完第一条SQL后会被自动清除。我踩过一个大坑在startPage和查询之间多了一句goodsMapper.countByExample(query)结果PageHelper把countByExample也分页了count结果直接变成1行前端分页组件总页数显示异常。后来翻源码才发现PageHelper拦截的是下一次查询只要中间穿插任何一个Mapper查询都会导致分页失效或分页错乱。解决办法是确保startPage紧贴目标查询如果确实需要再查一次数量写在startPage之前或使用PageInfo获取total。再有一个容易忽略的点PageInfo.getTotal()在分页插件里是完整的总记录数不是当前页记录数。很多人误写为list.size()导致前端只显示当前页条数分页组件怎么点都只剩一页。这是分页接口最常见的Bug之一。4.4 一级缓存和二级缓存的实际表现MyBatis缓存机制也是面试题常客放在项目里必须知道它的真实行为。一级缓存是SqlSession级别的本地缓存。同一个SqlSession中执行两次完全相同的SQL第二次直接走缓存不查数据库。Spring整合MyBatis时SqlSession由SqlSessionTemplate管理每次mapper方法都会开启关闭SqlSession所以一级缓存对单条Mapper调用几乎不起作用。也就是说你在SpringBoot项目里别指望用一级缓存做性能优化。二级缓存是namespace级别的缓存跨SqlSession共享。可以在Mapper XML上加cache/开启。但它默认对多表关联查询不友好如果A表修改只清理A对应namespace的缓存关联表B的缓存还存在脏数据。我实际项目中基本不依赖二级缓存一般用Redis做应用层缓存可控性强也不容易出数据一致性问题。如果非要开二级缓存查询可返回类型的实体必须实现Serializable接口否则缓存序列化时会报NotSerializableException。这个错误在运行期很隐蔽数据量小的时候没事一查量上来就崩。再次提醒项目早期能把缓存架构想清楚后面能省很多事。5. Vue3前端工程化Vite搭建、状态管理与接口封装5.1 项目初始化与目录结构设计前端我用Vite创建了Vue3项目整体依赖是Vue3.4 Vue Router 4 Pinia Element Plus Axios。命令很简单npm create vitelatest bootpf-frontend -- --template vue创建完成后安装依赖。Element Plus对表单处理、表格展示、分页组件提供了现成方案开发管理后台效率极高。这里我需要明确建议一点Element Plus组件库体积不小按需引入比全量引入好。使用unplugin自动按需导入组件和样式打包体积至少能降一半。前端标准目录结构如下src ├── api # 接口请求封装每个模块一个文件 ├── assets # 静态资源 ├── components # 通用组件 ├── router # 路由文件 ├── stores # Pinia状态管理 ├── utils # 工具函数 ├── views # 页面视图 │ ├── Home.vue │ ├── GoodsList.vue │ ├── GoodsDetail.vue │ ├── PublishGoods.vue │ ├── Login.vue │ └── OrderList.vue └── App.vue5.2 Axios封装与请求拦截器Axios封装是所有前后端分离项目的地基。我在utils/request.js里创建了一个带拦截器的实例import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器注入token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request这个封装的优点是所有页面不用关心token怎么加、错误弹窗怎么弹、401如何处理。单独页面里调接口只需要关心功能代码。接口模块再按领域拆分成api/goods.js、api/order.js、api/user.js按需导入即可。这里需要注意前后端返回结构的约定统一。后端统一返回code/message/data前端拦截器通过code判断业务状态而不是HTTP状态码。前后端开发和联调时应签署同样的契约避免出现各干各的没法对接的问题。5.3 Pinia状态管理与关键页面设计Pinia作为Vue3官方推荐的状态管理方案对比Vuex的优势是TS支持更好、去掉了mutations概念、写法更简洁。本项目用它管理用户登录状态import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || null) }), getters: { isLoggedIn: (state) !!state.token, userId: (state) state.userInfo?.id || null }, actions: { setLoginData(token, userInfo) { this.token token this.userInfo userInfo localStorage.setItem(token, token) localStorage.setItem(userInfo, JSON.stringify(userInfo)) }, logout() { this.token this.userInfo null localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })个人认为token和用户信息同步存到localStorage和Pinia是折中的方案。Pinia保证组件内响应式刷新localStorage保证页面刷新后信息不丢失。只存Pinia的话一刷新页面登录态就没了需要重新登录麻烦得很。商品列表页引入了一个通用分页组件。每当页码或筛选条件变化时调用商品列表接口获取数据。这里有个细节搜索条件变化时必须把页码重置为1否则用户在第5页搜索关键词时显示的是空列表而且页码还停留在5体验极差。前端写个watch监听筛选条件变化时先page.current 1再发请求就能避免此坑。5.4 图片上传与多图预览的实现方式发布商品页的多图上传组件Element Plus的el-upload组件在http-request属性里可以自定义上传请求不走组件的默认行为。我把它指向自己封装的upload接口拿到返回的URL后存入表单的imageList数组。组件内部还要处理预览使用URL.createObjectURL(file)生成临时访问地址这样图片会在上传前立刻显示体验更顺滑。上传组件在on-success回调里取后端的URL并push进列表。这里注意响应拦截器已经把response.data返回了所以拿到的是后端data字段——也就是upload接口返回的{ url: /upload/xxx.jpg }对象不是整个响应体。这个对应关系联调时容易迷路建议前后端对接时先在浏览器Network面板确认响应结构。6. 前后端联调中的实际踩坑与解决方案6.1 跨域配置与Vite代理前后端分离后跨域问题是绕不开的。开发环境下Vite利用代理解决// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }通过Vite代理把/api前缀请求转发到后端。这样浏览器看到的请求是同源的跨域问题自然规避。但是生产环境需要nginx反向代理配置否则打包后的dist文件请求仍然会404。nginx配置示例server { listen 80; server_name your_domain; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; } location /upload/ { alias /home/deploy/upload/; } }后端也要做CORS配置兜底。虽然开发环境有Vite代理但某些工具直连后端调试时仍需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); } }注意一个细节allowCredentials(true)和allowedOriginPatterns(*)必须同时配置而allowedOrigins(*)会与allowCredentials(true)冲突被客户端浏览器拒绝这是SpringBoot低版本的一个大坑。在此涉及Credentials即cookie/token不要省略。6.2 前后端字段命名冲突问题后端字段命名是snake_case前端JavaScript习惯驼峰命名。如果没有映射前端拿到的字段就是created_at这种下划线命名在模板里手动映射很容易出错。我在后端VO对象里统一使用camelCase命名配map-underscore-to-camel-case: true后端返回给前端的JSON字段自动变成createdAt。前端直接用驼峰属性即可两端节奏对不上时优先以接口文档为准不要各改各的。养成随手维护接口文档的习惯能减少至少一半联调时间。6.3 日期格式序列化与显示不一致另一个高频坑是日期字段。后端LocalDateTime默认序列化成数组或带T的ISO格式前端显示出来要么是一串看着无感的数字要么带T隔开不友好。解决方式是在后端实体类上统一加日期格式化注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createdAt;加上以后前端展示就正常了。如果接口需要返回时间戳则可以用Long类型或JsonSerialize(using ToStringSerializer.class)根据场景灵活切换。统一时间字段处理策略后前端展示层就不用做各种奇怪的字符串切割操作了。6.4 数据权限与越权操作普通用户登录后只能操作自己的数据。后端接口要校验资源归属。例如用户A删除商品接口如果只判断商品存在而不判断商品属于A那A就能删除用户B的发布这是致命漏洞。下单接口同理。订单创建时要检查商品状态、校验买家不是卖家本人。这些校验逻辑放在Service层而不仅是前端隐藏按钮——前端控制只是体验后端校验才是安全底线。写这类接口时我建议你习惯性地问自己如果请求绕过前端直接打到这个接口数据会不会被破坏如果会就得在后端补上校验。7. 部署打包、系统优化与下一步扩展方向7.1 Maven打包与SpringBoot Jar运行后端打包用Maven默认生命周期打包即可生成可执行Jar。我用Spring Initializr初始化工程时自带的Maven配置没有特殊调整。如果想简化打包命令用以下命令即可mvn clean package -DskipTests java -jar bootpf-server.jar --server.port8080需要注意application.yml中的配置项在部署机上可能要覆写比如数据库地址、上传目录绝对路径。使用启动参数--spring.profiles.activeprod指定生产环境配置文件比每次改配置强得多。数据库密码不要写进代码仓库用环境变量或配置中心管理。数据库初始化用的backend/sql/init.sql里面包含建库、建表、初始分类数据和测试用户数据。部署前执行一次即可测试用户密码统一用BCrypt加密过的哈希值不要明文。如果将来容器化部署可以在Dockerfile中用entrypoint脚本自动执行SQL初始化我这里暂不展开。7.2 前端打包与Nginx部署前端打包命令npm run build生成dist目录拷贝到nginx的html目录即可。打包后一个访客首次加载页面比较慢时需要对静态资源做gzip压缩和缓存配置。nginx里加一段gzip on; gzip_types text/css application/javascript application/json image/svgxml; location /assets/ { expires 30d; add_header Cache-Control public, immutable; }7.3 业务扩展从单体到高可用的演进路径这套系统在功能完整度上已经能支撑小范围商用但距离生产级还有一定距离。如果后续计划上线运营我的建议是走这样一条演进路径引入Redis缓存热点商品详情、家庭首页推荐位降低MySQL压力。增加ElasticSearch实现全文搜索替换LIKE模糊查询。对接真实支付渠道替换模拟支付流程。引入消息队列如RabbitMQ处理下单后的异步任务。图片存储迁移到云OSS配合CDN加速访问。微服务化拆分为用户中心、商品中心、订单中心但要等业务规模确实需要时再动手过早拆服务反而增加维护成本。搜索这块多说一句MyBatis的LIKE查询在数据量超过几十万后性能会明显下降索引也帮不上忙除非用全文索引。ElasticSearch不只是高级它直接改变了搜索的体验和数据库的性能压力。如果用户搜索需求很强建议尽早规划。写在最后的几句经验整套系统从0到1跑通我最深刻的体会是技术栈只是工具真正决定项目成败的是业务约束能不能被条理清晰地转化为代码结构。二手交易里一物一单的状态机、乐观锁更新、JWT鉴权、动态SQL分页这些点单独拿出来都是常规操作但组合在一起就是个完整项目也确实能涵盖Java后端及前端的大量高频面试考点。另外部署方面再分享一个小技巧。上传目录、日志目录、配置文件这类东西一定在项目里统一管理用相对路径开发时很爽部署到服务器就容易炸。平时开发时尽量用绝对路径写死或者通过配置项注入别在代码里拼接路径。这些依赖你上线时才发现就晚了人在紧急上线时会更慌乱。这个源码仓库我会持续维护包括接口文档、环境部署脚本、常见问题排查都会慢慢更新。有需要的话从源码的README.md开始看里面录制了一套从环境准备到部署上线的全程操作说明。希望这套代码和这篇复盘能让正在做类似项目的朋友少熬几个大夜。
阅读完成 · 觉得有帮助?
咨询建站