这个项目是我年前帮一位做校园创业的同学落地的二手物品交易平台,前后端从零搭到部署,整个过程踩了不少坑,也沉淀了不少可复用的经验。写这篇东西不是单纯展示源码,而是把架构设计、数据库建模、接口链路和联调细节讲透,让拿到这套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:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT UNSIGNED AUTO_INCREMENT | 主键 |
| username | VARCHAR(50) UNIQUE NOT NULL | 用户名,唯一索引 |
| password | VARCHAR(100) NOT NULL | BCrypt加密后的密码哈希 |
| nickname | VARCHAR(50) | 昵称 |
| avatar | VARCHAR(255) | 头像路径 |
| phone | VARCHAR(20) | 手机号 |
| status | TINYINT DEFAULT 1 | 1表示正常,0封禁 |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | 注册时间 |
密码字段特别注意,长度至少设到100。BCrypt哈希结果是60个字符,加上盐前缀,用VARCHAR(100)留足冗余,别用VARCHAR(32)不然数据会截断。这个坑我一个朋友踩过一次,注册成功但登录永远校验失败,排查了两小时发现是字段长度不够。
商品表goods:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT UNSIGNED | 商品ID |
| user_id | BIGINT UNSIGNED | 发布者ID |
| title | VARCHAR(100) | 商品标题 |
| description | TEXT | 商品描述 |
| price | DECIMAL(10,2) | 价格,注意用DECIMAL不用FLOAT |
| original_price | DECIMAL(10,2) | 原价或购买价 |
| category_id | INT | 分类ID |
| cover_image | VARCHAR(255) | 封面图路径 |
| image_list | TEXT/JSON | 多图路径,逗号分隔或JSON数组 |
| status | TINYINT | 1在售 2下架 3已售出 4待审核 |
| view_count | INT DEFAULT 0 | 浏览量 |
| created_at | DATETIME | 发布时间 |
价格这块我必须多说一句:电商系统的价格字段一律用DECIMAL(10,2),禁止用FLOAT或DOUBLE。浮点数在计算机里是近似存储,算账的时候会出现0.1 + 0.2 = 0.30000000000000004这种诡异结果,虽然用BigDecimal可以在Java层面纠正,但数据库层面直接存十进制小数才是最干净的方案。
订单表orders:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT UNSIGNED | 主键 |
| order_no | VARCHAR(32) UNIQUE | 业务订单号,唯一 |
| goods_id | BIGINT UNSIGNED | 商品ID |
| buyer_id | BIGINT UNSIGNED | 买家ID |
| seller_id | BIGINT UNSIGNED | 卖家ID |
| amount | DECIMAL(10,2) | 成交金额 |
| status | TINYINT | 1待付款 2待发货 3待收货 4已完成 5已取消 |
| created_at | DATETIME | 下单时间 |
| paid_at | DATETIME | 支付时间 |
| completed_at | DATETIME | 完成时间 |
订单号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里重写addResourceHandlers:
registry.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 version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.bootpf.mapper.GoodsMapper"> <select id="selectGoodsList" resultType="map"> SELECT id, title, price, cover_image, view_count FROM goods WHERE status = 1 <if test="categoryId != 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 id="selectGoodsByCondition" resultType="com.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 test="categoryId != null and categoryId != 0"> AND g.category_id = #{categoryId} </if> <if test="minPrice != null"> AND g.price >= #{minPrice} </if> <if test="maxPrice != null"> AND g.price <= #{maxPrice} </if> <if test="keyword != null and keyword != ''"> AND (g.title LIKE CONCAT('%', #{keyword}, '%') OR g.description LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY g.created_at DESC </select>这里的核心点在于>和<的转义。XML中不能直接写大于号小于号,解析XML会报错。要么转义,要么用<![CDATA[>]]>包裹。我第一次写>=直接报TypeMismatch,控制台提示"元素类型必须后跟属性规范",网上搜了一堆才想起来是XML原生规则。
where标签很聪明。当内部所有条件都为真时,它会自动去掉多余的AND,避免生成WHERE AND status = 1这种荒谬SQL。如果内部条件全不满足,则不拼接WHERE子句。这种容错机制省掉了大量人为判断。
4.3 分页插件的使用姿势与total取值坑
分页查询我用了PageHelper,它通过MyBatis拦截器在SQL执行前自动拼接LIMIT子句,用法很轻量。关键流程是:
PageHelper.startPage(pageNum, pageSize); List<GoodsVO> list = goodsMapper.selectGoodsByCondition(query); PageInfo<GoodsVO> 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 vite@latest 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 = "GMT+8") 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.port=8080需要注意application.yml中的配置项在部署机上可能要覆写,比如数据库地址、上传目录绝对路径。使用启动参数--spring.profiles.active=prod指定生产环境配置文件,比每次改配置强得多。数据库密码不要写进代码仓库,用环境变量或配置中心管理。
数据库初始化用的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/svg+xml; 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开始看,里面录制了一套从环境准备到部署上线的全程操作说明。希望这套代码和这篇复盘,能让正在做类似项目的朋友少熬几个大夜。