☰
PHP+UNIAPP多用户竞拍闪拍商城系统:订单状态机与Redis并发设计
2026/10/7 5:40:38 网站建设 项目流程

简介:面向多用户挂售、转卖、竞拍与闪拍场景的商城系统源码,同时涵盖数字藏品展示与交易。适用于二手交易、拍卖行、数字藏品平台、竞价转拍等业务场景,适合具备PHP和uni-app基础的中级开发者进行二次开发或快速落地上线。后端采用ThinkPHP框架,前端使用uni-app开发,可通过HBuilder X直接编译打包,整体前后端分离、目录结构清晰。资源包共2002个文件,其中包含473个PHP接口文件、378个JS交互脚本、79个Vue页面组件,并辅以配置文件、数据库脚本、图片素材和说明文档,压缩包整体约142.57MB,层级合理,便于按模块检索学习。已有230人学习下载,源码经实测可用,随包提供安装教程、数据库修改路径、运行目录与伪静态配置、后台默认账号密码及前端编译方式,能够帮助读者从环境部署、数据导入、接口联调到移动端打包快速走通全流程;同时附带的移动端构建文件可辅助直接验证核心功能。

1. 多用户挂售转卖竞拍闪拍商城系统/NFT数藏系统:用 PHP+UNIAPP 源码起步前,先想清楚这四件事

多用户挂售转卖竞拍闪拍商城系统/NFT数藏系统,光看标题就是个“全家桶”:一套源码里要同时跑起一口价挂售、二手转卖、限时竞拍、秒杀式闪拍,还要处理 NFT 数字藏品的凭证流转。这类项目用后端 PHP + 前端 UNIAPP 的源码方案最常见,PHP 负责订单、钱包、拍卖事务,UNIAPP 负责一套代码发微信小程序、H5 和安卓/iOS App。适合谁?想快速搭一个数藏或潮玩交易平台的开发者,或者接盘了类似 PHP 源码但不敢动拍卖模块的人。它真正的难点不在增删改查,而在四件事:订单状态机怎么设计、竞拍出价怎么保证不超卖、闪拍库存怎么在并发下扣得准、小程序端怎么过包体积和打包配置这道坎。把这四件事想明白,源码到手才不会变成一坨不敢改的黑匣子。

2. 拆解四种交易玩法与数据模型:先跑通 MySQL 层面的状态机

2.1 挂售、转卖、竞拍、闪拍:四种玩法在业务上怎么划分

很多源码把四种玩法都挂在同一张“商品表”上,靠一个 type 字段区分,短期能跑,但一到竞拍和闪拍就露馅。先厘清概念:挂售是平台或用户以一口价上架,买家付款即成交;转卖是用户对用户的二手交易,买家付款后进入平台担保,卖家发货、买家确认收货后钱才结算给卖家;竞拍是设定起拍价、加价幅度和倒计时,截拍时出价最高者得;闪拍更像秒杀,固定时间点开抢,库存少、并发高,拼的是接口响应速度。

我一般会把交易模型拆成三套并行的结构。挂售和转卖共用一个商品/资产模型,因为它们的核心动作都是“定价—支付—履约”,区别只在资金流向;竞拍单独用“场次+出价记录”模型,因为每次出价都是一次更新,必须记录完整的出价轨迹;闪拍单独用“活动+库存缓存”模型,因为它依赖预热的 Redis 库存。三套模型共享用户表、钱包表、订单表这三个基础底座,所有收入流水统一走钱包流水表,后面做佣金结算时才查得清。

2.2 核心表设计与 SQL:asset、auction_lot、auction_record、wallet_flow

先看资产表和拍卖场次表,这是整套系统的地基。asset 表同时服务挂售、转卖和拍卖,auction_lot 表单独存放竞拍场次与闪拍活动的动态价格信息。

