PHP游戏陪玩源码改造:WAP自适应与订单状态机实战
2026/9/16 1:55:42 网站建设 项目流程

简介:一套PHP开发的陪玩约玩平台源码,支持WAP手机端自适应,适合需要搭建陪玩、约玩或社交类平台的开发者和站长。系统内置在线聊天、在线下单等核心功能,可适配WAP端、微信公众号,并支持封装成App应用;新版相比旧版重点优化了聊一聊等交互模块,源码完全开源、不加密,方便本地部署与二次改造。

资源包共2000个文件,压缩包约76.76MB,以710个PHP文件构成服务端核心业务逻辑,389个JS负责前端交互,291个HTML与177个CSS组织页面结构和样式,另含数据库SQL脚本、txt说明文档、配置文件等,目录结构清晰,便于按模块定位和修改。目前已有287人学习/下载,可作为学习PHP社交系统开发的实战案例,也可直接用于快速搭建陪玩平台。附带后台登录密码重置说明,能帮助解决初始登录密码错误问题,减少配置时间。

1. 拿到PHP游戏陪玩平台源码后,先解决WAP手机端自适应

我上个月接手一个私活:给一套现成的PHP游戏陪玩平台源码,加上“美女约玩系统”的移动端入口。源码是外包用老PHP+MySQL写的,PC页面打开正常,手机浏览器一访问就出现横向滚动条、按钮错位、弹窗被顶到屏幕外。陪玩和约玩这类业务的用户基本都泡在手机上,WAP手机端自适应不是“加一段响应式CSS”就完事,它同时影响订单转化率、后端接口维护和后续App复用。这篇内容按我自己接单时的顺序展开:先理清数据库表,再做手机端自适应布局,然后实现订单状态机和支付回调,最后补安全加固和Nginx调优。新手能照着步骤一步步跑,老手直接跳到我讲状态机和三端统一的部分看边界条件。

2. 陪玩平台源码的数据库表结构:用户、陪玩师、订单怎么拆

约玩系统能跑起来,靠的不是页面好看,而是数据模型经不经得起状态变更。源码交付时都会带.sql文件,但你最好不要直接导入,因为很多老源码只给你两张表:member和order,所有业务字段全塞在备注里。拿到新项目,我一般先建三张核心表:user_loginplayer_profileorder_list。角色用字段区分,服务资料单独放,订单做状态快照。这样后面WAP列表页、App接口、运营导出都能复用同一套查询。

2.1 用户、陪玩师拆成两张表,避免全表都是NULL

为什么要拆:普通用户只有手机号和头像,陪玩师有游戏标签、服务城市、语音价格、接单状态。如果合在一张表里,数据量一大,索引和缓存都会浪费。下面这套表结构是这类陪玩源码里最常见的样子。

