1. 商店与拍卖系统的整体设计思路拆解
1.1 为什么商店和拍卖要放在一起讲
很多刚接触游戏客户端开发的朋友会觉得,商店和拍卖是两个完全不同的模块——一个是你跟系统做交易,一个是你跟其他玩家做交易。但如果你真正动手写过这两套系统,就会发现它们在底层的数据结构、UI交互逻辑、网络同步机制上有着惊人的相似度。我在实际项目中反复验证过,把这两个系统放在同一个架构层面来设计,能省下至少30%的重复代码量。
先说说它们到底解决什么问题。商店系统解决的是“玩家如何用游戏内货币换取固定道具”的问题,它的核心特征是卖方是系统,商品列表、价格、库存全部由服务端配置决定。拍卖系统解决的是“玩家之间如何用游戏内货币互相交易”的问题,它的核心特征是卖方是玩家,商品来源、定价、成交时机都带有不确定性。
这两者的共同点在于:都需要处理商品展示、货币校验、交易确认、物品转移、交易记录这五个核心环节。区别在于拍卖多了一层竞价逻辑和过期机制。所以我的做法是,先抽象出一个通用的“交易框架”,然后商店和拍卖各自作为这个框架的一个特化实现。
1.2 客户端在交易系统中到底负责什么
这里有一个新手很容易踩的坑:把太多逻辑放在客户端。我见过不少Demo项目,客户端自己算价格、自己判断能不能买、自己扣钱加物品,结果一联机就出各种同步问题。正确的做法是,客户端只负责三件事:展示、输入采集、结果呈现。所有涉及数值计算、状态变更、合法性校验的逻辑,全部交给服务端。
具体来说,当你点击商店里一个商品时,客户端做的事情是:把商品ID和购买数量打包成一个请求发给服务端,然后等待服务端返回结果。服务端会校验你的货币够不够、背包空间足不足、商品是否还在售,然后执行扣款和发货,最后把新的货币数量和物品信息推送给客户端。客户端拿到结果后刷新UI。整个流程里,客户端不做任何“信任自己”的判断。
拍卖系统也是同样的道理。你出价的时候,客户端只是把你的出价金额发给服务端,服务端来判断你的出价是否高于当前最高价、你的钱够不够冻结、拍卖是否还在有效期内。这种“客户端薄、服务端厚”的设计,是保证交易系统不出安全事故的基础。
1.3 数据结构的前瞻性设计
我在设计交易系统时,习惯先把数据结构定好,因为这部分一旦上线就很难改。商店和拍卖共用的核心结构大概长这样:
ItemInfo { itemId: int // 道具唯一标识 count: int // 数量 bindType: int // 绑定类型:0不绑定 1绑定 expireTime: long // 过期时间戳,0表示永不过期 } TradeRecord { recordId: long // 交易流水号 tradeType: int // 1商店购买 2商店出售 3拍卖上架 4拍卖成交 itemInfo: ItemInfo price: long // 单价 totalPrice: long // 总价 timestamp: long // 交易时间 status: int // 状态:进行中/已完成/已取消 }这个结构看起来简单,但有几个细节值得注意。bindType字段决定了物品能否再次交易,这是防止工作室刷金的关键。expireTime在商店里用于限时商品,在拍卖里用于上架时限。TradeRecord里的recordId必须全局唯一,方便后续查账和客服处理纠纷。
注意:道具ID的设计一定要留足扩展位。我见过用16位整数结果道具种类超过65535之后被迫重构的项目,那叫一个痛苦。建议直接用32位整数,甚至64位。
2. 商店系统的核心细节与实操要点
2.1 商店分类与商品配置表设计
商店在游戏里通常不是只有一个,而是分门别类:普通商店、公会商店、竞技场商店、限时活动商店等等。每种商店的货币类型不同,刷新机制也不同。我在配置这些商店时,会用一张主表和一张商品表来管理。
主表定义商店的基本属性:
| 字段名 | 类型 | 说明 |
|---|---|---|
| shopId | int | 商店唯一ID |
| shopName | string | 商店名称 |
| currencyType | int | 货币类型:1金币 2钻石 3公会币 4竞技币 |
| refreshType | int | 刷新类型:0不刷新 1每日 2每周 3手动 |
| refreshCost | int | 手动刷新消耗 |
| maxRefreshCount | int | 每日最大手动刷新次数 |
商品表定义每个商店里卖什么:
| 字段名 | 类型 | 说明 |
|---|---|---|
| shopId | int | 所属商店 |
| slotIndex | int | 货架位置 |
| itemId | int | 道具ID |
| price | int | 单价 |
| discount | float | 折扣系数 |
| stock | int | 库存,-1表示无限 |
| dailyLimit | int | 每日限购数量 |
| minLevel | int | 最低购买等级 |
这套配置表的好处是,策划可以直接在Excel里填,导出成JSON或二进制给客户端和服务端共用。客户端用这张表来渲染商品列表,服务端用同一张表来校验购买请求的合法性。
2.2 购买流程的完整实现
购买流程看起来简单,但要做到万无一失,需要把每一步都考虑清楚。我通常把购买分成以下几个阶段:
第一阶段:客户端预校验。玩家点击购买按钮后,客户端先做一轮本地检查——等级够不够、货币够不够、背包空间足不足。这一步只是为了提升用户体验,避免玩家点了半天才被服务端拒绝。但记住,这只是“预校验”,真正的校验在服务端。
第二阶段:发送购买请求。请求体里包含shopId、slotIndex、itemId、count这几个关键字段。这里有一个细节:一定要带上itemId,不能只传slotIndex。因为商店可能在两次请求之间刷新了,货架上的商品变了,只传位置会导致买错东西。
第三阶段:服务端校验与执行。服务端收到请求后,按顺序做这些检查:商店是否存在、货架位置是否合法、商品是否在售、玩家等级是否达标、限购次数是否用完、货币是否足够、背包是否有空间。全部通过后,在一个事务里完成扣款、扣库存、加物品、写交易记录。
第四阶段:结果推送。服务端把购买结果和最新的货币数量、物品信息推送给客户端。客户端收到后刷新UI,播放购买成功的特效。
# 服务端购买处理伪代码 def handle_buy_request(player, shop_id, slot_index, item_id, count): shop = get_shop_config(shop_id) if not shop: return error("商店不存在") slot = get_shop_slot(shop_id, slot_index) if not slot or slot.item_id != item_id: return error("商品已刷新,请重新选择") if player.level < slot.min_level: return error("等级不足") if slot.daily_limit > 0: bought = get_daily_bought_count(player, shop_id, slot_index) if bought + count > slot.daily_limit: return error("超过每日限购") total_price = int(slot.price * slot.discount) * count if player.currency[shop.currency_type] < total_price: return error("货币不足") if not has_bag_space(player, item_id, count): return error("背包空间不足") # 事务开始 deduct_currency(player, shop.currency_type, total_price) deduct_stock(shop_id, slot_index, count) add_item_to_bag(player, item_id, count) add_trade_record(player, TRADE_TYPE_SHOP_BUY, item_id, count, total_price) # 事务结束 return success(player.currency, player.bag)2.3 商店刷新的处理逻辑
商店刷新分两种:定时刷新和手动刷新。定时刷新由服务端在特定时间点触发,比如每天凌晨5点重置所有每日商店。手动刷新则是玩家主动消耗货币来重置货架。
这里有一个容易忽略的细节:刷新时要保留限购计数。如果玩家今天已经买了某个商品3次,刷新后这个商品的限购次数不应该重置,否则玩家可以通过反复刷新来绕过限购。我的做法是把限购计数存在一个独立的daily_bought表里,刷新只重置货架内容,不动这个表。
手动刷新的另一个坑是并发问题。如果玩家快速点击刷新按钮,可能会同时发出多个刷新请求。服务端需要做防重处理,比如给每个玩家加一个刷新锁,或者用请求序列号来去重。
实操心得:商店刷新后,客户端要清空当前的选中状态和购买数量输入框。我踩过一次坑,玩家选了5个商品,刷新后货架变了,但数量还是5,结果买错了东西,客服那边收到一堆投诉。
3. 拍卖系统的核心机制与实现细节
3.1 拍卖与商店的本质差异
拍卖系统比商店复杂得多,因为它引入了时间维度和玩家博弈。商店里商品永远在那,价格固定;拍卖里商品有时限,价格由市场决定。这就带来了几个商店没有的问题:竞价冲突、拍卖过期、成交结算、流拍处理。
我在设计拍卖系统时,核心围绕三个状态来展开:上架中、竞价中、已结束。上架中是拍卖刚创建,还没有人出价;竞价中是有人出价了,当前最高价被刷新;已结束是拍卖到期,要么成交要么流拍。
3.2 拍卖数据结构设计
拍卖的数据结构比商店复杂,因为要记录竞价历史。我通常用两张表:一张拍卖主表,一张竞价记录表。
拍卖主表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| auctionId | long | 拍卖唯一ID |
| sellerId | long | 卖家ID |
| itemInfo | ItemInfo | 拍卖物品 |
| startPrice | long | 起拍价 |
| buyoutPrice | long | 一口价,0表示不支持 |
| currentPrice | long | 当前最高价 |
| currentBidderId | long | 当前最高出价者 |
| startTime | long | 开始时间 |
| endTime | long | 结束时间 |
| status | int | 状态:1上架中 2竞价中 3已成交 4已流拍 5已取消 |
竞价记录表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| bidId | long | 竞价唯一ID |
| auctionId | long | 所属拍卖 |
| bidderId | long | 出价者 |
| bidPrice | long | 出价金额 |
| bidTime | long | 出价时间 |
这两张表配合使用,可以完整还原一个拍卖的全过程。竞价记录表在客服处理纠纷时特别有用,玩家说“我明明出价了怎么没买到”,一查记录就清楚了。
3.3 竞价流程的完整实现
竞价流程比购买流程多了一个“冻结资金”的环节。当玩家出价时,服务端不是直接扣钱,而是把这笔钱冻结起来。如果后来被其他人超价了,冻结解除,钱退回。如果最终成交,冻结的钱直接扣除给卖家。
# 服务端竞价处理伪代码 def handle_bid_request(player, auction_id, bid_price): auction = get_auction(auction_id) if not auction: return error("拍卖不存在") if auction.status not in [STATUS_LISTED, STATUS_BIDDING]: return error("拍卖已结束") if current_time() > auction.end_time: return error("拍卖已过期") if player.id == auction.seller_id: return error("不能给自己的拍卖出价") # 校验出价金额 min_bid = auction.current_price + get_min_bid_step(auction.current_price) if bid_price < min_bid: return error(f"出价不能低于{min_bid}") if bid_price > player.currency[CURRENCY_GOLD]: return error("金币不足") # 解冻前一个出价者的资金 if auction.current_bidder_id > 0: unfreeze_currency(auction.current_bidder_id, auction.current_price) send_mail(auction.current_bidder_id, "你已被超价", auction.current_price) # 冻结当前出价者的资金 freeze_currency(player.id, bid_price) # 更新拍卖状态 auction.current_price = bid_price auction.current_bidder_id = player.id auction.status = STATUS_BIDDING save_auction(auction) # 记录竞价 add_bid_record(auction_id, player.id, bid_price) # 广播给所有关注者 broadcast_auction_update(auction_id, auction) return success()这里有一个关键设计:最小加价幅度。不能让玩家每次只加1金币,否则拍卖会变成拉锯战。我的做法是根据当前价格动态计算最小加价幅度:
| 当前价格区间 | 最小加价幅度 |
|---|---|
| 0 - 1000 | 10 |
| 1001 - 10000 | 50 |
| 10001 - 100000 | 200 |
| 100001以上 | 1000 |
这个阶梯式加价幅度,既保证了拍卖的活跃度,又避免了恶意压价。
3.4 一口价与竞价并存的处理
很多游戏的拍卖支持一口价,就是玩家可以直接用某个价格买断,不用等拍卖结束。这个功能实现起来有一个坑:一口价和竞价的互斥处理。
如果拍卖已经有人出价了,一口价还能不能用?我的做法是:一口价始终可用,但价格要高于当前最高价。当有人用一口价买断时,服务端要解冻当前最高出价者的资金,把物品给买断者,然后把钱给卖家。整个过程要在一个事务里完成,否则容易出现“钱扣了东西没给”或者“东西给了钱没扣”的问题。
注意:一口价买断后,要给所有参与过竞价的玩家发邮件通知,告诉他们拍卖已结束。我见过一个项目忘了发通知,结果玩家一直在那等,以为拍卖还在进行。
4. 交易系统的安全防护与常见问题排查
4.1 防刷与防欺诈的核心策略
交易系统是游戏里最容易被攻击的模块之一。我总结了几条必须做的防护措施:
第一条:所有交易请求必须带序列号。客户端每次发请求带一个自增的序列号,服务端记录每个玩家最后处理的序列号,重复的序列号直接丢弃。这能防止重放攻击。
第二条:关键操作加频率限制。比如购买操作,同一个玩家每秒最多处理5次;竞价操作,每秒最多3次。超过限制的直接拒绝,并记录日志。
第三条:交易金额做上限校验。单次交易金额不能超过某个阈值,超过的要走二次确认。这个阈值可以根据游戏经济情况调整。
第四条:异常交易监控。服务端要记录所有交易日志,并定期分析。比如某个账号在短时间内频繁与另一个账号交易,或者交易价格明显偏离市场价,就要触发告警。
4.2 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 购买后物品没到账 | 事务未提交或回滚 | 查交易记录表和背包表 | 补发物品,修复事务逻辑 |
| 竞价后金币没冻结 | 冻结逻辑未执行 | 查冻结记录和玩家货币 | 补冻结,加日志监控 |
| 拍卖结束后未结算 | 定时任务未触发 | 查定时任务日志 | 手动结算,修复定时任务 |
| 商店刷新后限购重置 | 刷新逻辑误删计数 | 查daily_bought表 | 恢复计数,修复刷新逻辑 |
| 一口价买断后原出价者未退款 | 解冻逻辑遗漏 | 查冻结记录 | 补退款,修复买断逻辑 |
| 玩家能给自己出价 | 校验逻辑缺失 | 查竞价请求日志 | 加校验,封禁异常账号 |
4.3 我踩过的几个坑
第一个坑是货币类型混淆。早期项目里商店支持金币和钻石两种货币,结果有一次更新配置表时,把某个商品的价格货币类型填错了,导致玩家用金币买到了本该用钻石买的道具。后来我加了一个校验:配置表导出时自动检查货币类型和商品类型的匹配关系,不匹配的直接报错。
第二个坑是拍卖过期时间计算错误。我们用的是服务端时间戳,但有一次服务器时区配置错了,导致拍卖提前几小时结束。后来统一改成UTC时间戳,并且在客户端显示时做本地时区转换。
第三个坑是背包满时的处理。玩家购买时背包满了,服务端返回错误,但客户端没有正确处理,导致玩家以为购买成功了。后来我在客户端加了背包空间预检查,并且在服务端返回背包满的错误码时,弹窗提示玩家清理背包。
实操心得:交易系统的日志一定要详细。每一笔交易都要记录:谁、什么时候、买了什么、花了多少钱、交易前后的货币数量。这些日志在排查问题时能救命。我习惯把交易日志单独存一张表,保留至少90天。
5. 客户端UI交互的优化实践
5.1 商店界面的布局与交互细节
商店界面的核心是商品列表。我通常用滚动列表来展示,每个商品项包含:图标、名称、价格、限购信息、购买按钮。这里有几个交互细节值得注意:
商品图标要支持点击查看详情,弹窗里显示道具的完整描述和属性。价格要区分货币类型,金币用金色、钻石用蓝色,让玩家一眼就能分辨。限购信息要实时更新,买了一次之后剩余次数要立刻变化。
购买数量选择器我习惯用滑动条加输入框的组合。滑动条方便快速选择,输入框方便精确输入。但要注意,输入框要限制最大值,不能超过玩家能买得起的数量,也不能超过限购剩余数量。
5.2 拍卖界面的特殊处理
拍卖界面比商店多了一个竞价输入区和倒计时显示。倒计时我用的是服务端时间同步,客户端每秒更新一次显示。这里有一个细节:倒计时最后30秒要变红并闪烁,提醒玩家拍卖即将结束。
竞价输入区要显示当前最高价和最小加价幅度,玩家输入的价格如果低于最小加价,按钮要置灰并提示。一口价按钮要单独放,并且用醒目的颜色,因为一口价通常价格较高,需要玩家慎重操作。
拍卖列表要支持排序和筛选:按价格排序、按剩余时间排序、按物品类型筛选。这些功能看起来简单,但能大幅提升玩家的使用体验。
5.3 交易反馈与动效设计
交易成功或失败时,要有明确的反馈。成功时播放音效和特效,失败时弹窗提示原因。我特别注重失败提示的文案,不能只说“操作失败”,要说清楚为什么失败——“金币不足,还差500金币”比“货币不足”友好得多。
拍卖被超价时,要给玩家发推送通知。这个通知要包含:哪个拍卖、当前最高价、被谁超了(可以匿名)。玩家点击通知可以直接跳转到那个拍卖的详情页。
注意:所有UI操作都要加防连点。玩家快速点击购买按钮时,只处理第一次点击,后续的忽略。我见过因为没做防连点,玩家一次买了10个道具的案例,虽然最后客服给退了,但体验很差。
6. 交易系统的扩展性与后续迭代
6.1 跨服拍卖的实现思路
当游戏开服多了之后,跨服拍卖就成了刚需。实现跨服拍卖的核心是全局唯一的拍卖ID和跨服通信机制。我的做法是,拍卖数据存在中心服务器,各服客户端通过本服服务器代理访问中心服务器。竞价时,本服服务器把请求转发给中心服务器,中心服务器处理完后广播给所有服。
跨服拍卖的难点在于延迟。不同服的玩家网络延迟不同,可能导致竞价冲突。我的解决方案是,在中心服务器用队列串行处理所有竞价请求,保证先到先得。同时,客户端显示倒计时时,要用中心服务器的时间,而不是本地时间。
6.2 交易系统的数据分析
交易系统产生的数据非常有价值。我通常会分析几个指标:每日交易总量、平均交易价格、热门交易物品、异常交易账号。这些数据可以帮助策划调整经济系统,也可以帮助运营发现工作室。
比如,如果发现某个物品的交易价格突然暴涨,可能是有人在囤货。如果发现某个账号频繁与多个账号交易,可能是工作室在转移资产。这些分析结果要定期同步给运营团队。
6.3 后续可以扩展的方向
交易系统还有很多可以扩展的方向。比如预购功能,玩家可以设置一个心理价位,当拍卖价格低于这个价位时自动出价。比如交易行推荐,根据玩家的职业和等级,推荐可能需要的物品。比如交易税,对交易收取一定比例的手续费,回收游戏内货币。
我个人在实际操作中的体会是,交易系统的核心不在于功能多复杂,而在于稳定和安全。一个偶尔丢东西的拍卖行,比一个功能简单但从不丢东西的拍卖行,体验差得多。所以每次迭代,我都会把回归测试的重点放在交易流程上,确保每一笔交易都能正确完成。