소스 검색

feat:用户端-获取技师分组

刘学玺 4 달 전
부모
커밋
b99045a1af

+ 60 - 0
app/Http/Controllers/Client/CoachGroupController.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Http\Controllers\Client;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\Client\CoachGroupRequest;
+use App\Services\Client\CoachGroupService;
+
+class CoachGroupController extends Controller
+{
+    /**
+     * 获取技师分组列表
+     *
+     * @group 用户端-技师分组
+     * @description 获取技师分组列表,包括技术组、明星组和新人组
+     *
+     * @queryParam latitude float 纬度,范围:-90到90 Example: 39.9042
+     * @queryParam longitude float 经度,范围:-180到180 Example: 116.4074
+     * @queryParam radius integer 搜索半径(公里),范围:1到100 Example: 20
+     *
+     * @response {
+     *  "code": 0,
+     *  "message": "操作成功",
+     *  "data": {
+     *    "technical": [
+     *      {
+     *        "id": 1,
+     *        "name": "张三",
+     *        "avatar": "头像地址",
+     *        "portrait_images": ["图片1", "图片2"],
+     *        "formal_photo": "正式照片",
+     *        "city": "北京",
+     *        "work_years": 5,
+     *        "avg_score": 4.8,
+     *        "comment_count": 100,
+     *        "skill_tags": ["标签1", "标签2"],
+     *        "description": "个人简介",
+     *        "gender": 1,
+     *        "age": 28,
+     *        "height": 175,
+     *        "weight": 65,
+     *        "distance": 1.5
+     *      }
+     *    ],
+     *    "star": [],
+     *    "newcomer": []
+     *  }
+     * }
+     */
+    public function index(CoachGroupRequest $request): array
+    {
+        $service = new CoachGroupService(
+            latitude: $request->input('latitude'),
+            longitude: $request->input('longitude'),
+            radius: $request->input('radius')
+        );
+
+        return $this->success($service->getAllGroups());
+    }
+}

+ 53 - 0
app/Http/Requests/Client/CoachGroupRequest.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Http\Requests\Client;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class CoachGroupRequest extends FormRequest
+{
+    /**
+     * 判断用户是否有权限进行此请求
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * 获取验证规则
+     */
+    public function rules(): array
+    {
+        return [
+            'latitude' => ['nullable', 'numeric', 'between:-90,90'],
+            'longitude' => ['nullable', 'numeric', 'between:-180,180'],
+            'radius' => ['nullable', 'integer', 'min:1', 'max:100'],
+        ];
+    }
+
+    /**
+     * 获取验证错误的自定义属性
+     */
+    public function attributes(): array
+    {
+        return [
+            'latitude' => '纬度',
+            'longitude' => '经度',
+            'radius' => '搜索半径',
+        ];
+    }
+
+    /**
+     * 获取验证错误的自定义消息
+     */
+    public function messages(): array
+    {
+        return [
+            'latitude.between' => '纬度必须在 -90 到 90 之间',
+            'longitude.between' => '经度必须在 -180 到 180 之间',
+            'radius.min' => '搜索半径不能小于 1 公里',
+            'radius.max' => '搜索半径不能大于 100 公里',
+        ];
+    }
+}

+ 69 - 0
app/Models/CoachStatistic.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class CoachStatistic extends Model
+{
+    use HasFactory;
+
+    protected $table = 'coach_statistics';
+
+    protected $fillable = [
+        'coach_id',
+        'avg_score',
+        'comment_count',
+        'good_comment_count',
+        'medium_comment_count',
+        'bad_comment_count',
+        'tag_statistics',
+    ];
+
+    protected $casts = [
+        'avg_score' => 'decimal:2',
+        'comment_count' => 'integer',
+        'good_comment_count' => 'integer',
+        'medium_comment_count' => 'integer',
+        'bad_comment_count' => 'integer',
+        'tag_statistics' => 'array',
+    ];
+
+    /**
+     * 获取统计所属的技师
+     */
+    public function coach()
+    {
+        return $this->belongsTo(CoachUser::class, 'coach_id');
+    }
+
+    /**
+     * 更新标签统计
+     */
+    public function updateTagStatistics(array $tagCounts): bool
+    {
+        return $this->update([
+            'tag_statistics' => $tagCounts,
+        ]);
+    }
+
+    /**
+     * 更新评分统计
+     */
+    public function updateScoreStatistics(
+        float $avgScore,
+        int $commentCount,
+        int $goodCount,
+        int $mediumCount,
+        int $badCount
+    ): bool {
+        return $this->update([
+            'avg_score' => $avgScore,
+            'comment_count' => $commentCount,
+            'good_comment_count' => $goodCount,
+            'medium_comment_count' => $mediumCount,
+            'bad_comment_count' => $badCount,
+        ]);
+    }
+}

+ 8 - 0
app/Models/CoachUser.php

@@ -38,4 +38,12 @@ class CoachUser extends Model
     {
         return $this->belongsTo(MemberUser::class, 'user_id');
     }
+
+    /**
+     * 获取技师的统计数据
+     */
+    public function statistic()
+    {
+        return $this->hasOne(CoachStatistic::class, 'coach_id');
+    }
 }

+ 536 - 0
app/Services/Client/CoachGroupService.php

@@ -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),
+        ];
+    }
+}