CREATE TABLE `user_login` ( `id` int(11) NOT NULL AUTO_INCREMENT, `mobile` varchar(20) NOT NULL, `password_hash` varchar(255) NOT NULL, `role` tinyint(1) NOT NULL DEFAULT '0', `status` tinyint(1) NOT NULL DEFAULT '1', `last_login_time` int(11) DEFAULT NULL, `create_time` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_mobile` (`mobile`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `player_profile` ( `uid` int(11) NOT NULL, `nickname` varchar(30) NOT NULL, `avatar` varchar(500) DEFAULT NULL, `price_per_hour` decimal(10,2) NOT NULL DEFAULT '0.00', `game_tags` varchar(255) DEFAULT NULL, `city` varchar(50) DEFAULT NULL, `voice_video_price` decimal(10,2) DEFAULT NULL, `service_status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1可接单 0休息', `intro` text, PRIMARY KEY (`uid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

user_login表唯一索引加在mobile上,注册逻辑里先尝试插入,如果出现Duplicate entry就直接走登录,省一次查询。player_profileuid当主键而不是自增id,这样JOIN时少一个索引。password_hash存的是password_hash()生成的结果,哪怕数据库泄露,也不会明文密码直接可登录。game_tags用JSON字符串存游戏标签,初期够用,真要按标签筛选时再拆关联表。下表是三张表在WAP端各自承担的位置。

表名核心字段在WAP端的作用
user_loginmobile / role / status登录页、个人中心判断角色与封禁状态
player_profilenickname / avatar / game_tags / price_per_hour陪玩师列表卡片、详情页资料展示
order_listorder_id / user_id / player_uid / status我的订单列表、订单详情、退款入口

2.2 订单表存业务快照,WAP列表一次查出所需字段

订单表要同时服务用户端和陪玩师端:用户显示“我约到的”,陪玩师显示“我接到的”。订单金额、服务时间、游戏快照都要在下单那一刻固定下来,避免陪玩师改价格后历史订单跟着变。实际项目里我会把快照字段写在order_list里,不另外建快照表。

CREATE TABLE `order_list` ( `order_id` VARCHAR(32) NOT NULL COMMENT '对外业务单号', `user_id` int(11) NOT NULL, `player_uid` int(11) NOT NULL, `service_type` tinyint(1) NOT NULL COMMENT '1游戏陪玩 2语音陪聊 3线下约玩', `game_name` varchar(30) DEFAULT NULL COMMENT '快照:游戏名', `start_time` int(11) NOT NULL, `end_time` int(11) NOT NULL, `total_amount` decimal(10,2) NOT NULL, `commission_rate` decimal(5,2) NOT NULL DEFAULT '0.10', `status` tinyint(1) NOT NULL DEFAULT '0', `pay_time` int(11) DEFAULT NULL, `create_time` int(11) NOT NULL, PRIMARY KEY (`order_id`), KEY `idx_user_id` (`user_id`), KEY `idx_player_uid` (`player_uid`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='陪玩订单表';

这里不用自增id做主键,而用32位字符串订单号,原因是支付和短信都要带单号,自增id会暴露平台日单量,而且后续水平分表时字符串主键不容易冲突。game_name这种快照字段,来源是下单时从游戏列表里复制的,陪玩师那边改简介不会影响历史订单。

$sql = "SELECT u.id, p.nickname, p.avatar, p.game_tags, p.city, p.price_per_hour FROM user_login u INNER JOIN player_profile p ON p.uid = u.id WHERE u.role = 1 AND u.status = 1 AND p.service_status = 1 ORDER BY p.price_per_hour ASC LIMIT 20";

这段SQL是WAP陪玩师列表页的常用查法。INNER JOIN保证没有profile资料的误登记用户不出现。LIMIT 20固定截断,因为移动端列表页用下拉刷新比翻页更自然。status=1service_status=1两个过滤条件缺一不可。若发现列表慢,把EXPLAIN的结果发出来,问题通常出在两表字符集不一致导致索引失效。

3. WAP手机端自适应:从viewport到PHP端分流的三层做法

接下来解决“手机浏览器打开像港片字幕被切了一半”的问题。WAP自适应要从三个层面下手:HTML的viewport meta,CSS的rem和flex,PHP入口的设备与模板分流。三层缺一个,就会在某个Android厂商浏览器上翻车。

3.1 没有viewport meta,后面所有样式都是白搭

老源码经常没有这一行,导致手机浏览器默认把页面渲染在980px宽的虚拟画布上,再用手指缩放。第一件事是把下面这个meta放到模板head最前面:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

width=device-width让页面逻辑宽度等于设备宽度,initial-scale=1把缩放比固定成1:1,viewport-fit=cover适配iPhone刘海屏。不需要加maximum-scaleuser-scalable=no,它们会影响无障碍访问,iOS 10之后也不再完全生效。如果项目里有需要横向滚动的图片列表,再单独把那个容器的overflow-x:auto

3.2 用rem把375px设计稿等比放大到任意手机

手机屏幕逻辑宽度从320px到430px不等,像素级还原并不划算,按比例缩放才对。常见做法是拿设计稿宽度375px作为基准,用JS把根字体大小设成屏幕宽度的1/3.75。

<script> (function () { var docEl = document.documentElement; function setFontSize() { var width = docEl.clientWidth || window.innerWidth; docEl.style.fontSize = width / 375 * 100 + 'px'; } window.addEventListener('resize', setFontSize); setFontSize(); })(); </script>

计算后,375px宽的手机根字号就是100px,设计稿上1px对应0.01rem。商品卡片宽度设计稿375px,那就写3.75rem。需要注意,rem只用于宽度、字体、间距,不要用于1px边框和头像内阴影,那种场景用px反而稳。Android 4.4以下webview不认rem,但陪玩平台这种C端项目不用兼容那么老。

例如下面这个WAP约玩卡片:

.card { display: flex; padding: 0.12rem; background: #fff; border-radius: 0.08rem; } .card-avatar { width: 0.56rem; height: 0.56rem; border-radius: 50%; flex-shrink: 0; } .card-info { flex: 1; margin-left: 0.1rem; min-width: 0; } .card-price { font-size: 0.18rem; color: #ff6600; }

min-width: 0很关键:flex子项默认不会收缩到内容最小宽度以下,没有min-width: 0时昵称过长会把价格挤出去,导致右侧被截断。flex-shrink: 0保证头像不变形。还有一类常见问题是图片高度塌陷,给.card-avatarbackground-size: cover比直接塞img更可控。

下表是不同设备宽度下的rem换算结果:

设备逻辑宽度根字号一个0.18rem元素实际值
iPhone SE375px100px18px
安卓中档机412px109.87px19.78px
安卓大屏480px128px23px
折叠屏内屏634px169px30.5px

3.3 PHP服务端检测设备,跳转到对应模板目录

手机自适应不等于把PC页面缩一下,而是要换一套更薄的模板。PHP入口处用User-Agent判断是哪个端,PC就加载pc模板,WAP就加载wap模板。正确姿势是共用Controller,只换View。

function isMobileDevice(): bool { $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; $ua = strtolower($ua); $keywords = ['iphone', 'android', 'ipad', 'mobile', 'micromessenger']; foreach ($keywords as $word) { if (strpos($ua, $word) !== false) { return true; } } return false; } define('APP_TEMPLATE', isMobileDevice() ? 'wap' : 'pc');

这段代码放在公共入口文件最前面。关键词列表里加micromessenger,是因为微信内置浏览器访问时,即使UA里不含Android,也能提前被识别成移动端。判断完只改模板目录,不要在这里做301跳转,否则会出现“手机访问浏览器正常,分享到微信被拦截”的怪问题。如果同一个路由要输出JSON给App,判断放在路由分发层而不是业务层。

4. 约玩订单状态机:从待支付到已完成的PHP流程实现

订单状态机是整个约玩系统最容易出事的地方。源码里常见的错误写法是把status字段直接UPDATE,一旦并发请求互相覆盖,就会出现订单还没付钱就被显示成“服务中”。下面这套状态机我用了很多项目,核心是“先列允许迁移,再带条件更新”。

4.1 七个状态的流转边界

约玩订单从创建到结束,状态最少需要七个:待支付、待接单、服务中、待确认、已完成、已取消、申诉中。申诉中这个状态必须有,虚拟服务退款纠纷率比实物高很多。常见状态转移如下表:

当前状态允许的下一个状态触发人实际场景
0 待支付1 待接单 / 5 已取消用户/系统支付回调成功;主动取消或支付超时关单
1 待接单2 服务中 / 5 已取消陪玩师/系统陪玩师接单;超时未接单自动释放
2 服务中3 待确认 / 6 申诉中用户/陪玩师点击结束服务;服务中投诉
3 待确认4 已完成 / 6 申诉中用户用户确认收款;不满意走申诉
6 申诉中4 已完成 / 5 已取消平台人工介入后结算或退款

这个表格里,服务中不能直接跳到已完成,必须经过待确认。原因是不能让陪玩师单方面在系统里点“完成”就完成订单,用户那边至少要有一个确认弹窗。

4.2 用迁移映射表和行锁UPDATE实现状态机

PHP端实现,我习惯先写一个只读的允许迁移映射,再在更新语句的WHERE里带上当前状态。这样即使两个PHP进程同时请求,数据库层也只会放行一个。

$orderStatusTransitions = [ 0 => [1, 5], 1 => [2, 5], 2 => [3, 6], 3 => [4, 6], 6 => [4, 5], ]; /** * @param PDO $pdo * @param string $orderId * @param int $fromStatus * @param int $toStatus * @return bool 是否更新成功 */ function changeOrderStatus(PDO $pdo, string $orderId, int $fromStatus, int $toStatus): bool { global $orderStatusTransitions; if (!isset($orderStatusTransitions[$fromStatus])) { throw new InvalidArgumentException('未知的当前状态'); } if (!in_array($toStatus, $orderStatusTransitions[$fromStatus], true)) { throw new DomainException("非法状态迁移: {$fromStatus} -> {$toStatus}"); } $stmt = $pdo->prepare( "UPDATE order_list SET status = :to_status WHERE order_id = :order_id AND status = :from_status" ); $stmt->execute([ 'to_status' => $toStatus, 'order_id' => $orderId, 'from_status' => $fromStatus, ]); return $stmt->rowCount() === 1; }

上面这段代码先检查迁移映射,再执行带status条件的UPDATE。rowCount()返回1才表示状态真的从from变成了to,返回0说明订单状态已经被别人改过,这时候就抛DomainException,让前端提示“订单状态已变化,请刷新”。要注意,不能用关闭PDO::ATTR_EMULATE_PREPARES之外的方式模拟准备,目标是让参数值走MySQL协议而不是拼接进SQL。

4.3 支付回调、自动取消与幂等处理

支付回调是订单状态从0到1的唯一入口。回调逻辑里必须先验签,再查单,再改状态。按常见的微信支付流程,处理完成后回成功字符串,处理失败返回failure。下面对应的是验签通过后的核心代码段:

$orderId = $rawData['out_trade_no'] ?? ''; $payAmount = $rawData['total_fee'] ?? 0; $pdo->beginTransaction(); try { $order = queryOrder($pdo, $orderId); if (!$order) { throw new Exception('订单不存在'); } if ((int)$order['total_amount'] * 100 !== (int)$payAmount) { throw new Exception('订单金额不匹配'); } if (changeOrderStatus($pdo, $orderId, 0, 1)) { writePayLog($pdo, $rawData); $pdo->commit(); } else { $pdo->rollBack(); } } catch (Exception $e) { $pdo->rollBack(); logError($e->getMessage()); echo 'failure'; }

这里有个很多人踩的坑:支付回调里的total_fee单位是分,而数据库用decimal(10,2)存元,比较前必须乘100。另一个坑是状态机里0到1的重复回调,第二个请求的rowCount为0,此时不要返回failure,直接回success,幂等就靠这个处理。自动取消场景在Linux下用cron每分钟扫一次:

*/1 * * * * php /data/wwwroot/mobile/cron/autoCancelOrder.php >> /tmp/auto_cancel.log 2>&1

autoCancelOrder.php里执行“更新status=0create_time < NOW()-600的订单为5”的语句,更新条数就是本次释放的单量。这个SQL也要走预处理,且限制一次最多100条,避免大事务锁表。

5. WAP端上线前的安全加固与Nginx/PHP环境调优

约玩平台只要上线,就会遇到注册机刷手机号、低价订单被反复取消、支付金额被篡改这些事。源码里的老PHP处理方法如果不替换成下面这套,后面每天都会被报警轰炸。

5.1 用PDO预处理和HTML转义堵住两个基础漏洞

最危险的两个点是SQL注入和XSS。陪玩师昵称、个人简介、订单备注全是用户输入。价格排序接口如果直接用字符串拼接SQL,一次经典的注入可能把整个user_login表dump出来。修复方法是全项目禁用字符串拼接SQL,一律PDO预处理。

$stmt = $pdo->prepare("SELECT * FROM player_profile WHERE city = :city LIMIT 10"); $stmt->execute(['city' => $_GET['city'] ?? '']); $players = $stmt->fetchAll();

上面这段和拼接SQL的代码路径不同:参数值走的是MySQL协议里的二进制包,不会和SQL语句混在一起。除了SQL,还要处理XSS,尤其是WAP端用户昵称直接渲染到HTML里,要加htmlspecialchars

echo htmlspecialchars($row['nickname'], ENT_QUOTES, 'UTF-8');

ENT_QUOTES会把单引号和双引号都转义,这是输出场景的标准姿势。如果你用的是现成模板引擎,确认它默认开启自动转义;没有框架的情况下,所有echo变量都要过这层。

常见漏洞对应的修复方式见下表:

漏洞位置攻击方式修法
登录接口撞库手机号+密码+图形验证码
搜索接口SQL注入PDO预处理 + 排序字段白名单
头像上传上传WebShell改扩展名校验MIME,保存到独立目录
详情页简介XSShtmlspecialchars + 过滤script标签
支付金额前端篡改服务端重新计算total_amount

5.2 头像上传压缩:手机照片压到200KB内再入库

陪玩平台的头像和相册图片上传量极大,手机原图一张就5MB,直接存服务器磁盘很快就打满。WAP上传端拿到图片后,服务端用GD库统一压缩成宽度不超过620px的JPG。下面是可用的缩略图函数:

function compressImage(string $sourcePath, string $targetPath, int $maxWidth = 620): bool { $info = getimagesize($sourcePath); if ($info === false) { return false; } [$width, $height] = $info; $ratio = $maxWidth / $width; $newWidth = $width > $maxWidth ? $maxWidth : $width; $newHeight = $width > $maxWidth ? (int)($height * $ratio) : $height; $src = imagecreatefromstring(file_get_contents($sourcePath)); $dst = imagecreatetruecolor($newWidth, $newHeight); imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); imagejpeg($dst, $targetPath, 80); imagedestroy($src); imagedestroy($dst); return true; }

这个函数把超宽图片等比缩到620px内,JPEG质量为80。imagecreatefromstring直接读文件内容,不受扩展名影响,可以防止攻击者把PHP文件改名成jpg上传后再被include执行。真正稳妥的做法是把图片目录的PHP执行权限关掉,下面Nginx片段就是干这件事的。

5.3 Nginx配置静态资源不执行PHP,PHP-FPM按业务拆池

源码上线到LNMP环境时,很多问题不是来自PHP代码,而是Nginx配置太宽松。下面这个location是最基本的保命配置:

location ^~ /upload/ { location ~ \.php$ { return 403; } } location ^~ /static/ { expires 7d; add_header Cache-Control "public, max-age=604800"; } location ~ \.php$ { fastcgi_pass unix:/tmp/php.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }

第一个location让upload目录里所有php后缀直接返回403,即使攻击者上传了木马也执行不了。static目录加expires 7d,让WAP端的图片和css走浏览器缓存,减少同一台机器的PHP请求压力。PHP-FPM调优方面,单台4核8G服务器,pm.max_children设在20左右,pm.start_servers为10,pm.min_spare_servers为10,pm.max_spare_servers为20。配置后再开opcache.enable=1opcache.memory_consumption=128,PHP 7以上版本提升很明显。

6. 把WAP自适应升级成三端统一:接口层与最终验收技巧

源码如果只有WAP,下一个需求大概率是“做个App”或“做个小程序”。与其到那时再把WAP当接口剥出去,不如现在就把Controller改成只输出数据,页面渲染交给前端。WAP继续用PHP模板渲染也是一种路线,但作为源码二次开发者,至少要把接口层准备好。

6.1 用JWT给App端准备鉴权接口,WAP继续用session

WAP手机端自适应不等于放弃服务端渲染,但App端没法沿用session,所以要在入口处为API请求单独走JWT。生成阶段可以先写一个轻量函数:

function createJwt(int $uid, string $secret, int $expires = 7200): string { $header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); $payload = base64_encode(json_encode(['uid' => $uid, 'exp' => time() + $expires])); $sign = hash_hmac('sha256', $header . '.' . $payload, $secret); return $header . '.' . $payload . '.' . $sign; }

JWT的逻辑只有三步:header和payload都做base64,再用HMAC-SHA256签名,最后拼接成三段字符串。这里不要用RSA,HS256配一个32位随机密钥就够了。App客户端保存token,WAP继续用$_SESSION,两套鉴权互不干扰。接口分发可以在public/index.php里判断$_SERVER['REQUEST_URI']前缀,/api/开头就走接口路由,否则走模板。

6.2 用Chrome手机模拟和真实UA完成WAP自适应验收

最后一公里是验收。打开Chrome DevTools的手机模拟模式,UA设置成iPhone和Android机型分别刷新,检查三个地方:横向滚动条是否消失、底部操作栏是否被输入法顶起、下单按钮在320px宽下是否可点击。还有个容易被忽略的点:rem脚本必须在CSS加载前执行,否则首屏会闪。把脚本放在head里而不是body底部,再给html加一个最小font-size: 20px,防止超小屏下字体缩到看不清。

发布前用 curl 加 Android UA 请求首页,确认返回的 HTML 里面模板目录是 wap,而不是 PC:

curl -A "Mozilla/5.0 (Linux; Android 12; Pixel 5) AppleWebKit/537.36" http://your-domain.com/ | grep -o "pages/wap/[a-z]*"

grep 输出应当匹配 wap 模板文件,而不是 pc。这条验证命令在实际部署中能准确暴露“UA 判断写错”和“CDN 缓存了 PC 页面”两个问题。

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

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

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

立即咨询