get('page', 1); $perPage = request()->get('per_page', 15); // 获取当前用户 $user = MemberUser::find($userId); Log::info('Current user and coordinates:', [ 'user' => $user ? $user->id : null, 'latitude' => $latitude, 'longitude' => $longitude, ]); // 检查用户状态 if (! $user) { throw new \Exception('用户未登录'); } if ($user->state !== UserStatus::OPEN->value) { throw new \Exception('用户状态异常'); } // 使用 Redis 的 georadius 命令获取附近的技师 ID $nearbyCoachIds = Redis::georadius('coach_locations', $longitude, $latitude, 40, 'km', ['WITHDIST']); $coachData = array_map(function ($item) { [$id, $type] = explode('_', $item[0]); return ['id' => $id, 'type' => $type, 'distance' => $item[1]]; }, $nearbyCoachIds); // 提取所有的id $coachIds = array_unique(array_column($coachData, 'id')); // 分页截取 coachIds $paginatedCoachIds = array_slice($coachIds, ($page - 1) * $perPage, $perPage); // 查询数据库获取技师信息 $coaches = CoachUser::query() ->whereIn('id', $paginatedCoachIds) ->whereHas('info', function ($query) { $query->where('state', TechnicianAuthStatus::PASSED->value); }) ->whereHas('real', function ($query) { $query->where('state', TechnicianAuthStatus::PASSED->value); }) ->whereHas('qual', function ($query) { $query->where('state', TechnicianAuthStatus::PASSED->value); }) ->with(['info:id,nickname,avatar,gender']) ->paginate($perPage); // 遍历技师并设置距离 foreach ($coaches as $coach) { $coach->distance = round($coachData[array_search($coach->id, array_column($coachData, 'id'))]['distance'] ?? null, 2); } // 按 distance 升序排序 $coaches = $coaches->sortBy('distance')->values(); return $coaches; } /** * 获取技师详情 */ public function getCoachDetail($coachId, $latitude, $longitude) { // 检查Redis连接 try { $pingResult = Redis::connection()->ping(); Log::info('Redis connection test:', ['ping_result' => $pingResult]); } catch (\Exception $e) { Log::error('Redis connection error:', ['error' => $e->getMessage()]); throw new \Exception('Redis连接失败:'.$e->getMessage()); } // 检查Redis中的所有位置数据 $allLocations = Redis::zrange('coach_locations', 0, -1, 'WITHSCORES'); Log::info('All locations in Redis:', ['locations' => $allLocations]); // 获取当前用户 $user = Auth::user(); Log::info('Current user and coordinates:', [ 'user' => $user ? $user->id : null, 'latitude' => $latitude, 'longitude' => $longitude, 'coach_id' => $coachId, ]); // 检查用户状态 if (! $user) { throw new \Exception('用户未登录'); } if ($user->state !== UserStatus::OPEN->value) { throw new \Exception('用户状态异常'); } // 获取技师信息 $coach = CoachUser::where('state', TechnicianStatus::ACTIVE->value) ->whereHas('info', function ($query) { $query->where('state', TechnicianAuthStatus::PASSED->value); }) ->whereHas('real', function ($query) { $query->where('state', TechnicianAuthStatus::PASSED->value); }) ->whereHas('qual', function ($query) { $query->where('state', TechnicianAuthStatus::PASSED->value); }) ->with(['info:id,nickname,avatar,gender']) ->find($coachId); if (! $coach) { throw new \Exception('技师不存在'); } // 从 Redis 获取技师的 id_home 和 id_work 的经纬度 $homeLocation = Redis::geopos('coach_locations', $coachId.'_'.TechnicianLocationType::COMMON->value); $workLocation = Redis::geopos('coach_locations', $coachId.'_'.TechnicianLocationType::CURRENT->value); // 检查输入的经纬度是否有效 if (! is_numeric($latitude) || ! is_numeric($longitude)) { Log::error('Invalid coordinates:', ['latitude' => $latitude, 'longitude' => $longitude]); throw new \Exception('无效的经纬度坐标'); } // 临时存储用户当前位置用于计算距离 $tempKey = 'user_temp_'.$user->id; Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey); // 计算距离(单位:km) $distanceHome = null; $distanceWork = null; if ($homeLocation && ! empty($homeLocation[0])) { $distanceHome = Redis::geodist('coach_locations', $tempKey, $coachId.'_'.TechnicianLocationType::COMMON->value, 'km'); Log::info('Home distance calculation:', [ 'from' => $tempKey, 'to' => $coachId.'_'.TechnicianLocationType::COMMON->value, 'distance' => $distanceHome, 'home_location' => $homeLocation[0], ]); } if ($workLocation && ! empty($workLocation[0])) { $distanceWork = Redis::geodist('coach_locations', $tempKey, $coachId.'_'.TechnicianLocationType::CURRENT->value, 'km'); } // 删除临时位置点 Redis::zrem('coach_locations', $tempKey); // 选择最近的距离 $distances = array_filter([$distanceHome, $distanceWork]); $coach->distance = ! empty($distances) ? round(min($distances), 2) : null; return $coach; } /** * 设置技师位置信息 * * @param int $coachId 技师ID * @param float $latitude 纬度 * @param float $longitude 经度 * @param int $type 位置类型 (current|common) * @return bool * * @throws \Exception */ public function setCoachLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value) { if (! is_numeric($latitude) || ! is_numeric($longitude)) { Log::error('Invalid coordinates in setCoachLocation:', [ 'coach_id' => $coachId, 'latitude' => $latitude, 'longitude' => $longitude, ]); throw new \Exception('无效的经纬度坐标'); } if (! in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value])) { throw new \Exception('无效的位置类型,必须是 current 或 common'); } $key = $coachId.'_'.$type; $result = Redis::geoadd('coach_locations', $longitude, $latitude, $key); Log::info('Coach location set:', [ 'coach_id' => $coachId, 'type' => $type, 'key' => $key, 'latitude' => $latitude, 'longitude' => $longitude, 'result' => $result, ]); // 验证数据是否成功写入 $location = Redis::geopos('coach_locations', $key); Log::info('Verify location after set:', [ 'key' => $key, 'location' => $location, ]); return $result; } /** * 获取技师可预约时间段列表 * * @param int $coachId 技师ID * @param string|null $date 日期,默认当天 * @return array 返回格式:[ * 'date' => '2024-03-22', * 'day_of_week' => '星期五', * 'is_today' => false, * 'time_slots' => [ * [ * 'start_time' => '09:00', * 'end_time' => '09:30', * 'is_available' => true, * 'duration' => 30 * ] * ], * 'total_slots' => 1, * 'updated_at' => '2024-03-22 10:00:00' * ] * * @throws \Exception */ public function getSchedule(int $coachId, ?string $date = null) { try { // 默认使用当天日期 $date = $date ? Carbon::parse($date)->format('Y-m-d') : now()->toDateString(); $targetDate = Carbon::parse($date); // 验证技师信息 $coach = CoachUser::with('info')->find($coachId); abort_if(! $coach, 404, '技师不存在'); abort_if($coach->info->state != TechnicianStatus::ACTIVE->value, 400, '技师状态异常'); // 验证日期 abort_if($targetDate->startOfDay()->lt(now()->startOfDay()), 400, '不能查询过去的日期'); abort_if($targetDate->diffInDays(now()) > 30, 400, '只能查询未来30天内的时间段'); $cacheKey = "coach:timeslots:{$coachId}:{$date}"; Cache::forget($cacheKey); // 使用缓存减少数据库查询 return Cache::remember( "coach:timeslots:{$coachId}:{$date}", now()->addMinutes(15), // 缓存15分钟 function () use ($coachId, $date, $targetDate) { // 获取技师排班信息 $schedule = CoachSchedule::where('coach_id', $coachId) ->where('state', 1) ->first(); if (! $schedule || empty($schedule->time_ranges)) { return $this->formatResponse($date, []); } $timeRanges = json_decode($schedule->time_ranges, true); // 获取当天所有订单 $dayOrders = $this->getDayOrders($coachId, $date); // 生成时间段列表 $timeSlots = $this->generateAvailableTimeSlots( $date, $timeRanges, $dayOrders, $targetDate->isToday() ); return $this->formatResponse($date, $timeSlots); } ); } catch (\Exception $e) { Log::error('获取可预约时间段失败', [ 'coach_id' => $coachId, 'date' => $date, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } } /** * 获取当天所有订单 * * @param int $coachId 技师ID * @param string $date 日期 * @return array 订单列表 */ private function getDayOrders(int $coachId, string $date): array { $date = Carbon::parse($data['date'] ?? $date); $startOfDay = $date->startOfDay()->format('Y-m-d H:i:s'); $endOfDay = $date->endOfDay()->format('Y-m-d H:i:s'); return Order::where('coach_id', $coachId) ->whereBetween('service_time', [$startOfDay, $endOfDay]) ->whereIn('state', [ OrderStatus::ACCEPTED->value, OrderStatus::DEPARTED->value, OrderStatus::ARRIVED->value, OrderStatus::SERVING->value, ]) ->select(['id', 'service_start_time', 'service_end_time', 'state']) ->get() ->toArray(); } /** * 生成可用时间段列表 * * @param string $date 日期 * @param array $timeRanges 排班时间段 * @param array $dayOrders 当天订单 * @param bool $isToday 是否是当天 * @return array 可用时间段列表 */ private function generateAvailableTimeSlots( string $date, array $timeRanges, array $dayOrders, bool $isToday ): array { $timeSlots = []; $currentTime = now(); foreach ($timeRanges as $range) { $start = Carbon::parse($date.' '.$range['start_time']); $end = Carbon::parse($date.' '.$range['end_time']); // 如果是当天且开始时间已过,从下一个30分钟时间点开始 if ($isToday && $start->lt($currentTime)) { $start = $currentTime->copy()->addMinutes(30)->floorMinutes(30); // 如果调整后的开始时间已超过结束时间,跳过此时间段 if ($start->gt($end)) { continue; } } // 生成30分钟间隔的时间段 while ($start->lt($end)) { $slotStart = $start->format('H:i'); $slotEnd = $start->copy()->addMinutes(30)->format('H:i'); // 检查时间段是否被订单占用 if (! $this->hasConflictingOrder($date, $slotStart, $slotEnd, $dayOrders)) { $timeSlots[] = [ 'start_time' => $slotStart, 'end_time' => $slotEnd, 'is_available' => true, 'duration' => 30, ]; } $start->addMinutes(30); } } return $timeSlots; } /** * 格式化返回数据 * * @param string $date 日期 * @param array $timeSlots 时间段列表 * @return array 格式化后的数据 */ private function formatResponse(string $date, array $timeSlots): array { $targetDate = Carbon::parse($date); return [ 'date' => $date, 'day_of_week' => $targetDate->isoFormat('dddd'), // 星期几 'is_today' => $targetDate->isToday(), 'time_slots' => $timeSlots, 'total_slots' => count($timeSlots), 'updated_at' => now()->toDateTimeString(), ]; } /** * 检查是否与已有订单冲突 * * @param string $date 日期 * @param string $startTime 开始时间 * @param string $endTime 结束时间 * @param array $dayOrders 当天订单 * @return bool 是否冲突 */ private function hasConflictingOrder( string $date, string $startTime, string $endTime, array $dayOrders ): bool { $slotStart = Carbon::parse("$date $startTime"); $slotEnd = Carbon::parse("$date $endTime"); foreach ($dayOrders as $order) { $orderStart = Carbon::parse($order['service_start_time']); $orderEnd = Carbon::parse($order['service_end_time']); // 检查时间段是否重叠 if (($slotStart >= $orderStart && $slotStart < $orderEnd) || ($slotEnd > $orderStart && $slotEnd <= $orderEnd) || ($slotStart <= $orderStart && $slotEnd >= $orderEnd)) { return true; } } return false; } /** * 验证技师服务时间是否在可用时间段内 */ public function validateServiceTimeWithinCoachAvailability(int $coachId, string $serviceTime): bool { $serviceDateTime = Carbon::parse($serviceTime); $coachSchedule = $this->getSchedule($coachId, $serviceDateTime->format('Y-m-d')); foreach ($coachSchedule['time_slots'] as $slot) { $slotStart = Carbon::parse($serviceDateTime->format('Y-m-d').' '.$slot['start_time']); $slotEnd = Carbon::parse($serviceDateTime->format('Y-m-d').' '.$slot['end_time']); if ($serviceDateTime->between($slotStart, $slotEnd)) { return true; } } return false; } }