☰
Spring Boot整合MyBatis实现C2C追星商城:从订单状态机到防超卖实战
2026/10/15 22:46:23 网站建设 项目流程

做过这类系统的人应该都有同感:第一次看到“追星商城”这个题目,很容易觉得它就是一个普普通通的二手交易网站,把商品上架、购物车、下单、支付这些流程照搬过来就完事了。但真正动手设计之后才发现,粉丝周边转卖和普通闲置交易完全是两回事——交易的信任模型、商品的非标程度、订单状态流转的粒度、以及各种防刷防骗的细节,都会逼着你重新思考整张表结构和接口设计。

这篇文章我打算从需求分析开始,把技术选型、核心表设计、交易链路实现、安全风控,以及我在开发中踩过的一些比较深的坑,完整梳理一遍。项目代号就叫“追星商城”,技术路线是Spring Boot整合MyBatis的经典服务端渲染结构。无论是正在做类似项目的同学,还是想自己练手写一个完整的C2C交易平台,这篇文章应该都能给你一份可以照着落地的设计思路。

1. 追星周边转卖,和普通二手电商差在哪

1.1 目标人群与真实交易习惯

追星商城的用户群体非常聚焦:核心是粉丝,其次是以粉丝为主体的代购、后援会成员、以及少量专门倒卖周边的个人卖家。这些人买卖的商品也很有特点——专辑、小卡、特典卡、应援棒、签名周边、演唱会物料,都是特定偶像或者组合的官方周边。

这批用户的实际交易习惯,和普通二手用户不太一样。很多人是在粉丝群里看到卖家发图,加好友看细节图,确认瑕疵,然后通过交易平台跳转付款。日常交流里经常出现“带拍”“对光”“瑕不掩瑜”“再贩”这类圈内黑话。作为平台设计者,你不需要掌握这些黑话,但你必须理解一个事实:这个场景下的交易决策高度依赖图片细节和信任背书,而不是简单的参数对比和价格排序。

所以商城的商品发布页,图片的作用比普通二手平台更重。我在设计商品表的时候,特意要求图片至少支持五张,封面图单独一个字段,详情页轮播图走图床。标题允许写得很长,但前几个词会自动截取作为列表展示。这类小细节对用户实际体验的提升,远大于多做一个花哨的推荐算法。

1.2 平台必须解决的核心问题

这类平台真正需要覆盖的,不只是“发布商品→浏览→下单→支付”这条明线,还有一条更重要的暗线:信任担保。

普通电商平台是平台对消费者,买家天然信任“官方自营”。而C2C转卖平台是两个陌生人之间的交易,买家担心买到盗版、卖家担心买家到手刀、双方都担心对方跑路。所以平台的价值不在于流量分发,而在于给交易兜底。

我之前梳理需求时,把整个系统拆成了四个核心角色:买家、卖家、平台运营者、系统管理员。买家关心的是找到靠谱商品、确认周边真伪、付款后能收到货;卖家关心的是商品曝光、交易不被白嫖、款项能安全到账;平台运营者关心的是商品内容合规、交易纠纷处理;管理员则是做用户管理和数据统计。

围绕这四个角色,交易主流程可以概括为:发布→发现→担保下单→支付→发货→验货→确认→售后。和普通B2C相比,少了复杂的SKU规格和购物车逻辑,但多了商品审核、申诉仲裁、物流凭证、自动确认收货这类信任维度的设计。这就是为什么我说,拿一个现成的B2C商城代码来改追星商城,很可能改到一半就发现处处别扭,因为底层的业务模型就不匹配。

2. 技术选型:SSM和Spring Boot到底怎么搭

2.1 不要把SSM和Spring Boot对立起来

标题里同时出现了“SSM”和“springboot”,有人会困惑:这到底是两个方案还是两个框架叠着用?我明确说一下:Spring Boot本身并不是一个跟SSM并列的框架,它可以整合Spring MVC、MyBatis等组件。用Spring Boot整合Spring + SpringMVC + MyBatis,搭建出来的工程结构,本质上就是一套“Boot化的SSM”。

所以“SSM springboot”不是二选一,而是怎么组合的问题。我的建议是直接以Spring Boot为底座,MyBatis做持久层,SpringMVC通过Spring Boot的Web模块来托管,不单独写一堆XML配置。Spring Boot 2.5 + mybatis-spring-boot-starter + Thymeleaf + MySQL + Druid连接池,这套组合用来做中小型交易系统非常成熟,开发效率比传统SSM高很多,而且遇到问题搜起来资料也最多。

