
滑板圈子里有个很实在的现象装备的流通速度比大多数运动器材都快。原因不复杂——动作练到一定程度板面磨穿了要换桥和轮子的损耗程度不一样要拆开来出新手入坑又想先收一套成色好的练手二手市场就这么被需求撑起来了。我一直在想与其去闲鱼上和一堆通用商品混在一起不如干脆做一个专门面向滑板装备的交易系统把“品牌、板面宽度、桥距、轮子硬度、成色等级”这类滑板特有的属性结构化交易流程也能做得更贴合圈子习惯。于是就有了这个用 SpringBoot Vue 搭建的“二手滑板交易系统”项目。这套系统前端是 Vue Element UI后端是 SpringBoot 2.x MyBatis-Plus MySQLRedis 做缓存和会话管理覆盖了商品发布、商品浏览与筛选、购物车、订单交易、支付模拟、个人中心、后台管理这些电商核心链路。如果你正准备做 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.7Redis 用来做登录会话和热门的浏览计数。前端是 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 加盐我建议直接用 BCryptSpring Security 自带的 BCryptPasswordEncoder 就可以了不用自己造轮子。用户表的字段规划大致如下字段类型说明idbigint用户ID雪花IDusernamevarchar(64)唯一用户名登录账号passwordvarchar(128)BCrypt 加密存储phonevarchar(20)注册手机号做唯一索引avatarvarchar(255)头像图片地址credit_scoreint信用分默认100statustinyint1正常0冻结之所以把 username 和 phone 都设唯一索引是因为登录和找回密码两条链路都用得到这两个字段查询频率高索引是必需的。2.2 商品表把滑板的规格字段做规范商品表是整个系统的核心。除了常规的 title、price、description、images、status、view_count我专门设计了一组滑板垂直字段brand品牌、board_width板面宽度、truck_model支架型号、wheel_hardness轮子硬度、condition_level成色等级 9成新/7成新/有明显磨损、category整板/板面/桥/轮子配件。二手交易最在意的就是成色和配置这两类信息做进结构化字段后列表页就能直接按条件筛选比在长文本描述里搜索可靠得多。商品表核心字段字段类型说明idbigint商品IDuser_idbigint发布者ID关联用户表titlevarchar(128)商品标题brandvarchar(64)滑板品牌board_widthvarchar(32)板面宽度如 8.0/8.125 英寸truck_modelvarchar(64)支架型号wheel_hardnessvarchar(32)轮子硬度如 101Acondition_leveltinyint成色等级1-5pricedecimal(10,2)售价statustinyint1在售2下架3已售出imagesvarchar(2000)图片地址逗号分隔view_countint浏览数create_timedatetime发布时间status 字段单独拿出来说一下。商品状态从“在售”到“已售出”不是一步到位的中间要经过“买家下单”这个中间状态对应我后面会讲的“锁定库存”逻辑。数据库里的状态值只存数字业务含义靠枚举类去映射这是为了杜绝魔法数字散落各处。2.3 订单与购物车交易链路的关键载体订单表的设计要点是“冗余”。买家、卖家、商品快照这三者在订单里都必须有买家可以用 user_id buyer_id 区分seller_id 是发布者冗余进去的因为同一个商品的买卖双方都是平台注册用户。我额外加了 goods_snapshot下单时的商品快照JSON 字符串这是二手交易项目的重点——滑板可能被卖家改过价格或下架但买家下单那一刻看到的配置和价格需要定格下来否则产生纠纷时没有依据。购物车表比较常规user_id、goods_id、create_time加一个唯一索引 (user_id, goods_id) 防止重复加车。订单表结构简化如下字段类型说明idbigint订单IDorder_novarchar(64)订单号唯一索引buyer_idbigint买家IDseller_idbigint卖家IDgoods_idbigint商品IDgoods_snapshottext下单时的商品快照JSONamountdecimal(10,2)实付金额statustinyint订单状态机见下节pay_typetinyint1微信2支付宝3模拟支付receiver_name / receiver_phone / receiver_addressvarchar收货信息create_timedatetime下单时间订单状态我用了 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 实现起来非常清爽PageGoods page new Page(pageNum, pageSize); LambdaQueryWrapperGoods 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);这套写法的优势是“条件与参数解耦”某个筛选项不传就是 nullwrapper 的 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 后台管理商品审核与会员管理后台管理端我给管理员配置了几组核心操作商品列表审核批量下架违规商品、用户列表冻结账号、订单总览、数据看板。因为项目不大后台不单独做权限框架用 role1 的字段标记管理员即可。但要注意一点后台所有写操作必须校验当前用户角色不能仅靠前端隐藏入口接口层也要拦截。用自定义注解 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 调用 sendMessagesendMessage 加 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而在于状态机的严谨设计、并发下的数据一致性、以及金额精度这种基础但绝对不能错的基础工程习惯。滑板商城这个垂直场景让这些通用问题有了实在的载体无论是面试讲项目还是写论文做分析都会有很清晰的落脚点。希望这份拆解能帮你少走点弯路也欢迎你在这个框架上继续加自己的创意。