get('per_page', 15); // 基础验证(用户状态和Redis连接) $this->validateBasicRequirements($userId); // 获取搜索半径设置 $distanceSettingItem = $this->getDistanceSetting(); $maxSearchRadius = $distanceSettingItem?->max_value ?? 40; // 默认40公里 // 获取附近的技师(使用Redis GEO功能) $nearbyCoaches = $this->getNearbyCoaches($longitude, $latitude, $maxSearchRadius); if (empty($nearbyCoaches)) { return ['items' => [], 'total' => 0]; } // 处理技师距离数据(提取ID和距离信息) [$coachDistances, $coachIds] = $this->processCoachDistances($nearbyCoaches); // 构建技师查询(包含认证状态和距离筛选) $query = $this->buildCoachQuery($coachIds, $distanceSettingItem, $coachDistances); // 获取分页结果(带基本信息) $coaches = $query->with(['info:id,nickname,avatar,gender']) ->paginate($perPage); // 处理返回结果(添加距离信息) $items = $this->processCoachResults($coaches, $coachDistances); return [ 'items' => $items, 'total' => $coaches->total() ]; } /** * 验证基本要求 * * @param int $userId 用户ID * @param bool $checkRedis 是否检查Redis连接 * @throws \Illuminate\Database\Eloquent\ModelNotFoundException 当用户不存在时 * @throws \Symfony\Component\HttpKernel\Exception\HttpException 当验证失败时 */ protected function validateBasicRequirements(int $userId, bool $checkRedis = true): void { // 验证用户状态 $user = MemberUser::findOrFail($userId); abort_if(!$user, 401, '用户未登录'); abort_if($user->state !== UserStatus::OPEN->value, 400, '用户状态异常'); // 检查Redis连接 if ($checkRedis) { abort_if(!Redis::ping(), 500, 'Redis服务不可用'); } } /** * 获取距离设置项 * * @return \App\Models\SettingItem|null 距离设置项 */ protected function getDistanceSetting(): ?SettingItem { return SettingItem::query() ->whereHas('group', function ($query) { $query->where('code', 'order'); }) ->where('code', 'distance') ->first(); } /** * 获取附近的技师 * * @param float $longitude 经度 * @param float $latitude 纬度 * @param int $maxSearchRadius 最大搜索半径(km) * @return array 附近技师的位置和距离信息 */ protected function getNearbyCoaches(float $longitude, float $latitude, int $maxSearchRadius): array { return Redis::georadius( 'coach_locations', $longitude, $latitude, $maxSearchRadius, 'km', ['WITHDIST'] ); } /** * 处理技师距离数据 * * @param array $nearbyCoaches Redis返回的原始数据 * @return array{0: array, 1: array} [距离映射, 技师ID列表] */ protected function processCoachDistances(array $nearbyCoaches): array { $coachDistances = []; $coachIds = []; foreach ($nearbyCoaches as $coach) { [$id, $type] = explode('_', $coach[0]); // 只保留最近的距离 if (!isset($coachDistances[$id]) || $coach[1] < $coachDistances[$id]) { $coachDistances[$id] = (float)$coach[1]; $coachIds[] = $id; } } return [$coachDistances, array_unique($coachIds)]; } /** * 添加技师认证状态检查条件 * * @param \Illuminate\Database\Eloquent\Builder $query * @return \Illuminate\Database\Eloquent\Builder */ protected function addAuthStatusChecks($query) { return $query->whereHas('info', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value)) ->whereHas('real', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value)) ->whereHas('qual', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value)); } /** * 构建技师查询 * * @param array $coachIds 技师ID列表 * @param SettingItem|null $distanceSettingItem 距离设置项 * @param array $coachDistances 技师距离映射 * @return \Illuminate\Database\Eloquent\Builder */ protected function buildCoachQuery(array $coachIds, ?SettingItem $distanceSettingItem, array $coachDistances): \Illuminate\Database\Eloquent\Builder { $query = CoachUser::query() ->whereIn('id', $coachIds); // 添加认证状态检查 $this->addAuthStatusChecks($query); // 添加接单距离筛选条件 if ($distanceSettingItem) { $query->where(function ($q) use ($distanceSettingItem, $coachDistances) { $q->whereHas('settings', function ($sq) use ($distanceSettingItem, $coachDistances) { // 检查用户距离是否在技师设置的接单范围内 $sq->where('item_id', $distanceSettingItem->id) ->whereRaw('? <= CAST(value AS DECIMAL)', [ $coachDistances[$sq->getModel()->coach_id] ?? 0 ]); }) // 未设置接单距离的技师直接显示 ->orWhereDoesntHave('settings', function ($sq) use ($distanceSettingItem) { $sq->where('item_id', $distanceSettingItem->id); }); }); } // 实际距离排序 if (!empty($coachDistances)) { $query->orderByRaw('CASE coach_users.id ' . collect($coachDistances)->map(function ($distance, $id) { return "WHEN $id THEN CAST($distance AS DECIMAL(10,2))"; })->implode(' ') . ' ELSE ' . ($distanceSettingItem->default_value ?? PHP_FLOAT_MAX) . ' END ASC'); } return $query; } /** * 处理技师查询结果 * * @param \Illuminate\Pagination\LengthAwarePaginator $coaches 分页后的技师数据 * @param array $coachDistances 技师距离映射 * @return \Illuminate\Support\Collection */ protected function processCoachResults($coaches, array $coachDistances): \Illuminate\Support\Collection { return $coaches->getCollection() ->map(function ($coach) use ($coachDistances) { // 添加距离信息(保留2位小数) $coach->distance = round($coachDistances[$coach->id] ?? 0, 2); return $coach; }) ->values(); } /** * 验证地理坐标 * * @param float|null $latitude 纬度 * @param float|null $longitude 经度 * @throws \Exception 当坐标无效时 */ protected function validateCoordinates(?float $latitude, ?float $longitude): void { $isInvalid = !is_numeric($latitude) || !is_numeric($longitude); if ($isInvalid) { Log::error('Invalid coordinates:', ['latitude' => $latitude, 'longitude' => $longitude]); } abort_if($isInvalid, 422, '无效的经纬度坐标'); } /** * 获取技师详情 * * 业务流程: * 1. 验证用户身份和Redis连接状态 * 2. 验证技师存在性和认证状态 * 3. 获取技师位置信息(常驻地和当前位置) * 4. 计算用户与技师的距离(如果提供了用户坐标) * 5. 返回完整的技师信息 * * 技术实现: * - 使用Redis GEO功能计算距离 * - 多重认证状态验证 * - 关联查询技师信息 * - 临时存储用户位置 * * 数据处理: * - 技师位置数据从Redis获取 * - 距离计算保留2位小数 * - 选择最近的距离(常驻地和当前位置) * - 自动清理临时位置数据 * * 数据验证: * - 技师ID有效性 * - 用户状态检查 * - 地理坐标有效性 * - 认证状态验证 * * 异常处理: * - Redis连接异常 * - 技师不存在 * - 无效坐标 * - 认证状态异常 * * @param int $coachId 技师ID * @param float|null $latitude 用户当前纬度 * @param float|null $longitude 用户当前经度 * @return CoachUser 技师详细信息,包含距离计算结果 * @throws \Exception 当验证失败或查询异常时 */ public function getCoachDetail($coachId, $latitude, $longitude) { // 验证基本要求(用户状态和Redis连接) $this->validateBasicRequirements(Auth::id()); // 验证坐标 $this->validateCoordinates($latitude, $longitude); // 获取技师信息(包含认证状态检查) $query = CoachUser::where('state', TechnicianStatus::ACTIVE->value); $this->addAuthStatusChecks($query); // 获取技师详细信息 $coach = $query->with(['info:id,nickname,avatar,gender']) ->find($coachId); abort_if(!$coach, 404, '技师不存在'); // 计算并设置距离 $coach->distance = $this->calculateDistanceToCoach(Auth::id(), $coachId, $latitude, $longitude); return $coach; } /** * 计算用户到技师的距离 * * @param int $userId 用户ID * @param int $coachId 技师ID * @param float $latitude 用户纬度 * @param float $longitude 用户经度 * @return float|null 最近距离(公里) */ protected function calculateDistanceToCoach(int $userId, int $coachId, float $latitude, float $longitude): ?float { // 临时存储用户位置 $tempKey = 'user_temp_' . $userId; Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey); try { // 获取技师位置 $homeLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::COMMON->value); $workLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::CURRENT->value); $distances = []; // 计算到常驻地的距离 if ($homeLocation && !empty($homeLocation[0])) { $distances[] = $this->calculateDistance($tempKey, $coachId . '_' . TechnicianLocationType::COMMON->value); } // 计算到当前位置的距离 if ($workLocation && !empty($workLocation[0])) { $distances[] = $this->calculateDistance($tempKey, $coachId . '_' . TechnicianLocationType::CURRENT->value); } // 返回最近的距离 return !empty($distances) ? round(min(array_filter($distances)), 2) : null; } finally { // 确保清理临时位置数据 Redis::zrem('coach_locations', $tempKey); } } /** * 设置技师位置信息 * * 业务流程: * 1. 验证位置信息的有效性 * 2. 根据类型更新技师位置 * 3. 验证更新结果 * * 技术实现: * - 使用Redis GEOADD命令存储位置 * - 支持多种位置类型(常驻/当前) * - 位置更新验证 * * 数据验证: * - 坐标范围检查 * - 位置类型验证 * - 技师ID验证 * * 日志记录: * - 位置更新操作 * - 错误信息 * - 验证结果 * * @param int $coachId 技师ID * @param float $latitude 纬度坐标 * @param float $longitude 经度坐标 * @param int $type 位置类型 (TechnicianLocationType 举值) * @return bool 更新是否成功 * @throws \Exception 当参数无效或Redis操作失败时 */ public function setCoachLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value) { // 验证坐标 $this->validateCoordinates($latitude, $longitude); abort_if( !in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value]), 422, '无效的位置类型,必须是 current 或 common' ); $key = $coachId . '_' . $type; return Redis::geoadd('coach_locations', $longitude, $latitude, $key); } /** * 获取技师可预约时间段列表 * * 业务流程: * 1. 验证技师信息和状态 * 2. 获取技师排班设置 * 3. 获取已有预约订单 * 4. 计算用时间段 * 5. 缓存处结果 * * 技术实现: * - 使用Carbon处理日期时间 * - 缓存机制优化查询性能 * - 订单冲突检测 * - 时间段生成算法 * * 数据处理: * - 排班数据解析 * - 订单时间过滤 * - 时间段计算 * - 数据格式化 * * 缓存策略: * - 15分钟缓存时间 * - 按技师和日期缓存 * - 支持缓存更新 * * @param int $coachId 技师ID * @param string|null $date 日期,默认当天 * @return array 格式化的时间段表 * @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::find($coachId); abort_if(! $coach, 404, '技师不存在'); abort_if( (int)$coach->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 = is_array($schedule->time_ranges) ? $schedule->time_ranges : 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::SERVICING->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; } /** * 获取技师的地理位置信 * * @param string $key Redis中的位置键名 * @return array|null 位置信息 */ protected function getCoachLocation(string $key): ?array { return Redis::geopos('coach_locations', $key); } /** * 计算两点之间的距离 * * @param string $from 起点位置键名 * @param string $to 终点位置键名 * @return float|null 距离(km) */ protected function calculateDistance(string $from, string $to): ?float { return Redis::geodist('coach_locations', $from, $to, 'km'); } }