news 2026/10/14 4:00:14

PHP网约车H5打车系统源码实战:双端架构、订单状态机与Redis GEO

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP网约车H5打车系统源码实战:双端架构、订单状态机与Redis GEO

简介:这份PHP网约车H5打车系统源码面向希望快速搭建打车平台的开发者与创业者,提供乘客端与司机端完整解决方案,基于Yii框架开发,三端共用同一数据库,适合具备一定PHP基础、想研究或二次开发网约车业务的中高级开发者。资源包共2000个文件,涵盖591个md说明文档、441个js脚本、208个html页面、169个json配置、74个css样式及36个sql建表脚本等,压缩包约139.82MB,目录结构完整,便于按模块检索与部署。已有629人学习下载。读者可从中获得乘客下单、司机接单、后台管理等核心流程的完整代码实现,参考数据库配置方式与三端联调思路,并借助后台演示账号快速验证功能,为二次开发或项目落地提供可复用的基础框架与排错参考。

1. 一套 PHP 网约车 H5 打车系统,到底在解决什么问题

很多人第一次接触「PHP网约车H5打车系统源码」这个方向,是因为手里有个小场景要落地:园区通勤、县域拼车、酒店礼宾用车、景区接驳。这类需求有个共同点——订单量不大,但流程必须闭环:乘客能下单、司机能接单、位置能刷新、计价能算清、订单能追溯。用原生 App 做,双端上架加审核周期,成本直接劝退;用小程序做,又受平台规则牵制。H5 打车系统源码的价值就在这里:一套 PHP 后端同时给乘客端和司机端两个 H5 页面供数,浏览器打开即用,扫码就能进,部署在自有服务器上,数据自己攥着。

它适合谁?适合有一定 PHP 基础、能看懂 ThinkPHP 或 Laravel 路由与模型、手里有台能跑 MySQL 和 Redis 的服务器、想在一到两周内跑通一个可演示可小范围运营的打车闭环的开发者。不适合指望开箱即运营、完全不懂后端的人——源码是起点,不是终点。下面按「先立住原理、再动手复现、最后讲坑」的顺序拆开讲。

2. 双端架构与订单状态机:先想清楚再写代码

2.1 为什么乘客端和司机端要共用一套订单表

新手最容易犯的错,是给乘客端和司机端各建一张订单表,觉得「各管各的清爽」。真跑起来就翻车:乘客取消订单,司机端那张表不知道;司机到达上车点,乘客端状态没变。订单的本质是一次交易的两个视角,不是两份数据。

常见做法是一张orders表,用passenger_id和driver_id两个外键关联,状态字段status驱动两端展示。乘客端看到的是「等待接单 / 司机已接单 / 司机已到达 / 行程中 / 已完成」,司机端看到的是同一批状态的不同措辞。状态一变,两端轮询或长连接推送同一份数据,天然一致。

CREATE TABLE `orders` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '对外订单号,防遍历', `passenger_id` INT UNSIGNED NOT NULL, `driver_id` INT UNSIGNED DEFAULT NULL COMMENT '未接单时为 NULL', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待接单 1已接单 2已到达 3行程中 4已完成 5已取消', `start_lat` DECIMAL(10,7) NOT NULL, `start_lng` DECIMAL(10,7) NOT NULL, `end_lat` DECIMAL(10,7) DEFAULT NULL, `end_lng` DECIMAL(10,7) DEFAULT NULL, `estimate_price` DECIMAL(10,2) DEFAULT NULL COMMENT '预估,下单时算', `final_price` DECIMAL(10,2) DEFAULT NULL COMMENT '完单时算', `created_at` INT UNSIGNED NOT NULL, `updated_at` INT UNSIGNED NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_driver` (`status`, `driver_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:order_no单独生成而不是拿自增 id 对外,是为了防止别人顺着 id 遍历你的订单。idx_status_driver这个联合索引是给司机端「查我进行中的单」用的,少了它,司机一多查询就慢。estimate_price和final_price分开存,是因为预估价和实际价经常不一致(堵车、绕路),合并成一个字段后期对账会哭。