为什么不用前后端分离?两个原因。第一,这类项目的部署和验收往往希望“开箱即用”,服务端渲染只要一个打包后的jar包,内置Tomcat直接跑,不需要额外部署前端静态资源和Nginx;第二,交易系统的核心复杂度在服务端的状态流转和数据处理,前端用Thymeleaf加少量jQuery已经够用,强行上Vue/React反而把工程复杂度往上抬了一个台阶,对单人开发并不友好。

2.2 工程结构与请求流转

我搭建项目时采用的是经典分层结构:

com.project.fanmall ├── controller // 接口入口,只做参数接收和结果包装 ├── service // 业务逻辑,事务边界放在这一层 ├── mapper // MyBatis数据访问层 ├── entity // 数据表对应实体 ├── dto // 前端交互参数对象 ├── vo // 返回给前端的结果对象 ├── common // 常量、枚举、统一返回体、全局异常 └── config // 拦截器、WebMvc配置、定时任务配置

请求流转是标准的:前端请求进Controller,Controller调用Service,Service里面处理业务规则、事务、调用Mapper,Mapper返回数据后再转成VO交给页面渲染。Controller层不能直接操作数据库,也不能自己写订单的状态判断。

有一个原则我在设计时特别坚持:所有订单状态变更必须经过Service层的统一方法,不能直接update status。这样做的目的是让状态流转逻辑集中在一个地方,方便校验前置状态、记录操作日志、以及后续加消息通知。分散在Controller里写更新,短期看着快,后期排查问题时你会非常痛苦。

2.3 版本匹配与依赖陷阱

Spring Boot整合MyBatis时,最容易翻车的地方是版本不匹配。Spring Boot 2.x对应使用mybatis-spring-boot-starter 2.2.x,如果误用1.3.x,经常会冒出奇怪的SqlSessionFactory创建失败。这里我列一下自己实测下来比较稳的依赖组合:

组件版本
Spring Boot2.5.x
mybatis-spring-boot-starter2.2.x
MySQL Connector8.0.x
Druid1.2.x
PageHelper1.4.x
Lombok1.18.x

还有一个小细节:如果使用Druid,记得在启动类排除默认数据源自动配置,否则会出现DataSource类型冲突。这个问题在很多教程里不怎么提,但实际几乎每个用Druid的人都会碰到一次。

3. 把数据库表设计好,交易平台就成功了一半

3.1 核心表划分

交易系统最重要的不是代码,是表结构。表关系理清楚了,Service层的代码写起来非常顺;表设计烂了,后面每一步都在填坑。

追星商城的核心表我分成三组。第一组是用户域:用户表、收货地址表;第二组是商品域:商品表、商品类目表、商品收藏表;第三组是交易域:订单主表、订单明细表、支付流水表。另外还有运营支撑的表:举报申诉表、站内消息表、轮播图表。一共十二张表左右,就足够支撑整个系统的核心功能。

这里我多说一句:很多人习惯把订单表设计成“订单主表+订单明细表”的通用结构,哪怕一笔订单只有一个商品也不愿意去掉明细表。我建议保留这种标准设计,因为后续很可能出现套餐出售、捆绑周边的情况,明细表拆开能让扩展成本降到最低。

3.2 商品表的隐藏细节

商品表是整个平台信息密度最高的表。除了常规的标题、描述、价格、库存、图片之外,我额外设计了几个看起来不起眼但实际很关键的字段:

字段类型说明
origin_pricedecimal购入原价,用于评估是否溢价,粉丝交易特别在意这一点
sell_pricedecimal实际售价
stockint库存数量,转卖场景默认1,也可能有多张重复小卡在售
sell_statustinyint1在售、0已下架、2已售罄
versionint乐观锁版本号,防超卖用
verify_statustinyint0未验、1已验,预留的鉴定标记
sort_weightint人工干预排序权重,运营可调整曝光

为什么一定要有origin_price?因为粉丝交易里,买家会非常介意“这一单转卖有没有溢价”。有原价字段,商品详情页就能直接展示“原价108元,现价95元”,对话成本低很多。为什么用sell_status而不是delete_flag删除商品?因为下架不等于删除,卖家随时可能重新上架,状态字段比物理删除安全得多。

3.3 订单状态机设计

订单状态机是交易系统的心脏。我把它设计成了七个状态:

状态编码状态名称含义
0待支付已下单未付款,超时自动关闭
1待发货已支付成功,等待卖家发货
2待收货卖家已发货,等待买家确认
3已完成买家确认收货,交易结束
4已关闭超时未支付或主动取消
5退款中买家发起售后,待处理
6已退款退款完成,交易终止

