CoachService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. <?php
  2. namespace App\Services\Client;
  3. use App\Enums\OrderStatus;
  4. use App\Enums\TechnicianAuthStatus;
  5. use App\Enums\TechnicianLocationType;
  6. use App\Enums\TechnicianStatus;
  7. use App\Enums\TechnicianWorkStatus;
  8. use App\Enums\UserStatus;
  9. use App\Models\CoachSchedule;
  10. use App\Models\CoachUser;
  11. use App\Models\MemberUser;
  12. use App\Models\Order;
  13. use Illuminate\Support\Carbon;
  14. use Illuminate\Support\Facades\Auth;
  15. use Illuminate\Support\Facades\Cache;
  16. use Illuminate\Support\Facades\Log;
  17. use Illuminate\Support\Facades\Redis;
  18. class CoachService
  19. {
  20. /**
  21. * 获取技师列表
  22. *
  23. * @param int $userId 用户ID
  24. * @param float $latitude 纬度
  25. * @param float $longitude 经度
  26. * @return array ['items' => array, 'total' => int]
  27. *
  28. * @throws \Exception
  29. */
  30. public function getNearCoachList(int $userId, float $latitude, float $longitude)
  31. {
  32. try {
  33. // 参数验证
  34. abort_if(! is_numeric($latitude) || ! is_numeric($longitude), 400, '无效的经纬度坐标');
  35. abort_if($latitude < -90 || $latitude > 90 || $longitude < -180 || $longitude > 180,
  36. 400, '经纬度超出有效范围');
  37. $page = max(1, (int) request()->get('page', 1));
  38. $perPage = max(1, min(50, (int) request()->get('per_page', 15)));
  39. // 获取当前用户并检查状态
  40. $user = MemberUser::select(['id', 'state'])->find($userId);
  41. abort_if(! $user, 404, '用户不存在');
  42. abort_if($user->state !== UserStatus::OPEN->value, 400, '用户状态异常');
  43. Log::info('开始获取附近技师列表', [
  44. 'user_id' => $userId,
  45. 'latitude' => $latitude,
  46. 'longitude' => $longitude,
  47. 'page' => $page,
  48. 'per_page' => $perPage,
  49. ]);
  50. // 使用缓存减少Redis查询压力
  51. $cacheKey = "nearby_coaches:{$latitude}:{$longitude}";
  52. $nearbyCoachIds = Cache::remember($cacheKey, now()->addMinutes(1), function () use ($longitude, $latitude) {
  53. return Redis::georadius(
  54. 'coach_locations',
  55. $longitude,
  56. $latitude,
  57. 40,
  58. 'km',
  59. ['WITHDIST', 'ASC']
  60. ) ?: [];
  61. });
  62. if (empty($nearbyCoachIds)) {
  63. Log::info('附近没有找到技师', compact('latitude', 'longitude'));
  64. return ['items' => [], 'total' => 0];
  65. }
  66. // 处理技师数据
  67. $coachData = collect($nearbyCoachIds)->map(function ($item) {
  68. [$id, $type] = explode('_', $item[0]);
  69. return [
  70. 'id' => (int) $id,
  71. 'type' => $type,
  72. 'distance' => round((float) $item[1], 2),
  73. ];
  74. })->unique('id')->values();
  75. // 分页处理
  76. $total = $coachData->count();
  77. $offset = ($page - 1) * $perPage;
  78. $paginatedCoachIds = $coachData->slice($offset, $perPage)
  79. ->pluck('id')
  80. ->toArray();
  81. if (empty($paginatedCoachIds)) {
  82. return ['items' => [], 'total' => 0];
  83. }
  84. // 查询数据库获取技师信息
  85. $coaches = CoachUser::query()
  86. ->whereIn('id', $paginatedCoachIds)
  87. ->where('state', TechnicianStatus::ACTIVE->value)
  88. ->whereHas('info', function ($query) {
  89. $query->where('state', TechnicianAuthStatus::PASSED->value);
  90. })
  91. ->whereHas('real', function ($query) {
  92. $query->where('state', TechnicianAuthStatus::PASSED->value);
  93. })
  94. ->whereHas('qual', function ($query) {
  95. $query->where('state', TechnicianAuthStatus::PASSED->value);
  96. })
  97. ->with(['info' => function ($query) {
  98. $query->select(['id', 'coach_id', 'nickname', 'avatar', 'gender', 'state']);
  99. }])
  100. ->get();
  101. // 设置距离并格式化数据
  102. $items = $coaches->map(function ($coach) use ($coachData) {
  103. $distanceData = $coachData->firstWhere('id', $coach->id);
  104. return [
  105. 'id' => $coach->id,
  106. 'nickname' => $coach->info->nickname ?? '',
  107. 'avatar' => $coach->info->avatar ?? '',
  108. 'gender' => $coach->info->gender ?? '',
  109. 'state' => $coach->state,
  110. 'state_text' => TechnicianStatus::fromValue($coach->state)->label(),
  111. 'work_status' => $coach->work_status,
  112. 'work_status_text' => TechnicianWorkStatus::fromValue($coach->work_status)->label(),
  113. 'distance' => $distanceData ? $distanceData['distance'] : null,
  114. ];
  115. })->sortBy('distance')->values()->all();
  116. Log::info('成功获取附近技师列表', [
  117. 'total' => $total,
  118. 'page' => $page,
  119. 'per_page' => $perPage,
  120. 'found' => count($items),
  121. ]);
  122. return [
  123. 'items' => $items,
  124. 'total' => $total,
  125. ];
  126. } catch (\Exception $e) {
  127. Log::error('获取附近技师列表失败', [
  128. 'user_id' => $userId,
  129. 'latitude' => $latitude,
  130. 'longitude' => $longitude,
  131. 'error' => $e->getMessage(),
  132. 'trace' => $e->getTraceAsString(),
  133. ]);
  134. throw $e;
  135. }
  136. }
  137. /**
  138. * 获取技师详情
  139. */
  140. public function getCoachDetail($coachId, $latitude, $longitude)
  141. {
  142. // 检查Redis连接
  143. try {
  144. $pingResult = Redis::connection()->ping();
  145. Log::info('Redis connection test:', ['ping_result' => $pingResult]);
  146. } catch (\Exception $e) {
  147. Log::error('Redis connection error:', ['error' => $e->getMessage()]);
  148. throw new \Exception('Redis连接失败:'.$e->getMessage());
  149. }
  150. // 检查Redis中的所有位置数据
  151. $allLocations = Redis::zrange('coach_locations', 0, -1, 'WITHSCORES');
  152. Log::info('All locations in Redis:', ['locations' => $allLocations]);
  153. // 获取当前用户
  154. $user = Auth::user();
  155. Log::info('Current user and coordinates:', [
  156. 'user' => $user ? $user->id : null,
  157. 'latitude' => $latitude,
  158. 'longitude' => $longitude,
  159. 'coach_id' => $coachId,
  160. ]);
  161. // 检查用户状态
  162. if (! $user) {
  163. throw new \Exception('用户未登录');
  164. }
  165. if ($user->state !== UserStatus::OPEN->value) {
  166. throw new \Exception('用户状态异常');
  167. }
  168. // 获取技师信息
  169. $coach = CoachUser::where('state', TechnicianStatus::ACTIVE->value)
  170. ->whereHas('info', function ($query) {
  171. $query->where('state', TechnicianAuthStatus::PASSED->value);
  172. })
  173. ->whereHas('real', function ($query) {
  174. $query->where('state', TechnicianAuthStatus::PASSED->value);
  175. })
  176. ->whereHas('qual', function ($query) {
  177. $query->where('state', TechnicianAuthStatus::PASSED->value);
  178. })
  179. ->with(['info:id,nickname,avatar,gender'])
  180. ->find($coachId);
  181. if (! $coach) {
  182. throw new \Exception('技师不存在');
  183. }
  184. // 从 Redis 获取技师的 id_home 和 id_work 的经纬度
  185. $homeLocation = Redis::geopos('coach_locations', $coachId.'_'.TechnicianLocationType::COMMON->value);
  186. $workLocation = Redis::geopos('coach_locations', $coachId.'_'.TechnicianLocationType::CURRENT->value);
  187. // 检查输入的经纬度是否有效
  188. if (! is_numeric($latitude) || ! is_numeric($longitude)) {
  189. Log::error('Invalid coordinates:', ['latitude' => $latitude, 'longitude' => $longitude]);
  190. throw new \Exception('无效的经纬度坐标');
  191. }
  192. // 临时存储用户当前位置用于计算距离
  193. $tempKey = 'user_temp_'.$user->id;
  194. Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey);
  195. // 计算距离(单位:km)
  196. $distanceHome = null;
  197. $distanceWork = null;
  198. if ($homeLocation && ! empty($homeLocation[0])) {
  199. $distanceHome = Redis::geodist('coach_locations', $tempKey, $coachId.'_'.TechnicianLocationType::COMMON->value, 'km');
  200. Log::info('Home distance calculation:', [
  201. 'from' => $tempKey,
  202. 'to' => $coachId.'_'.TechnicianLocationType::COMMON->value,
  203. 'distance' => $distanceHome,
  204. 'home_location' => $homeLocation[0],
  205. ]);
  206. }
  207. if ($workLocation && ! empty($workLocation[0])) {
  208. $distanceWork = Redis::geodist('coach_locations', $tempKey, $coachId.'_'.TechnicianLocationType::CURRENT->value, 'km');
  209. }
  210. // 删除临时位置点
  211. Redis::zrem('coach_locations', $tempKey);
  212. // 选择最近的距离
  213. $distances = array_filter([$distanceHome, $distanceWork]);
  214. $coach->distance = ! empty($distances) ? round(min($distances), 2) : null;
  215. return $coach;
  216. }
  217. /**
  218. * 设置技师位置信息
  219. *
  220. * @param int $coachId 技师ID
  221. * @param float $latitude 纬度
  222. * @param float $longitude 经度
  223. * @param int $type 位置类型 (current|common)
  224. * @return bool
  225. *
  226. * @throws \Exception
  227. */
  228. public function setCoachLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value)
  229. {
  230. if (! is_numeric($latitude) || ! is_numeric($longitude)) {
  231. Log::error('Invalid coordinates in setCoachLocation:', [
  232. 'coach_id' => $coachId,
  233. 'latitude' => $latitude,
  234. 'longitude' => $longitude,
  235. ]);
  236. throw new \Exception('无效的经纬度坐标');
  237. }
  238. if (! in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value])) {
  239. throw new \Exception('无效的位置类型,必须是 current 或 common');
  240. }
  241. $key = $coachId.'_'.$type;
  242. $result = Redis::geoadd('coach_locations', $longitude, $latitude, $key);
  243. Log::info('Coach location set:', [
  244. 'coach_id' => $coachId,
  245. 'type' => $type,
  246. 'key' => $key,
  247. 'latitude' => $latitude,
  248. 'longitude' => $longitude,
  249. 'result' => $result,
  250. ]);
  251. // 验证数据是否成功写入
  252. $location = Redis::geopos('coach_locations', $key);
  253. Log::info('Verify location after set:', [
  254. 'key' => $key,
  255. 'location' => $location,
  256. ]);
  257. return $result;
  258. }
  259. /**
  260. * 获取技师可预约时间段列表
  261. *
  262. * @param int $coachId 技师ID
  263. * @param string|null $date 日期,默认当天
  264. * @return array 返回格式:[
  265. * 'date' => '2024-03-22',
  266. * 'day_of_week' => '星期五',
  267. * 'is_today' => false,
  268. * 'time_slots' => [
  269. * [
  270. * 'start_time' => '09:00',
  271. * 'end_time' => '09:30',
  272. * 'is_available' => true,
  273. * 'duration' => 30
  274. * ]
  275. * ],
  276. * 'total_slots' => 1,
  277. * 'updated_at' => '2024-03-22 10:00:00'
  278. * ]
  279. *
  280. * @throws \Exception
  281. */
  282. public function getSchedule(int $coachId, ?string $date = null)
  283. {
  284. try {
  285. // 默认使用当天日期
  286. $date = $date ?: now()->toDateString();
  287. $targetDate = Carbon::parse($date);
  288. // 验证技师信息
  289. $coach = CoachUser::with('info')->find($coachId);
  290. abort_if(! $coach, 404, '技师不存在');
  291. abort_if($coach->info->state != TechnicianStatus::ACTIVE->value,
  292. 400, '技师状态异常');
  293. // 验证日期
  294. abort_if($targetDate->startOfDay()->lt(now()->startOfDay()),
  295. 400, '不能查询过去的日期');
  296. abort_if($targetDate->diffInDays(now()) > 30,
  297. 400, '只能查询未来30天内的时间段');
  298. // 使用缓存减少数据库查询
  299. return Cache::remember(
  300. "coach:timeslots:{$coachId}:{$date}",
  301. now()->addMinutes(15), // 缓存15分钟
  302. function () use ($coachId, $date, $targetDate) {
  303. // 获取技师排班信息
  304. $schedule = CoachSchedule::where('coach_id', $coachId)
  305. ->where('state', 1)
  306. ->first();
  307. if (! $schedule || empty($schedule->time_ranges)) {
  308. return $this->formatResponse($date, []);
  309. }
  310. $timeRanges = json_decode($schedule->time_ranges, true);
  311. // 获取当天所有订单
  312. $dayOrders = $this->getDayOrders($coachId, $date);
  313. // 生成时间段列表
  314. $timeSlots = $this->generateAvailableTimeSlots(
  315. $date,
  316. $timeRanges,
  317. $dayOrders,
  318. $targetDate->isToday()
  319. );
  320. return $this->formatResponse($date, $timeSlots);
  321. }
  322. );
  323. } catch (\Exception $e) {
  324. Log::error('获取可预约时间段失败', [
  325. 'coach_id' => $coachId,
  326. 'date' => $date,
  327. 'error' => $e->getMessage(),
  328. 'trace' => $e->getTraceAsString(),
  329. ]);
  330. throw $e;
  331. }
  332. }
  333. /**
  334. * 获取当天所有订单
  335. *
  336. * @param int $coachId 技师ID
  337. * @param string $date 日期
  338. * @return array 订单列表
  339. */
  340. private function getDayOrders(int $coachId, string $date): array
  341. {
  342. return Order::where('coach_id', $coachId)
  343. ->where('service_time', $date)
  344. ->whereIn('state', [
  345. OrderStatus::ACCEPTED->value,
  346. OrderStatus::DEPARTED->value,
  347. OrderStatus::ARRIVED->value,
  348. OrderStatus::SERVING->value,
  349. ])
  350. ->select(['id', 'service_start_time', 'service_end_time', 'state'])
  351. ->get()
  352. ->toArray();
  353. }
  354. /**
  355. * 生成可用时间段列表
  356. *
  357. * @param string $date 日期
  358. * @param array $timeRanges 排班时间段
  359. * @param array $dayOrders 当天订单
  360. * @param bool $isToday 是否是当天
  361. * @return array 可用时间段列表
  362. */
  363. private function generateAvailableTimeSlots(
  364. string $date,
  365. array $timeRanges,
  366. array $dayOrders,
  367. bool $isToday
  368. ): array {
  369. $timeSlots = [];
  370. $currentTime = now();
  371. foreach ($timeRanges as $range) {
  372. $start = Carbon::parse($date.' '.$range['start_time']);
  373. $end = Carbon::parse($date.' '.$range['end_time']);
  374. // 如果是当天且开始时间已过,从下一个30分钟时间点开始
  375. if ($isToday && $start->lt($currentTime)) {
  376. $start = $currentTime->copy()->addMinutes(30)->floorMinutes(30);
  377. // 如果调整后的开始时间已超过结束时间,跳过此时间段
  378. if ($start->gt($end)) {
  379. continue;
  380. }
  381. }
  382. // 生成30分钟间隔的时间段
  383. while ($start->lt($end)) {
  384. $slotStart = $start->format('H:i');
  385. $slotEnd = $start->copy()->addMinutes(30)->format('H:i');
  386. // 检查时间段是否被订单占用
  387. if (! $this->hasConflictingOrder($date, $slotStart, $slotEnd, $dayOrders)) {
  388. $timeSlots[] = [
  389. 'start_time' => $slotStart,
  390. 'end_time' => $slotEnd,
  391. 'is_available' => true,
  392. 'duration' => 30,
  393. ];
  394. }
  395. $start->addMinutes(30);
  396. }
  397. }
  398. return $timeSlots;
  399. }
  400. /**
  401. * 格式化返回数据
  402. *
  403. * @param string $date 日期
  404. * @param array $timeSlots 时间段列表
  405. * @return array 格式化后的数据
  406. */
  407. private function formatResponse(string $date, array $timeSlots): array
  408. {
  409. $targetDate = Carbon::parse($date);
  410. return [
  411. 'date' => $date,
  412. 'day_of_week' => $targetDate->isoFormat('dddd'), // 星期几
  413. 'is_today' => $targetDate->isToday(),
  414. 'time_slots' => $timeSlots,
  415. 'total_slots' => count($timeSlots),
  416. 'updated_at' => now()->toDateTimeString(),
  417. ];
  418. }
  419. /**
  420. * 检查是否与已有订单冲突
  421. *
  422. * @param string $date 日期
  423. * @param string $startTime 开始时间
  424. * @param string $endTime 结束时间
  425. * @param array $dayOrders 当天订单
  426. * @return bool 是否冲突
  427. */
  428. private function hasConflictingOrder(
  429. string $date,
  430. string $startTime,
  431. string $endTime,
  432. array $dayOrders
  433. ): bool {
  434. $slotStart = Carbon::parse("$date $startTime");
  435. $slotEnd = Carbon::parse("$date $endTime");
  436. foreach ($dayOrders as $order) {
  437. $orderStart = Carbon::parse($order['service_start_time']);
  438. $orderEnd = Carbon::parse($order['service_end_time']);
  439. // 检查时间段是否重叠
  440. if (($slotStart >= $orderStart && $slotStart < $orderEnd) ||
  441. ($slotEnd > $orderStart && $slotEnd <= $orderEnd) ||
  442. ($slotStart <= $orderStart && $slotEnd >= $orderEnd)) {
  443. return true;
  444. }
  445. }
  446. return false;
  447. }
  448. }