最近有个朋友问我,做一个带竞拍功能的微信小程序大概要多久,能不能直接用现成的商城框架改一改。我告诉他,拍卖和普通电商完全是两码事——普通购物车是“加购-下单-支付”,拍卖的核心是“出价-超时-成交”,中间还有保证金、出价递增、自动延时这些规则。如果一开始没想清楚,后面改起来会非常痛苦。
我最近正好完成了一个基于 Spring Boot 的微信小程序拍卖平台,从需求梳理、表结构设计、竞拍并发控制到小程序端倒计时交互、支付回调、订阅消息,整个链路都跑通了。这篇文章把整个项目的设计思路、关键实现和踩过的坑按模块整理出来,适合正在做类似项目、或者准备从零搭建拍卖类小程序的开发者参考。我会尽量讲清楚每一步的“为什么”,而不是只贴一堆代码。
1. 拍卖业务形态分析:它不是商城,而是“规则引擎”
1.1 拍卖和普通电商的核心差异
很多人的第一反应是:拍卖不就是商品表加一个价格字段,用户点一下出价,然后价格涨一涨吗?真做起来就会发现,这里面有太多业务规则要落地。
普通电商的订单是“用户自选数量,以标价结算”,系统只需要保证库存不超卖。拍卖则完全反过来:商品只有一件(或一批),价格由多个买家实时竞争决定,系统必须保证同一时刻只有一个有效最高价;出价金额必须满足加价幅度;拍卖结束前几分钟有人出价时,结束时间是否要自动延长;买家出价时是否需要先缴纳保证金;如果买家违约,保证金怎么扣;如果流拍,商品怎么处理。这些都是拍卖平台必须考虑的状态机。
所以我做的第一件事不是建表,而是把完整拍卖流程的“状态流转图”画出来,包括商品状态(草稿、待审核、竞拍中、已成交、已流拍)、订单状态(待支付、已支付、已发货、已完成、已关闭)、用户资金状态(已冻结保证金、已解冻、已扣款)等。最多的时候,状态字段有十多个,如果不提前定好,后面联调时到处是坑。
1.2 技术选型:为什么后端是 Spring Boot
这个项目选择 Spring Boot 是综合考虑后的决定,不是说其他技术不行,而是 Spring Boot 在这类场景下的确占优势。我用一句话概括:Spring Boot 提供了从 API 层到持久层、从事务管理到连接池的一整套完整方案,自己在中间只需要写业务规则,不用花大量时间拼框架。
对比一下其他方案:Node.js 的 Express 写起来轻快,但项目变大后回调流程和事务处理容易散;Python 的 Django 自带 Admin 后台确实方便,但高并发下性能调优不如 Java 系成熟;而 Spring Boot 生态里的 Spring Data JPA、MyBatis、Redis、RabbitMQ、Spring Security 全都现成,招人也容易。小程序端需要一个稳定、可维护、能处理复杂事务的后端,Spring Boot 更适合做这件事。
另外,微信小程序官方提供的 HTTP API 是基于 HTTPS 的,Spring Boot 天然能配合 Nginx 部署 HTTPS 服务,不需要额外引入其他网关。对于拍卖这种对一致性和事务要求较高的业务,Spring Boot 的声明式事务非常顺手。出价、冻结保证金、更新最高价这几个操作必须在一个事务里完成,用@Transactional就很干净。
1.3 项目整体架构:前后端分离的模块划分
我的工程结构没有用网上常见的单一 module 一把梭,而是按业务模块拆分成几个 package,因为拍卖项目会同时涉及用户、商品、拍卖、订单、支付、消息等多个独立模块,如果所有 Controller 堆在一起,后期维护会很难受。
大致结构如下:
com.example.auction ├── common // 通用配置、常量、异常处理、工具类 ├── controller // 各模块的HTTP接口 ├── service // 业务逻辑层 ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体类 ├── dto // 请求和响应对象 ├── task // 定时任务,如拍卖结束扫描 ├── mq // 消息队列(如果需要异步处理) └── config // Redis、WebMvc、拦截器配置服务端只提供 JSON 数据接口,小程序端所有渲染都在前端完成。小程序端的目录结构则按页面划分,首页是拍卖场次列表,详情页承载商品信息和出价操作,订单页处理支付和发货状态。这里要特别说明:拍卖详情页的小程序端是重头戏,因为它需要实时显示剩余时间、当前价、出价记录、我的出价状态,这些交互逻辑占据了小程序端 60% 的工作量。
2. 服务端核心模块:登录、商品、拍卖与订单
2.1 微信登录与用户体系绑定
微信小程序不像传统网站那样有用户名密码,它依赖微信的wx.login()获取临时code,然后后端拿着 code 调用微信接口换取openid。正常情况下,一个用户对应一个 openid,我们用它作为用户表的唯一标识。
实现流程很简单:前端wx.login()拿到 code 后传给后端,后端再调用jscode2session接口。这个接口成功后会返回openid和session_key。openid 直接存在用户表里,session_key 在需要解密手机号的时候才用。为了安全,我给用户表同时设计了一个unionId字段(如果绑定过开放平台),但正常情况下都用 openid 识别用户。
有一点需要注意:很多教程会让你用code去换取 openid,然后直接返回 openid 给前端。这种做法有风险,因为一旦你的接口被第三方恶意调用,别人可以拿到 openid 伪装用户。更稳妥的做法是生成自己的token(比如 UUID 或 JWT),用 Redis 保存 token 和 user_id 的映射关系,设置过期时间。我实测下来,微信小程序的session_key有效期一般在三天左右,但用户每次冷启动都会重新wx.login(),所以 token 有效期设成 24 小时也够用。如果用户进行支付等敏感操作,我还会要求前端把用户 ID 也传过来再做一次校验。
2.2 商品发布与拍卖场次管理
拍卖商品不是直接上架的,它要经过“后台录入商品信息 -> 设置起拍价、加价幅度、保证金比例、开拍时间、结束时间 -> 提交审核 -> 审核通过后进入拍卖场次”的流程。我的做法是把商品表和拍卖场次表分开,因为同一件商品可能被不同场次复用(比如定时拍卖、限时闪拍),而且拍卖场次本身需要有状态管理。
数据库里设计了以下几个核心字段:
- 商品表(product):商品名称、描述、图片列表、起拍价、市场参考价、商品状态
- 拍卖场次表(auction_session):关联商品ID、起拍价、当前最高价、加价幅度、保证金比例、开始时间、结束时间、状态(未开始/进行中/已结束/已成交/已流拍)
这里有一个容易出错的地方:当前最高价要不要冗余在拍卖场次表里?我在后期把current_price字段加了上去。原因是在出价页下拉刷新、列表页展示时,都要实时显示最新价格。如果每次都要从出价记录表里MAX(price)查询,数据量上去后会很慢。冗余字段配合 Redis 缓存可以在性能与一致性之间取得平衡。每次有新出价,我不但更新出价记录表,还会同步更新拍卖场次表的current_price和current_bidder_id,并通过事务保证这两个操作原子完成。
2.3 出价接口:并发控制与防超卖
这应该是整个项目最核心的接口,也是拍卖系统与普通商城差异最大的地方。拍卖出价必须具备以下条件:
- 用户必须缴纳过保证金(或信用额度足够)
- 用户不能是当前最高出价者(自己不能跟自己竞价)
- 出价金额必须大于当前最高价,且不低于当前最高价加加价幅度
- 拍卖必须处于“进行中”状态
我最初以为只要用 SQLUPDATE带上条件就行了,比如:
UPDATE auction_session SET current_price = #{price}, current_bidder_id = #{userId} WHERE auction_id = #{auctionId} AND status = 1 AND current_price < #{price}这条 SQL 通过current_price < #{price}的条件能避免低出价覆盖高价格,但无法防止两个并发请求同时读到同一个当前价、然后都去更新(实际上因为 UPDATE 会锁行,所以第二个会等待,更新影响行数为 0,可以判断是否成功)。但是更恶心的场景是:出价记录表和拍卖场次表的更新要一起完成,如果事务隔离级别控制不好,还是会出现脏数据。
因此我在 Service 层做了逻辑锁:先根据auctionId去 Redis 获取一个分布式锁(SET lock_auction_xxx 1 EX 3 NX),拿到锁之后再去查数据库、校验规则、写入出价记录、更新拍卖场次,最后释放锁。核心代码如下简化逻辑:
@Transactional(rollbackFor = Exception.class) public BidResult placeBid(BidRequest request) { // 1. 加Redis分布式锁,防止并发 boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock:auction:" + request.getAuctionId(), "1", 3, TimeUnit.SECONDS); if (!locked) { throw new BizException("当前竞拍人数过多,请重试"); } try { // 2. 查询拍卖场次,校验是否进行中 AuctionSession session = auctionMapper.selectById(request.getAuctionId()); if (session.getStatus() != AuctionStatus.RUNNING.getValue()) { throw new BizException("拍卖未在进行中"); } // 3. 校验加价幅度 BigDecimal minPrice = session.getCurrentPrice().add(session.getBidStep()); if (request.getPrice().compareTo(minPrice) < 0) { throw new BizException("出价不能低于当前价加加价幅度"); } // 4. 校验用户保证金 UserAccount account = accountMapper.selectByUserId(request.getUserId()); if (account.getFrozenAmount().compareTo(session.getDepositAmount()) < 0) { throw new BizException("保证金不足"); } // 5. 插入出价记录,更新当前价 bidRecordMapper.insert(...); auctionMapper.updateCurrentPrice(...); return BidResult.success(); } finally { redisTemplate.delete("lock:auction:" + request.getAuctionId()); } }这里最关键的是事务和 Redis 锁的配合:锁的作用是让多个请求串行化,事务的作用是保证多表数据的一致性。Redis 锁可能导致锁超时后请求还在执行,所以 Redis 锁的超时时间要大于业务执行时间(我设成 3 秒,实际最慢的请求也就几十毫秒)。如果业务执行超过锁超时时间,最后一步删锁时可能把别人的锁删掉——这一点我偷懒了,实际生产建议用 LUA 脚本或 Redisson 来保证删除锁时校验 value 是否为自己的。小项目可以先用简单方案,但要在注释里标注清楚风险。
2.4 拍卖结束与订单生成
拍卖结束后不能只靠用户前端倒计时到 0 就结束,因为会有用户在前端延时结束、网络差等场景。我用了一个定时任务,每 10 秒扫描一次所有“进行中”且结束时间小于当前时间的拍卖。扫描到之后,将状态置为“已结束”。如果拍卖场次有最高价出价记录,则生成待支付订单;如果没有出价记录,则标记为流拍。同时,将最高出价人的保证金状态从“冻结”改为“待抵扣”,其他竞拍人的保证金自动解冻。
生成订单的逻辑要小心:必须保证同一时间只有一个任务在处理同一个场次,避免定时任务与用户出价接口并发冲突。我的做法是先通过 UPDATE 把拍卖状态从“进行中”改成“已结束”,用UPDATE ... WHERE id = ? AND status = 'running',如果影响行数是 0,说明被人抢了或者已经结束,直接跳过。这样利用数据库行锁天然避免了并发问题。
订单表沿用电商风格:订单号、场次ID、买家ID、商品ID、成交价、状态、支付时间、发货信息。注意成交价生成后不能后续修改,因为它是拍卖最终结果。
3. 小程序端:从倒计时到出价再到支付
3.1 首页拍卖会场与倒计时处理
小程序首页展示当前正在进行和即将开始的拍卖场次。这里最容易忽略的是时间同步问题。如果直接用服务器返回的剩余秒数在前端做倒计时,每分钟还需要重新获取吗?其实可以这样:服务端返回一个serverTime和endTime,前端用serverTime减去本地时间得到时间差offset,然后用endTime - Date.now() + offset计算剩余时间。但我实测发现小程序端Date.now()取到的是用户手机本地时间,用户手机时间不准会导致倒计时错误。
更稳妥的做法是:服务端直接返回endTime的毫秒值,前端每次渲染时用endTime - Date.now()计算,但不要依赖这个结果去进行精确的“结束”操作,结束动作只校验服务端状态。因为前端展示出现一两秒偏差并没有太大问题,只要最终写库时以服务端为准就行。
首页的列表我是用的微信原生onPullDownRefresh下拉刷新,加一个定时器每 5 秒刷新一次当前列表里的价格。一开始我也想过用 WebSocket 实时推送,但后来考虑做的是通用拍卖平台,用户数量不大,用轮询简单可靠,没必要为了炫技增加复杂度。
3.2 出价面板与价格实时刷新
出价面板是拍卖页最核心的交互。我设计的是底部固定区域,显示当前最高价、我的出价、加价幅度,以及几个快捷出价按钮:+1倍加价步长、+2倍加价步长、自定义出价。自定义出价输入框要校验金额是否满足最低出价,否则点击“提交出价”会提示错误。
价格刷新我不能每 5 秒刷整页,因为用户可能在盯着价格,整页刷新容易导致输入状态丢失。我用接口单独返回价格、出价次数、最高出价人昵称,前端拿到后做局部数据更新。同时,出价成功后要立即在页面顶部插入一条出价记录,而不是等下一次轮询。这样体验上很接近实时。
这里还遇到一个小问题:用户快速点击“出价”按钮时,因为网络延迟没有立即反馈,可能出现连续发送多个请求。前端必须做“按钮禁用”和“节流”,我的做法是在收到响应前禁用按钮,并把最新一次价格作为参数传入,这样后端也会判断加价幅度是否符合,防止无效请求。
3.3 支付流程与订阅消息
拍卖成交后,买家进入订单页,点击“去支付”时会创建微信小程序支付需要的prepay_id,然后用wx.requestPayment拉起支付。支付成功后会有两个回调:微信支付服务器回调服务器notify_url,以及小程序端wx.requestPayment返回的success回调。注意,小程序端返回success不等于支付一定成功,最终以服务器收到的回调为准。
服务器处理支付回调时,一定要做幂等处理:同一个订单可能收到多次回调,如果不判断order_status是否已经是paid,就会重复更新库存、重复给用户加拍卖档案。我在订单表加了一个支付流水号字段,每次回调会检查这个字段,如果已存在就直接返回“成功”响应给微信,不再重复处理。
拍卖结束给买家发送“恭喜你拍中”的订阅消息,给卖家发送“商品已拍出”的订阅消息,这些都是通过微信小程序的订阅消息。这里有个坑:订阅消息需要用户主动订阅一次,用户不同意就发不了。因此我在用户进入拍卖详情页时弹出“允许发送拍卖结果通知”的授权请求,这个只能提前申请,不能事后补。
4. 数据库设计、Redis 缓存与踩坑实录
4.1 核心表结构设计
我列出几张核心表字段,方便大家做表结构时参考:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, openid, union_id, nickname, avatar, phone, status | 用户基础信息 |
| user_account | user_id, balance, frozen_amount, total_deposit | 用户钱包,保证金从这里冻结 |
| product | id, name, description, images, market_price, status | 商品信息 |
| auction_session | id, product_id, start_price, current_price, current_bidder_id, bid_step, deposit_ratio, start_time, end_time, status | 拍卖场次,核心表 |
| bid_record | id, auction_id, user_id, price, created_time | 每次出价记录,用于展示 |
| user_deposit | id, user_id, auction_id, amount, status | 保证金流水 |
| auction_order | id, order_no, auction_id, product_id, buyer_id, price, status, pay_time | 成交后生成的订单 |
bid_record数据量会随着拍卖次数膨胀,建议定期归档。索引方面,auction_id + created_time是查询出价记录的高频条件;auction_session的status + end_time是定时任务扫描的条件,这两个索引一定要建。
我最初没有建user_account表,而是在user表里直接放balance和frozen_amount。后来发现钱包和用户信息属于不同变更频率的数据,放一起会导致频繁行锁,所以拆开了。拆开后,用户下注和出价时锁定的只是钱包表的行,不会影响用户基本信息的更新。
4.2 Redis 在竞拍场景的用法
Redis 在这个项目里承担了四个职责:分布式锁、实时价格缓存、token 存储、热点数据缓存。
实时价格缓存我用了一个 String 结构,key 为auction:price:{auctionId},value 是当前价的字符串。出价成功后,除了更新数据库,还要把新价格写入 Redis。前端轮询价格接口时,先查 Redis,没命中再查数据库。这样能避免几十个用户轮询同一个场次时把数据库打爆。虽然数据库中也有current_price字段,但 Redis 的读取性能更好,而且不会因为数据库行锁产生等待。
要特别注意的是缓存与数据库的一致性。每次价格更新时,我先更新数据库,再更新 Redis。理论上存在数据库成功但 Redis 失败的情况,但 Redis 失败概率极低,而且下一次出价或定时任务会把新价格重新写入,所以基本可以接受。如果追求强一致,可以采用先更新 Redis 再更新数据库、或者用消息队列保证最终一致。我选择的是简单方案,毕竟拍卖价格本身是从数据库读取的最终成交价,Redis 缓存只要保证展示准确性即可。
4.3 微信小程序的时间同步坑
前面提到倒计时时间同步,这里再展开聊聊。小程序端获取服务器时间一般是通过请求某个接口,但如果你在出价接口的返回里附带serverTime字段,前端每次请求都能顺便校准一次。我的做法是在公共的响应体里加一个serverTime字段,前端收到响应后更新本地变量。这样即便用户自己改手机时间,下一次请求也会自动纠正偏移。
我在测试时曾经故意把手机时间改快 5 分钟,结果显示倒计时提前几分钟就变成 0,但服务端并不会把拍卖状态改成已结束,所以用户点击出价时会收到“拍卖已结束”。虽然服务端是正确的,但用户体验会非常糟糕。因此,倒计时要显示服务端剩余时间,结束状态以服务端为准,两者不能混淆。
另外,小程序端使用wx.setInterval做倒计时时,页面切到后台会被系统挂起,回到前台后setInterval可能连续执行多次。我的解决办法是在页面onShow时重新计算一次剩余时间,并且定时器回调里先判断页面是否可见。
4.4 并发超卖与连环出价的坑
我在开发过程中最头疼的是“连环出价”场景:用户 A 和用户 B 同时盯着同一场拍卖,A 出价 100,B 也提交 100,系统只能有一个成功。性能压测时我模拟了 500 个并发请求同时出价,最终全部成功或失败都很正常,但如果只依赖乐观锁不带 Redis 分布式锁,确实偶发出现价格记录为 100 和 101 同时存在的情况(两条记录价格一样,但上一条只更新到数据库,下一分钟又查到旧值)。
所以最终方案是“分布式锁 + 数据库乐观锁”双保险。分布式锁解决同一场次的大并发串行问题,数据库乐观锁(WHERE current_price < #{price})做最终兜底。只有先拿到 Redis 锁,并且数据库更新影响行数为 1,才算真正出价成功。
还有一个容易忽略的坑:出价记录必须插入用户昵称和时间,可用户可能在中途修改昵称。如果每次出价都联查用户表,性能低;如果不联查,展示历史出价记录时会显示旧的昵称。我的方案是bid_record表不存昵称,查询列表时用JOIN user,反正一次最多显示几十条记录,这个连接开销完全可以承受。
5. 测试、部署与上线后的建议
5.1 拍卖场景的模拟测试
这种带严格状态机的项目不能只靠单元测试,更重要的是模拟完整流程。我用 JUnit 写了一个集成测试,从创建商品、创建场次、用户充值、缴纳保证金、多人出价、拍卖结束、生成订单、支付回调,一条龙跑下来。过程中发现很多“状态流转”的问题,比如用户已经支付完订单后,又去出价,系统提示“拍卖已结束”,这个没问题;但如果同一用户既是卖家又是买家,系统需要限制为不能参与自己商品的竞拍,我在出价接口里加了auction.product.ownerId == userId的校验。
测试时我还发现:如果服务器当前时间恰好处于开拍前 1 秒,前端显示“即将开始”,但这个状态转换需要定时任务扫描才能更新成“进行中”。为了避免延迟,我在小程序端请求场次详情时增加一个“按需检查状态”的接口:如果查询到当前时间大于开始时间但状态仍为 0(未开始),则立即把状态更新为 1(进行中)。这属于“懒加载状态”,能有效减少定时任务的调度压力。
5.2 部署上线:HTTPS、域名与小程序后台配置
微信小程序要求所有请求域名必须是 HTTPS 并且在小程序后台配置域名白名单。部署时我用 Nginx 反向代理了 Spring Boot 服务,配置 SSL 证书。开发环境的域名要和线上环境分开,否则会频繁遇到“request 合法域名不匹配”的问题。我第一次配置时只配了https://www.example.com/api路径,结果小程序访问根路径时报错,后来才发现小程序后台request合法域名需要填写域名根地址,而不是带路径。
服务端部署我推荐用 Docker,因为 Java 环境、运行参数、配置文件可以一起打包。我写了一个 Dockerfile,基础镜像用 Java 17,通过--spring.profiles.active=prod区分环境。数据库用云数据库,Redis 用云缓存,这样免去自己维护环境的压力。拍卖定时任务可以部署在同一个服务实例上,但要注意多实例部署时定时任务会重复执行——我用的是Spring Scheduled,单机部署没问题,如果以后扩展成多实例,建议引入分布式调度或把任务写入数据库表,再加分布式锁控制。
5.3 后续功能扩展思路
这个项目目前满足了基本拍卖流程,但离真正商用的拍卖平台还有一些距离。比如可以增加“直播拍卖”:主持人开直播,用户观看直播时直接点击出价,这需要引入直播组件;也可以增加“代理出价”:用户委托系统在别人出价时自动加价,直到达到心理价位,这需要设计一套优先级队列;还可以增加“流拍再拍”:流拍商品自动降为普通商品或者转入下一场次。
如果拍卖平台用户量再大一个数量级,出价记录可以改用时序数据库,拍卖大厅的 WebSocket 推送也可以提上日程。但商业项目要遵循一个原则:先把核心交易链路做稳,再去考虑花活。拍卖的核心就是“价高者得,公平且稳定”,只要这个底线守住,后续扩展都是加分项。
最后分享一个小经验:做这种微信小程序项目,一定不要忽略“服务端时间戳”的意义,更不要在客户端判断拍卖是否结束。所有关键状态都以数据库和服务端为准,宁可让界面延迟 1 秒,也不能让两个用户同时拍到同一件商品。反过来说,只要扛过了并发出价的测试,拍卖小程序的其他功能基本都是常规开发,没有特别难的地方。希望这篇实战细节对你有帮助。