-- 资产表:挂售、转卖、拍卖共用的藏品/商品 CREATE TABLE `asset` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `title` varchar(120) NOT NULL COMMENT '标题', `cover` varchar(255) NOT NULL COMMENT '封面图URL', `owner_id` bigint unsigned NOT NULL COMMENT '当前持有者用户ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待上架 1挂售中 2竞拍中 3已锁定 4已售出', `price` decimal(10,2) DEFAULT NULL COMMENT '挂售一口价', `chain_hash` varchar(64) DEFAULT NULL COMMENT '链上凭证哈希', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_owner_status` (`owner_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资产/藏品表'; -- 拍卖场次表:竞拍与闪拍共用 CREATE TABLE `auction_lot` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `asset_id` bigint unsigned NOT NULL COMMENT '拍品资产ID', `type` tinyint NOT NULL COMMENT '1竞拍 2闪拍', `start_price` decimal(10,2) NOT NULL COMMENT '起拍价/闪拍价', `step_price` decimal(10,2) DEFAULT NULL COMMENT '加价幅度,闪拍为0', `start_at` datetime NOT NULL COMMENT '开拍时间', `end_at` datetime NOT NULL COMMENT '截拍时间', `max_price` decimal(10,2) DEFAULT NULL COMMENT '当前最高出价', `max_user_id` bigint unsigned DEFAULT NULL COMMENT '当前最高出价用户', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0未开拍 1进行中 2已截拍 3已流拍', PRIMARY KEY (`id`), KEY `idx_type_status` (`type`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拍卖场次表';

asset 的 status 字段把“挂售中”和“竞拍中”都放进去了,但拍卖的实时价格不在 asset 表,而在 auction_lot 的 max_price 和 max_user_id 字段里。这么设计是有意的:拍卖每次出价都要更新最高价,如果更新 asset 表本身,会跟挂售下单的锁竞争;单独放一张表,读详情页时只需查 auction_lot 而不 JOIN 出价明细,性能更好。max_price 是冗余字段,它的值在每次有效出价时同步写入,查询列表页直接取,不需要 COUNT 出价记录。对应的 auction_record 记录每次出价的完整轨迹,包括出价人、出价金额、出价时间,wallet_flow 则记录每一笔资金变动,买家支付、平台佣金、卖家结算都走这张表。

2.3 订单状态机的边界:流拍、超时未支付、转卖担保

订单状态机是这类项目里最容易埋坑的地方。挂售订单的状态流转简单:待支付到已支付,再到待发货、已发货、已完成,超时未支付自动取消。转卖要多一个“担保中”状态——买家付款后钱冻结在平台账户,卖家发货、买家确认收货后,资金才从平台结算给卖家。竞拍订单则多一条时间驱动的路径:截拍后系统生成待支付订单,用户在限定时间内(一般 15 分钟)未支付,订单关闭并罚没保证金,场次落到“已流拍”,如果此时有第二位出价者,可以走“顺延成交”逻辑。

闪拍的订单状态更接近电商秒杀:扣减库存成功先锁定库存,然后创建待支付订单,支付超时释放库存。这里要特别处理一个边界:竞拍截拍瞬间和闪拍开拍瞬间,都有大量请求同时到达,状态机的判断必须依赖数据库行锁或 Redis 原子操作,不能用“先 SELECT 再 UPDATE”的普通写法。我在做这类系统时,会把状态机的所有流转写进一个独立的服务类,禁止在 Controller 里直接 UPDATE 状态字段,否则后面接支付回调、超时任务、退款任务时,状态会乱成一团。

3. PHP 后端的并发与事务:竞拍出价、闪拍抢购、NFT 凭证生成的落地写法

3.1 竞拍出价:Redis+Lua 保证“校验+更新”的原子性

竞拍最忌讳的做法是 PHP 里先 SELECT 当前最高价,比较后 UPDATE。并发 100 时,两个请求读到同一个 max_price,都认为自己的出价有效,最后落库的顺序就错了。正确做法是把“读价格—比大小—写价格”放到 Redis 的 Lua 脚本里原子执行,数据库落库放到后面。我用 phpredis 扩展时的标准写法如下。

// 竞拍出价:校验+更新 Redis 原子完成 $keyLot = 'auction_lot:' . $lotId; $keyPrice = 'auction_lot:' . $lotId . ':max_price'; $result = $redis->eval( <<<LUA local current = tonumber(redis.call('GET', KEYS[2]) or '0') -- 当前最高价 local bid = tonumber(ARGV[1]) -- 本次出价 local minBid = tonumber(ARGV[2]) -- 本次有效最低价 local leftMs = tonumber(ARGV[3]) -- 剩余毫秒 if leftMs <= 0 then return -1 -- 已截拍 end if bid < minBid or bid <= current then return 0 -- 出价低于有效价 end redis.call('SET', KEYS[2], bid) return 1 LUA, [$keyLot, $keyPrice, $bidPrice, $minBid, $leftMs], 2 // 前两个为 KEYS,其余为 ARGV ); switch ($result) { case -1: throw new BizException('拍卖已结束'); case 0: throw new BizException('出价低于当前有效价'); } // Redis 校验通过后,数据库落库 $pdo->beginTransaction(); $stmt = $pdo->prepare('INSERT INTO auction_record (lot_id, user_id, bid_price, created_at) VALUES (?,?,?,?)'); $stmt->execute([$lotId, $userId, $bidPrice, date('Y-m-d H:i:s')]); $pdo->prepare('UPDATE auction_lot SET max_price = ?, max_user_id = ? WHERE id = ?') ->execute([$bidPrice, $userId, $lotId]); $pdo->commit();

这套写法的关键是 KEYS 与 ARGV 的拆分:KEYS 是 Redis 的键名,参与集群分片计算,ARGV 是纯参数。$minBid 由服务端计算,等于当前最高价加阶梯价,不能由前端传,否则有人可以传低价刷记录。leftMs 由服务端时间戳计算:截拍时间减当前 time(),避免用户改本地时间绕过倒计时。还有一点:Lua 脚本内不能做数据库操作,也不能调用外部 API,所以要先用 Redis 挡住非法请求,再落 MySQL。落库失败时,Redis 里的价格已经变了,所以我会把 INSERT 和 UPDATE 放进事务,并在异常时回滚并把 Redis 价格回退到旧值——这个回退逻辑可以在 PHP 里先记录旧价格再回写。

3.2 闪拍抢购:库存预热到 Redis,扣减成功才写订单

闪拍的场景和竞拍不同,它拼的是开拍瞬间的吞吐量。常见做法是开拍前把库存预热到 Redis,扣减在 Redis 原子完成,数据库主要记录扣减成功后的订单。闪拍的库存扣减脚本和竞拍的逻辑思路上很像,但目标从“比较价格”变成了“判断库存”。

// 开拍前10分钟预热库存 $redis->set('flash_sale:stock:' . $activityId, 200, ['EX' => 7200]); // 库存200件,2小时后过期 // 用户点击抢购时执行 $result = $redis->eval( <<<LUA local stock = tonumber(redis.call('GET', KEYS[1]) or '0') if stock <= 0 then return 0 -- 已抢光 end redis.call('DECR', KEYS[1]) return 1 LUA, ['flash_sale:stock:' . $activityId], 1 ); if ($result != 1) { throw new BizException('手慢了,已抢光'); } // 扣减成功后创建待支付订单,写数据库 $orderNo = createOrder($userId, $activityId);

预热为什么会放在 Redis 而不是直接查 MySQL?因为 200 件库存开拍瞬间可能有 2 万人同时请求,全部打 MySQL,行锁会直接把数据库拖死。Redis 单线程执行 DECR,天然排队,2 万请求从 Redis 层面过滤到只剩 200 个成功,数据库只需要处理这 200 个写请求。库存键要设置过期时间,防止活动结束后残留脏数据,也防止有人开拍前刷库存。创建订单失败时,需要回补库存:把 Redis 的库存 DECR 改回 INCR,同时记录一条扣减失败日志,否则活动结束盘点时会发现库存数和订单数对不上。

3.3 NFT 凭证生成:哈希与链上哈希字段的取舍

NFT 数藏系统的核心凭证是 asset 表的 chain_hash 字段。很多号称 NFT 的源码其实只是本地数据库存了一个哈希字符串,并没有真正对接链上,这个要看清楚再决定取舍。做真上链的话,PHP 后端需要调用合约接口,把链上返回的交易哈希写入 chain_hash;做平台内凭证模式,chain_hash 只是一个不可篡改的本地唯一标识。

// 生成平台内唯一凭证 $hash = hash('sha256', $assetId . '_' . $userId . '_' . time() . '_' . uniqid()); $pdo->prepare('UPDATE asset SET chain_hash = ?, owner_id = ? WHERE id = ?') ->execute([$hash, $newOwnerId, $assetId]); // 真上链时:调用合约接口返回交易哈希,落库 $txHash = $contractClient->mint($assetNo, $newOwnerAddress); // 伪代码示意 $pdo->prepare('UPDATE asset SET chain_hash = ?, tx_hash = ? WHERE id = ?') ->execute([$hash, $txHash, $assetId]);

平台内凭证的 chain_hash 生成要加入用户 ID、时间戳和随机串,避免两个资产生成完全相同的哈希。真上链模式的 tx_hash 是链上交易收据的哈希,平台内凭证和链上凭证是并存的两套字段,前端展示时可以看到“平台凭证”“链上哈希”两个不同维度。做转卖时,新买家成交后必须重新生成平台内凭证哈希,旧哈希作废——这一步是很多源码漏掉的,导致一张图可以在不同用户手里同时存在。真上链情况下要调用合约做转移,不能只改 owner_id,否则链上所有权和平台持有记录不一致,用户一查链就露馅。

4. UNIAPP 前端:从创建项目到微信小程序打包上架

4.1 HBuilderX 创建项目与 pages.json 目录约定

UNIAPP 前端工程建议直接用 HBuilderX 创建默认模板,也可以从 Vue3 + Vite 的 GitHub 模板开始。我习惯把项目目录按业务模块拆:pages 下放页面,components 放公共组件,store 用 Pinia 管理用户态和全局数据,utils 放请求封装和工具函数。pages.json 是 UNIAPP 的路由和页面配置文件,相当于微信小程序的 app.json。

{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/auction/detail", "style": { "navigationBarTitleText": "拍品详情", "enablePullDownRefresh": false } }, { "path": "pages/auction/my-bids", "style": { "navigationBarTitleText": "我的出价" } } ], "tabBar": { "color": "#7A7E83", "selectedColor": "#007AFF", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "globalStyle": { "navigationBarTextStyle": "black", "navigationBarBackgroundColor": "#F8F8F8", "backgroundColor": "#F8F8F8" } }

pages.json 里的路径第一项必须是首页,tabBar 页面不能放在分包里,否则会报错。把拍卖详情页和我的出价页都放在主包里没问题,但如果页面数量多,要把它们拆到 subPackages 分包里。UNIAPP 会依据 pages.json 生成微信小程序的 app.json,所以修改页面路径后要先重新编译,再拿到微信开发者工具里预览,直接在开发者工具里改 app.json 是无效的——这个顺序搞反了会白忙活半天。

4.2 微信小程序打包配置:appid、基础库与 2MB 超限解法

微信小程序打包上架,manifest.json 的 mp-weixin 节点是核心。常见做法是填好自己的小程序 appid,不填的话开发者工具会使用测试号,很多接口和分包上传都用不了。基础库版本我一般设置到 3.0 以上,向下兼容写 2.30.0 也能跑,但会比较老。编译模式建议选“生产模式”再上传,开发模式带一堆 console 和 sourcemap,包体积会大不少。

{ "mp-weixin": { "appid": "你的小程序appid", "setting": { "urlCheck": true, "es6": true, "minified": true, "postcss": true }, "usingComponents": true, "optimization": { "subPackages": true } } }

最让人头疼的报错是“source size 2612kb exceed max limit 2mb”。2MB 限制是微信主包的硬性要求,总包大小上限其实更高,但主包不能超。第一次遇到这个报错时,我的第一反应是压缩图片,但图片通常已经放在 CDN 上了,页面代码和组件才是大头。解法按优先级排:第一,把非首屏页全拆到 subPackages 分包里,主包只留首页、tabBar 页面和公共组件;第二,检查是否引入了完整的 UI 组件库,按需引入替代全量引入;第三,编译设置里开启压缩,去掉 console 日志和 sourcemap。不过不写“minified”之外,HBuilderX 的“运行—运行到小程序模拟器”里,编译模式选“发行”而不是“运行”,包体积会明显小一截。

4.3 竞拍页倒计时与出价:服务器时间戳+轮询方案

竞拍页面的倒计时是前端最容易翻车的地方。新手直接拿本地时间计算剩余时间,用户改一下手机时间,倒计时就提前结束或者延长。正确做法是进入页面时请求一次服务器接口拿到当前时间戳,再按秒递减,剩余时间以服务端为准。出价接口只接受服务端下发的剩余时间,前端倒计时只看展示用途。

// utils/time.js - 倒计时封装 export function startCountdown(serverEndAt, onTick, onFinish) { let remaining = serverEndAt * 1000 - Date.now() const timer = setInterval(() => { remaining -= 1000 if (remaining <= 0) { clearInterval(timer) onFinish() return } onTick(formatRemaining(remaining)) }, 1000) return timer } // 页面中使用 const res = await api.getAuctionInfo(lotId) this.endAt = res.data.end_at // 服务端返回的截拍时间戳,单位秒 this.timer = startCountdown(this.endAt, (text) => { this.countdownText = text }, () => { this.auctionEnded = true })

这里有个细节:Date.now() 是毫秒,服务器返回的 end_at 如果是秒,要乘以 1000 再算。多次进入页面或小程序切后台再唤醒时,定时器会不准,我在 onShow 生命周期里会重新请求接口校准剩余时间,而不是依靠旧的 timer 计时。出价按钮点击后要加防重复提交锁,本地用 boolean 变量控制,同时把出价金额传给服务端校验——前端防重复是体验,后端防重复才是底线。竞拍列表页、详情页、我的出价页共用同一个时间校准工具,避免三处各写一套导致时间差越来越大。

5. 上线前避坑:竞拍时间错乱、库存超卖、小程序包超限的 5 个排查记录

5.1 倒计时差 30 秒:本地时间与服务器时间混用

现象:用户反馈倒计时还剩 30 秒时,点出价提交,提示竞拍已结束;反过来,倒计时归零后页面还能出价成功。

原因:前端用 Date.now() 本地时间,和服务器的 time() 存在系统时间偏差;用户手机时间设置不准时,偏差会扩大到几分钟。

解决:所有关键时间判定全部在后端完成。前端只负责展示由服务端计算好的剩余秒数,出价接口在 Lua 脚本里用服务器时间戳算 leftMs。前端每 30 秒重新拉一次服务器时间和剩余时间校准,不要依赖页面本地累计。

5.2 出价成功但成交的不是他:SELECT 再 INSERT 的经典竞态

现象:两个用户几乎同时出价,两个人都收到“出价成功”的提示,落库后成交记录却是另一个人。

原因:后端代码先查 MAX(price),再判断,再 INSERT,两个并发请求查到同一价格,都判断有效,先后写入。

解决:并发控制下沉到 Redis Lua,价格判断和更新原子完成后再落库。竞拍类系统不建议只用数据库事务解决并发,行锁在高竞争下会把数据库拖垮;Redis 拦截成本最低。而且 Lua 校验通过后,数据库写入用事务包裹,回滚时记得回退 Redis 价格。

5.3 闪拍一秒被清空:缺少限流和库存预热

现象:开拍 1 秒内库存变成 0,后台发现大部分订单来自同一 IP 段,疑似脚本抢购。

原因:接口没有限流,一个人可以通过循环请求反复调抢购接口;同时库存直接查数据库扣减,数据库连接被打满后正常用户全被拒之门外。

解决:抢购接口做两层控制。第一层用 Redis 计数器做每用户每秒限流,比如 1 秒最多 2 次请求;第二层库存预热到 Redis,数据库只处理扣减成功的订单创建。还可以在开拍前 10 秒拒绝所有请求,避免预热前流量打穿数据库。日志里记录每个用户 IP 的请求次数,活动结束后拉黑异常账号。

5.4 微信小程序报 source size 2612kb exceed max limit 2mb

现象:HBuilderX 编译后上传,微信开发者工具提示主包超 2MB。

原因:所有页面和组件都在主包,或引入了全量 UI 库,或图片以 base64 形式嵌在代码里。

解决:先跑一遍“发行—小程序”模式,看是否变小;然后把拍卖详情、订单列表等非 tabBar 页面拆进 subPackages 分包;最后把 base64 图片替换成 CDN 链接。注意 tabBar 页面必须在主包,不能分包。体积压缩后先在开发者工具里清缓存再预览,否则可能看到旧的体积数据。

5.5 App 打包后请求一直失败:localhost 指到了手机自己

现象:H5 和微信小程序里接口正常,打包成安卓 App 后所有请求报“无法连接服务器”。

原因:代码里 BASE_URL 写的是 http://localhost:8080,App 运行在手机上时,localhost 指向手机自身,不是电脑或云端服务器。

解决:把 API 地址提取成环境配置文件,通过 UNIAPP 内置的环境变量区分开发、测试、生产。App 真机调试时,先用局域网 IP 加端口访问本地 PHP 服务,确认连通后再切到正式域名。这里的坑在于开发时浏览器里能跑,不代表 App 里能跑——App 没有浏览器那么宽松的跨域规则,PHP 端要正确配置跨域头,同时只有 HTTPS 域名才能在正式打包的 App 里正常请求。

6. 验证与进阶:用压测找出并发瓶颈,管理后台的三个可复用设计

6.1 出价接口压测:ab 命令与几个关键指标

并发模块做完以后,必须用工具验证,不能靠“感觉没问题”就上线。我一般用 Apache ab 压测出价接口,同时观察 Redis 和 MySQL 的负载。

# 并发200个请求,总量5000,压竞拍出价接口 ab -n 5000 -c 200 -p bid.json -T application/json http://你的域名/api/auction/bid

压测时重点看三个指标:失败请求数、平均响应时间、Redis 的 Gets 和 Sets 速率。如果失败请求来自 Lua 返回 0,说明并发控制是生效的;如果出现 500 错误,多半是数据库连接池被打满或事务死锁。压测完不要只看成功率,还要核对 auction_record 表里的记录数和 Redis 里的最高价是否一致,防止漏单。另外我会在压测时同时跑一个脚本模拟超时未支付订单关闭,确认状态机在并发和超时两条路径同时触发时不乱。

6.2 管理后台的三个可复用设计:场次管理、佣金结算、风控日志

项目稳定后,管理后台才是日常运营的主战场。第一个可复用设计是拍卖场次管理:后台创建场次、关联资产、设定起拍价和时间,前端自动显示倒计时。第二个是佣金结算:所有订单支付完成后,按配置的佣金比例分账,平台佣金进平台钱包,卖家货款进卖家可提现余额,结算记录可导出对账。第三个是风控日志:出价记录、抢购记录、限流拦截记录全部落表,异常 IP 和用户自动标记,运营可以一键冻结账号。

这三个设计都不复杂,但能把源码的价值放大很多。我最早做这类系统时跳过压测直接上线,结果竞拍首日就出现超卖,半夜起来手动回滚订单,从那以后我养成了两个习惯:任何并发接口改完必须跑完压测才合代码;上线前把 MySQL 慢查询日志和 Redis 的命中率监控打开,数据比感觉可靠得多。这个方向值不值得投入,取决于你的业务是否需要同时承载交易、拍卖和数藏三种场景,如果只是单一商城,用这套全家桶反而增加运维成本;如果确实要多玩法并行,按上面的数据模型和并发方案走一遍,坑会少很多。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询