尧图精选

PHP+UniApp实现NFT数藏交易系统:挂售、竞拍与转卖架构解析

🕒 发布时间:2026/9/16 16:11:58 📁 来源:尧图网络
简介这是一套面向商城类创业团队与PHP开发者的多用户挂售转卖竞拍系统源码整合NFT数藏与闪拍玩法。后端采用PHP前端使用uni-app覆盖后台商品挂单、竞拍场次配置、用户实时出价、提货及转售等完整流程并支持余额、支付宝与微信多种手续费支付方式。压缩包内共2002个文件以PHP业务逻辑、uni-app前端页面与Vue组件为主同时包含JS交互脚本、JSON配置文件、HTML模板及说明文档整体约142MB目录结构清晰适合作为二开或学习参考。已有185人浏览学习源码内附教程便于快速部署与理解竞拍、转售等核心模块。1. 挂售、转卖、竞拍、闪拍、NFT数藏这套系统到底在跑什么你从标题里读到五个动作挂售、转卖、竞拍、闪拍以及 NFT 数藏。第一次接这类项目的人往往先去翻商品分类和前台页面模板真正让项目延期的反而是另一件事一件藏品在用户 A 手上挂售用户 B 竞拍买走B 转头提价转卖用户 C 又通过闪拍模式拍下。商品没变但持有人已经换了三次每轮价格都不一样账户流水还得对得上。这种业务下的绝大部分复杂度不在 UI而在“资产归属”和“交易状态”这两条数据链上。我习惯把这类 PHPUNIAPP 源码先当成一个带状态机的账务系统来读确认每张交易表会不会重复入账、转让后原持有人是否还有操作权再去看页面。这篇按后端数据模型、PHP 并发接口、UniApp 前端、部署验收四个层面拆开讲既给刚拿到源码需要跑通的人看也给准备在此基础上做二开的工程师参考。下面先从最容易被忽略的一张表开始。2. 后端建模商品、持仓、拍卖单、出价记录的字段与状态转移设计2.1 把“商品表”和“谁持有它”拆成两张表普通商城系统里一张商品表加一张库存表就够多用户数藏平台不行。原因在于数字藏品是“唯一所有权”商品同一件藏品的持有者会随着成交不断变化而商品本身的信息如名称、封面、原始价格是不变的。把“持有人”塞进商品表每次转卖都要 update 商品表的 user_id历史持有关系就全丢了。常见的设计是拆成两张表。第一张goods只存藏品本身的静态信息包括数藏唯一编码第二张user_asset是持仓流水表每次所有权转移就插入一条新记录代表“谁在什么时间、以什么价格获得了这件藏品”。这样转卖多少次都有据可查也能支撑“一个账号下同一藏品有多份”的副本场景。CREATE TABLE goods ( id int(11) unsigned NOT NULL AUTO_INCREMENT, title varchar(120) NOT NULL COMMENT 藏品名称, cover varchar(255) NOT NULL DEFAULT COMMENT 封面图, origin_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 首发价, nft_code varchar(64) NOT NULL DEFAULT COMMENT 数藏唯一编码, nft_hash varchar(120) NOT NULL DEFAULT COMMENT 图片文件指纹, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0 待发售 1 流通中 2 下架, create_time int(11) unsigned NOT NULL, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT数藏藏品基础信息; CREATE TABLE user_asset ( id int(11) unsigned NOT NULL AUTO_INCREMENT, user_id int(11) unsigned NOT NULL COMMENT 当前持有人, goods_id int(11) unsigned NOT NULL COMMENT 藏品ID, source tinyint(1) NOT NULL DEFAULT 1 COMMENT 1 首发购买 2 竞拍获得 3 转卖获得, buy_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 获得时价格, avail tinyint(1) NOT NULL DEFAULT 1 COMMENT 1 可挂售 2 挂售锁定中, create_time int(11) unsigned NOT NULL, PRIMARY KEY (id), KEY idx_user (user_id,goods_id), KEY idx_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户持有藏品流水;这里有几个字段需要重点解释。user_asset.avail是容易被忽略的开关。用户把藏品挂上拍卖单后资产必须立即置为“锁定中”否则同一件藏品会被同时挂售两次前端也能看到重复在售记录。source字段不是为了展示用而是为了做返佣或手续费计算——首发和转卖的手续费率通常不同。goods.nft_hash存的是图片文件的 MD5 或 SHA-1 指纹用于校验数字藏品文件有没有被替换。二次开发时不要把它理解成区块链上的哈希它只是平台内防篡改的一个凭证字段。若平台接入了联盟链或版权存证服务这个字段可与存证编号关联但 PHP 端只需要保证写入和校验一致。2.2 拍卖单表四种卖法共用同一张业务表挂售、转卖、竞拍、闪拍在页面上是四个入口落库时大多数源码会统一放进一张sell_order表通过type区分模式。好处是结算逻辑只写一套坏处是不同模式对字段要求不同设计时要留足可空字段。CREATE TABLE sell_order ( id int(11) unsigned NOT NULL AUTO_INCREMENT, asset_id int(11) unsigned NOT NULL COMMENT user_asset 资产ID, seller_id int(11) unsigned NOT NULL COMMENT 挂售人, type tinyint(1) NOT NULL COMMENT 1 挂售 2 竞拍 3 闪拍, resale tinyint(1) NOT NULL DEFAULT 0 COMMENT 0 首发挂售 1 转卖, start_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 起拍价, current_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 当前价格, step_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 最低加价幅度, buy_now_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 一口价挂售模式必填, start_time int(11) unsigned NOT NULL COMMENT 开始时间, end_time int(11) unsigned NOT NULL COMMENT 结束时间, buyer_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 最终买家, deal_price decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 成交价, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0 进行中 1 已成交 2 已撤销 3 已过期, create_time int(11) unsigned NOT NULL, PRIMARY KEY (id), KEY idx_type_status (type,status), KEY idx_end_time (end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT交易单主表;字段参数在不同模式下的含义差异建议后端统一写在接口文档里二开的人最容易被这个坑到字段挂售竞拍闪拍current_price等于售价随出价更新随闪拍价格衰减更新step_price不参与校验每次加价的最小幅度时间阶梯价格间隔buy_now_price必填可空填写则支持一口价截拍不参与end_time下架时间结拍时间每轮闪拍结束时间status 流转终点买家付款后成交出价记录落库后成交下单即成交还有一个隐蔽字段是resale。它不是业务类型而是一个标记。首次发行的藏品被 A 买走A 再挂出来时resale1。手续费结算、转卖溢价统计都依赖这个标记后续做数据报表时如果发现价格异常翻倍先查是不是这里写错了。2.3 状态推进谁允许走到下一步谁拦住多用户拍卖系统最容易出 bug 的地方不是新建订单而是状态被非法跳转。A 的挂售单还没成交B 不可能对它有操作权已经成交的竞拍单不能再次被出价。实现上靠两把锁数据库字段status和业务里的操作前校验。我用一个简单的状态表来约束自己写代码时不漏分支当前状态允许操作下一状态0 进行中买家出价/买家一口价购买/卖家撤销1 已成交 / 2 已撤销1 已成交买家确认收货/系统自动确认状态不变生成资产记录2 已撤销无操作资产回到卖家可用状态3 已过期系统定时任务扫描处理资产回到卖家可用状态需要说明的是status1之后不能立刻把user_asset转移到买家名下中间还隔着付款确认。竞拍结束只是产生了“中标人”付款完成才能转移资产。这一步如果状态设计不到位就会出现买家未付款但藏品已经从卖家持仓里消失的情况这是售后纠纷的主要来源。结算脚本通常由定时任务驱动每分钟扫描一次end_time已过且status0的订单。之前见过有人用页面加载时触发结算结果流量一高就重复结算所以记住过期结标和付款回调必须放在服务端可控的入口。3. PHP 接口的并发控制出价原子性、倒计时校验与转卖落账3.1 出价接口Redis 锁和数据库条件更新缺一不可竞拍系统的核心难点是多人同时出价。PHP 接口往往是传统同步模型两个请求同时读到current_price100同时算出110同时写回最后库里只剩一条 110但两位用户界面都提示出价成功这就是经典的超卖问题。处理思路分两层。第一层用 Redis 做一个进程内互斥锁防止同一拍卖单的写操作重叠第二层在数据库里再做一次“当前价格匹配”校验保证即使 Redis 锁失效数据库层面也不会覆盖别人刚更新的价格。这两层不是重复而是互为兜底。以 ThinkPHP 示例出价接口核心逻辑是这样public function bid() { $auctionId intval(input(post.auction_id)); $price intval(input(post.price)); // 价格统一按“分”传入 $lockKey auction_lock_ . $auctionId; if (!$this-redis-set($lockKey, 1, [NX, EX 3])) { return json([code 1, msg 操作太频繁请稍后再试]); } try { Db::startTrans(); // 加悲观锁读取当前拍卖单避免本次操作期间被其他事务修改 $order Db::name(sell_order) -where(id, $auctionId) -lock(true) -find(); if (!$order || $order[status] ! 0) { throw new \Exception(拍卖已结束); } if ($order[end_time] time()) { throw new \Exception(已到截止时间); } $nextPrice $order[current_price] $order[step_price]; if ($price $nextPrice) { throw new \Exception(加价幅度不足); } // 乐观条件更新用旧价格做匹配防止覆盖 $affected Db::name(sell_order) -where(id, $auctionId) -where(current_price, $order[current_price]) -update([current_price $price]); if (!$affected) { throw new \Exception(价格已被其他人抢先更新); } Db::name(bid_log)-insert([ auction_id $auctionId, user_id $this-uid, price $price, create_time time(), ]); Db::commit(); $this-redis-del($lockKey); return json([code 0, msg 出价成功]); } catch (\Exception $e) { Db::rollback(); $this-redis-del($lockKey); return json([code 1, msg $e-getMessage()]); } }这段代码的关键点有三个。price统一按分传是为了避免 JavaScript 端浮点计算 0.10.2 带来的偏差。PHP 端decimal字段虽然能存小数但比较运算中有可能踩精度问题。收整数存整数展示时再除以 100这是账务系统的标准做法。lock(true)是 InnoDB 的行锁它保证当前事务在读这条拍卖单时其他事务必须等待。配合 Redis 锁使用后出价并发基本能被控制在安全范围内。注意这里 Redis 锁是“软锁”过期时间 3 秒如果逻辑执行超过 3 秒锁自动释放所以最后还要靠数据库条件更新兜底。where(current_price, $order[current_price])这一步是乐观锁的典型实现。即使两个请求同时进入第一个事务提交后第二个事务的 update 条件已经不成立affected0就会抛出异常。建议检查一下你手上源码的 update 语句里有没有这个条件很多简化版代码会直接update sell_order set current_price$price一旦被并发打到就会静默覆盖。3.2 倒计时的服务端校准前端页面显示倒计时后端接口校验截止时间这两者必须对齐。不少源码犯过一个低级错误前端把end_time转成剩余秒数后不断自减后端接口却用date(Y-m-d H:i:s)和数据库时间比较而服务器的时区或者数据库时区配置不一致就会导致前端显示还有 30 秒后端已经返回“拍卖结束”。处理这类问题我一般会在启动时从后端拉一次时间差后续所有倒计时都基于这个偏移量计算。接口里固定返回两个字段server_time和end_time前端只算差值不直接信任客户端本地时间。// 竞拍详情接口返回值 $data [ auction_id $order[id], current_price intval($order[current_price]), end_time intval($order[end_time]), server_time time(), remaining $order[end_time] - time(), ];前端收到remaining后做一次性展示后续每秒减 1每次出价请求再把server_time同步一次这样能有效避免用户通过改手机时间绕过倒计时。虽然服务端最终校验兜底但前端不显示误导信息能少很多客诉。3.3 转卖挂售和成交结算的幂等处理转卖接口本质上和首次挂售一样只是多了资产归属校验。用户在“我的藏品”里点转卖时后端要校验两件事该资产属于当前用户且user_asset.avail1未被锁定。校验通过后把资产置为锁定再插入一条sell_order。成交后的资产转移不能只做一次 update必须带上幂等控制。常见的做法是给交易流水表加一个唯一索引比如order_no在事务里先判断是否已经处理过再执行资产转移和钱包入账。下面这段是成交确认逻辑的关键部分public function confirmDeal($sellOrderId) { Db::startTrans(); try { // 重复回调防护订单状态必须是进行中才能进入成交 $order Db::name(sell_order) -where(id, $sellOrderId) -where(status, 0) -lock(true) -find(); if (!$order) { throw new \Exception(订单状态已更新请勿重复操作); } // 生成唯一交易流水号 $tradeNo T . date(YmdHis) . mt_rand(1000, 9999); // 买家的资产记录 Db::name(user_asset)-insert([ user_id $order[buyer_id], goods_id $order[goods_id], source $order[resale] ? 3 : 1, buy_price $order[deal_price], avail 1, create_time time(), ]); // 卖家的钱包流水 Db::name(wallet_log)-insert([ order_no $tradeNo, user_id $order[seller_id], amount $order[deal_price], type 1, create_time time(), ]); // 原资产标记失效防止二次交易 Db::name(user_asset) -where(id, $order[asset_id]) -update([avail 0]); // 订单状态置为已经成交 Db::name(sell_order) -where(id, $sellOrderId) -update([status 1]); Db::commit(); return $tradeNo; } catch (\Exception $e) { Db::rollback(); throw $e; } }这里没有把原user_asset记录的user_id改成买家而是插入一条新记录这个设计在转卖场景里非常关键。原记录保留着“卖家何时以何价获得”的信息新记录则记录“买家以何价获得”两段历史完整可查。如果直接更新原记录查询历史成交价就只能依赖订单表数据会变得很碎。wallet_log里的order_no建议要加上唯一索引保证在支付回调重试、网络抖动等场景下不会插入两条相同的流水。4. UniApp 前端页面商品列表、竞拍卡片、个人藏品与多端打包4.1 页面骨架与公共请求封装UniApp 这层的工作量并不是每个页面写一套 UI而是把“一套代码跑三端”的规则定清楚。拿这份类型的源码来说pages.json 里的核心页面通常包括这几类{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 首页 } }, { path: pages/shop/list, style: { navigationBarTitleText: 挂售列表 } }, { path: pages/auction/detail, style: { navigationBarTitleText: 竞拍详情 } }, { path: pages/asset/mine, style: { navigationBarTitleText: 我的藏品 } } ], tabBar: { list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/shop/list, text: 市场 }, { pagePath: pages/asset/mine, text: 藏品 } ] } }pages/shop/list和pages/auction/detail是核心页面。列表页展示在售商品包含类型标签和当前价格详情页承担出价、一键购、闪拍抢购等操作。至于公共请求封装我建议所有请求都走一个request.js统一处理code判断、token注入和错误提示。后端返回的数据结构往往是一个数组对象比如{code:0, data:{list:[...]}}这个封装里需要固定处理“业务失败和网络失败是两种状态”。// utils/request.js const request (url, method, data) { return new Promise((resolve, reject) { uni.request({ url: getApp().globalData.apiHost url, method: method || GET, data: data || {}, timeout: 15000, header: { Content-Type: application/json, token: uni.getStorageSync(token) }, success: (res) { if (res.statusCode 200) { if (res.data.code 0) { resolve(res.data.data); } else { uni.showToast({ title: res.data.msg, icon: none }); reject(res.data); } } else { uni.showToast({ title: 网络异常, icon: none }); reject(res); } }, fail: (err) { uni.showToast({ title: 请求失败, icon: none }); reject(err); } }); }); }; export default request;这里要特别说明timeout: 15000。H5 和小程序环境下默认超时时间不太一样如果不显式设置竞拍接口在高并发下会出现长时间 pending用户重复点击出价按钮就会产生连续请求。设置 15 秒超时后前端还需要在按钮上做防重复点击提交后把按钮置为loading状态。4.2 竞拍卡片倒计时和出价交互竞拍页面最核心的是倒计时展示和出价操作。倒计时不能用纯前端setInterval跑逻辑在前面后端校准里已经说过这里给出一个完整的实现片段。view classauction-card image :srcitem.cover modeaspectFill/image view classprice当前价 ¥{{ (item.current_price / 100).toFixed(2) }}/view view classtime v-ifitem.type 2距结束 {{ formatRemain(item.remaining) }}/view view classtime v-else-ifitem.type 3闪拍价即将上调/view button typeprimary :disableditem.remaining 0 || item.bidding clickdoBid(item) {{ item.type 3 ? 立即抢拍 : 出价 }}/button /viewexport default { methods: { formatRemain(seconds) { if (seconds 0) return 已结束; const h Math.floor(seconds / 3600); const m Math.floor((seconds % 3600) / 60); const s seconds % 60; return ${h}时${m}分${s}秒; }, async doBid(item) { if (item.bidding) return; item.bidding true; try { const data await request(/api/auction/bid, POST, { auction_id: item.id, price: item.type 3 ? item.current_price : item.current_price item.step_price }); uni.showToast({ title: 出价成功 }); this.loadList(); } catch (e) { // 错误提示已在 request 中统一处理 } finally { item.bidding false; } } } };闪拍和竞拍的触发条件不同。竞拍是用户输入或选择加价幅度后提交闪拍更像是抢购价格按时间阶梯变化用户看到合适价格直接下单。所以在doBid里闪拍传入的价格就是当前价后端通过时间计算当前应售价格前端不做任何价格推导。4.3 H5 嵌入微信公众号获取定位和三端条件编译部分数藏平台会把闪拍和线下活动结合需要获取用户位置。UniApp 里uni.getLocation在 App 和微信小程序里配置权限后可以直接调用但在 H5 嵌入微信公众号的场景下原生定位接口权限不够必须先走微信 JS-SDK 的config注入。常见做法是用条件编译区分平台// #ifdef MP-WEIXIN uni.getLocation({ type: gcj02, success: (res) { console.log(小程序定位, res.latitude, res.longitude); } }); // #endif // #ifdef H5 const jweixin require(jweixin-module); jweixin.config({ debug: false, appId: this.wxAppId, timestamp: this.wxTimestamp, nonceStr: this.wxNonceStr, signature: this.wxSignature, jsApiList: [getLocation] }); jweixin.ready(() { jweixin.getLocation({ type: gcj02, success: (res) { console.log(公众号定位, res.latitude, res.longitude); } }); }); // #endif注意wxTimestamp、wxSignature这些参数必须由后端 PHP 接口生成签名算法是微信 JS-SDK 的固定流程用当前页面 URL 加jsapi_ticket做 SHA-1。很多开发者在这里踩坑原因是签名用的 URL 必须去掉#后面的部分而且要在进入页面时就生成不能在onLoad之后等异步数据返回再调。4.4 微信小程序和安卓打包检查UniApp 的打包是接手这类源码时最常卡住的环节。微信小程序端需要注意在manifest.json里填好小程序 AppID在小程序管理后台把接口域名加入 request 合法域名否则所有请求都会报url not in domain list如果用了 WebSocket 做实时出价推送socket 合法域名也要单独配置。安卓端打包时HBuilderX 云打包需要准备签名证书证书的 SHA1 指纹要填到manifest.json的 App 模块配置里。如果集成了微信登录、微信支付还要在微信开放平台配置应用签名。最容易忽略的是manifest.json里的App权限配置Android 端定位权限、存储权限不勾选调用uni.getLocation或图片上传时会直接失败但控制台报错往往不明显。这里给出一份三端平台的配置对照表项目微信小程序H5 公众号Android App接口域名request 合法域名无跨域限制但需 CORS无定位app.json 配置 permission微信 JS-SDK 签名Android 权限声明支付微信支付商户号JSAPI 支付开放平台应用签名打包工具微信开发者工具上传无需打包HBuilderX 云打包5. 拿到源码后的一小时验收部署、改价测试与接口自检5.1 宝塔部署的关键参数这套系统在宝塔面板部署时最隐蔽的问题出在 PHP 版本和扩展上。PHP 建议用 8.0 或 7.4低于 7.4 时UniApp 后端接口里常见的??语法、箭头函数会直接报语法错误。装好面板后先确认这几个扩展已启用fileinfo、redis、opcache如果是图片处理类数藏系统gd或imagick也要开着。运行目录必须指向public伪静态选择 thinkphp 规则。很多源码下载后第一步打不开页面不是代码问题而是站点根目录指错了。宝塔的伪静态配置里选thinkphp规则后所有 URL 都会经过index.php入口这样路由才能生效。数据库导入完成后需要修改.env文件里的数据库连接信息。配置好之后最后一步是加定时任务拍卖过期结标依赖它* * * * * cd /www/wwwroot/你的站点目录 php think auction:settle /tmp/auction_settle.log 21这个命令每分钟扫描一次所有status0且end_time已过的拍卖单。如果站点目录里没有think文件就去public目录里找cli入口。定时任务执行失败时竞拍到期后状态不会变化前端会一直显示“进行中”实际上已经无法操作这是最常见的“僵尸单”问题。5.2 低成本验证一套完整业务流程验证系统是否正常不要直接上真实商品。我会先在后台创建一个测试藏品价格设置为几分钱然后准备两个测试账号 A 和 B走一遍完整闭环。第一步用账号 A 发起一个竞拍单起拍价 1 分加价幅度 1 分持续时间 5 分钟。第二步用账号 B 出价 2 分再到账号 A 出价 3 分此时应显示账号 A 为当前最高出价者。第三步等拍卖倒计时结束查看定时任务日志里是否生成了成交记录。第四步去账号 A 的藏品列表里确认原藏品还在但已变成不可挂售状态去账号 B 的藏品列表里确认新增了一条藏品。整个验证过程的检查点有三个sell_order.status是否从 0 变为 1bid_log里是否产生了三条出价记录user_asset里是否新插入了一条属于 B 的记录。如果这三个条件满足说明核心的竞拍闭环是通的。遇到任何一环缺失先看定时任务日志再看数据库里的end_time和服务器当前时间是否一致。5.3 转卖逻辑的二次确认转卖是这套系统里最容易被“半成品”实现忽略的模块。验证转卖时用 B 把刚获得的藏品重新挂售价格改为 5 分然后用账号 A 买下。这里有个细节要特别检查B 的原有资产记录是否被打上avail0A 是否新增了一条source3的资产记录。如果转卖后 B 的藏品还能再次操作说明user_asset的状态没有被联动更新。最后补一个实用的小技巧验证码接口在宝塔环境里容易出现图片不显示的问题。多数源码的验证码是 PHP 端用 GD 库生成图片如果gd扩展没装接口返回的不是图片而是报错文本。上线前把这个接口单独测试一遍比等到用户注册时才发现要好得多。系统跑通之后再去做支付通道配置和运营数据报表底层交易链路稳了上面加什么都顺手。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →