|
@@ -0,0 +1,536 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services\Client;
|
|
|
+
|
|
|
+use App\Enums\TechnicianStatus;
|
|
|
+use App\Models\CoachCommentTag;
|
|
|
+use App\Models\CoachStatistic;
|
|
|
+use App\Models\CoachUser;
|
|
|
+use Illuminate\Support\Collection;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Redis;
|
|
|
+use Illuminate\Support\Facades\Cache;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 技师分组服务类
|
|
|
+ * 负责处理技师的技术组、明星组和新人组的数据获取和处理
|
|
|
+ */
|
|
|
+class CoachGroupService
|
|
|
+{
|
|
|
+ /**
|
|
|
+ * 每组展示的技师数量
|
|
|
+ */
|
|
|
+ private const GROUP_LIMIT = 3;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 最小评价数要求
|
|
|
+ */
|
|
|
+ private const MIN_COMMENTS = 10;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 默认搜索半径(公里)
|
|
|
+ */
|
|
|
+ private const DEFAULT_RADIUS = 20;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Redis 键名前缀
|
|
|
+ */
|
|
|
+ private const REDIS_KEY = 'coach_locations';
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 技师位置类型
|
|
|
+ */
|
|
|
+ private const LOCATION_TYPE = '1'; // 1表示当前位置
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 技师标签代码
|
|
|
+ */
|
|
|
+ private const TAG_CODES = [
|
|
|
+ 'SKILL' => 'skill_professional', // 手法专业
|
|
|
+ 'IMAGE' => 'image_excellent', // 形象优秀
|
|
|
+ ];
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 用户当前位置信息
|
|
|
+ */
|
|
|
+ private readonly ?float $latitude;
|
|
|
+
|
|
|
+ private readonly ?float $longitude;
|
|
|
+
|
|
|
+ private readonly int $radius;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 缓存相关常量
|
|
|
+ */
|
|
|
+ private const CACHE_TTL = 300; // 缓存时间5分钟
|
|
|
+ private const CACHE_TAGS = 'coach_groups';
|
|
|
+ private const CACHE_KEY_PREFIX = 'coach_groups:';
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构造函数
|
|
|
+ */
|
|
|
+ public function __construct(?float $latitude = null, ?float $longitude = null, ?int $radius = null)
|
|
|
+ {
|
|
|
+ $this->latitude = $latitude;
|
|
|
+ $this->longitude = $longitude;
|
|
|
+ $this->radius = $radius ?? self::DEFAULT_RADIUS;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有分组的技师数据
|
|
|
+ *
|
|
|
+ * @return array{technical: array, star: array, newcomer: array}
|
|
|
+ */
|
|
|
+ public function getAllGroups(): array
|
|
|
+ {
|
|
|
+ $cacheKey = $this->generateCacheKey();
|
|
|
+
|
|
|
+ return Cache::tags(self::CACHE_TAGS)->remember($cacheKey, self::CACHE_TTL, function () {
|
|
|
+ return [
|
|
|
+ 'technical' => $this->getTechnicalGroup(),
|
|
|
+ 'star' => $this->getStarGroup(),
|
|
|
+ 'newcomer' => $this->getNewcomerGroup(),
|
|
|
+ ];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成缓存键
|
|
|
+ */
|
|
|
+ private function generateCacheKey(): string
|
|
|
+ {
|
|
|
+ $params = [
|
|
|
+ 'lat' => $this->latitude,
|
|
|
+ 'lng' => $this->longitude,
|
|
|
+ 'rad' => $this->radius,
|
|
|
+ ];
|
|
|
+
|
|
|
+ return self::CACHE_KEY_PREFIX . md5(serialize($params));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取技术组技师列表
|
|
|
+ *
|
|
|
+ * @return array 技师列表
|
|
|
+ */
|
|
|
+ protected function getTechnicalGroup(): array
|
|
|
+ {
|
|
|
+ $tagId = $this->getTagId(self::TAG_CODES['SKILL']);
|
|
|
+ if (!$tagId) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->getCoachesByTag($tagId, 'skill');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取明星组技师列表
|
|
|
+ *
|
|
|
+ * @return array 技师列表
|
|
|
+ */
|
|
|
+ protected function getStarGroup(): array
|
|
|
+ {
|
|
|
+ $tagId = $this->getTagId(self::TAG_CODES['IMAGE']);
|
|
|
+ if (!$tagId) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->getCoachesByTag($tagId, 'image');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取新人组技师列表
|
|
|
+ *
|
|
|
+ * @return array 技师列表
|
|
|
+ */
|
|
|
+ protected function getNewcomerGroup(): array
|
|
|
+ {
|
|
|
+ // 获取基础查询构建器
|
|
|
+ $query = $this->getBaseCoachQuery();
|
|
|
+
|
|
|
+ // 添加新人组特有的排序
|
|
|
+ $coaches = $query->orderByDesc('newcomer_sort')
|
|
|
+ ->orderByDesc('newcomer_sort_updated_at')
|
|
|
+ ->orderByDesc('updated_at')
|
|
|
+ ->limit(self::GROUP_LIMIT)
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ // 如果有位置信息,添加距离并过滤
|
|
|
+ if ($this->hasLocation()) {
|
|
|
+ $coaches = $this->appendCoachesDistance($coaches);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->formatCoaches($coaches);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取基础的技师查询构建器
|
|
|
+ */
|
|
|
+ private function getBaseCoachQuery(): \Illuminate\Database\Eloquent\Builder
|
|
|
+ {
|
|
|
+ return CoachUser::query()
|
|
|
+ ->where('state', TechnicianStatus::ACTIVE)
|
|
|
+ ->whereHas('info')
|
|
|
+ ->with(['info', 'statistic']);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据标签获取技师列表
|
|
|
+ *
|
|
|
+ * @param int $tagId 标签ID
|
|
|
+ * @param string $rateField 评分字段名
|
|
|
+ * @return array 技师列表
|
|
|
+ */
|
|
|
+ private function getCoachesByTag(int $tagId, string $rateField): array
|
|
|
+ {
|
|
|
+ // 获取基础查询构建器
|
|
|
+ $query = $this->buildTagBasedQuery($tagId, $rateField);
|
|
|
+
|
|
|
+ // 执行查询
|
|
|
+ $coaches = $query->get();
|
|
|
+
|
|
|
+ // 处理位置信息
|
|
|
+ if ($this->hasLocation()) {
|
|
|
+ $coaches = $this->appendCoachesDistance($coaches);
|
|
|
+ $coaches = $coaches->sortBy('distance')->values();
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->formatCoaches($coaches);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建基于标签的查询构建器
|
|
|
+ *
|
|
|
+ * @param int $tagId 标签ID
|
|
|
+ * @param string $rateField 评分字段名
|
|
|
+ * @return \Illuminate\Database\Eloquent\Builder 查询构建器
|
|
|
+ */
|
|
|
+ private function buildTagBasedQuery(int $tagId, string $rateField): \Illuminate\Database\Eloquent\Builder
|
|
|
+ {
|
|
|
+ // 构建JSON路径
|
|
|
+ $jsonPath = $this->buildJsonPath($tagId);
|
|
|
+
|
|
|
+ // 获取基础查询
|
|
|
+ $query = $this->getBaseTagStatisticQuery($jsonPath);
|
|
|
+
|
|
|
+ // 添加选择字段
|
|
|
+ return $this->addTagStatisticFields($query, $jsonPath, $rateField);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建JSON路径
|
|
|
+ *
|
|
|
+ * @param int $tagId 标签ID
|
|
|
+ * @return string JSON路径
|
|
|
+ */
|
|
|
+ private function buildJsonPath(int $tagId): string
|
|
|
+ {
|
|
|
+ return sprintf('$."%d"', $tagId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取基础标签统计查询
|
|
|
+ */
|
|
|
+ private function getBaseTagStatisticQuery(string $jsonPath): \Illuminate\Database\Eloquent\Builder
|
|
|
+ {
|
|
|
+ return CoachStatistic::query()
|
|
|
+ ->join('coach_users', 'coach_statistics.coach_id', '=', 'coach_users.id')
|
|
|
+ ->where('coach_users.state', TechnicianStatus::ACTIVE)
|
|
|
+ ->where('coach_statistics.comment_count', '>=', self::MIN_COMMENTS)
|
|
|
+ ->whereRaw('JSON_EXTRACT(tag_statistics, ?) IS NOT NULL', [$jsonPath]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 添加标签统计相关字段
|
|
|
+ *
|
|
|
+ * @param \Illuminate\Database\Eloquent\Builder $query 查询构建器
|
|
|
+ * @param string $jsonPath JSON路径
|
|
|
+ * @param string $rateField 评分字段名
|
|
|
+ * @return \Illuminate\Database\Eloquent\Builder 查询构建器
|
|
|
+ */
|
|
|
+ private function addTagStatisticFields($query, string $jsonPath, string $rateField): \Illuminate\Database\Eloquent\Builder
|
|
|
+ {
|
|
|
+ // 构建标签统计表达式
|
|
|
+ $countExpr = $this->buildTagCountExpression($jsonPath);
|
|
|
+ $rateExpr = $this->buildTagRateExpression($countExpr);
|
|
|
+
|
|
|
+ return $query->select([
|
|
|
+ 'coach_users.*',
|
|
|
+ 'coach_statistics.comment_count',
|
|
|
+ 'coach_statistics.tag_statistics',
|
|
|
+ 'coach_statistics.avg_score',
|
|
|
+ DB::raw("{$countExpr} as {$rateField}_count"),
|
|
|
+ DB::raw("{$rateExpr} as {$rateField}_rate"),
|
|
|
+ ])
|
|
|
+ ->orderByRaw("{$rateField}_rate * avg_score DESC")
|
|
|
+ ->with(['info'])
|
|
|
+ ->limit(self::GROUP_LIMIT);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建标签计数表达式
|
|
|
+ *
|
|
|
+ * @param string $jsonPath JSON路径
|
|
|
+ * @return string SQL表达式
|
|
|
+ */
|
|
|
+ private function buildTagCountExpression(string $jsonPath): string
|
|
|
+ {
|
|
|
+ return "CAST(JSON_EXTRACT(tag_statistics, '{$jsonPath}') AS UNSIGNED)";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建标签比率表达式
|
|
|
+ *
|
|
|
+ * @param string $countExpr 计数表达式
|
|
|
+ * @return string SQL表达式
|
|
|
+ */
|
|
|
+ private function buildTagRateExpression(string $countExpr): string
|
|
|
+ {
|
|
|
+ return "({$countExpr} / comment_count * 100)";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取标签ID
|
|
|
+ *
|
|
|
+ * @param string $code 标签代码
|
|
|
+ * @return int|null 标签ID
|
|
|
+ */
|
|
|
+ private function getTagId(string $code): ?int
|
|
|
+ {
|
|
|
+ static $tagCache = [];
|
|
|
+
|
|
|
+ // 使用静态缓存避免重复查询
|
|
|
+ if (!isset($tagCache[$code])) {
|
|
|
+ $tagCache[$code] = CoachCommentTag::where('code', $code)->value('id');
|
|
|
+ }
|
|
|
+
|
|
|
+ return $tagCache[$code];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 为技师列表添加距离信息
|
|
|
+ *
|
|
|
+ * @param Collection $coaches 技师集合
|
|
|
+ * @return Collection 添加了距离信息的技师集合
|
|
|
+ */
|
|
|
+ private function appendCoachesDistance(Collection $coaches): Collection
|
|
|
+ {
|
|
|
+ // 批量获取位置信息
|
|
|
+ $locations = $this->batchGetCoachLocations($coaches);
|
|
|
+
|
|
|
+ // 批量计算距离
|
|
|
+ return $this->batchCalculateDistances($coaches, $locations);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量获取技师位置信息
|
|
|
+ *
|
|
|
+ * @param Collection $coaches 技师集合
|
|
|
+ * @return array 位置信息数组 [coach_id => [longitude, latitude]]
|
|
|
+ */
|
|
|
+ private function batchGetCoachLocations(Collection $coaches): array
|
|
|
+ {
|
|
|
+ if ($coaches->isEmpty()) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 批量生成位置键名
|
|
|
+ $locationKeys = $coaches->map(fn($coach) => $this->getLocationKey($coach->id))->toArray();
|
|
|
+
|
|
|
+ // 使用管道批量获取位置信息
|
|
|
+ $positions = Redis::pipeline(function ($pipe) use ($locationKeys) {
|
|
|
+ foreach ($locationKeys as $key) {
|
|
|
+ $pipe->geopos(self::REDIS_KEY, $key);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 整理位置信息
|
|
|
+ $locations = [];
|
|
|
+ foreach ($coaches as $index => $coach) {
|
|
|
+ if (!empty($positions[$index][0])) {
|
|
|
+ $locations[$coach->id] = $positions[$index][0];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $locations;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量计算技师距离
|
|
|
+ *
|
|
|
+ * @param Collection $coaches 技师集合
|
|
|
+ * @param array $locations 位置信息数组
|
|
|
+ * @return Collection 添加了距离信息的技师集合
|
|
|
+ */
|
|
|
+ private function batchCalculateDistances(Collection $coaches, array $locations): Collection
|
|
|
+ {
|
|
|
+ if (empty($locations)) {
|
|
|
+ return $coaches;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建临时位置点
|
|
|
+ $tempKey = $this->createTempLocationPoint();
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 使用管道批量计算距离
|
|
|
+ $distances = Redis::pipeline(function ($pipe) use ($locations, $tempKey) {
|
|
|
+ foreach ($locations as $coachId => $position) {
|
|
|
+ $locationKey = $this->getLocationKey($coachId);
|
|
|
+ $pipe->geodist(self::REDIS_KEY, $tempKey, $locationKey, 'km');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 为技师添加距离信息
|
|
|
+ $index = 0;
|
|
|
+ foreach ($coaches as $coach) {
|
|
|
+ if (isset($locations[$coach->id])) {
|
|
|
+ $coach->distance = (float)$distances[$index++];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 过滤并排序
|
|
|
+ return $coaches
|
|
|
+ ->filter(fn($coach) => isset($coach->distance) && $coach->distance <= $this->radius)
|
|
|
+ ->values();
|
|
|
+
|
|
|
+ } finally {
|
|
|
+ $this->removeTempLocationPoint($tempKey);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取技师位置的Redis键名
|
|
|
+ *
|
|
|
+ * @param int $coachId 技师ID
|
|
|
+ * @return string Redis键名
|
|
|
+ */
|
|
|
+ private function getLocationKey(int $coachId): string
|
|
|
+ {
|
|
|
+ return sprintf('%d_%s', $coachId, self::LOCATION_TYPE);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建临时位置点
|
|
|
+ *
|
|
|
+ * @return string ���时点的键名
|
|
|
+ */
|
|
|
+ private function createTempLocationPoint(): string
|
|
|
+ {
|
|
|
+ $tempKey = sprintf('temp_%f_%f', $this->latitude, $this->longitude);
|
|
|
+ Redis::geoadd(
|
|
|
+ self::REDIS_KEY,
|
|
|
+ $this->longitude,
|
|
|
+ $this->latitude,
|
|
|
+ $tempKey
|
|
|
+ );
|
|
|
+ return $tempKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除临时位置点
|
|
|
+ *
|
|
|
+ * @param string $tempKey 临时点的键名
|
|
|
+ */
|
|
|
+ private function removeTempLocationPoint(string $tempKey): void
|
|
|
+ {
|
|
|
+ Redis::zrem(self::REDIS_KEY, $tempKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查是否有位置信息
|
|
|
+ *
|
|
|
+ * @return bool 是否有位置信息
|
|
|
+ */
|
|
|
+ private function hasLocation(): bool
|
|
|
+ {
|
|
|
+ return $this->latitude !== null && $this->longitude !== null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 格式化技师数据
|
|
|
+ *
|
|
|
+ * @param Collection $coaches 技师集合
|
|
|
+ * @return array 格式化后的技师数组
|
|
|
+ */
|
|
|
+ protected function formatCoaches(Collection $coaches): array
|
|
|
+ {
|
|
|
+ return $coaches->map(function ($coach) {
|
|
|
+ return [
|
|
|
+ ...$this->getBasicInfo($coach),
|
|
|
+ ...$this->getLocationInfo($coach),
|
|
|
+ ...$this->getEvaluationInfo($coach),
|
|
|
+ ...$this->getPersonalInfo($coach),
|
|
|
+ ];
|
|
|
+ })->toArray();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取技师基本信息
|
|
|
+ *
|
|
|
+ * @param CoachUser $coach 技师模型
|
|
|
+ * @return array 基本信息数组
|
|
|
+ */
|
|
|
+ private function getBasicInfo($coach): array
|
|
|
+ {
|
|
|
+ return [
|
|
|
+ 'id' => $coach->id,
|
|
|
+ 'name' => $coach->info->nickname ?? '未知',
|
|
|
+ 'avatar' => $coach->info->portrait_images[0] ?? null,
|
|
|
+ 'portrait_images' => collect($coach->info->portrait_images ?? [])->values()->toArray(),
|
|
|
+ 'formal_photo' => $coach->formal_photo,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取技师地理和工作信息
|
|
|
+ *
|
|
|
+ * @param CoachUser $coach 技师模型
|
|
|
+ * @return array 地理和工作信息数组
|
|
|
+ */
|
|
|
+ private function getLocationInfo($coach): array
|
|
|
+ {
|
|
|
+ $data = [
|
|
|
+ 'city' => $coach->info->intention_city ?? '',
|
|
|
+ 'work_years' => (int)($coach->info->work_years ?? 0),
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 添加距离信息(如果有)
|
|
|
+ if (isset($coach->distance)) {
|
|
|
+ $data['distance'] = round($coach->distance, 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取技师评价信息
|
|
|
+ *
|
|
|
+ * @param CoachUser $coach 技师模型
|
|
|
+ * @return array 评价信息数组
|
|
|
+ */
|
|
|
+ private function getEvaluationInfo($coach): array
|
|
|
+ {
|
|
|
+ return [
|
|
|
+ 'avg_score' => round(optional($coach->statistic)->avg_score ?? 0, 1),
|
|
|
+ 'comment_count' => (int)(optional($coach->statistic)->comment_count ?? 0),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取技师个人信息
|
|
|
+ *
|
|
|
+ * @param CoachUser $coach 技师模型
|
|
|
+ * @return array 个人信息数组
|
|
|
+ */
|
|
|
+ private function getPersonalInfo($coach): array
|
|
|
+ {
|
|
|
+ return [
|
|
|
+ 'skill_tags' => $coach->info->skill_tags ?? [],
|
|
|
+ 'description' => $coach->info->description ?? '',
|
|
|
+ 'gender' => $coach->info->gender ?? 0,
|
|
|
+ 'age' => (int)($coach->info->age ?? 0),
|
|
|
+ 'height' => (int)($coach->info->height ?? 0),
|
|
|
+ 'weight' => (int)($coach->info->weight ?? 0),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+}
|