简介面向多用户挂售、转卖、竞拍与闪拍场景的商城系统源码同时涵盖数字藏品展示与交易。适用于二手交易、拍卖行、数字藏品平台、竞价转拍等业务场景适合具备PHP和uni-app基础的中级开发者进行二次开发或快速落地上线。后端采用ThinkPHP框架前端使用uni-app开发可通过HBuilder X直接编译打包整体前后端分离、目录结构清晰。资源包共2002个文件其中包含473个PHP接口文件、378个JS交互脚本、79个Vue页面组件并辅以配置文件、数据库脚本、图片素材和说明文档压缩包整体约142.57MB层级合理便于按模块检索学习。已有230人学习下载源码经实测可用随包提供安装教程、数据库修改路径、运行目录与伪静态配置、后台默认账号密码及前端编译方式能够帮助读者从环境部署、数据导入、接口联调到移动端打包快速走通全流程同时附带的移动端构建文件可辅助直接验证核心功能。1. 多用户挂售转卖竞拍闪拍商城系统/NFT数藏系统用 PHPUNIAPP 源码起步前先想清楚这四件事多用户挂售转卖竞拍闪拍商城系统/NFT数藏系统光看标题就是个“全家桶”一套源码里要同时跑起一口价挂售、二手转卖、限时竞拍、秒杀式闪拍还要处理 NFT 数字藏品的凭证流转。这类项目用后端 PHP 前端 UNIAPP 的源码方案最常见PHP 负责订单、钱包、拍卖事务UNIAPP 负责一套代码发微信小程序、H5 和安卓/iOS App。适合谁想快速搭一个数藏或潮玩交易平台的开发者或者接盘了类似 PHP 源码但不敢动拍卖模块的人。它真正的难点不在增删改查而在四件事订单状态机怎么设计、竞拍出价怎么保证不超卖、闪拍库存怎么在并发下扣得准、小程序端怎么过包体积和打包配置这道坎。把这四件事想明白源码到手才不会变成一坨不敢改的黑匣子。2. 拆解四种交易玩法与数据模型先跑通 MySQL 层面的状态机2.1 挂售、转卖、竞拍、闪拍四种玩法在业务上怎么划分很多源码把四种玩法都挂在同一张“商品表”上靠一个 type 字段区分短期能跑但一到竞拍和闪拍就露馅。先厘清概念挂售是平台或用户以一口价上架买家付款即成交转卖是用户对用户的二手交易买家付款后进入平台担保卖家发货、买家确认收货后钱才结算给卖家竞拍是设定起拍价、加价幅度和倒计时截拍时出价最高者得闪拍更像秒杀固定时间点开抢库存少、并发高拼的是接口响应速度。我一般会把交易模型拆成三套并行的结构。挂售和转卖共用一个商品/资产模型因为它们的核心动作都是“定价—支付—履约”区别只在资金流向竞拍单独用“场次出价记录”模型因为每次出价都是一次更新必须记录完整的出价轨迹闪拍单独用“活动库存缓存”模型因为它依赖预热的 Redis 库存。三套模型共享用户表、钱包表、订单表这三个基础底座所有收入流水统一走钱包流水表后面做佣金结算时才查得清。2.2 核心表设计与 SQLasset、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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 竞拍出价RedisLua 保证“校验更新”的原子性竞拍最忌讳的做法是 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:8080App 运行在手机上时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 的命中率监控打开数据比感觉可靠得多。这个方向值不值得投入取决于你的业务是否需要同时承载交易、拍卖和数藏三种场景如果只是单一商城用这套全家桶反而增加运维成本如果确实要多玩法并行按上面的数据模型和并发方案走一遍坑会少很多。希望帮到你。本文还有配套的精品资源点击获取