参数上,经纬度用DECIMAL(10,7)而不是FLOAT,精度约 1 厘米,够用且不会出现浮点误差导致「同一个点算两次距离不一样」。时间戳统一用INT UNSIGNED存秒级,跨时区展示交给前端。

2.2 订单状态机怎么设计才不出乱子

状态字段最怕的是「谁都能改」。正确做法是把合法迁移写死成一张表,任何状态变更都走同一个方法校验。

当前状态允许迁移到触发方
0 待接单1 已接单 / 5 已取消司机 / 乘客
1 已接单2 已到达 / 5 已取消司机 / 双方
2 已到达3 行程中 / 5 已取消司机 / 乘客
3 行程中4 已完成司机
4 已完成无—
5 已取消无—
// 状态迁移白名单,放在公共 Service 里 private const TRANSITIONS = [ 0 => [1, 5], 1 => [2, 5], 2 => [3, 5], 3 => [4], 4 => [], 5 => [], ]; public function changeStatus(int $orderId, int $to, int $operatorId, string $role): bool { $order = OrderModel::lockForUpdate()->find($orderId); // 行锁,防并发 if (!$order) { throw new \Exception('订单不存在'); } if (!in_array($to, self::TRANSITIONS[$order->status] ?? [], true)) { throw new \Exception("非法状态迁移: {$order->status} -> {$to}"); } // 权限校验:只有对应角色能触发对应迁移 if ($role === 'driver' && $order->driver_id !== $operatorId) { throw new \Exception('无权操作该订单'); } $order->status = $to; $order->updated_at = time(); return $order->save(); }

逻辑说明:lockForUpdate()是关键,两个请求同时抢同一单时,数据库行锁保证只有一个能改成功,另一个读到的是改后的状态,直接撞白名单被拒。没有这把锁,就会出现「两个司机同时接同一单」的经典事故。参数上$role用来区分操作方,乘客和司机能触发的迁移不同,比如「行程中→已完成」只允许司机点。

提示:状态机一定要在服务端校验,前端按钮置灰只是体验,不是安全边界。绕过前端直接调接口的人多得是。

3. 乘客端下单与司机端接单:把核心链路跑通

3.1 乘客下单接口:预估价怎么算才不亏

下单接口要干三件事:校验参数、算预估价、写订单。预估价算法是整个系统里最容易埋雷的地方,常见做法是「起步价 + 里程费 + 时长费」,但里程怎么算、按直线还是按路径,差别很大。

