|
@@ -2,106 +2,341 @@
|
|
|
|
|
|
namespace App\Services\Client;
|
|
|
|
|
|
-use App\Enums\OrderStatus;
|
|
|
-use App\Enums\TechnicianAuthStatus;
|
|
|
-use App\Enums\TechnicianLocationType;
|
|
|
-use App\Enums\TechnicianStatus;
|
|
|
+use App\Models\Order;
|
|
|
use App\Enums\UserStatus;
|
|
|
-use App\Models\CoachSchedule;
|
|
|
use App\Models\CoachUser;
|
|
|
+use App\Enums\OrderStatus;
|
|
|
use App\Models\MemberUser;
|
|
|
-use App\Models\Order;
|
|
|
+use App\Models\CoachSchedule;
|
|
|
use Illuminate\Support\Carbon;
|
|
|
+use App\Enums\TechnicianStatus;
|
|
|
+use App\Enums\TechnicianAuthStatus;
|
|
|
+use Illuminate\Support\Facades\Log;
|
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
+use App\Enums\TechnicianLocationType;
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
-use Illuminate\Support\Facades\Log;
|
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
-
|
|
|
+use App\Models\SettingItem;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 用户端技师服务类
|
|
|
+ *
|
|
|
+ * 该服务类处理与技师相关的所有业务逻辑,包括:
|
|
|
+ * - 获取附近技师列表
|
|
|
+ * - 获取技师详细信息
|
|
|
+ * - 管理技师位置信息
|
|
|
+ * - 处理技师排班和预约时间段
|
|
|
+ *
|
|
|
+ * 主要功能:
|
|
|
+ * 1. 基于地理位置的技师查询和排序
|
|
|
+ * 2. 技师认证状态验证
|
|
|
+ * 3. 技师位置信息的缓存管理
|
|
|
+ * 4. 技师预约时间段的计算和管理
|
|
|
+ */
|
|
|
class CoachService
|
|
|
{
|
|
|
/**
|
|
|
- * 获取技师列表
|
|
|
+ * 获取附近技师列表
|
|
|
+ *
|
|
|
+ * 业务流程:
|
|
|
+ * 1. 验证用户身份和状态
|
|
|
+ * 2. 获取系统设置的最大搜索半径
|
|
|
+ * 3. 使用Redis GEO功能查询范围内的技师
|
|
|
+ * 4. 过滤并验证技师认证状态
|
|
|
+ * 5. 根据技师接单距离设置筛选
|
|
|
+ * 6. 计算并排序技师距离
|
|
|
+ * 7. 返回分页后的技师列表
|
|
|
+ *
|
|
|
+ * 技术实现:
|
|
|
+ * - 使用Redis GEORADIUS命令进行地理位置查询
|
|
|
+ * - 使用Eloquent关联加载技师信息
|
|
|
+ * - 实现基于距离的排序
|
|
|
+ * - 支持分页功能
|
|
|
+ *
|
|
|
+ * 数据处理:
|
|
|
+ * - 技师位置数据存储在Redis中
|
|
|
+ * - 技师基本信息从数据库获取
|
|
|
+ * - 认证状态多表关联验证
|
|
|
+ * - 接单距离设置验证
|
|
|
+ * - 距离数据格式化(保留2位小数)
|
|
|
+ *
|
|
|
+ * 筛选条件:
|
|
|
+ * 1. 技师认证状态检查
|
|
|
+ * - 基本信息认证通过
|
|
|
+ * - 实名认证通过
|
|
|
+ * - 资质认证通过
|
|
|
+ * 2. 距离筛选
|
|
|
+ * - 有设置接单距离:用户距离 <= 设置值
|
|
|
+ * - 无设置接单距离:在系统搜索半径内即可
|
|
|
+ *
|
|
|
+ * 排序规则:
|
|
|
+ * - 按照技师到用户的实际距离升序排序
|
|
|
+ * - 未获取到距离的技师排在最后
|
|
|
+ *
|
|
|
+ * 异常处理:
|
|
|
+ * - 用户未登录/状态异常
|
|
|
+ * - Redis连接异常
|
|
|
+ * - 无效的地理坐标
|
|
|
+ * - 设置项未找到
|
|
|
+ *
|
|
|
+ * @param int $userId 当前用户ID
|
|
|
+ * @param float $latitude 纬度坐标 (-90 到 90)
|
|
|
+ * @param float $longitude 经度坐标 (-180 到 180)
|
|
|
+ * @return array{
|
|
|
+ * items: array{
|
|
|
+ * id: int,
|
|
|
+ * info: array{
|
|
|
+ * id: int,
|
|
|
+ * nickname: string,
|
|
|
+ * avatar: string,
|
|
|
+ * gender: int
|
|
|
+ * },
|
|
|
+ * distance: float
|
|
|
+ * }[],
|
|
|
+ * total: int
|
|
|
+ * } 技师列表和总数
|
|
|
+ * @throws \Exception 当用户验证失败或Redis操作异常时
|
|
|
*/
|
|
|
public function getNearCoachList(int $userId, float $latitude, float $longitude)
|
|
|
{
|
|
|
- $page = request()->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,
|
|
|
- ]);
|
|
|
+ // 基础验证(用户状态和Redis连接)
|
|
|
+ $this->validateBasicRequirements($userId);
|
|
|
|
|
|
- // 检查用户状态
|
|
|
- if (! $user) {
|
|
|
- throw new \Exception('用户未登录');
|
|
|
- }
|
|
|
+ // 获取搜索半径设置
|
|
|
+ $distanceSettingItem = $this->getDistanceSetting();
|
|
|
+ $maxSearchRadius = $distanceSettingItem?->max_value ?? 40; // 默认40公里
|
|
|
|
|
|
- if ($user->state !== UserStatus::OPEN->value) {
|
|
|
- throw new \Exception('用户状态异常');
|
|
|
+ // 获取附近的技师(使用Redis GEO功能)
|
|
|
+ $nearbyCoaches = $this->getNearbyCoaches($longitude, $latitude, $maxSearchRadius);
|
|
|
+ if (empty($nearbyCoaches)) {
|
|
|
+ return ['items' => [], 'total' => 0];
|
|
|
}
|
|
|
|
|
|
- // 使用 Redis 的 georadius 命令获取附近的技师 ID
|
|
|
- $nearbyCoachIds = Redis::georadius('coach_locations', $longitude, $latitude, 40, 'km', ['WITHDIST']);
|
|
|
+ // 处理技师距离数据(提取ID和距离信息)
|
|
|
+ [$coachDistances, $coachIds] = $this->processCoachDistances($nearbyCoaches);
|
|
|
|
|
|
- $coachData = array_map(function ($item) {
|
|
|
- [$id, $type] = explode('_', $item[0]);
|
|
|
+ // 构建技师查询(包含认证状态和距离筛选)
|
|
|
+ $query = $this->buildCoachQuery($coachIds, $distanceSettingItem, $coachDistances);
|
|
|
|
|
|
- return ['id' => $id, 'type' => $type, 'distance' => $item[1]];
|
|
|
- }, $nearbyCoachIds);
|
|
|
+ // 获取分页结果(带基本信息)
|
|
|
+ $coaches = $query->with(['info:id,nickname,avatar,gender'])
|
|
|
+ ->paginate($perPage);
|
|
|
|
|
|
- // 提取所有的id
|
|
|
- $coachIds = array_unique(array_column($coachData, 'id'));
|
|
|
+ // 处理返回结果(添加距离信息)
|
|
|
+ $items = $this->processCoachResults($coaches, $coachDistances);
|
|
|
|
|
|
- // 分页截取 coachIds
|
|
|
- $paginatedCoachIds = array_slice($coachIds, ($page - 1) * $perPage, $perPage);
|
|
|
+ return [
|
|
|
+ 'items' => $items,
|
|
|
+ 'total' => $coaches->total()
|
|
|
+ ];
|
|
|
+ }
|
|
|
|
|
|
- // 查询数据库获取技师信息
|
|
|
- $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);
|
|
|
+ /**
|
|
|
+ * 验证基本要求
|
|
|
+ *
|
|
|
+ * @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->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');
|
|
|
})
|
|
|
- ->with(['info:id,nickname,avatar,gender'])
|
|
|
- ->paginate($perPage);
|
|
|
+ ->where('code', 'distance')
|
|
|
+ ->first();
|
|
|
+ }
|
|
|
|
|
|
- // 遍历技师并设置距离
|
|
|
- foreach ($coaches as $coach) {
|
|
|
- $coach->distance = round($coachData[array_search($coach->id, array_column($coachData, 'id'))]['distance'] ?? null, 2);
|
|
|
+ /**
|
|
|
+ * 获取附近的技师
|
|
|
+ *
|
|
|
+ * @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<string, float>, 1: array<int>} [距离映射, 技师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;
|
|
|
+ }
|
|
|
}
|
|
|
- // 按 distance 升序排序
|
|
|
- $coaches = $coaches->sortBy('distance')->values();
|
|
|
|
|
|
- return $coaches;
|
|
|
+ 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();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 获取技师详情
|
|
|
+ *
|
|
|
+ * 业务流程:
|
|
|
+ * 1. 验证Redis连接状态
|
|
|
+ * 2. 验证用户身份和状态
|
|
|
+ * 3. 获取技师基本信息和认证状态
|
|
|
+ * 4. 获取技师位置信息
|
|
|
+ * 5. 计算用户与技师之间的距离
|
|
|
+ *
|
|
|
+ * 技术实现:
|
|
|
+ * - Redis连接状态检查
|
|
|
+ * - 多重认证状态验证
|
|
|
+ * - 地理位置距离计算
|
|
|
+ * - 数据关联查询
|
|
|
+ *
|
|
|
+ * 数据验证:
|
|
|
+ * - 技师ID有效性
|
|
|
+ * - 用户状态检查
|
|
|
+ * - 地理坐标有效性
|
|
|
+ * - 认证状态验证
|
|
|
+ *
|
|
|
+ * 异常处理:
|
|
|
+ * - Redis连接异常
|
|
|
+ * - 技师不存在
|
|
|
+ * - 无效坐标
|
|
|
+ * - 认证状态异常
|
|
|
+ *
|
|
|
+ * @param int $coachId 技师ID
|
|
|
+ * @param float|null $latitude 用户当前纬度
|
|
|
+ * @param float|null $longitude 用户当前经度
|
|
|
+ * @return CoachUser 技师详细信息
|
|
|
+ * @throws \Exception 当验证失败或查询异常时
|
|
|
*/
|
|
|
public function getCoachDetail($coachId, $latitude, $longitude)
|
|
|
{
|
|
|
+ // 验证基本要求
|
|
|
+ $this->validateBasicRequirements(Auth::id());
|
|
|
+
|
|
|
// 检查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());
|
|
|
+ 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();
|
|
|
|
|
@@ -120,18 +355,11 @@ class CoachService
|
|
|
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'])
|
|
|
+ // 修改查询以处理可能的 null 值
|
|
|
+ $query = CoachUser::where('state', TechnicianStatus::ACTIVE->value);
|
|
|
+ $this->addAuthStatusChecks($query);
|
|
|
+
|
|
|
+ $coach = $query->with(['info:id,nickname,avatar,gender'])
|
|
|
->find($coachId);
|
|
|
|
|
|
if (! $coach) {
|
|
@@ -139,17 +367,17 @@ class CoachService
|
|
|
}
|
|
|
|
|
|
// 从 Redis 获取技师的 id_home 和 id_work 的经纬度
|
|
|
- $homeLocation = Redis::geopos('coach_locations', $coachId.'_'.TechnicianLocationType::COMMON->value);
|
|
|
- $workLocation = Redis::geopos('coach_locations', $coachId.'_'.TechnicianLocationType::CURRENT->value);
|
|
|
+ $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;
|
|
|
+ $tempKey = 'user_temp_' . $user->id;
|
|
|
Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey);
|
|
|
|
|
|
// 计算距离(单位:km)
|
|
@@ -157,20 +385,20 @@ class CoachService
|
|
|
$distanceWork = null;
|
|
|
|
|
|
if ($homeLocation && ! empty($homeLocation[0])) {
|
|
|
- $distanceHome = Redis::geodist('coach_locations', $tempKey, $coachId.'_'.TechnicianLocationType::COMMON->value, 'km');
|
|
|
+ $distanceHome = Redis::geodist('coach_locations', $tempKey, $coachId . '_' . TechnicianLocationType::COMMON->value, 'km');
|
|
|
Log::info('Home distance calculation:', [
|
|
|
'from' => $tempKey,
|
|
|
- 'to' => $coachId.'_'.TechnicianLocationType::COMMON->value,
|
|
|
+ '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');
|
|
|
+ $distanceWork = Redis::geodist('coach_locations', $tempKey, $coachId . '_' . TechnicianLocationType::CURRENT->value, 'km');
|
|
|
}
|
|
|
|
|
|
- // 删除临时位置点
|
|
|
+ // 删除临位置点
|
|
|
Redis::zrem('coach_locations', $tempKey);
|
|
|
|
|
|
// 选择最近的距离
|
|
@@ -183,13 +411,32 @@ class CoachService
|
|
|
/**
|
|
|
* 设置技师位置信息
|
|
|
*
|
|
|
- * @param int $coachId 技师ID
|
|
|
- * @param float $latitude 纬度
|
|
|
- * @param float $longitude 经度
|
|
|
- * @param int $type 位置类型 (current|common)
|
|
|
- * @return bool
|
|
|
+ * 业务流程:
|
|
|
+ * 1. 验证位置信息的有效性
|
|
|
+ * 2. 根据类型更新技师位置
|
|
|
+ * 3. 验证更新结果
|
|
|
+ *
|
|
|
+ * 技术实现:
|
|
|
+ * - 使用Redis GEOADD命令存储位置
|
|
|
+ * - 支持多种位置类型(常驻/当前)
|
|
|
+ * - 位置更新验证
|
|
|
*
|
|
|
- * @throws \Exception
|
|
|
+ * 数据验证:
|
|
|
+ * - 标范围检查
|
|
|
+ * - 位置类型验证
|
|
|
+ * - 技师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)
|
|
|
{
|
|
@@ -206,7 +453,7 @@ class CoachService
|
|
|
throw new \Exception('无效的位置类型,必须是 current 或 common');
|
|
|
}
|
|
|
|
|
|
- $key = $coachId.'_'.$type;
|
|
|
+ $key = $coachId . '_' . $type;
|
|
|
$result = Redis::geoadd('coach_locations', $longitude, $latitude, $key);
|
|
|
|
|
|
Log::info('Coach location set:', [
|
|
@@ -231,25 +478,34 @@ class CoachService
|
|
|
/**
|
|
|
* 获取技师可预约时间段列表
|
|
|
*
|
|
|
- * @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
|
|
|
+ * 业务流程:
|
|
|
+ * 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)
|
|
|
{
|
|
@@ -261,14 +517,23 @@ class CoachService
|
|
|
// 验证技师信息
|
|
|
$coach = CoachUser::with('info')->find($coachId);
|
|
|
abort_if(! $coach, 404, '技师不存在');
|
|
|
- abort_if($coach->info->state != TechnicianStatus::ACTIVE->value,
|
|
|
- 400, '技师状态异常');
|
|
|
+ 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天内的时间段');
|
|
|
+ abort_if(
|
|
|
+ $targetDate->startOfDay()->lt(now()->startOfDay()),
|
|
|
+ 400,
|
|
|
+ '不能询过去的日期'
|
|
|
+ );
|
|
|
+ abort_if(
|
|
|
+ $targetDate->diffInDays(now()) > 30,
|
|
|
+ 400,
|
|
|
+ '只能查询未来30天内的时间段'
|
|
|
+ );
|
|
|
|
|
|
$cacheKey = "coach:timeslots:{$coachId}:{$date}";
|
|
|
Cache::forget($cacheKey);
|
|
@@ -303,7 +568,6 @@ class CoachService
|
|
|
return $this->formatResponse($date, $timeSlots);
|
|
|
}
|
|
|
);
|
|
|
-
|
|
|
} catch (\Exception $e) {
|
|
|
Log::error('获取可预约时间段失败', [
|
|
|
'coach_id' => $coachId,
|
|
@@ -334,7 +598,7 @@ class CoachService
|
|
|
OrderStatus::ACCEPTED->value,
|
|
|
OrderStatus::DEPARTED->value,
|
|
|
OrderStatus::ARRIVED->value,
|
|
|
- OrderStatus::SERVING->value,
|
|
|
+ OrderStatus::SERVICING->value,
|
|
|
])
|
|
|
->select(['id', 'service_start_time', 'service_end_time', 'state'])
|
|
|
->get()
|
|
@@ -345,7 +609,7 @@ class CoachService
|
|
|
* 生成可用时间段列表
|
|
|
*
|
|
|
* @param string $date 日期
|
|
|
- * @param array $timeRanges 排班时间段
|
|
|
+ * @param array $timeRanges 班时间段
|
|
|
* @param array $dayOrders 当天订单
|
|
|
* @param bool $isToday 是否是当天
|
|
|
* @return array 可用时间段列表
|
|
@@ -360,10 +624,10 @@ class CoachService
|
|
|
$currentTime = now();
|
|
|
|
|
|
foreach ($timeRanges as $range) {
|
|
|
- $start = Carbon::parse($date.' '.$range['start_time']);
|
|
|
- $end = Carbon::parse($date.' '.$range['end_time']);
|
|
|
+ $start = Carbon::parse($date . ' ' . $range['start_time']);
|
|
|
+ $end = Carbon::parse($date . ' ' . $range['end_time']);
|
|
|
|
|
|
- // 如果是当天且开始时间已过,从下一个30分钟时间点开始
|
|
|
+ // 果是当天且开始时间已过,从下一个30分钟时间点开始
|
|
|
if ($isToday && $start->lt($currentTime)) {
|
|
|
$start = $currentTime->copy()->addMinutes(30)->floorMinutes(30);
|
|
|
// 如果调整后的开始时间已超过结束时间,跳过此时间段
|
|
@@ -440,7 +704,8 @@ class CoachService
|
|
|
// 检查时间段是否重叠
|
|
|
if (($slotStart >= $orderStart && $slotStart < $orderEnd) ||
|
|
|
($slotEnd > $orderStart && $slotEnd <= $orderEnd) ||
|
|
|
- ($slotStart <= $orderStart && $slotEnd >= $orderEnd)) {
|
|
|
+ ($slotStart <= $orderStart && $slotEnd >= $orderEnd)
|
|
|
+ ) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
@@ -457,8 +722,8 @@ class CoachService
|
|
|
$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']);
|
|
|
+ $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;
|
|
@@ -467,4 +732,27 @@ class CoachService
|
|
|
|
|
|
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');
|
|
|
+ }
|
|
|
}
|