对应的状态流转规则只有几条简单但严格的约束:

  • 0 → 1:支付回调成功,且校验支付金额、订单号、签名合法
  • 0 → 4:超时未支付,定时任务批量关单
  • 1 → 2:卖家填写物流单号并发货
  • 2 → 3:买家确认收货,或发货后满7天系统自动确认
  • 1/2 → 5:买家发起退款,卖家同意后进入5
  • 5 → 6:退款原路返回,状态终态

状态流转的全部逻辑都写在OrderStateMachine这个类里,Service层每次想改订单状态必须先调用它校验状态是否合法,非法状态直接抛自定义业务异常。这套设计在开发阶段就帮我拦截了大量因调用顺序混乱导致的状态错乱问题。

4. 交易链路的核心实现:下单、支付、库存扣减

4.1 下单防重与库存扣减

下单是整个系统并发压力最大的入口,也是最容易出现逻辑漏洞的地方。在这类转卖平台上,热门偶像的限时周边经常会出现多人同时抢购同一件商品的情况。如果库存只有1件,两个人同时下单,就可能出现超卖。

我的处理方式是比较务实的乐观锁方案。在商品表中维护一个version字段,扣库存的时候不是简单地stock-1,而是带版本号条件去更新:

// Service层核心逻辑 @Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, Long goodsId, Integer qty) { // 1. 查商品并锁定行,防止状态判断并发冲突 Goods goods = goodsMapper.selectByIdForUpdate(goodsId); if (goods == null || goods.getSellStatus() != 1) { throw new BizException("商品已下架"); } // 2. 扣库存 int rows = goodsMapper.deductStock(goodsId, qty, goods.getVersion()); if (rows == 0) { throw new BizException("手慢了,库存被抢光了"); } // 3. 创建订单主表记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setBuyerId(userId); order.setSellerId(goods.getUserId()); order.setGoodsId(goodsId); order.setTotalAmount(goods.getSellPrice().multiply(new BigDecimal(qty))); order.setStatus(OrderStatus.WAIT_PAY.getCode()); orderMapper.insert(order); // 4. 写入订单明细 OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setGoodsId(goodsId); item.setGoodsTitle(goods.getTitle()); item.setGoodsCover(goods.getCoverImg()); item.setGoodsPrice(goods.getSellPrice()); item.setQty(qty); orderItemMapper.insert(item); return order.getId(); }

对应的Mapper更新语句长这样:

<update id="deductStock"> update goods set stock = stock - #{qty}, version = version + 1 where id = #{id} and stock >= #{qty} and version = #{version} </update>

and stock >= #{qty}和and version = #{version}两个条件同时生效,保证在并发情况下只有一个人能扣成功。这个方案的好处是,不需要引入Redis分布式锁也能扛住中小型系统的并发,实现成本很低,性能损耗也小。如果后续用户量上来,再换成Redis锁或者MQ异步扣减都来得及——但第一版,乐观锁已经足够。

4.2 支付回调的幂等设计

真实接入微信或支付宝支付,对个人项目和大部分练手项目来说成本偏高,所以我做的第一版是模拟支付,但回调接口的协议设计完全照真实支付来写。这样以后真要接第三方支付,只需要替换掉支付发起,回调处理逻辑不用动。

模拟支付回调接口长这样:

@PostMapping("/pay/callback") public Result payCallback(@RequestBody PayCallbackRequest req) { // 演示用固定密钥,正式项目改为配置项 String sign = MD5Util.md5(req.getOrderNo() + req.getPayAmount() + "fans-mall-key"); if (!sign.equals(req.getSign())) { return Result.error("签名校验失败"); } boolean ok = orderService.handlePaidCallback(req.getOrderNo(), req.getPayTradeNo(), req.getPayAmount()); return ok ? Result.success() : Result.error("回调处理失败"); }

Service层的处理逻辑必须确保幂等:

@Transactional(rollbackFor = Exception.class) public boolean handlePaidCallback(String orderNo, String tradeNo, BigDecimal amount) { // 加锁查订单,防止并发回调 Order order = orderMapper.selectByOrderNoForUpdate(orderNo); if (order == null || order.getStatus() != OrderStatus.WAIT_PAY.getCode()) { // 幂等关键:状态已经不是待支付,直接返回成功不再重复处理 return false; } // 金额校验,防止恶意伪造回调 if (order.getPayAmount().compareTo(amount) != 0) { return false; } orderMapper.updateStatusByOrderNo(orderNo, OrderStatus.WAIT_PAY.getCode(), OrderStatus.PAID.getCode(), tradeNo); return true; }

很多人写回调时只关注“更新状态为已支付”,忽略了一个关键细节:如果同一个回调通知被发送两次,第二次进来时订单已经处于待发货状态,这时候应该直接返回成功而不是报错。支付渠道的重试机制非常常见,一旦你返回失败,它会反复重试,造成大量无意义的日志和事务操作。所以我在Service里对“状态不是待支付”的情况做幂等处理,这是交易系统的一个基本功。

4.3 超时未支付关单

用户下单后不支付,订单会一直占着库存。转卖这种少量库存的场景,这个问题尤其要命。所以必须有超时关单机制,把库存释放出来。

第一版我用的是Spring Schedule定时任务,每两分钟扫描一次“创建时间超过30分钟且状态为待支付”的订单,批量关闭并回补库存:

@Scheduled(fixedDelay = 120000) public void closeExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredOrders(30); for (Order order : expiredOrders) { try { orderService.closeOrder(order.getOrderNo()); } catch (Exception e) { log.error("关单失败, orderNo={}", order.getOrderNo(), e); } } }

closeOrder内部同样是事务操作:更新订单状态为已关闭,同时把商品库存回补。这个回补动作直接放在同一个事务里就能解决,不需要额外引入MQ。为什么不用延时队列?因为当前体量下订单量不大,定时扫描的延迟误差完全可以接受,引入Redisson或者RocketMQ会显著增加系统复杂度。做技术选型一定要看场景,这是我在很多项目里慢慢悟出来的。

5. 转卖平台最容易忽略的安全与风控设计

5.1 担保交易流程的落地

追星商城这类C2C平台,如果直接做成“买家付款→钱到卖家手里→卖家发货”,那骗子卖家的成本基本为零:收钱拉黑就行。所以平台必须承担担保角色,钱不能直接到卖家账户,要等买家确认收货后才结算给卖家。

完整的担保流程是:

  1. 买家下单并付款,钱进入平台虚拟中间账户
  2. 平台通知卖家发货
  3. 卖家填写物流单号,状态变为待收货
  4. 买家收到货并确认无误,点击确认收货
  5. 平台把货款结算给卖家账户余额
  6. 买家确认之前,卖家不能提现这笔款项

这个流程在真实支付体系里需要企业资质和支付渠道的“分账”能力,个人项目做不了完全真实的分账。但我仍然建议在数据库层面把“支付流水表”和“结算流水表”分开设计。支付流水记录第三方交易流水号、支付金额、支付时间;结算流水记录订单编号、卖家ID、结算金额、结算状态。哪怕第一版只做展示不真正拨款,这套数据结构也能让支付链路的逻辑跟退款、提现、对账接上,不会出现“钱从哪里来的,钱去哪里了”一笔糊涂账。

5.2 防黄牛抢购与恶意下单

热门偶像的周边上架后,可能被同一批账号反复下单、取消、再下单,甚至有人写脚本批量抢购低价商品再高价转卖。防刷设计不能省。

我在系统里做了三层基础防护。第一层是登录后的下单接口加Redis频率限制,同一个用户ID在一定时间内最多创建N个订单,超过则提示操作频繁;第二层是短时间同一IP的请求限流,由拦截器统一处理;第三层是下单时生成一个前端提交用的幂等键,用户点击下单按钮后,前端把这个幂等键传给后端,后端通过Redis的setNX保证同一个幂等键只能被处理一次。

// 防重复提交:Redis setNX 幂等键 boolean locked = redisTemplate.opsForValue().setIfAbsent("order:submit:" + idempotentKey, "1", 10, TimeUnit.SECONDS); if (!locked) { throw new BizException("订单正在提交中,请勿重复操作"); }

这里有个容易被忽视的细节:幂等键判断要放在事务开启之前,否则高并发下多个请求可能同时进入同一个事务,幂等判断就形同虚设了。

5.3 内容审核与举报机制

转卖平台上的商品图片可能涉及违规内容,文字描述里也可能出现引战、辱骂等争议信息。商品发布时必须做两层审核。第一层是系统自动过滤,标题和描述通过敏感词库做拦截;第二层是人工审核,新发布的商品默认状态为“待审核”,运营后台审核通过后才会出现在前台列表。

很多人会觉得人工审核延误上架效率,但C2C平台的教训告诉我,跳过审核的代价更大。出现违规商品后,轻则被投诉,重则影响整个平台的正常运营。所以我在商品表里专门留了一个audit_status字段,0待审核、1通过、2驳回。驳回时后台可以填写驳回原因,推送给卖家。这个设计不复杂,但能让平台运营有很强的主动权。

举报与申诉表也很重要。买家如果发现商品图片与实物不符、卖家拒绝发货,可以提交举报,后台生成一条待处理工单。工单里附带订单号、聊天记录截图URL、举报理由,运营人员可以直接看到该订单的完整信息,做出仲裁。这套机制第一版只需要一张表加一个后台列表页,但它传递的信号很重要:平台不是不管事,平台能管到每一个纠纷。

6. 开发中踩过最深的几个坑

6.1 事务失效的三类现场

这类项目百分之八十的诡异Bug都出在事务上。我至少踩过三类事务失效的坑,每一个都花了不少时间排查。

第一类是同类内部方法调用。Spring的@Transactional基于AOP代理,通过this调用同类方法时,走的是对象本身的调用,不是代理调用,事务注解完全不生效。我一开始在OrderService里写了一个public void createOrderAndCallCallback()方法,内部直接调this.handlePaidCallback(),结果回调失败时外层事务根本没回滚,订单状态和库存对不上。解决办法是把需要事务隔离的操作拆到不同的Bean,或者注入自身的代理对象再调用。

第二类是try-catch吞异常。如果事务方法内部自己捕获了异常但没有重新抛出,Spring感知不到异常发生,就不会回滚。这属于最典型的事务陷阱:

// 错误示范:异常被吞,事务不会回滚 @Transactional(rollbackFor = Exception.class) public void createOrder() { try { orderMapper.insert(order); goodsMapper.deductStock(goodsId, 1, version); } catch (Exception e) { log.error("下单失败", e); } }

正确做法是让异常继续往上抛,或者捕获后手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。但实际推荐做法是:Service层不要自己catch异常,交由全局异常处理器统一捕获并包装返回。

第三类是rollbackFor没有配置。Spring默认只回滚RuntimeException和Error,如果你抛的是自定义Checked异常,又不设置rollbackFor = Exception.class,那事务照样不回滚。这个坑极其隐蔽,因为代码看起来是完全正常的。

6.2 MyBatis动态SQL的细节

MyBatis的<if>、<choose>等标签用起来很方便,但稍不留神就会拼出一个诡异的SQL。最常见的问题是用手写where 1=1来凑动态条件,这在语法上没问题,但会让SQL不好读,索引利用率也可能受影响。

更致命的是${}和#{}混用。${}是字符串拼接,存在SQL注入风险;#{}是预编译参数,安全。但有些场景比如动态排序字段、动态表名,必须用${},这时候一定要做白名单校验。我在商品搜索接口里允许前端传sortField和sortOrder,后端接收后先比对枚举,只有create_time、sell_price、sales这几个字段能通过,其他一律拒绝。这样就算有人恶意传值也注入不了。

另外,多条件查询里的<where>标签会自动处理多余的AND和OR,<set>标签会自动处理多余的逗号,这是MyBatis提供的很有用的能力。一开始不熟的时候,我宁可多写几个专门的方法也不硬拼一个大动态SQL,等熟练了再合并也不迟。

6.3 PageHelper分页的线程绑定问题

分页插件PageHelper用起来简单,但它内部用的是ThreadLocal保存分页参数,所以PageHelper.startPage()之后必须紧跟执行一条Mapper查询,中间不能穿插别的数据库操作。我第一次用的时候在startPage和Mapper查询之间加了一段操作日志的写入,结果分页参数被日志那条SQL吃掉了,列表数据不仅没分页,日志表反而变成了分页查询,当时排查了很久才发现问题根源。

后来我把分页查询封装成一个统一的分页工具方法:

public <T> PageResult<T> pageQuery(int pageNum, int pageSize, Supplier<List<T>> querySupplier) { PageHelper.startPage(pageNum, pageSize); List<T> list = querySupplier.get(); PageInfo<T> pageInfo = new PageInfo<>(list); return new PageResult<>(pageInfo.getTotal(), pageInfo.getList()); }

所有Service层需要分页的地方都走这个方法,既保证了startPage和查询紧邻执行,又统一了分页结果的返回格式。现在团队里新同学接手代码,也不需要每次写分页时都小心翼翼。

这个项目开发到这里,主干功能已经完整能跑通了。如果是从零开始学习这类系统,我最大的建议是:数据库状态机一定要先设计好,最好画成流转表格贴在代码注释里,然后Service层所有状态变更都走统一入口。做到这两点,后面开发基本就是填肉,不会出现那种改一个Bug引出三个新Bug的连锁事故。先把核心交易链路做扎实,再考虑收藏、消息、积分这些锦上添花的功能,你会发现整个实现过程会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询