滑板圈子里有个很实在的现象:装备的流通速度比大多数运动器材都快。原因不复杂——动作练到一定程度,板面磨穿了要换,桥和轮子的损耗程度不一样要拆开来出,新手入坑又想先收一套成色好的练手,二手市场就这么被需求撑起来了。我一直在想,与其去闲鱼上和一堆通用商品混在一起,不如干脆做一个专门面向滑板装备的交易系统,把“品牌、板面宽度、桥距、轮子硬度、成色等级”这类滑板特有的属性结构化,交易流程也能做得更贴合圈子习惯。于是就有了这个用 SpringBoot + Vue 搭建的“二手滑板交易系统”项目。
这套系统前端是 Vue + Element UI,后端是 SpringBoot 2.x + MyBatis-Plus + MySQL,Redis 做缓存和会话管理,覆盖了商品发布、商品浏览与筛选、购物车、订单交易、支付模拟、个人中心、后台管理这些电商核心链路。如果你正准备做 Java 后端方向的项目练手,或者毕业设计想选一个“技术栈完整但不夸张”的题目,这个项目可以当一份直接能复现、能跑通、能讲明白的参考。
我把整个项目的设计思路、库表结构、核心模块实现、踩坑实录分几块讲清楚,按这个顺序看下来,你就能知道一张订单从“买家看到滑板”到“卖家确认发货”的完整逻辑,也能明白为什么状态字段要用枚举而不是数字散着写,为什么金额必须用 BigDecimal,为什么前端打包之后能直接塞进 SpringBoot 里。
1. 项目整体设计与思路拆解
1.1 为什么选“二手滑板”这个切入点
做电商类项目,最怕的不是技术难度,而是业务模型太泛。做通用商城,商品的多规格、sku、促销、库存预警、多级分类这些全部堆上来,一个人很难在有限时间内做出深度。二手滑板这个切入点的好处在于:
- 商品属性垂直且明确:滑板有品牌、板面尺寸、支架(桥)型号、轮子硬度、轴承等级,这些字段天然适合结构化建模,而且和“能不能卖出去”直接挂钩。
- 价格体系简单:二手商品不搞预售、不搞满减,核心是“卖家定价 + 买家可议价”,交易链路短,适合聚焦核心流程。
- 供需两侧需求真实:我身边玩板的同事就有“出旧板回血、收二手练招”的习惯,拿这种场景做系统,演示和答辩的时候都能讲得生动。
这样选型让系统在覆盖“商品、订单、用户”三大电商核心链路的同时,不会陷入复杂的营销逻辑,能把每个环节做扎实。
1.2 技术选型背后的理由
后端主框架我用的是 SpringBoot 2.7.x + Java 8,这个组合太成熟了,网上遇到问题基本都有现成答案。ORM 层没有用 JPA 而是选了 MyBatis-Plus,原因很直接:二手商品列表涉及大量条件组合查询(品牌 + 价格区间 + 成色 + 关键词分页),MyBatis-Plus 的 LambdaQueryWrapper 写动态条件非常顺手,而且代码量比写 XML 少得多。数据库用的 MySQL 5.7,Redis 用来做登录会话和热门的浏览计数。前端是 Vue 2 + Element UI,打包之后直接静态资源放进 SpringBoot 的 static 目录,部署时只需要一个 jar 包,省去了单独配 Nginx 的麻烦。
这套方案最大的优势是“可解释性”强。别人问“为什么用 Redis 存登录状态”,你可以从分布式会话、session 共享、token 有效期这些角度展开;问“为什么用 MyBatis-Plus”,你可以从分页插件、条件构造器、字段自动填充这些点去讲。桩都站在常规实践上,不虚不偏。
2. 数据库设计与核心表结构
2.1 用户表:别只存账号密码
用户表 базово包含 id、username、password、phone、avatar,但我在设计时加了两个字段:credit_score(信用分)和 status(账号状态),信用分给后续“卖家权限、交易互评”预留了入口,账号状态则用于后台封禁管理。密码存储这里要提醒一句:明文存储是绝对红线,工程实践最低要求也是 MD5 加盐,我建议直接用 BCrypt,Spring Security 自带的 BCryptPasswordEncoder 就可以了,不用自己造轮子。
用户表的字段规划大致如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 用户ID,雪花ID |
| username | varchar(64) | 唯一用户名,登录账号 |
| password | varchar(128) | BCrypt 加密存储 |
| phone | varchar(20) | 注册手机号,做唯一索引 |
| avatar | varchar(255) | 头像图片地址 |
| credit_score | int | 信用分,默认100 |
| status | tinyint | 1正常,0冻结 |
之所以把 username 和 phone 都设唯一索引,是因为登录和找回密码两条链路都用得到这两个字段,查询频率高,索引是必需的。
2.2 商品表:把滑板的规格字段做规范
商品表是整个系统的核心。除了常规的 title、price、description、images、status、view_count,我专门设计了一组滑板垂直字段:brand(品牌)、board_width(板面宽度)、truck_model(支架型号)、wheel_hardness(轮子硬度)、condition_level(成色等级 9成新/7成新/有明显磨损)、category(整板/板面/桥/轮子配件)。二手交易最在意的就是成色和配置,这两类信息做进结构化字段后,列表页就能直接按条件筛选,比在长文本描述里搜索可靠得多。
商品表核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 商品ID |
| user_id | bigint | 发布者ID,关联用户表 |
| title | varchar(128) | 商品标题 |
| brand | varchar(64) | 滑板品牌 |
| board_width | varchar(32) | 板面宽度(如 8.0/8.125 英寸) |
| truck_model | varchar(64) | 支架型号 |
| wheel_hardness | varchar(32) | 轮子硬度(如 101A) |
| condition_level | tinyint | 成色等级,1-5 |
| price | decimal(10,2) | 售价 |
| status | tinyint | 1在售,2下架,3已售出 |
| images | varchar(2000) | 图片地址,逗号分隔 |
| view_count | int | 浏览数 |
| create_time | datetime | 发布时间 |
status 字段单独拿出来说一下。商品状态从“在售”到“已售出”不是一步到位的,中间要经过“买家下单”这个中间状态,对应我后面会讲的“锁定库存”逻辑。数据库里的状态值只存数字,业务含义靠枚举类去映射,这是为了杜绝魔法数字散落各处。
2.3 订单与购物车:交易链路的关键载体
订单表的设计要点是“冗余”。买家、卖家、商品快照这三者在订单里都必须有,买家可以用 user_id + buyer_id 区分,seller_id 是发布者冗余进去的,因为同一个商品的买卖双方都是平台注册用户。我额外加了 goods_snapshot(下单时的商品快照,JSON 字符串),这是二手交易项目的重点——滑板可能被卖家改过价格或下架,但买家下单那一刻看到的配置和价格需要定格下来,否则产生纠纷时没有依据。
购物车表比较常规:user_id、goods_id、create_time,加一个唯一索引 (user_id, goods_id) 防止重复加车。订单表结构简化如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 订单ID |
| order_no | varchar(64) | 订单号,唯一索引 |
| buyer_id | bigint | 买家ID |
| seller_id | bigint | 卖家ID |
| goods_id | bigint | 商品ID |
| goods_snapshot | text | 下单时的商品快照,JSON |
| amount | decimal(10,2) | 实付金额 |
| status | tinyint | 订单状态机,见下节 |
| pay_type | tinyint | 1微信,2支付宝,3模拟支付 |
| receiver_name / receiver_phone / receiver_address | varchar | 收货信息 |
| create_time | datetime | 下单时间 |
订单状态我用了 0待支付、1待发货、2待收货、3已完成、4已取消、5售后申诉,状态流转严格按照“待支付→待发货→待收货→已完成”的顺序,任意跳转都要做合法性校验。
3. 后端核心模块实现
3.1 登录注册与全局会话
会话这块我用了 Redis + Token 模式。用户登录成功后生成 UUID token,以 token 为 key、用户 ID 为 value 存入 Redis,并设置 7 天过期时间。前端后续请求在 Header 里带 Authorization,后端通过拦截器统一解析 token,从 Redis 拿到用户信息后放入 ThreadLocal,方便业务代码随时取当前登录用户。
这样做而不是直接用 HttpSession,核心原因是前后端分离后,Session 跨域处理比较麻烦(要配 CORS 的 allowCredentials,还要保证前端 withCredentials 对齐),而 Token 模式天然跨域无状态,后端只有 Redis 需要保持会话数据,扩展出水平部署节点时也不会断登录。
登录接口的典型实现,核心代码如下:
@PostMapping("/api/auth/login") public Result login(@RequestBody LoginDTO dto) { User user = userService.findByUsername(dto.getUsername()); if (user == null || !BCryptPasswordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("token:" + token, user.getId().toString(), 7, TimeUnit.DAYS); return Result.success(token); }拦截器部分要注意放行路径。登录接口、注册接口、商品列表页和商品详情页是公开的,下单、购物车、个人中心全部要拦截。我会用一个 PathPattern 的 exclude 列表管理放行路径,新增接口时最常漏的就是忘记加白名单导致线上 401 排查半天。
3.2 商品发布与图片上传的实操细节
商品发布是卖家操作的第一步,流程包含:填写滑板规格信息、上传图片、设置价格和成色、提交入库。后端校验的核心有两点:一是 title 不能为空、价格必须在 0.01 到 99999 之间;二是图片数量限制在 5 张以内、单张不能超过 5MB。
图片上传这一块,我推荐直接走本地上传 + 虚拟静态映射。在 application.yml 里配置 upload.path,配合一个 WebMvcConfigurer 把本地目录映射为 /images/** 的访问路径:
upload: path: /data/skate-market/images/@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceHandler("file:" + uploadPath + "/"); }这里有一个必须要处理的坑:上传的图片要按日期分目录存储,文件名不能保留用户原始文件名,否则可能出现中文乱码、重名覆盖、非法字符穿透目录这些风险。我统一用 UUID 重命名,目录结构形如 /20240312/uuid.jpg,既便于排查又避免冲突。如果需要生产级方案,直接把存储抽象成 OSS 或 MinIO,流程不变,只替换存储实现即可。
3.3 商品列表查询与筛选:MyBatis-Plus 条件构造的实战
二手滑板列表页的筛选条件很典型:品牌、价格区间、成色、搜索关键词、排序(最新/价格/浏览量)。用 MyBatis-Plus 的 LambdaQueryWrapper 实现起来非常清爽:
Page<Goods> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(brand), Goods::getBrand, brand) .eq(conditionLevel != null, Goods::getConditionLevel, conditionLevel) .between(minPrice != null && maxPrice != null, Goods::getPrice, minPrice, maxPrice) .and(StringUtils.hasText(keyword), w -> w.like(Goods::getTitle, keyword).or().like(Goods::getDescription, keyword)) .eq(Goods::getStatus, GoodsStatus.ON_SALE.getCode()) .orderByDesc(sort == 1, Goods::getPrice) .orderByDesc(sort == 2, Goods::getViewCount); return goodsMapper.selectPage(page, wrapper);这套写法的优势是“条件与参数解耦”:某个筛选项不传就是 null,wrapper 的 condition 参数自动忽略这一条件,不会拼出错误的 SQL。综合排序我默认按 create_time 倒序。注意“最新发布”本身也要建立在 status=在售 的基础上,不能把已下架的商品展出来。
3.4 购物车下单与订单状态机
购物车功能是常规的增删改查,但“从购物车创建订单”这一步是整个系统的核心事务场景。流程是:勾选购物车商品 → 后端校验商品状态 → 冻结商品(status 变更为“锁定中”)→ 生成待支付订单 → 支付成功后卖家发货。
这里事务设计最容易翻车。如果只锁商品、生成订单分两步执行且不在同一事务中,用户支付超时取消订单后商品状态恢复就容易出现并发问题。我的做法是:在同一个事务方法里完成“创建订单 + 更新商品状态”,商品状态采用乐观锁版本号控制:
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long goodsId, Long buyerId) { Goods goods = goodsMapper.selectByIdForUpdate(goodsId); if (goods == null || goods.getStatus() != GoodsStatus.ON_SALE.getCode()) { throw new BizException("商品不存在或已下架"); } // 生成订单号: 时间戳 + 用户ID + 随机数 String orderNo = generateOrderNo(buyerId); Order order = buildOrder(goods, buyerId, orderNo); orderMapper.insert(order); // 锁定商品 goods.setStatus(GoodsStatus.LOCKED.getCode()); goodsMapper.updateById(goods); return order; }selectByIdForUpdate 用数据库行锁防并发下单,两个买家同时点“立即购买”时只有一个能成功,这是这个流程里我踩过坑后加上的关键一行。如果是纯代码层面的 synchronize 或者分布式锁,在这个单体项目里都能解决问题,但行锁是最贴合数据库语义、最容易讲清楚的一种。
支付流程我用的是模拟支付:买家点“支付”后,后端模拟第三方支付平台回调,直接更新订单状态为“待发货”,同时生成一笔流水记录。接真实支付不需要改表结构,只需在 pay 接口换成调用微信/支付宝下单 API,再写一个回调接收方法把订单状态推进即可。
3.5 后台管理:商品审核与会员管理
后台管理端我给管理员配置了几组核心操作:商品列表审核(批量下架违规商品)、用户列表(冻结账号)、订单总览、数据看板。因为项目不大,后台不单独做权限框架,用 role=1 的字段标记管理员即可。但要注意一点:后台所有写操作必须校验当前用户角色,不能仅靠前端隐藏入口,接口层也要拦截。用自定义注解 @RequireAdmin + 拦截器实现,逻辑很直接。
数据看板部分我做了三个统计:今日新增用户、在售商品总数、今日订单金额,用就是 SUM 和 COUNT 的 SQL,配合 ECharts 画简单折线图。
4. 前端页面与工程化部署
4.1 页面清单与路由设计
前端页面我按用户链路拆成了六个核心页面:
- 首页:商品流推荐,展示在售滑板
- 商品详情页:滑板规格、成色、卖家信息、立即购买
- 发布商品页:滑板信息表单 + 图片上传
- 购物车页:购物车商品数量与下单
- 订单列表页:买家订单、卖家订单分 Tab 展示
- 个人中心页:个人信息、地址管理、我发布的商品
路由设计遵循“懒加载”原则,每个页面单独走 vue-router 的 component: () => import(...),首次加载体积会明显小。axios 封装了 request 拦截器,统一从 localStorage 取 token 注入 Header,响应拦截器统一处理 401 跳转登录。
4.2 Vue 打包后塞进 SpringBoot:一条命令搞定部署
开发调试时前后端分离跑两个端口,需要配前端代理 /api 到后端 localhost:8080。但生产环境更省事的方式是让前端把路由刷成 history 模式,然后执行 npm run build,生成 dist 目录后把里面的 static 和 index.html 复制到 SpringBoot 的 src/main/resources/static 下。重新打包 jar,一个进程跑起整站,前端路由刷新 404 的问题也不会出现。
如果只是复制文件,SpringBoot 默认的静态资源处理就能覆盖掉。要注意的地方是 Vite 或 Webpack 配置的 publicPath 必须改成相对路径 "./",否则打包后的 JS/CSS 路径是绝对路径,部署到子路径时全部 404。
前端部署的关键命令汇总:
npm run build cp -r dist/* src/main/resources/static/ mvn clean package -DskipTests java -jar target/skate-market.jar4.3 跨域与接口调试的经验
开发环境跨域,前端代理就能解决,不需要后端开启全局 CORS。如果你确实要在后端配 CORS,注意两点:allowedOriginPatterns 不能配 "*" 之后再 allowCredentials(true),这会直接报错;而且不要把 CorsFilter 注册在拦截器之前,否则带 Authorization 的请求会被拦截器拦截成 401,排查起来非常隐蔽。
我用 Chrome DevTools 的 Network 面板调试时,习惯把 jwt token 手动填到 Authorization Header 里测试受保护接口,这样能快速定位是后端逻辑问题还是前端没带 token 的问题。SpringBoot 项目里配置了统一的异常处理 @RestControllerAdvice 之后,接口返回的错误信息就能做到规范统一,前端直接弹 Message 提示即可。
5. 常见问题与排查技巧实录
5.1 BigDecimal 与精度问题
金额计算是电商项目的第一位的坑。如果金额字段用 double 存储,计算折扣、分摊、统计时会出现 0.1 + 0.2 != 0.3 这种经典问题。我在项目里所有涉及金额的字段都用 decimal(10,2),Java 侧用 BigDecimal,枚举里写金额常量也用 BigDecimal.valueOf。价格比较时一律用 compareTo 而不是 equals,因为 equals 会连小数位一起比较。这些细节在代码评审里是高频扣分点,但很多人直到线上出账不平才回过头来改,代价就很大。
5.2 事务失效的三种隐性场景
事务这块我有过三次教训,列出来给你提个醒:
- 同一个类内部方法调用,比如 saveOrder 调用 sendMessage,sendMessage 加 @Transactional 并不会生效,因为 Spring AOP 代理默认不拦截自身调用。解决方法是分离 Service 或者注入自身代理。
- 事务方法内部 try-catch 吞掉了异常,事务不会回滚。rollbackFor 默认只能捕获 RuntimeException 和 Error,如果你 catch 了所有异常却没手动回滚,事务就悄悄提交了危险数据。
- 表引擎不是 InnoDB,事务直接失效。MySQL 5.7 默认是 InnoDB 不用管,如果碰到 MyISAM 的表,要注意。
5.3 商品状态不同步问题
最初版本我没有“锁定中”这个状态。两个买家同时下单时,A 买家创建了订单,B 买家还能继续下单,最后两个订单指向同一件滑板,卖家根本没有能力履约。加上“锁定中”状态之后,状态流转就清晰了:在售(1)→ 锁定中(2)→ 已售出(3),订单取消则从 2 回到 1;只有状态为 1 时才能被下单。这个模型是所有交易系统的基本功,花几天时间慢慢吃透,比多写一百个 CRUD 接口有价值。
5.4 SpringBoot 版本与依赖冲突速查
我项目里用的 SpringBoot 2.7.x,搭配 MyBatis-Plus 3.5.x。新手最容易踩的是 MyBatis-Plus 版本和 SpringBoot 版本不兼容,导致启动时自动装配报错。通用经验是:SpringBoot 2.x 配 MyBatis-Plus 3.4 以上,SpringBoot 3.x 配 MyBatis-Plus 3.5.4 以上,同时注意 javax.* 和 jakarta.* 包名差异。另一个高频问题是 Lombok 版本过低会报 java.lang.ClassNotFoundException,把 Lombok 升到 IDEA 推荐的当前版本即可。
5.5 图片上传失败的常见原因
图片上传功能最容易出错的实际不是代码,而是三个外部因素:目录没有写权限、请求大小超过 SpringBoot 默认 max-file-size(默认 1MB)、Nginx 或网关配置了 body 大小限制。我的配置是把上传大小放宽到 10MB,指定了合理的 upload.path 并且确保目录存在,同时给 upload 目录设置可写权限。排查时可以看后端日志有没有 MultipartException,有的话基本就是这三个原因。
6. 把这个项目往深做的三个方向
如果你想把项目继续做深、做出差异化,我列三个方向供参考:
- 交易保障和申诉流程:最常见的方向。买家收货后发现“描述与实物不符”时,增加申诉入口和客服介入流程,这会让系统比普通商城更有“平台感”。
- 议价与消息通知:二手交易场景里,“小刀(砍价)”是高频行为。增加订单议价列表和站内信通知,业务闭环更完整。
- 推荐系统简单版:根据用户历史浏览和收藏记录,按品牌、预算做协同过滤或标签匹配,在首页做“你可能感兴趣”,这个方向适合想冲算法实习的同学。
项目做完之后,我个人最大的体会是:做一个二手交易系统,核心难点不在 CRUD,而在于状态机的严谨设计、并发下的数据一致性、以及金额精度这种基础但绝对不能错的基础工程习惯。滑板商城这个垂直场景让这些通用问题有了实在的载体,无论是面试讲项目还是写论文做分析,都会有很清晰的落脚点。希望这份拆解能帮你少走点弯路,也欢迎你在这个框架上继续加自己的创意。