public function createOrder(array $params, int $passengerId): array { // 1. 参数校验 $validator = Validator::make($params, [ 'start_lat' => 'required|numeric|between:-90,90', 'start_lng' => 'required|numeric|between:-180,180', 'end_lat' => 'required|numeric|between:-90,90', 'end_lng' => 'required|numeric|between:-180,180', ]); if ($validator->fails()) { throw new \Exception($validator->errors()->first()); } // 2. 算直线距离(Haversine),再乘一个绕路系数 $distanceKm = $this->haversine( $params['start_lat'], $params['start_lng'], $params['end_lat'], $params['end_lng'] ); $roadFactor = 1.35; // 直线转实际路径的经验系数,城市路况一般 1.3~1.5 $realKm = $distanceKm * $roadFactor; // 3. 计价:起步价 + 超出部分里程费 $basePrice = 8.00; // 起步价,含 3 公里 $baseKm = 3.0; $perKm = 2.20; $extraKm = max(0, $realKm - $baseKm); $estimate = $basePrice + $extraKm * $perKm; // 4. 写订单 $order = OrderModel::create([ 'order_no' => $this->genOrderNo(), 'passenger_id' => $passengerId, 'status' => 0, 'start_lat' => $params['start_lat'], 'start_lng' => $params['start_lng'], 'end_lat' => $params['end_lat'], 'end_lng' => $params['end_lng'], 'estimate_price' => round($estimate, 2), 'created_at' => time(), 'updated_at' => time(), ]); return ['order_id' => $order->id, 'order_no' => $order->order_no, 'estimate' => $order->estimate_price]; } private function haversine($lat1, $lng1, $lat2, $lng2): float { $R = 6371; // 地球半径 km $dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1); $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; return $R * 2 * atan2(sqrt($a), sqrt(1 - $a)); }

逻辑说明:roadFactor这个系数是血泪经验——直线距离永远小于实际行驶距离,直接拿直线算价,跑一趟你亏一趟。1.35 是城市路况的常见经验值,县域或景区路况简单可以调到 1.2,复杂城区调到 1.5。参数上$basePrice、$baseKm、$perKm建议做成配置表而不是写死,不同城市、不同时段(夜间加价)都要能改。

genOrderNo()常见做法是「日期 + 随机数 + 用户 id 后四位」,保证唯一且不暴露自增规律。

3.2 司机端接单:怎么防止一单被两个人抢

司机端接单接口的核心是原子性。错误写法是先查再改:

// 错误示范:查和改之间有窗口期 $order = OrderModel::find($orderId); if ($order->status === 0) { $order->driver_id = $driverId; $order->status = 1; $order->save(); }

两个司机几乎同时请求,都查到status === 0,都执行了 save,后写的覆盖先写的,结果两个司机都以为自己接单了。正确做法是用带条件的原子更新:

public function acceptOrder(int $orderId, int $driverId): bool { // 一条 SQL 完成「检查+更新」,数据库保证原子性 $affected = OrderModel::where('id', $orderId) ->where('status', 0) // 只有待接单才能接 ->whereNull('driver_id') // 且还没人接 ->update([ 'driver_id' => $driverId, 'status' => 1, 'updated_at' => time(), ]); if ($affected === 0) { // 影响行数为 0,说明被别人抢先了或状态不对 throw new \Exception('手慢了,订单已被接走'); } return true; }

逻辑说明:where('status', 0)和whereNull('driver_id')是双重保险,update返回影响行数,为 0 就说明条件不满足。这条 SQL 在 InnoDB 里是原子的,不需要额外加锁。参数上,如果业务允许「司机接单后 30 秒内可无责取消」,那取消逻辑也要走状态机,不能直接改driver_id为 NULL。

注意:接单成功后要立刻给乘客端推消息,否则乘客盯着「等待接单」会以为系统卡了。推送可以用轮询兜底,但体验上长连接更好。

3.3 位置上报与司机列表:Redis GEO 怎么用

司机端要定时上报位置,乘客端要查「附近有哪些司机」。用 MySQL 存经纬度再算距离,司机一多就慢。常见做法是用 Redis 的 GEO 结构。

# 司机上报位置:GEOADD key 经度 纬度 成员 GEOADD driver:geo 116.397128 39.916527 "driver_1001" # 查乘客附近 3 公里内的司机,返回距离(米) GEOSEARCH driver:geo FROMLONLAT 116.397000 39.916000 BYRADIUS 3 km ASC COUNT 20 WITHDIST
// PHP 侧封装 public function reportLocation(int $driverId, float $lat, float $lng): void { Redis::geoadd('driver:geo', $lng, $lat, 'driver_' . $driverId); // 同时更新司机在线状态和最后上报时间,用于剔除僵尸司机 Redis::hset('driver:online', $driverId, time()); } public function nearbyDrivers(float $lat, float $lng, float $radiusKm = 3): array { $list = Redis::geosearch('driver:geo', $lng, $lat, $radiusKm, 'km', ['ASC', 'COUNT' => 20, 'WITHDIST']); // 过滤掉超过 60 秒没上报的司机 $now = time(); return array_filter($list, function ($item) use ($now) { $last = Redis::hget('driver:online', str_replace('driver_', '', $item[0])); return $last && ($now - $last) < 60; }); }

逻辑说明:GEOADD的成员名用driver_前缀,方便反查司机 id。GEOSEARCH的WITHDIST会返回每个司机到乘客的距离,前端可以直接展示「距您 800 米」。参数上,BYRADIUS 3 km的半径别设太大,否则返回一堆远处的司机没意义;COUNT 20限制返回数量,防止一次拉太多。

僵尸司机过滤是必须的——司机关了 App 但没触发下线,位置还留在 Redis 里,乘客看到「附近有车」结果叫不到,体验直接崩。用driver:online哈希记录最后上报时间,超过 60 秒就当离线。

4. 计价、支付与订单完结:钱的事不能含糊

4.1 实际计价和预估价对不上怎么办

预估价是下单时算的,实际价是完单时算的。两者对不上是常态,关键是规则要透明。常见做法是完单时按实际行驶里程和时长重算,但设置一个「预估价保护」:实际价超过预估价 20% 时,超出部分由平台承担或需要乘客确认。

public function finishOrder(int $orderId, int $driverId, array $track): array { $order = OrderModel::where('id', $orderId) ->where('driver_id', $driverId) ->where('status', 3) ->firstOrFail(); // 从轨迹点累计实际里程 $realKm = 0.0; for ($i = 1; $i < count($track); $i++) { $realKm += $this->haversine( $track[$i - 1]['lat'], $track[$i - 1]['lng'], $track[$i]['lat'], $track[$i]['lng'] ); } $durationMin = (time() - $order->start_time) / 60; $basePrice = 8.00; $baseKm = 3.0; $perKm = 2.20; $perMin = 0.30; // 时长费,堵车时补偿司机 $final = $basePrice + max(0, $realKm - $baseKm) * $perKm + $durationMin * $perMin; // 预估价保护:超出 20% 的部分打折 if ($final > $order->estimate_price * 1.2) { $final = $order->estimate_price * 1.2; } $order->final_price = round($final, 2); $order->status = 4; $order->updated_at = time(); $order->save(); return ['final_price' => $order->final_price, 'real_km' => round($realKm, 2)]; }

逻辑说明:轨迹点累计里程比直线距离准得多,但要注意轨迹点本身有 GPS 漂移,两点距离小于 10 米时可以跳过,否则等红灯时里程会虚增。参数上$perMin时长费是很多新手会漏的,没有它,堵车时司机干等不赚钱,接单意愿会掉。

4.2 支付回调怎么保证不重复加钱

支付回调是事故高发区。用户付一次款,回调可能来两次(网络重试),如果不做幂等,订单会被标记两次完成,甚至重复加余额。

public function handlePayCallback(string $orderNo, string $tradeNo, float $amount): bool { // 幂等:用 tradeNo 做唯一键,重复回调直接返回成功 $exists = PayLogModel::where('trade_no', $tradeNo)->find(); if ($exists) { return true; // 已经处理过,直接告诉支付方「收到了」 } Db::startTrans(); try { $order = OrderModel::where('order_no', $orderNo)->lockForUpdate()->find(); if (!$order || $order->status !== 4) { throw new \Exception('订单状态异常'); } if (abs($order->final_price - $amount) > 0.01) { throw new \Exception('金额不符'); } PayLogModel::create([ 'trade_no' => $tradeNo, 'order_no' => $orderNo, 'amount' => $amount, 'created_at' => time(), ]); // 这里再触发司机结算、乘客发票等后续逻辑 Db::commit(); return true; } catch (\Exception $e) { Db::rollback(); throw $e; } }

逻辑说明:trade_no唯一键是幂等的核心,重复回调在第一步就被挡掉。lockForUpdate()防止同一订单的并发回调。金额校验用abs(...) > 0.01而不是!=,是因为浮点数比较有精度问题。参数上,PayLogModel建议加唯一索引uk_trade_no,让数据库兜底,代码漏了也不会重复。

提示:回调接口一定要先返回成功再处理业务,或者处理完立刻返回,别在里面做耗时操作,否则支付方会一直重试。

5. 部署与联调避坑:那些让你加班到凌晨的细节

5.1 环境配置的三个必调参数

PHP 跑打车系统,有几个配置不改,上线必出问题。

配置项默认值建议值原因
max_execution_time3060计价、轨迹计算可能超 30 秒
memory_limit128M256M批量处理订单时内存吃紧
date.timezoneUTCAsia/Shanghai时间戳展示错 8 小时
; php.ini 里改 max_execution_time = 60 memory_limit = 256M date.timezone = Asia/Shanghai

逻辑说明:max_execution_time只算 PHP 自身执行时间,不算数据库等待,所以设 60 秒是留余量。date.timezone不改的话,date('Y-m-d')出来的日期会差一天,对账时能把你逼疯。

5.2 位置刷新频率与电量、流量的平衡

司机端 H5 定时上报位置,频率设多少是个权衡。设 3 秒一次,司机手机电量掉得快、流量哗哗走;设 30 秒一次,乘客看到的车是「跳着走」的。

常见做法是动态频率:司机在「已接单但未到达」阶段用 5 秒一次,因为乘客在等;「行程中」用 10 秒一次;「空闲」用 30 秒一次。

// 司机端 H5 动态上报 let interval = 30000; // 默认空闲 30 秒 function setReportInterval(status) { if (status === 1) interval = 5000; // 已接单,乘客在等 else if (status === 3) interval = 10000; // 行程中 else interval = 30000; // 空闲 } setInterval(() => { navigator.geolocation.getCurrentPosition(pos => { fetch('/api/driver/report', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ lat: pos.coords.latitude, lng: pos.coords.longitude }) }); }); }, interval);

逻辑说明:navigator.geolocation在 H5 里需要 HTTPS 才能用,HTTP 下浏览器直接拒绝,这是新手最容易踩的坑。参数上,enableHighAccuracy默认 false,打车场景建议开 true,虽然耗电但定位准。

5.3 常见问题排查清单

现象:乘客下单后司机端看不到订单。原因:司机端查询条件写的是status = 0 AND driver_id IS NULL,但下单时driver_id默认写成了 0 而不是 NULL。 解决:建表时driver_id默认值设为 NULL,或者查询时用(driver_id IS NULL OR driver_id = 0)。

现象:订单状态卡在「行程中」不动。原因:司机端完单请求超时,前端没重试,后端也没收到。 解决:完单接口做重试机制,前端失败后 3 秒重试一次,最多 3 次;后端加日志,记录每次状态变更。

现象:Redis GEO 查不到附近司机。原因:GEOADD时经纬度顺序写反了,Redis 要求先经度后纬度。 解决:统一封装reportLocation($lat, $lng),内部调geoadd($lng, $lat),别在业务代码里直接调。

现象:支付回调重复触发,订单被结算两次。原因:没做幂等,或者幂等键用了order_no而不是trade_no。 解决:用支付方返回的trade_no做唯一键,数据库加唯一索引兜底。

现象:H5 页面在部分安卓机上报「定位失败」。原因:这些机型默认禁止浏览器定位,或者用户拒绝了权限。 解决:前端加引导提示,让用户手动开启;同时提供「手动输入上车点」的兜底方案。

6. 从能跑到好用:几个让系统更稳的进阶技巧

系统跑通只是第一步,真正上线运营后,你会发现「能跑」和「好用」之间隔着无数细节。这里挑几个我踩过坑、后来觉得最值的技巧讲。

第一,订单超时自动取消。乘客下单后如果 90 秒没司机接,订单应该自动取消并通知乘客,否则乘客干等,体验极差。实现方式是用 Redis 的过期键或者延时队列,别用 MySQL 定时扫表——订单量一上来,扫表会把数据库拖垮。

// 下单时投递一个延时任务 Redis::zadd('order:timeout', time() + 90, $orderNo); // 独立的消费者进程轮询 $due = Redis::zrangebyscore('order:timeout', 0, time(), ['LIMIT' => [0, 100]]); foreach ($due as $orderNo) { // 尝试取消,只有还是待接单状态才取消 OrderModel::where('order_no', $orderNo)->where('status', 0)->update(['status' => 5]); Redis::zrem('order:timeout', $orderNo); }

逻辑说明:zadd用时间戳做 score,消费者只取到期的。where('status', 0)保证已经被接单的订单不会被误取消。参数上,90 秒是经验值,县域场景可以放宽到 120 秒,因为司机密度低。

第二,司机接单后的「到达确认」。司机点了「已到达」但人没到,乘客投诉;司机到了但忘了点,乘客也投诉。常见做法是结合地理围栏:司机位置进入上车点 200 米范围内,自动提示「您已到达,请确认」,司机点一下即可,减少扯皮。

第三,计价规则的配置化。别把起步价、里程费写死在代码里。做成一张price_rules表,按城市、时段、车型配置,改价不用发版。

字段类型说明
city_codeVARCHAR(16)城市编码
car_typeTINYINT车型:1 经济 2 舒适 3 商务
base_priceDECIMAL(10,2)起步价
base_kmDECIMAL(5,2)起步含公里数
per_kmDECIMAL(10,2)每公里单价
per_minDECIMAL(10,2)每分钟时长费
night_ratioDECIMAL(3,2)夜间加价系数,如 1.3

第四,日志要记全。订单每次状态变更、每次位置上报、每次支付回调,都要落日志。出问题时,日志是唯一的后悔药。我一般会单独建一张order_logs表,记录order_id、from_status、to_status、operator_id、role、created_at,排查时按订单 id 一拉,整条链路清清楚楚。

第五,压测别省。上线前用工具模拟 100 个乘客同时下单、50 个司机同时接单,看看数据库连接池够不够、Redis 有没有慢查询。我见过太多系统在演示时流畅,一上真实流量就雪崩,根因往往是某个查询没走索引。

最后说个习惯:每次改完计价或状态机相关代码,我都会手动跑一遍「下单→接单→到达→行程中→完单→支付」的完整链路,再跑一遍「下单→取消」和「下单→超时取消」两个分支。这三个流程覆盖了 90% 的线上事故场景,花五分钟跑一遍,比事后查日志划算得多。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:59:59

OKR在跨职能团队的落地全流程:目标对齐、信心指数与复盘避坑

1. 为什么我会在151团队里推行OKR&#xff1a;一次失败的教训先解释一下什么叫“151团队”。这里不是某个神秘组织&#xff0c;而是我们内部对一个约15人跨职能小组的称呼&#xff1a;产品、研发、设计、数据分析各占几个坑位&#xff0c;专门负责一条独立的业务线。这个团队规…

作者头像 李华
网站建设 2026/10/14 3:58:59

基于SSM与OpenCV的人脸识别考勤系统设计与实现

1. 为什么人脸识别考勤是2026届最稳的Java毕设选题每年毕业季&#xff0c;总有一批人被选题折磨到失眠。前后端分离太卷、纯管理系统太水、算法方向又怕自己数学底子扛不住。如果你正好处在"想要有点技术含量&#xff0c;但又不能难到做不出来"的区间&#xff0c;人脸…

作者头像 李华
网站建设 2026/10/14 3:57:59

MySQL学生成绩管理系统设计:从建表到统计查询的完整实践指南

简介&#xff1a;这套MySQL学生成绩管理系统课程设计文档&#xff0c;以高校数据库课程设计为背景&#xff0c;面向需要完成成绩管理系统的学生与开发者。资源包含1个doc格式文档&#xff0c;压缩包约3.95MB&#xff0c;已有5073人学习。文档从项目背景与意义出发&#xff0c;围…

作者头像 李华
网站建设 2026/10/14 3:57:56

基于SpringBoot+Vue+MySQL的社团管理系统设计与部署实战

1. 项目概览与技术选型思路1.1 这个系统解决什么问题社团管理系统在高校和中学里几乎是刚需。每个学期开始&#xff0c;社团招新、活动审批、成员登记、场地预约、积分统计&#xff0c;全靠手工表格处理的话&#xff0c;一个百人规模的社团联合会能把负责同学逼疯。我见过太多用…

作者头像 李华
网站建设 2026/10/14 3:57:22

实战:Spring Boot+Vue+WebSocket构建高并发知识竞赛系统

简介&#xff1a;知识竞赛系统是一套基于C/MFC开发的可运行在线竞赛平台&#xff0c;面向需要组织答题活动、管理题库、统计排名的开发者或课程设计者。系统覆盖试题的添加、删除、修改&#xff0c;参赛人员录入&#xff0c;在线答题计时、自动评分&#xff0c;以及按正确率、答…

作者头像 李华