request->post('realname')); $mobile = preg_replace('/[^\d]/', '', trim((string)$this->request->post('mobile'))); $rawIdcard = strtoupper(trim((string)$this->request->post('idcard_no', $this->request->post('idcard', '')))); $idcardFront = trim((string)$this->request->post('idcard_front')); $idcardBack = trim((string)$this->request->post('idcard_back')); $certImages = trim((string)$this->request->post('cert_images')); $province = trim((string)$this->request->post('province', '')); $city = trim((string)$this->request->post('city', '')); $district = trim((string)$this->request->post('district', '')); $cityParts = []; foreach ([$province, $city, $district] as $part) { if ($part === '') { continue; } $duplicated = false; foreach ($cityParts as $ep) { if (mb_strpos($ep, $part) !== false) { $duplicated = true; break; } } if (!$duplicated) { $cityParts[] = $part; } } $normalizedCity = mb_substr(implode(' ', $cityParts), 0, 50); $skillsRaw = $this->request->post('skills', null, null); $skillCatIds = []; if (is_string($skillsRaw)) { $decSkills = json_decode(htmlspecialchars_decode(trim($skillsRaw)), true); $skillsArr = is_array($decSkills) ? $decSkills : explode(',', $skillsRaw); } elseif (is_array($skillsRaw)) { $skillsArr = $skillsRaw; } else { $skillsArr = []; } foreach ($skillsArr as $sv) { if (is_array($sv)) { continue; } foreach (explode(',', (string)$sv) as $partId) { $cid = (int)trim($partId, " \t\n\r\0\x0B\"'[]"); if ($cid > 0 && !in_array($cid, $skillCatIds, true)) { $skillCatIds[] = $cid; } } } if ($realname === '' || $mobile === '' || $rawIdcard === '') { $this->error('请填写真实姓名、手机号和身份证号'); } if ($idcardFront === '' || $idcardBack === '') { $this->error('请上传身份证人像面和国徽面照片'); } if (!preg_match('/^1\d{10}$/', $mobile)) { $this->error('手机号格式不正确'); } if ($normalizedCity === '') { $this->error('请选择服务城市'); } if (empty($skillCatIds)) { $this->error('请至少选择一个服务类目'); } $exist = Db::name('master')->where('user_id', $this->uid())->find(); if ($exist && $exist['verify_status'] === 'pass') { $this->error('您已是认证师傅,无需重复入驻'); } // 若是驳回后重提且未修改脱敏展示号,沿用原存储的完整身份证号;否则校验完整身份证号并真实入库 $needVerify = true; if (strpos($rawIdcard, '*') !== false) { if ($exist && !empty($exist['idcard_no']) && strpos((string)$exist['idcard_no'], '*') === false) { $finalIdcard = (string)$exist['idcard_no']; $needVerify = false; } else { $this->error('请填写真实完整的18位身份证号码'); return; } } else { if (!preg_match('/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/', $rawIdcard)) { $this->error('身份证号码格式不正确,请输入18位有效居民身份证'); } $finalIdcard = $rawIdcard; } // 若前端携带了有效期限参数,进行过期拦截校验 $rawValidDate = trim((string)$this->request->post('valid_date', '')); if (!empty($rawValidDate)) { $expireChk = TencentVerifyService::checkIdCardExpired($rawValidDate); if (!empty($expireChk['is_expired'])) { $this->error('该身份证已过有效期限(截止 ' . ($expireChk['expire_date'] ?: $rawValidDate) . '),无法通过实名认证'); return; } } // 腾讯云公安权威二要素实名核验(姓名 + 身份证号) if ($needVerify) { $verifyRes = TencentVerifyService::verifyIdCard($realname, $finalIdcard); if (!$verifyRes['pass']) { $this->error('实名核验未通过:' . $verifyRes['msg']); return; } } $catNameMap = Db::name('service_cat')->where('id', 'in', $skillCatIds)->column('name', 'id'); $save = [ 'realname' => mb_substr($realname, 0, 50), 'mobile' => $mobile, 'idcard_no' => $finalIdcard, 'idcard_front' => $idcardFront, 'idcard_back' => $idcardBack, 'cert_images' => $certImages, 'city' => $normalizedCity, 'verify_status' => 'pass', 'audit_msg' => '腾讯云二要素实名核验通过', 'updatetime' => time(), ]; Db::startTrans(); try { if ($exist) { $masterId = (int)$exist['id']; Db::name('master')->where('id', $masterId)->update($save); Db::name('master_skill')->where('master_id', $masterId)->delete(); } else { $save['user_id'] = $this->uid(); $save['createtime'] = time(); $masterId = Db::name('master')->insertGetId($save); } foreach ($skillCatIds as $catId) { $cName = isset($catNameMap[$catId]) ? (string)$catNameMap[$catId] : ''; Db::name('master_skill')->insert([ 'master_id' => $masterId, 'cat_id' => (int)$catId, 'skill_id' => 0, 'skill_name' => $cName, 'category' => '安装维修', 'is_custom' => 0, 'status' => 'normal', 'createtime' => time(), ]); } Db::commit(); } catch (\Exception $e) { Db::rollback(); $this->error('提交失败:' . $e->getMessage()); } $this->success('实名认证成功,已获得接单资格!', ['master_id' => (int)$masterId]); } /** * 身份证 OCR 智能文字识别 * @ApiMethod (POST) */ public function ocrIdCard() { $image = trim((string)$this->request->post('image')); $side = strtolower(trim((string)$this->request->post('side', 'front'))); if (empty($image)) { $this->error('请提供身份证照片地址'); } $cardSide = ($side === 'back' || $side === 'reverse') ? 'BACK' : 'FRONT'; // 处理图片公网访问完整 URL $targetImg = $image; if (!preg_match('/^https?:\/\//i', $targetImg) && strpos($targetImg, 'data:image') !== 0) { $cdnUrl = \think\Config::get('upload.cdnurl') ?: (\think\Config::get('site.cdnurl') ?: $this->request->domain()); $targetImg = rtrim($cdnUrl, '/') . '/' . ltrim($targetImg, '/'); } $res = TencentVerifyService::idCardOcr($targetImg, $cardSide); if (!$res['success']) { $this->error($res['msg']); } $this->success('识别成功', $res['data']); } /** * 我的师傅档案 */ public function info() { $master = $this->masterInfo(); if (!$master) { $this->success('未入驻', ['applied' => false]); } $skillsRaw = Db::name('master_skill')->alias('s') ->join('service_cat c', 'c.id = s.cat_id', 'LEFT') ->field('s.cat_id,s.skill_id,s.skill_name,s.category,s.is_custom,c.name as cat_name,c.icon') ->where('s.master_id', $master['id']) ->select(); $skills = []; foreach ($skillsRaw as $sr) { $name = !empty($sr['skill_name']) ? $sr['skill_name'] : ($sr['cat_name'] ?? ''); if ($name) { $skills[] = [ 'cat_id' => (int)$sr['cat_id'], 'skill_id' => (int)$sr['skill_id'], 'name' => $name, 'category' => $sr['category'] ?: '服务技能', 'is_custom' => (int)$sr['is_custom'], 'icon' => $sr['icon'] ?? '' ]; } } $todo = Db::name('order')->where('master_id', $master['id']) ->where('status', 'in', [OrderStatus::ACCEPTED, OrderStatus::SCHEDULED, OrderStatus::ARRIVED, OrderStatus::WORKING]) ->count(); $pendingVerify = Db::name('order')->where('master_id', $master['id']) ->where('status', OrderStatus::PENDING_VERIFY)->count(); $rawIdcard = (string)($master['idcard_no'] ?? ''); $maskedIdcard = strlen($rawIdcard) >= 10 ? (substr($rawIdcard, 0, 6) . str_repeat('*', max(4, strlen($rawIdcard) - 10)) . substr($rawIdcard, -4)) : $rawIdcard; $this->success('', [ 'applied' => true, 'master' => [ 'id' => (int)$master['id'], 'realname' => $master['realname'], 'mobile' => $master['mobile'], 'idcard_no' => $maskedIdcard, 'idcard' => $maskedIdcard, 'idcard_front' => TencentVerifyService::getWatermarkUrl((string)($master['idcard_front'] ?? '')), 'idcard_back' => TencentVerifyService::getWatermarkUrl((string)($master['idcard_back'] ?? '')), 'idcard_front_raw' => (string)($master['idcard_front'] ?? ''), 'idcard_back_raw' => (string)($master['idcard_back'] ?? ''), 'cert_images' => (string)($master['cert_images'] ?? ''), 'avatar' => $master['avatar'], 'city' => $master['city'], 'lng' => (string)($master['lng'] ?? ''), 'lat' => (string)($master['lat'] ?? ''), 'serve_radius' => max(5, (int)($master['serve_radius'] ?? 15)), 'level' => (int)$master['level'], 'score' => (float)$master['score'], 'order_count' => (int)$master['order_count'], 'accept_rate' => (float)$master['accept_rate'], 'ontime_rate' => (float)$master['ontime_rate'], 'work_status' => $master['work_status'], 'verify_status' => $master['verify_status'], 'audit_msg' => $master['audit_msg'], 'money' => (float)$master['money'], 'frozen_money' => (float)$master['frozen_money'], 'todo_count' => (int)$todo, 'verify_count' => (int)$pendingVerify, ], 'skills' => $skills, 'cert_summary' => $this->buildMasterCertSummary((int)$master['id']), ]); } /** * 接单开关 * @ApiMethod (POST) */ public function toggleWork() { $master = $this->requireMaster(); $status = $this->request->post('work_status', ''); if (!in_array($status, ['on', 'off'], true)) { $this->error('参数错误'); } Db::name('master')->where('id', $master['id'])->update(['work_status' => $status, 'updatetime' => time()]); $this->success($status === 'on' ? '已开启接单' : '已暂停接单', ['work_status' => $status]); } /** * 更新师傅当前位置(经纬度持久化与可选城市解析) * @ApiMethod (POST) */ public function updateLocation() { $master = $this->requireMaster(); $rawLat = $this->request->post('lat', ''); $rawLng = $this->request->post('lng', ''); if (!$this->isValidCoord($rawLat, $rawLng)) { $this->error('定位坐标无效'); } $lat = round((float)$rawLat, 6); $lng = round((float)$rawLng, 6); $inputCity = trim((string)$this->request->post('city', '')); $inputDistrict = trim((string)$this->request->post('district', '')); $serveRadiusRaw = $this->request->post('serve_radius', ''); $resolvedCity = $inputCity; $resolvedDistrict = $inputDistrict; $inputLabel = trim((string)$this->request->post('label', '')); $resolvedPoi = ''; $prevLat = (float)($master['lat'] ?? 0); $prevLng = (float)($master['lng'] ?? 0); $movedFar = !$this->isValidCoord($prevLat, $prevLng) || abs($lat - $prevLat) > 0.05 || abs($lng - $prevLng) > 0.05; // 事务外调用天地图逆地理编码识别真实省市区与地标(未传完整城市或发生位移时执行) if ($resolvedCity === '' || $resolvedDistrict === '' || $movedFar) { $geo = TiandituService::regeocoder($lat, $lng); if (is_array($geo)) { if ($resolvedCity === '') { $resolvedCity = trim((string)($geo['city'] ?: ($geo['province'] ?? ''))); } if ($resolvedDistrict === '') { $resolvedDistrict = trim((string)($geo['district'] ?? '')); } $resolvedPoi = trim((string)($geo['poi'] ?? '')); } } $locationLabel = $inputLabel !== '' ? $inputLabel : trim($resolvedCity . $resolvedDistrict . ($resolvedPoi !== '' ? ('·' . $resolvedPoi) : '')); $updateData = [ 'lng' => (string)$lng, 'lat' => (string)$lat, 'updatetime' => time(), ]; if (trim((string)$master['city']) === '' && $resolvedCity !== '') { $updateData['city'] = mb_substr($resolvedCity, 0, 50); } $serveRadius = (int)($master['serve_radius'] ?? 15); if ($serveRadius <= 0) { $serveRadius = 15; } if ($serveRadiusRaw !== '' && is_numeric($serveRadiusRaw)) { $serveRadius = max(5, min(200, (int)$serveRadiusRaw)); $updateData['serve_radius'] = $serveRadius; } Db::name('master')->where('id', (int)$master['id'])->update($updateData); $this->success('定位已更新', [ 'lat' => $lat, 'lng' => $lng, 'location_city' => $resolvedCity, 'location_district' => $resolvedDistrict, 'location_label' => $locationLabel, 'master_city' => $updateData['city'] ?? (string)$master['city'], 'serve_radius' => $serveRadius, ]); } /** * 地点关键词检索(供定位受限或手动切换听单区域使用) * @ApiMethod (GET) */ public function searchLocation() { $this->requireMaster(); $keyword = trim((string)$this->request->param('keyword', '')); if (mb_strlen($keyword) < 2) { $this->success('', ['list' => []]); } $city = trim((string)$this->request->param('city', '')); $rawLat = $this->request->param('lat', ''); $rawLng = $this->request->param('lng', ''); $nearLat = $this->isValidCoord($rawLat, $rawLng) ? (float)$rawLat : null; $nearLng = $nearLat !== null ? (float)$rawLng : null; $list = TiandituService::searchPlaces(mb_substr($keyword, 0, 50), 15, $city, $nearLat, $nearLng); $this->success('', ['list' => $list]); } /** * 独立位置选择页聚合上下文:坐标逆解析 + 天地图周边 POI + 服务半径内实时待接工单统计 + 真实活跃城市列表 * @ApiMethod (GET) */ public function locationContext() { $master = $this->requireMaster(); $rawLat = $this->request->param('lat', ''); $rawLng = $this->request->param('lng', ''); $category = trim((string)$this->request->param('category', 'all')); if (!in_array($category, ['all', 'residential', 'office', 'plaza'], true)) { $category = 'all'; } $radiusRaw = $this->request->param('serve_radius', ''); $serveRadius = is_numeric($radiusRaw) ? max(5, min(200, (int)$radiusRaw)) : max(5, (int)($master['serve_radius'] ?? 15)); $hasCoord = $this->isValidCoord($rawLat, $rawLng); if ($hasCoord) { $lat = round((float)$rawLat, 6); $lng = round((float)$rawLng, 6); } elseif ($this->isValidCoord($master['lat'] ?? '', $master['lng'] ?? '')) { $lat = round((float)$master['lat'], 6); $lng = round((float)$master['lng'], 6); $hasCoord = true; } else { $lat = 0.0; $lng = 0.0; } $center = null; $nearbyList = []; $activeCity = trim((string)$this->request->param('city', '')); if ($hasCoord) { $nearby = TiandituService::nearbyPlaces($lat, $lng, $category, 3000, 15); $center = $nearby['center']; $nearbyList = $nearby['list']; if ($activeCity === '' && is_array($center) && !empty($center['city'])) { $activeCity = trim((string)$center['city']); } } elseif ($activeCity === '' && !empty($master['city'])) { $activeCity = trim((string)$master['city']); } $orderStats = $this->countLocationOrderStats((int)$master['id'], $hasCoord ? $lat : null, $hasCoord ? $lng : null, $serveRadius, $activeCity); $activeCities = $this->getActiveHallCities((int)$master['id']); $this->success('', [ 'has_coord' => $hasCoord, 'lat' => $hasCoord ? $lat : '', 'lng' => $hasCoord ? $lng : '', 'serve_radius' => $serveRadius, 'category' => $category, 'center' => $center, 'nearby_list' => $nearbyList, 'order_stats' => $orderStats, 'active_cities' => $activeCities, 'master_city' => trim((string)($master['city'] ?? '')), ]); } /** * 轻量位置工单预览统计(切换服务半径或选中地标时毫秒级计算,不额外请求外部地图) * @ApiMethod (GET) */ public function locationOrderStats() { $master = $this->requireMaster(); $rawLat = $this->request->param('lat', ''); $rawLng = $this->request->param('lng', ''); $city = trim((string)$this->request->param('city', '')); $radiusRaw = $this->request->param('serve_radius', ''); $serveRadius = is_numeric($radiusRaw) ? max(5, min(200, (int)$radiusRaw)) : max(5, (int)($master['serve_radius'] ?? 15)); $hasCoord = $this->isValidCoord($rawLat, $rawLng); $lat = $hasCoord ? round((float)$rawLat, 6) : null; $lng = $hasCoord ? round((float)$rawLng, 6) : null; $stats = $this->countLocationOrderStats((int)$master['id'], $lat, $lng, $serveRadius, $city); $this->success('', $stats); } /** * 通过城市名解析中心坐标(用于位置页切换城市快捷飞跃定位) * @ApiMethod (GET) */ public function locateCity() { $this->requireMaster(); $city = trim((string)$this->request->param('city', '')); if ($city === '') { $this->error('请提供城市名称'); } $geo = TiandituService::geocoder($city); if (!is_array($geo) || !$this->isValidCoord($geo['lat'] ?? '', $geo['lng'] ?? '')) { $places = TiandituService::searchPlaces($city, 3); if (!empty($places[0])) { $geo = ['lat' => $places[0]['lat'], 'lng' => $places[0]['lng']]; } } if (!is_array($geo) || !$this->isValidCoord($geo['lat'] ?? '', $geo['lng'] ?? '')) { $this->error('未能解析该城市坐标,请尝试搜索具体区县'); } $lat = round((float)$geo['lat'], 6); $lng = round((float)$geo['lng'], 6); $regeo = TiandituService::regeocoder($lat, $lng); $this->success('', [ 'lat' => $lat, 'lng' => $lng, 'city' => is_array($regeo) && !empty($regeo['city']) ? $regeo['city'] : $city, 'district' => is_array($regeo) ? trim((string)($regeo['district'] ?? '')) : '', 'address' => is_array($regeo) && !empty($regeo['formatted_address']) ? $regeo['formatted_address'] : $city, ]); } /** * 订单大厅:支持基于定位的智能推荐、离我最近与多种排序及单类型筛选 */ public function hall() { $master = $this->requireMaster(); $this->recycleExpired(); $catIds = $this->getMasterSkillCatIds((int)$master['id']); $page = max(1, (int)$this->request->param('page', 1)); $limit = min(30, max(1, (int)$this->request->param('limit', 10))); $sort = trim((string)$this->request->param('sort', 'recommend')); // recommend 智能推荐 / distance 离我最近 / new 最新 / amount 报酬 / urgent 急单 / time 时效 if ($sort === '') { $sort = 'recommend'; } $type = $this->request->param('type', 'all'); // all / fixed 一口价 / quote 师傅报价 / custom 用户自主定价 $buildBase = function () use ($catIds) { $q = Db::name('order')->where('master_id', 0) ->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING]); if (!empty($catIds)) { $q->where('cat_id', 'in', $catIds); } return $q; }; $build = function () use ($buildBase, $type) { $q = $buildBase(); if ($type === 'fixed') { $q->where('status', OrderStatus::PENDING_ACCEPT)->where('price_type', 'fixed'); } elseif ($type === 'quote') { $q->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING])->where('price_type', 'quote'); } elseif ($type === 'custom') { $q->where('status', OrderStatus::PENDING_ACCEPT)->where('price_type', 'custom'); } return $q; }; $counts = [ 'all' => (int)$buildBase()->count(), 'fixed' => (int)$buildBase()->where('status', OrderStatus::PENDING_ACCEPT)->where('price_type', 'fixed')->count(), 'quote' => (int)$buildBase()->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING])->where('price_type', 'quote')->count(), 'custom' => (int)$buildBase()->where('status', OrderStatus::PENDING_ACCEPT)->where('price_type', 'custom')->count(), ]; $masterRecord = $this->masterInfo(); $rawLat = $this->request->param('lat', ''); $rawLng = $this->request->param('lng', ''); $reqCity = trim((string)$this->request->param('city', '')); $reqHasValidCoord = $this->isValidCoord($rawLat, $rawLng); $dbHasValidCoord = $masterRecord && $this->isValidCoord($masterRecord['lat'] ?? '', $masterRecord['lng'] ?? ''); $hasLocation = $reqHasValidCoord || $dbHasValidCoord; if ($reqHasValidCoord) { $userLat = round((float)$rawLat, 6); $userLng = round((float)$rawLng, 6); // 当传入了实时有效坐标且与已保存位置有显著偏差时轻量同步至 fa_master(不阻塞发起外网请求) $needSync = !$dbHasValidCoord || abs($userLat - (float)$masterRecord['lat']) > 0.002 || abs($userLng - (float)$masterRecord['lng']) > 0.002; if ($needSync && $masterRecord) { Db::name('master')->where('id', (int)$masterRecord['id'])->update([ 'lng' => (string)$userLng, 'lat' => (string)$userLat, 'updatetime' => time(), ]); } } elseif ($dbHasValidCoord) { $userLat = (float)$masterRecord['lat']; $userLng = (float)$masterRecord['lng']; } else { $userLat = 0.0; $userLng = 0.0; } $serveRadius = ($masterRecord && (int)$masterRecord['serve_radius'] > 0) ? (int)$masterRecord['serve_radius'] : 15; $validCoordCond = $this->getValidOrderCoordSql(); $distFormula = $this->getDistanceSqlFormula(); // 真实定位场景下的当前所在城市识别:优先使用实时传入城市;若未传入,根据 35km 内最近已知坐标订单推断,绝不拿跨城注册地误判 $locationCity = ''; if ($hasLocation) { if ($reqCity !== '') { $locationCity = $reqCity; } else { $nearestSql = Db::name('order') ->where('city', '<>', '') ->whereRaw($validCoordCond) ->field("city, {$distFormula} AS _near_km") ->buildSql(); $nearestRow = Db::table($nearestSql . ' nc') ->bind(['h_lat1' => $userLat, 'h_lng1' => $userLng, 'h_lat2' => $userLat]) ->where('_near_km <= 35.0') ->order('_near_km asc') ->find(); if ($nearestRow && !empty($nearestRow['city'])) { $locationCity = trim((string)$nearestRow['city']); } } } else { $locationCity = $masterRecord ? trim((string)$masterRecord['city']) : ''; } $cityToken = $this->extractCityToken($locationCity); $total = (int)$build()->count(); $selectColumns = 'id, order_no, user_id, cat_id, service_id, service_name, service_image, price_type, goods_images, goods_model, goods_num, floor, has_elevator, urgent, remark, contact, mobile, province, city, district, address, lng, lat, appoint_time, base_amount, floor_amount, distance_amount, urgent_amount, discount_amount, total_amount, pay_amount, commission_rate, commission_snapshot, status, createtime, auto_quote_timeout, order_source, qual_req, master_req, diff_status, diff_amount'; if (in_array($sort, ['recommend', 'distance'], true) && $hasLocation) { $sameCityExpr = $cityToken !== '' ? "CASE WHEN city LIKE :h_city_like THEN 1 ELSE 0 END" : "0"; $subSql = $build() ->field($selectColumns . ", CASE WHEN {$validCoordCond} THEN 1 ELSE 0 END AS _has_coord, CASE WHEN {$validCoordCond} THEN {$distFormula} ELSE 999999.0 END AS _dist_km, {$sameCityExpr} AS _same_city") ->buildSql(); $outerBind = [ 'h_lat1' => $userLat, 'h_lng1' => $userLng, 'h_lat2' => $userLat, ]; if ($cityToken !== '') { $outerBind['h_city_like'] = '%' . $cityToken . '%'; } $outerQuery = Db::table($subSql . ' o'); if ($sort === 'distance') { $outerQuery->bind($outerBind)->order('_has_coord desc, _dist_km asc, urgent desc, id desc'); } else { $outerBind['rec_radius'] = (float)$serveRadius; $outerQuery->bind($outerBind)->orderRaw( '(CASE WHEN _has_coord = 1 AND _dist_km <= :rec_radius THEN 1000.0 WHEN _has_coord = 1 AND _dist_km <= 50.0 THEN 500.0 WHEN _has_coord = 0 AND _same_city = 1 THEN 300.0 WHEN _has_coord = 1 THEN 100.0 ELSE 0.0 END + CASE WHEN _has_coord = 1 THEN (300.0 / (1.0 + _dist_km / 5.0)) ELSE 0.0 END + (urgent * 120.0)) DESC, id DESC' ); } $list = $outerQuery->page($page, $limit)->select(); } elseif (in_array($sort, ['recommend', 'distance'], true) && $cityToken !== '') { $subSql = $build() ->field($selectColumns . ', CASE WHEN city LIKE :h_city_like THEN 1 ELSE 0 END AS _same_city') ->buildSql(); $list = Db::table($subSql . ' o') ->bind(['h_city_like' => '%' . $cityToken . '%']) ->order('_same_city desc, urgent desc, id desc') ->page($page, $limit) ->select(); } else { if ($sort === 'amount') { $order = 'pay_amount desc, id desc'; } elseif ($sort === 'urgent') { $order = 'urgent desc, id desc'; } elseif ($sort === 'time') { $order = 'appoint_time asc, id desc'; } elseif (in_array($sort, ['recommend', 'distance'], true)) { $order = 'urgent desc, id desc'; } else { $order = 'id desc'; } $list = $build()->field($selectColumns)->order($order)->page($page, $limit)->select(); } // 批量预查询本页订单的报价数量与备用服务图标,消除循环内查询 $orderIds = array_column($list, 'id'); $quoteCountMap = []; $serviceImgMap = []; if (!empty($orderIds)) { $quoteRows = Db::name('order_quote') ->where('order_id', 'in', $orderIds) ->field('order_id, COUNT(*) AS cnt') ->group('order_id') ->select(); foreach ($quoteRows as $qr) { $quoteCountMap[(int)$qr['order_id']] = (int)$qr['cnt']; } $missingImgServiceIds = []; foreach ($list as $lr) { if (empty($lr['service_image']) && !empty($lr['service_id'])) { $missingImgServiceIds[] = (int)$lr['service_id']; } } $missingImgServiceIds = array_values(array_unique($missingImgServiceIds)); if (!empty($missingImgServiceIds)) { $serviceImgMap = Db::name('service')->where('id', 'in', $missingImgServiceIds)->column('image', 'id'); } } $now = time(); foreach ($list as &$row) { unset($row['verify_code'], $row['_has_coord'], $row['_dist_km'], $row['_same_city']); $row['appoint_text'] = $row['appoint_time'] ? date('Y-m-d H:i', (int)$row['appoint_time']) : '接单后与业主预约'; $row['pay_amount'] = (float)$row['pay_amount']; $hallPayBase = (float)$row['pay_amount'] > 0 ? (float)$row['pay_amount'] : (float)($row['total_amount'] ?? 0); $hallSettleCalc = CommissionService::calcByOrder($row, $hallPayBase); $row['commission_rate'] = $hallSettleCalc['commission_rate']; $row['commission'] = $hallSettleCalc['commission']; $row['master_fee'] = $hallSettleCalc['settle_amount']; $row['urgent'] = (int)$row['urgent']; $row['mobile'] = substr_replace($row['mobile'], '****', 3, 4); $rawAddr = (string)$row['address']; $maskedAddr = preg_replace('/\d+栋|\d+单元|\d+室|\d+号楼/', '**', $rawAddr); $prefix = trim($row['city'] . $row['district']); if ($prefix !== '' && strpos($maskedAddr, $row['district']) === false && strpos($maskedAddr, $row['city']) === false) { $row['address'] = $prefix . $maskedAddr; } else { $row['address'] = $maskedAddr; } $row['status_text'] = OrderStatus::label($row['status']); $remain = (int)$row['createtime'] + 7 * 86400 - $now; $row['left_seconds'] = max(0, $remain); $row['quote_count'] = $quoteCountMap[(int)$row['id']] ?? 0; $row['remaining_quote_slots'] = max(0, 4 - $row['quote_count']); $row['price_type_text'] = $row['price_type'] === 'fixed' ? '一口价' : ($row['price_type'] === 'quote' ? '师傅报价' : ($row['price_type'] === 'custom' ? '用户自主定价' : '按面积计价')); $row['user_price'] = $row['price_type'] === 'custom' ? (float)$row['base_amount'] : 0; $goodsName = ''; $addonServices = ''; $fallbackImg = ''; if (!empty($row['goods_images'])) { $parsed = json_decode((string)$row['goods_images'], true); if (is_array($parsed) && !empty($parsed['goods_list']) && is_array($parsed['goods_list'])) { $firstItem = $parsed['goods_list'][0]; if (is_array($firstItem)) { $goodsName = isset($firstItem['goods_name']) && is_string($firstItem['goods_name']) ? $firstItem['goods_name'] : (isset($firstItem['item_name']) && is_string($firstItem['item_name']) ? $firstItem['item_name'] : ''); if (!empty($firstItem['addon_services'])) { if (is_array($firstItem['addon_services'])) { $adLabels = []; foreach ($firstItem['addon_services'] as $adIt) { if (is_string($adIt)) { $adLabels[] = $adIt; } elseif (is_array($adIt) && !empty($adIt['name'])) { $adLabels[] = $adIt['name'] . (!empty($adIt['num']) ? 'x' . (int)$adIt['num'] : ''); } } $addonServices = implode(' · ', $adLabels); } else { $addonServices = (string)$firstItem['addon_services']; } } if (!empty($firstItem['goods_image']) && is_string($firstItem['goods_image'])) { $fallbackImg = $firstItem['goods_image']; } } } } $row['goods_name'] = $goodsName ?: $row['service_name']; $row['addon_services'] = rtrim(str_replace(';', ' · ', (string)$addonServices), ' ·'); $row['goods_num'] = max(1, (int)($row['goods_num'] ?? 1)); $row['service_type_tag'] = (strpos((string)$row['service_name'], '修') !== false || strpos((string)$row['service_name'], '检') !== false) ? '维修' : '安装'; $img = !empty($row['service_image']) ? $row['service_image'] : $fallbackImg; if (empty($img) && !empty($row['service_id']) && !empty($serviceImgMap[(int)$row['service_id']])) { $img = $serviceImgMap[(int)$row['service_id']]; } if (!empty($img)) { $row['service_image'] = strpos($img, 'http') === 0 ? $img : cdnurl($img, true); } else { $row['service_image'] = ''; } $distKm = $hasLocation ? $this->calculateDistance($userLat, $userLng, (float)$row['lat'], (float)$row['lng']) : null; $row['distance_km'] = $distKm; $row['distance_text'] = $this->formatDistanceText($distKm); $row['in_serve_radius'] = ($distKm !== null && $distKm <= $serveRadius) ? 1 : 0; $recommendReason = ''; if ($hasLocation) { if ($distKm !== null) { if ($distKm <= 3.0) { $recommendReason = '距您较近'; } elseif ($distKm <= $serveRadius) { $recommendReason = (int)$row['urgent'] === 1 ? '附近急单' : '服务圈内'; } elseif ($distKm <= 50.0) { $recommendReason = '同城订单'; } } elseif ($cityToken !== '' && mb_strpos((string)$row['city'], $cityToken) !== false) { $recommendReason = '同城订单'; } } $row['recommend_reason'] = $recommendReason; } unset($row); $this->success('', [ 'list' => $list, 'total' => $total, 'counts' => $counts, 'page' => $page, 'has_more' => ($page * $limit) < $total, 'sort' => $sort, 'type' => $type, 'has_location' => $hasLocation, 'has_realtime_location' => $reqHasValidCoord, 'location_source' => $reqHasValidCoord ? 'realtime' : ($dbHasValidCoord ? 'saved' : 'none'), 'location_city' => $locationCity, 'serve_radius' => $serveRadius, ]); } /** * 抢单(条件更新保证并发安全) * @ApiMethod (POST) */ public function grab() { $master = $this->requireMaster(); if ($master['work_status'] !== 'on') { $this->error('您已暂停接单,请先开启'); } $orderId = (int)$this->request->post('order_id'); $order = Db::name('order')->where('id', $orderId)->find(); if (!$order) { $this->error('订单不存在'); } if ((int)$order['user_id'] === (int)$this->uid()) { $this->error('不能承接或报价自己发布的订单'); } if ($order['price_type'] === 'quote') { $this->error('该订单为师傅报价订单,请提交报价'); } if ((int)$order['status'] !== OrderStatus::PENDING_ACCEPT || (int)$order['master_id'] !== 0) { $this->error('手慢了,该订单已被接走'); } $affected = Db::name('order') ->where('id', $orderId) ->where('status', OrderStatus::PENDING_ACCEPT) ->where('master_id', 0) ->update([ 'master_id' => (int)$master['id'], 'status' => OrderStatus::ACCEPTED, 'accept_time' => time(), 'updatetime' => time(), ]); if (!$affected) { $this->error('手慢了,该订单已被接走'); } $this->addLog($orderId, OrderStatus::ACCEPTED, '师傅接单:' . $master['realname'], 'master', (int)$master['id']); $this->notify('user', (int)$order['user_id'], '师傅已接单', '师傅 ' . $master['realname'] . ' 已接单,将尽快与您联系', 'order', $orderId); $this->success('抢单成功,请在 2 小时内联系客户', [ 'order_id' => $orderId, 'deadline' => time() + 7200, 'user_mobile' => $order['mobile'], ]); } /** * 标记已联系客户(2 小时考核) * @ApiMethod (POST) */ public function contact() { $order = $this->ownOrder((int)$this->request->post('order_id'), 'master'); if ((int)$order['contact_time'] > 0) { $this->success('已记录'); } Db::name('order')->where('id', $order['id'])->update(['contact_time' => time(), 'updatetime' => time()]); $this->addLog((int)$order['id'], (int)$order['status'], '师傅已联系客户', 'master', (int)$order['master_id']); $this->success('已记录联系时间'); } /** * 设置预约上门时间 * @ApiMethod (POST) */ public function schedule() { $order = $this->ownOrder((int)$this->request->post('order_id'), 'master'); if (!in_array((int)$order['status'], [OrderStatus::ACCEPTED, OrderStatus::APPOINTED], true)) { $this->error('当前状态(' . OrderStatus::label((int)$order['status']) . ')不可修改预约时间'); } $appointTime = (int)$this->request->post('appoint_time'); if ($appointTime <= 0) { $this->error('请选择上门时间'); } if ((int)$order['status'] === OrderStatus::ACCEPTED) { $this->transit($order, OrderStatus::SCHEDULED, '已与客户约定上门时间:' . date('Y-m-d H:i', $appointTime), [ 'appoint_time' => $appointTime, 'contact_time' => (int)$order['contact_time'] ?: time(), ], 'master', (int)$order['master_id']); } else { Db::name('order')->where('id', $order['id'])->update(['appoint_time' => $appointTime, 'updatetime' => time()]); $this->addLog((int)$order['id'], OrderStatus::SCHEDULED, '师傅变更预约上门时间为:' . date('Y-m-d H:i', $appointTime), 'master', (int)$order['master_id']); } $this->notify('user', (int)$order['user_id'], '师傅已预约上门', '约定时间:' . date('m-d H:i', $appointTime), 'order', (int)$order['id']); $this->success('预约成功'); } /** * 到达现场(定位打卡,不覆盖客户收货地址经纬度,签到坐标记录入日志) * @ApiMethod (POST) */ public function arrive() { $order = $this->ownOrder((int)$this->request->post('order_id'), 'master'); $lng = trim((string)$this->request->post('lng', '')); $lat = trim((string)$this->request->post('lat', '')); $extra = ['arrive_time' => time()]; $remark = '师傅已到达现场'; if ($lng !== '' && $lat !== '') { $remark .= sprintf('(签到坐标: %.6f, %.6f)', (float)$lng, (float)$lat); if (!empty($order['lat']) && !empty($order['lng'])) { $check = TiandituService::checkArriveDistance((float)$order['lat'], (float)$order['lng'], (float)$lat, (float)$lng, 1500.0); if (!$check['is_pass']) { $remark .= " [异地打卡预警: 偏离{$check['distance']}米]"; } } } $this->transit($order, OrderStatus::ARRIVED, $remark, $extra, 'master', (int)$order['master_id']); $this->notify('user', (int)$order['user_id'], '师傅已到达', '师傅已到达服务地址,请做好准备', 'order', (int)$order['id']); $this->success('已打卡到达'); } /** * 开始服务 * @ApiMethod (POST) */ public function startJob() { $order = $this->ownOrder((int)$this->request->post('order_id'), 'master'); $this->transit($order, OrderStatus::WORKING, '师傅开始施工', ['start_time' => time()], 'master', (int)$order['master_id']); $this->notify('user', (int)$order['user_id'], '师傅已开工', '师傅已开始服务,请留意进度', 'order', (int)$order['id']); $this->success('已开工'); } /** * 完工并提交现场取证 → 进入待验收 * @ApiMethod (POST) */ public function finish() { $order = $this->ownOrder((int)$this->request->post('order_id'), 'master'); $images = (array)$this->request->post('images/a', []); $damage = trim((string)$this->request->post('damage_remark', '')); Db::startTrans(); try { $this->transit($order, OrderStatus::PENDING_VERIFY, '师傅完工,等待客户验收', [ 'finish_time' => time(), ], 'master', (int)$order['master_id']); foreach ($images as $url) { if (!$url) { continue; } Db::name('order_media')->insert([ 'order_id' => (int)$order['id'], 'master_id' => (int)$order['master_id'], 'stage' => $damage !== '' ? 'damage' : 'finish', 'type' => 'image', 'url' => (string)$url, 'watermark' => date('Y-m-d H:i') . ' ' . $order['address'], 'remark' => $damage !== '' ? mb_substr($damage, 0, 255) : '', 'createtime' => time(), ]); } Db::commit(); } catch (\Exception $e) { Db::rollback(); $this->error('提交失败:' . $e->getMessage()); } $code = (string)$order['verify_code']; $this->notify('user', (int)$order['user_id'], '师傅已完工,待您验收', '验收码:' . $code . ',请核对后确认完成', 'order', (int)$order['id']); $this->success('已提交完工,等待客户验收'); } /** * 我的订单(师傅视角) */ /** * 师傅端:输入客户 6 位验收核销码完成工单验收结算 * @ApiMethod (POST) */ public function verifyCode() { $orderId = (int)$this->request->post('order_id'); $code = trim((string)$this->request->post('verify_code', '')); if ($orderId <= 0 || $code === '') { $this->error('请输入 6 位核销验证码'); } $master = $this->requireMaster(); $order = Db::name('order')->where('id', $orderId)->find(); if (!$order) { $this->error('订单不存在'); } if ((int)$order['master_id'] !== (int)$master['id']) { $this->error('无权操作该订单'); } if ((int)$order['status'] !== OrderStatus::PENDING_VERIFY) { $this->error('当前状态(' . OrderStatus::label($order['status']) . ')不可核销'); } if ((string)$order['verify_code'] !== '' && (string)$order['verify_code'] !== $code) { $this->error('核销验证码不正确,请核对后重新输入'); } $masterId = (int)$master['id']; $payAmount = (float)$order['pay_amount']; $settleCalc = CommissionService::calcByOrder($order); $rate = $settleCalc['commission_rate']; $commission = $settleCalc['commission']; $settleAmount = $settleCalc['settle_amount']; $newMoney = 0.00; Db::startTrans(); try { $lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find(); if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::PENDING_VERIFY || (int)$lockedOrder['settle_status'] === 1) { throw new \InvalidArgumentException('该工单已核销完成或状态已变更,请勿重复提交'); } $payAmount = (float)$lockedOrder['pay_amount']; $settleCalc = CommissionService::calcByOrder($lockedOrder); $rate = $settleCalc['commission_rate']; $commission = $settleCalc['commission']; $settleAmount = $settleCalc['settle_amount']; $this->transit($lockedOrder, OrderStatus::COMPLETED, '师傅已录入客户 6 位验收核销码,核销完工', [ 'verify_time' => time(), 'settle_status' => 1, 'settle_amount' => $settleAmount, ], 'master', $masterId); Db::name('settle')->insert([ 'order_id' => $orderId, 'master_id' => $masterId, 'order_amount' => $payAmount, 'commission_rate' => $rate, 'commission' => $commission, 'settle_amount' => $settleAmount, 'status' => 'settled', 'settle_time' => time(), 'createtime' => time(), ]); $masterInfo = Db::name('master')->where('id', $masterId)->lock(true)->find(); $newMoney = round((float)$masterInfo['money'] + $settleAmount, 2); Db::name('master')->where('id', $masterId)->update([ 'money' => $newMoney, 'order_count' => (int)$masterInfo['order_count'] + 1, 'updatetime' => time(), ]); Db::name('wallet_log')->insert([ 'user_type' => 'master', 'user_id' => $masterId, 'type' => 'settle', 'amount' => $settleAmount, 'balance' => $newMoney, 'order_id' => $orderId, 'remark' => '工单 ' . $lockedOrder['order_no'] . ' 验收核销码结算入账', 'createtime' => time(), ]); Db::commit(); } catch (\think\exception\HttpResponseException $e) { Db::rollback(); throw $e; } catch (\InvalidArgumentException $e) { Db::rollback(); $this->error($e->getMessage()); return; } catch (\Exception $e) { Db::rollback(); \think\Log::error('[Master::verifyCode] ' . $e->getMessage()); $this->error('核销失败:' . $e->getMessage()); return; } $this->notify('user', (int)$order['user_id'], '订单已核销完成', '您的工单已由师傅现场核销完成,感谢您的支持!', 'order', $orderId); $this->success('验收核销成功!工费已结算至您的钱包', [ 'settle_amount' => $settleAmount, 'balance' => $newMoney ]); } public function myOrders() { return $this->orders(); } public function orders() { $master = $this->requireMaster(); $status = $this->request->param('status', 'active'); $page = max(1, (int)$this->request->param('page', 1)); $limit = min(30, max(1, (int)$this->request->param('limit', 10))); // TP5 中 count() 会重置查询条件,故用闭包重建 $build = function () use ($master, $status) { $q = Db::name('order')->where('master_id', $master['id']); if ($status === 'active') { $q->where('status', 'in', [OrderStatus::ACCEPTED, OrderStatus::SCHEDULED, OrderStatus::ARRIVED, OrderStatus::WORKING]); } elseif ($status === 'verify') { $q->where('status', OrderStatus::PENDING_VERIFY); } elseif ($status === 'completed') { $q->where('status', OrderStatus::COMPLETED); } return $q; }; $total = $build()->count(); $list = $build()->order('id desc')->page($page, $limit)->select(); $serviceIds = array_values(array_unique(array_filter(array_map('intval', array_column($list, 'service_id'))))); $serviceImgMap = $serviceIds ? Db::name('service')->where('id', 'in', $serviceIds)->column('image', 'id') : []; foreach ($list as &$row) { unset($row['verify_code']); $row['status_text'] = OrderStatus::label($row['status']); $row['pay_amount'] = (float)$row['pay_amount']; $row['appoint_text'] = $row['appoint_time'] ? date('m-d H:i', (int)$row['appoint_time']) : '尽快上门'; $row['urgent'] = (int)$row['urgent']; $row['masked_mobile'] = substr_replace($row['mobile'], '****', 3, 4); $img = !empty($row['service_image']) ? $row['service_image'] : ''; if (empty($img) && !empty($row['service_id']) && isset($serviceImgMap[(int)$row['service_id']])) { $img = $serviceImgMap[(int)$row['service_id']]; } if ($img && strpos($img, 'http') !== 0) { $img = cdnurl($img, true); } $row['service_image'] = $img; $row['goods_image_url'] = $img; } unset($row); $this->success('', [ 'list' => $list, 'total' => (int)$total, 'page' => $page, 'has_more' => ($page * $limit) < (int)$total, ]); } /** * 收入概览(含待确认收款的微信商家转账单与提现申请记录) */ public function income() { $master = $this->requireMaster(); // 顺带轻量同步最新 3 笔处理中的微信转账提现单(如师傅上次已确认收款但未回调或超时自动退回) $syncCandidates = Db::name('withdraw') ->where('master_id', (int)$master['id']) ->where('status', 'pass') ->where('out_bill_no', '<>', '') ->where('transfer_state', 'in', ['ACCEPTED', 'PROCESSING', 'WAIT_USER_CONFIRM', 'TRANSFERING', 'CANCELING']) ->order('id desc') ->limit(3) ->select(); $syncErrors = []; if (!empty($syncCandidates)) { foreach ($syncCandidates as $cand) { try { PaymentService::syncWithdrawTransfer((int)$cand['id']); } catch (\Throwable $se) { $errTxt = '提现单 #WD' . (int)$cand['id'] . ' 同步失败:' . $se->getMessage(); $syncErrors[] = $errTxt; \think\Log::error('[Master::income syncWithdraw] ' . $errTxt); } } $master = $this->masterInfo(true) ?: $master; } $todayStart = strtotime(date('Y-m-d')); $todayIncome = Db::name('wallet_log')->where('user_type', 'master')->where('user_id', $master['id']) ->where('type', 'settle')->where('createtime', '>=', $todayStart)->sum('amount'); $totalIncome = Db::name('wallet_log')->where('user_type', 'master')->where('user_id', $master['id']) ->where('type', 'settle')->sum('amount'); $logs = Db::name('wallet_log')->where('user_type', 'master')->where('user_id', $master['id']) ->order('id desc')->limit(30)->select(); // 动态读取独立配置中心提现规则与今日额度统计 $withdrawConfig = FinanceConfigService::buildMasterWithdrawClientPayload($master); $limits = PaymentService::getWithdrawLimits(); $limits['today_used'] = $withdrawConfig['today_amount']; $limits['today_remain'] = $withdrawConfig['remain_daily_amount'] >= 0 ? $withdrawConfig['remain_daily_amount'] : (float)$limits['daily_user_limit']; // 近期提现单列表与待师傅点击确认收款的提现单 $rawWithdraws = Db::name('withdraw') ->where('master_id', (int)$master['id']) ->order('id desc') ->limit(30) ->select(); $withdrawList = []; $pendingConfirmList = []; foreach ($rawWithdraws as $wd) { $item = $this->formatWithdrawItem($wd); $withdrawList[] = $item; if ($item['can_confirm']) { $pendingConfirmList[] = $item; } } $this->success('', [ 'money' => (float)$master['money'], 'frozen_money' => (float)$master['frozen_money'], 'today_income' => round((float)$todayIncome, 2), 'total_income' => round((float)$totalIncome, 2), 'order_count' => (int)$master['order_count'], 'logs' => $logs, 'withdraw_config' => $withdrawConfig, 'withdraw_limits' => $limits, 'withdraw_list' => $withdrawList, 'pending_confirm_withdraws' => $pendingConfirmList, 'sync_errors' => $syncErrors, ]); } /** * 报价(报价竞价单) * @ApiMethod (POST) */ public function quote() { $master = $this->requireMaster(); if ($master['work_status'] !== 'on') { $this->error('您已暂停接单,请先开启'); } $orderId = (int)$this->request->post('order_id'); $amount = (float)$this->request->post('amount'); $desc = trim((string)$this->request->post('description', '')); $hours = min(72, max(1, (int)$this->request->post('valid_hours', 24))); $order = Db::name('order')->where('id', $orderId)->find(); if (!$order) { $this->error('订单不存在'); } if ((int)$order['user_id'] === (int)$this->uid()) { $this->error('不能承接或报价自己发布的订单'); } if ($order['price_type'] !== 'quote') { $this->error('该订单不是报价竞价订单'); } if (!in_array((int)$order['status'], [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true)) { $this->error('该订单已不在报价阶段'); } if ((int)$order['master_id'] > 0) { $this->error('该订单已选定师傅'); } if ($amount <= 0) { $this->error('请填写报价金额'); } if (Db::name('order_quote')->where('order_id', $orderId)->where('master_id', $master['id'])->find()) { $this->error('您已报价,请勿重复提交'); } Db::name('order_quote')->insert([ 'order_id' => $orderId, 'master_id' => (int)$master['id'], 'amount' => round($amount, 2), 'description' => mb_substr($desc, 0, 500), 'valid_hours' => $hours, 'expire_time' => time() + $hours * 3600, 'status' => 'wait', 'createtime' => time(), ]); // 兼容旧流程:已支付但仍在待接单的报价单,收到首个报价时进入「报价中」 if ((int)$order['status'] === OrderStatus::PENDING_ACCEPT) { $this->transit($order, OrderStatus::QUOTING, '收到师傅报价,订单进入报价中', [], 'master', (int)$master['id']); } $this->notify('user', (int)$order['user_id'], '收到新报价', '师傅 ' . $master['realname'] . ' 对订单 ' . $order['order_no'] . ' 报价 ¥' . round($amount, 2) . ',请前往订单详情选择', 'order', $orderId); $this->success('报价已提交'); } /** * 申请提现(支持微信零钱新版商家转账 1005 场景额度校验与 OpenID 绑定) * @ApiMethod (POST) */ public function withdraw() { $master = $this->requireMaster(); $amount = round((float)$this->request->post('amount'), 2); $wxCode = trim((string)$this->request->post('wx_code', '')); $channel = trim((string)$this->request->post('channel', 'wechat')); if (!in_array($channel, ['wechat', 'bank'], true)) { $channel = 'wechat'; } $account = mb_substr(trim((string)$this->request->post('account', '')), 0, 100); // 1. 执行独立配置中心的全量提现规则与微信小程序转账合规校验 try { $validated = FinanceConfigService::validateWithdrawRequest($master, $amount, $channel, $account); } catch (\InvalidArgumentException $ve) { $this->error($ve->getMessage()); return; } $wdCfg = $validated['cfg']; $fee = $validated['fee']; $arrive = $validated['arrive_amount']; if ($account === '' && $channel === 'wechat') { $account = '微信零钱'; } // 2. 在数据库事务外解析并补绑当前收款微信号 OpenID $openid = ''; if ($channel === 'wechat' && $wdCfg['transfer_mode'] === 'wx_mch_transfer') { try { $openid = PaymentService::resolveUserOpenid($this->uid(), $wxCode); } catch (\Throwable $oe) { $this->error($oe->getMessage()); return; } } $now = time(); Db::startTrans(); try { $lockedMaster = Db::name('master')->where('id', (int)$master['id'])->lock(true)->find(); if (!$lockedMaster || $amount > (float)$lockedMaster['money']) { throw new \InvalidArgumentException('可提现余额不足'); } $newMoney = round((float)$lockedMaster['money'] - $amount, 2); $newFrozen = round((float)$lockedMaster['frozen_money'] + $amount, 2); Db::name('master')->where('id', (int)$lockedMaster['id'])->update([ 'money' => $newMoney, 'frozen_money' => $newFrozen, 'updatetime' => $now, ]); $withdrawId = (int)Db::name('withdraw')->insertGetId([ 'master_id' => (int)$lockedMaster['id'], 'amount' => $amount, 'fee' => $fee, 'arrive_amount' => $arrive, 'channel' => $channel, 'account' => $account, 'openid' => $openid, 'rule_snapshot' => $validated['rule_snapshot'], 'status' => 'wait', 'createtime' => $now, 'updatetime' => $now, ]); Db::name('wallet_log')->insert([ 'user_type' => 'master', 'user_id' => (int)$lockedMaster['id'], 'type' => 'withdraw', 'amount' => -$amount, 'balance' => $newMoney, 'order_id' => 0, 'remark' => '提现申请(手续费 ¥' . number_format($fee, 2, '.', '') . ',预计到账 ¥' . number_format($arrive, 2, '.', '') . ')', 'createtime' => $now, ]); Db::commit(); } catch (\think\exception\HttpResponseException $e) { Db::rollback(); throw $e; } catch (\InvalidArgumentException $e) { Db::rollback(); $this->error($e->getMessage()); return; } catch (\Exception $e) { Db::rollback(); \think\Log::error('[Master::withdraw] ' . $e->getMessage()); $this->error('提现申请失败:' . $e->getMessage()); return; } // 3. 若命中「小额免审自动发起微信转账」,在本地事务外自动发起商家转账(失败必落库记录中文原因并明确回传,严禁静默吞掉) $autoInitiated = false; $canConfirmNow = false; $autoFailReason = ''; if ( $channel === 'wechat' && $wdCfg['transfer_mode'] === 'wx_mch_transfer' && $wdCfg['audit_mode'] === 'auto_small' && $amount <= (float)$wdCfg['auto_max_amount'] ) { try { $autoWd = PaymentService::initiateWithdrawTransfer($withdrawId, '小额免审自动发起微信商家转账'); $autoInitiated = true; $formattedAuto = $this->formatWithdrawItem($autoWd); $canConfirmNow = !empty($formattedAuto['can_confirm']); } catch (\Throwable $ae) { $autoFailReason = trim((string)$ae->getMessage()); Db::name('withdraw')->where('id', $withdrawId)->update([ 'audit_msg' => mb_substr('【小额免审自动转账失败】' . $autoFailReason, 0, 480), 'updatetime' => time(), ]); \think\Log::error('[Master::withdraw auto_small] withdraw_id=' . $withdrawId . ' 自动转账失败: ' . $autoFailReason); } } if ($canConfirmNow) { $tipMsg = '提现已自动审核通过,请立即确认收款入账微信零钱'; } elseif ($autoFailReason !== '') { $tipMsg = '提现申请已提交并冻结金额,但微信自动转账发起失败:' . $autoFailReason . ',已记录原因待财务核查处理'; } else { $tipMsg = '提现申请已提交,审核通过后请在钱包内确认收款'; } $this->success($tipMsg, [ 'withdraw_id' => $withdrawId, 'arrive_amount' => $arrive, 'auto_initiated' => $autoInitiated, 'can_confirm' => $canConfirmNow, 'auto_fail_reason' => $autoFailReason, ]); } /** * 获取微信商家转账小程序确认收款凭证(供前端 wx.requestMerchantTransfer 调起官方收款页) * @ApiMethod (POST) */ public function transferPackage() { $master = $this->requireMaster(); $withdrawId = (int)$this->request->post('withdraw_id'); if ($withdrawId <= 0) { $this->error('参数错误'); } $wd = Db::name('withdraw')->where('id', $withdrawId)->find(); if (!$wd || (int)$wd['master_id'] !== (int)$master['id']) { $this->error('无权访问该提现记录', null, 403); } // 强制实时向微信网关查单核验最新状态,核验失败直接中断阻止拉起失效收款凭证 try { $wd = PaymentService::syncWithdrawTransfer($withdrawId); } catch (\Throwable $e) { \think\Log::error('[Master::transferPackage sync] withdraw_id=' . $withdrawId . ' err=' . $e->getMessage()); $this->error('核验微信转账状态失败:' . $e->getMessage()); return; } if ($wd['status'] === 'done') { $this->success('该笔提现已收款入账微信零钱', [ 'already_done' => true, 'withdraw' => $this->formatWithdrawItem($wd), ]); } if ($wd['status'] === 'reject') { $this->error('该笔提现未完成(' . ($wd['fail_reason'] ?: $wd['audit_msg'] ?: '已退回') . '),金额已退回可用余额'); } if (empty($wd['package_info']) || !in_array($wd['transfer_state'], ['WAIT_USER_CONFIRM', 'TRANSFERING'], true)) { $this->error('当前提现单状态(' . PaymentService::transferStateLabel($wd['transfer_state']) . ')暂不可拉起确认收款,请稍后重试'); } $wxSvc = new WxPayV3Service(); $this->success('', [ 'already_done' => false, 'withdraw_id' => (int)$wd['id'], 'mch_id' => $wxSvc->getMchId(), 'app_id' => $wxSvc->getAppId(), 'package_info' => (string)$wd['package_info'], 'out_bill_no' => (string)$wd['out_bill_no'], 'arrive_amount' => (float)$wd['arrive_amount'], ]); } /** * 师傅从微信确认收款页面返回后主动同步收款入账状态 * @ApiMethod (POST) */ public function syncWithdraw() { $master = $this->requireMaster(); $withdrawId = (int)$this->request->post('withdraw_id'); if ($withdrawId <= 0) { $this->error('参数错误'); } $wd = Db::name('withdraw')->where('id', $withdrawId)->find(); if (!$wd || (int)$wd['master_id'] !== (int)$master['id']) { $this->error('无权访问该提现记录', null, 403); } try { $wd = PaymentService::syncWithdrawTransfer($withdrawId); } catch (\Throwable $e) { \think\Log::error('[Master::syncWithdraw] ' . $e->getMessage()); $this->error('同步微信转账状态失败:' . $e->getMessage()); return; } $formatted = $this->formatWithdrawItem($wd); $isDone = $wd['status'] === 'done'; $msg = $isDone ? '收款成功,资金已入账微信零钱' : ('当前状态:' . $formatted['status_text']); $this->success($msg, [ 'done' => $isDone, 'withdraw' => $formatted, ]); } /** * 格式化单条提现记录输出 */ protected function formatWithdrawItem(array $wd) { $status = (string)($wd['status'] ?? 'wait'); $txState = strtoupper((string)($wd['transfer_state'] ?? '')); $channel = (string)($wd['channel'] ?? 'wechat'); $canConfirm = ($status === 'pass' && $channel === 'wechat' && !empty($wd['package_info']) && in_array($txState, ['WAIT_USER_CONFIRM', 'TRANSFERING'], true)); $statusText = '财务审核中'; if ($status === 'done') { $statusText = '已到账零钱'; } elseif ($status === 'reject') { $statusText = '已退回余额'; } elseif ($status === 'pass') { if ($canConfirm) { $statusText = '待确认收款'; } elseif ($txState === 'CANCELING') { $statusText = '撤销处理中'; } elseif ($txState !== '') { $statusText = PaymentService::transferStateLabel($txState); } else { $statusText = '审核通过待打款'; } } return [ 'id' => (int)$wd['id'], 'amount' => (float)$wd['amount'], 'fee' => (float)$wd['fee'], 'arrive_amount' => (float)$wd['arrive_amount'], 'channel' => $channel, 'account' => (string)($wd['account'] ?? ''), 'status' => $status, 'status_text' => $statusText, 'transfer_state' => $txState, 'transfer_state_text' => PaymentService::transferStateLabel($txState), 'can_confirm' => $canConfirm, 'out_bill_no' => (string)($wd['out_bill_no'] ?? ''), 'audit_msg' => (string)($wd['audit_msg'] ?? ''), 'fail_code' => (string)($wd['fail_code'] ?? ''), 'fail_reason' => (string)($wd['fail_reason'] ?? ''), 'createtime' => (int)($wd['createtime'] ?? 0), 'confirm_time' => (int)($wd['confirm_time'] ?? 0), 'time_text' => !empty($wd['createtime']) ? date('Y-m-d H:i', (int)$wd['createtime']) : '', ]; } /** * 超 2 小时未联系客户 → 订单自动回流大厅 */ protected function recycleExpired() { $rows = Db::name('order') ->where('status', OrderStatus::ACCEPTED) ->where('contact_time', 0) ->where('accept_time', '<', time() - 7200) ->limit(50) ->select(); foreach ($rows as $row) { $toStatus = $row['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT; Db::name('order')->where('id', $row['id'])->update([ 'master_id' => 0, 'status' => $toStatus, 'accept_time' => null, 'updatetime' => time(), ]); if ($row['price_type'] === 'quote') { Db::name('order_quote')->where('order_id', $row['id'])->where('status', 'accept')->update(['status' => 'wait']); } $this->addLog($row['id'], $toStatus, $row['price_type'] === 'quote' ? '师傅超 2 小时未联系客户,订单重新开放报价' : '超 2 小时未联系客户,订单自动回流大厅', 'system', 0); } } /** * 信用体系:综合评分 / 接单率 / 准时率 / 好评率 / 投诉率 */ public function credit() { $master = $this->requireMaster(); $mid = (int)$master['id']; $reviewTotal = (int)Db::name('review')->where('master_id', $mid)->where('status', 'normal')->count(); $reviewGood = (int)Db::name('review')->where('master_id', $mid)->where('status', 'normal')->where('score', '>=', 4)->count(); $appealCount = (int)Db::name('appeal')->where('master_id', $mid)->count(); $orderCount = (int)$master['order_count']; $this->success('', [ 'score' => (float)$master['score'], 'level' => (int)$master['level'], 'accept_rate' => (float)$master['accept_rate'], 'ontime_rate' => (float)$master['ontime_rate'], 'good_rate' => $reviewTotal > 0 ? round($reviewGood * 100 / $reviewTotal, 1) : 0, 'has_review' => $reviewTotal > 0, 'complaint_rate' => $orderCount > 0 ? round($appealCount * 100 / $orderCount, 1) : 0, 'order_count' => (int)$master['order_count'], 'review_total' => $reviewTotal, 'appeal_total' => $appealCount, 'rules' => [ '综合评分 = 客户评价星级平均值(5 分制)', '接单率 = 已联系客户订单 / 接单总数', '准时率 = 按预约时间到达 / 上门总数', '好评率 = 4 星及以上评价占比', '投诉率 = 申诉工单数 / 完成订单数', ], ]); } /** * 申诉中心:列表 */ public function appeals() { $master = $this->requireMaster(); $list = Db::name('appeal')->where('master_id', $master['id'])->order('id desc')->limit(30)->select(); $typeText = ['misjudge' => '误判申诉', 'review' => '恶意评价', 'deduct' => '扣款申诉', 'other' => '其他']; $statusText = ['wait' => '待处理', 'handling' => '处理中', 'done' => '已处理', 'reject' => '已驳回']; foreach ($list as &$row) { $row['type_text'] = isset($typeText[$row['type']]) ? $typeText[$row['type']] : '其他'; $row['status_text'] = isset($statusText[$row['status']]) ? $statusText[$row['status']] : $row['status']; $row['time_text'] = date('Y-m-d H:i', (int)$row['createtime']); $imgs = []; if (!empty($row['images'])) { $dec = json_decode((string)$row['images'], true); $rawImgs = is_array($dec) ? $dec : explode(',', (string)$row['images']); foreach ($rawImgs as $im) { $im = trim((string)$im); if ($im !== '') { $imgs[] = strpos($im, 'http') === 0 ? $im : cdnurl($im, true); } } } // 兼容清洗历史拼接在文本末尾的 [举证图片: ...] $content = (string)$row['content']; if (preg_match('/\[举证图片:\s*(.+?)\]\s*$/u', $content, $matches)) { $content = trim(preg_replace('/\[举证图片:\s*(.+?)\]\s*$/u', '', $content)); foreach (explode(',', $matches[1]) as $extraIm) { $extraIm = trim($extraIm); if ($extraIm !== '') { $full = strpos($extraIm, 'http') === 0 ? $extraIm : cdnurl($extraIm, true); if (!in_array($full, $imgs, true)) { $imgs[] = $full; } } } } $row['content'] = $content; $row['image_list'] = $imgs; } unset($row); $this->success('', ['list' => $list]); } /** * 申诉中心:提交 * @ApiMethod (POST) */ public function appeal() { $master = $this->requireMaster(); $type = $this->request->post('type', 'other'); $orderId = (int)$this->request->post('order_id', 0); $content = trim((string)$this->request->post('content')); if (!in_array($type, ['misjudge', 'review', 'deduct', 'other'], true)) { $this->error('申诉类型错误'); } if (mb_strlen($content) < 5) { $this->error('请填写至少 5 个字的申诉说明'); } if (Db::name('appeal')->where('master_id', $master['id'])->where('status', 'wait')->count() >= 5) { $this->error('您有多个待处理申诉,请等待处理完成'); } Db::name('appeal')->insert([ 'master_id' => $master['id'], 'order_id' => $orderId, 'type' => $type, 'content' => mb_substr($content, 0, 500), 'images' => (string)$this->request->post('images', ''), 'status' => 'wait', 'createtime' => time(), 'updatetime' => time(), ]); $this->success('申诉已提交,平台将在 24 小时内处理'); } /** * 我的报价记录 */ public function quotes() { $master = $this->requireMaster(); $list = Db::name('order_quote')->alias('q') ->join('order o', 'o.id = q.order_id', 'LEFT') ->field('q.*, o.order_no, o.service_name, o.pay_amount as order_amount, o.status as order_status') ->where('q.master_id', $master['id']) ->order('q.id desc') ->limit(30) ->select(); $statusText = ['wait' => '等待客户选择', 'accept' => '已被选中', 'reject' => '未被选中', 'expired' => '已过期']; foreach ($list as &$row) { $row['status_text'] = isset($statusText[$row['status']]) ? $statusText[$row['status']] : $row['status']; $row['amount'] = (float)$row['amount']; $row['time_text'] = date('m-d H:i', (int)$row['createtime']); $row['expire_text'] = date('m-d H:i', (int)$row['expire_time']); } unset($row); $this->success('', ['list' => $list]); } /** * 获取师傅已配置的技能类目 ID 列表 */ protected function getMasterSkillCatIds($masterId) { $catIds = Db::name('master_skill')->where('master_id', (int)$masterId)->where('cat_id', '>', 0)->column('cat_id'); return array_values(array_filter(array_unique($catIds))); } /** * 订单表有效坐标 SQL 过滤片段 */ protected function getValidOrderCoordSql() { return "(lng REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' AND lat REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' AND CAST(lng AS DECIMAL(10,6)) BETWEEN -180 AND 180 AND CAST(lat AS DECIMAL(10,6)) BETWEEN -90 AND 90 AND (ABS(CAST(lng AS DECIMAL(10,6))) > 0.0001 OR ABS(CAST(lat AS DECIMAL(10,6))) > 0.0001))"; } /** * 球面公里距离参数化 SQL 表达式 */ protected function getDistanceSqlFormula() { return "(6371.0 * ACOS(LEAST(1.0, GREATEST(-1.0, COS(RADIANS(:h_lat1)) * COS(RADIANS(CAST(lat AS DECIMAL(10,6)))) * COS(RADIANS(CAST(lng AS DECIMAL(10,6))) - RADIANS(:h_lng1)) + SIN(RADIANS(:h_lat2)) * SIN(RADIANS(CAST(lat AS DECIMAL(10,6))))))))"; } /** * 统计指定锚点与服务半径内的真实待接工单数量 */ protected function countLocationOrderStats($masterId, $lat, $lng, $serveRadius, $city = '') { $serveRadius = max(5, min(200, (int)$serveRadius)); $catIds = $this->getMasterSkillCatIds($masterId); $buildPool = function () use ($catIds) { $q = Db::name('order') ->where('master_id', 0) ->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING]); if (!empty($catIds)) { $q->where('cat_id', 'in', $catIds); } return $q; }; $totalOrderCount = (int)$buildPool()->count(); $inRadiusCount = 0; if ($lat !== null && $lng !== null && $this->isValidCoord($lat, $lng)) { $validCoordCond = $this->getValidOrderCoordSql(); $distFormula = $this->getDistanceSqlFormula(); $subSql = $buildPool() ->whereRaw($validCoordCond) ->field("id, {$distFormula} AS _dist_km") ->buildSql(); $inRadiusCount = (int)Db::table($subSql . ' stat_o') ->bind([ 'h_lat1' => (float)$lat, 'h_lng1' => (float)$lng, 'h_lat2' => (float)$lat, 'r_limit' => (float)$serveRadius, ]) ->where('_dist_km <= :r_limit') ->count(); } $cityToken = $this->extractCityToken($city); $cityOrderCount = 0; if ($cityToken !== '') { $cityOrderCount = (int)$buildPool()->where('city', 'like', '%' . $cityToken . '%')->count(); } return [ 'serve_radius' => $serveRadius, 'in_radius_count' => $inRadiusCount, 'city_order_count' => $cityOrderCount, 'total_order_count' => $totalOrderCount, ]; } /** * 统计平台真实有待接工单的城市分布,供位置页城市快捷切换展示(0 假数据) */ protected function getActiveHallCities($masterId) { $catIds = $this->getMasterSkillCatIds($masterId); $q = Db::name('order') ->where('master_id', 0) ->where('status', 'in', [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING]) ->where('city', '<>', ''); if (!empty($catIds)) { $q->where('cat_id', 'in', $catIds); } $rows = $q->field('city, COUNT(*) AS order_count') ->group('city') ->order('order_count desc') ->limit(12) ->select(); $list = []; foreach ($rows as $r) { $cName = trim((string)($r['city'] ?? '')); if ($cName === '') { continue; } $list[] = [ 'city' => $cName, 'order_count' => (int)($r['order_count'] ?? 0), ]; } return $list; } /** * 校验经纬度坐标是否有效 */ protected function isValidCoord($lat, $lng) { if (!is_numeric($lat) || !is_numeric($lng)) { return false; } $latF = (float)$lat; $lngF = (float)$lng; if (abs($latF) < 0.0001 && abs($lngF) < 0.0001) { return false; } return $latF >= -90.0 && $latF <= 90.0 && $lngF >= -180.0 && $lngF <= 180.0; } /** * 从省市区文本中提取地级市/区域匹配关键词,用于无精确坐标订单的同城推荐兜底 */ protected function extractCityToken($cityStr) { $cityStr = trim((string)$cityStr); if ($cityStr === '') { return ''; } if (preg_match('/([^\s省]+?(?:市|自治州|地区|盟))/u', $cityStr, $m)) { $token = trim($m[1]); // 去除直辖市或复合字符串前缀(如“贵州省贵阳市”已在上面截出市级) if (mb_strlen($token) > 2 && mb_substr($token, -1) === '市') { return mb_substr($token, 0, mb_strlen($token) - 1); } return $token; } $parts = preg_split('/\s+/u', $cityStr); return trim((string)end($parts)); } /** * 格式化公里距离展示文案 */ protected function formatDistanceText($distKm) { if ($distKm === null) { return ''; } if ($distKm < 1.0) { return max(50, (int)round($distKm * 1000)) . 'm'; } return $distKm . 'km'; } /** * 计算两点间距离 (km) */ protected function calculateDistance($lat1, $lon1, $lat2, $lon2) { if (!$this->isValidCoord($lat1, $lon1) || !$this->isValidCoord($lat2, $lon2)) { return null; } $earthRadius = 6371; $dLat = deg2rad((float)$lat2 - (float)$lat1); $dLon = deg2rad((float)$lon2 - (float)$lon1); $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad((float)$lat1)) * cos(deg2rad((float)$lat2)) * sin($dLon / 2) * sin($dLon / 2); $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); return round($earthRadius * $c, 2); } /** * 师傅端订单抢单详情 * @ApiMethod (GET) */ /** * 师傅端:施工现场发起增项补差价申请 * @ApiMethod (POST) */ public function requestDiff() { $orderId = (int)$this->request->post('order_id'); $amount = round((float)$this->request->post('amount'), 2); $remark = trim((string)$this->request->post('remark', '现场增项费用')); $image = trim((string)$this->request->post('image', '')); if ($orderId <= 0 || $amount <= 0) { $this->error('请输入有效的补差价金额'); } $master = $this->requireMaster(); $order = Db::name('order')->where('id', $orderId)->find(); if (!$order || (int)$order['master_id'] !== (int)$master['id']) { $this->error('无权操作该订单'); } $allowedStatus = [OrderStatus::ARRIVED, OrderStatus::WORKING]; if (!in_array((int)$order['status'], $allowedStatus, true)) { $this->error('当前状态不可发起增项补差价'); } Db::name('order')->where('id', $orderId)->update([ 'diff_amount' => $amount, 'diff_status' => 1, // 1: 待客户支付 'diff_remark' => mb_substr($remark, 0, 255), 'diff_image' => $image, 'updatetime' => time(), ]); $this->addLog($orderId, (int)$order['status'], '师傅发起施工现场增项补差价申请 ¥' . $amount . ',原因:' . $remark, 'master', (int)$master['id']); $this->notify('user', (int)$order['user_id'], '师傅发起增项补差价申请', '工单 ' . $order['order_no'] . ' 师傅申请增项补差价 ¥' . $amount . ',请查看并在线支付', 'order', $orderId); $this->success('已发起增项补差价申请,请提醒客户在线支付'); } public function orderDetail() { $master = $this->requireMaster(); $masterId = (int)$master['id']; $orderId = (int)$this->request->param('id') ?: (int)$this->request->param('order_id'); if ($orderId <= 0) { $this->error('参数错误'); } $order = Db::name('order')->where('id', $orderId)->find(); if (!$order) { $this->error('订单不存在'); } $isMyOrder = ($masterId > 0 && (int)$order['master_id'] === $masterId); $inHall = in_array((int)$order['status'], [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true); if (!$isMyOrder && !$inHall) { $this->error('该订单已被其他师傅接单或已结束'); } $now = time(); $quoteCount = (int)Db::name('order_quote')->where('order_id', $orderId)->count(); $myQuote = $masterId > 0 ? Db::name('order_quote') ->where('order_id', $orderId) ->where('master_id', $masterId) ->find() : null; $maxQuotes = 4; $remainingQuoteSlots = max(0, $maxQuotes - $quoteCount); $deadline = (int)$order['createtime'] + 3 * 86400; $countdownSeconds = max(0, $deadline - $now); $mobile = (string)$order['mobile']; $address = (string)$order['address']; if (!$isMyOrder) { $mobile = strlen($mobile) >= 7 ? substr_replace($mobile, '****', 3, 4) : $mobile; $address = preg_replace('/\d+号|\d+栋|\d+单元|\d+室/', '***', $address); if (mb_strlen($address) > 10) { $address = mb_substr($address, 0, 8) . '****号'; } } $defaultLat = ($master && !empty($master['lat'])) ? (float)$master['lat'] : 0.0; $defaultLng = ($master && !empty($master['lng'])) ? (float)$master['lng'] : 0.0; $userLat = (float)$this->request->param('lat', $defaultLat); $userLng = (float)$this->request->param('lng', $defaultLng); $dist = $this->calculateDistance($userLat, $userLng, (float)$order['lat'], (float)$order['lng']); $service = Db::name('service')->where('id', $order['service_id'])->find(); $category = Db::name('service_cat')->where('id', $order['cat_id'])->find(); // 安全解析商品图片、视频、备注与多商品清单(彻底杜绝子数组强转 (string) 报错) $normalizeMediaList = function ($rawMedia) { $urls = []; if (empty($rawMedia)) { return $urls; } if (is_string($rawMedia)) { $dec = json_decode($rawMedia, true); $items = is_array($dec) ? $dec : explode(',', $rawMedia); } elseif (is_array($rawMedia)) { $items = $rawMedia; } else { return $urls; } foreach ($items as $it) { $u = ''; if (is_string($it) || is_numeric($it)) { $u = trim((string)$it); } elseif (is_array($it)) { if (isset($it['url']) && is_string($it['url'])) { $u = trim($it['url']); } elseif (isset($it['path']) && is_string($it['path'])) { $u = trim($it['path']); } elseif (isset($it['tempFilePath']) && is_string($it['tempFilePath'])) { $u = trim($it['tempFilePath']); } } if ($u !== '' && $u !== 'Array') { $full = strpos($u, 'http') === 0 ? $u : cdnurl($u, true); if (!in_array($full, $urls, true)) { $urls[] = $full; } } } return $urls; }; $goodsImages = []; $envImages = []; $goodsVideos = []; $parsedGoodsList = []; $topGoodsRemark = ''; $topServiceRemark = ''; $topExtras = []; if (!empty($order['goods_images'])) { $decoded = json_decode((string)$order['goods_images'], true); if (is_array($decoded)) { if (isset($decoded['images'])) { $goodsImages = array_merge($goodsImages, $normalizeMediaList($decoded['images'])); } elseif (!isset($decoded['goods_list'])) { $goodsImages = array_merge($goodsImages, $normalizeMediaList($decoded)); } if (isset($decoded['videos'])) { $goodsVideos = array_merge($goodsVideos, $normalizeMediaList($decoded['videos'])); } if (isset($decoded['goods_videos'])) { $goodsVideos = array_merge($goodsVideos, $normalizeMediaList($decoded['goods_videos'])); } if (!empty($decoded['goods_remark']) && is_string($decoded['goods_remark'])) { $topGoodsRemark = trim($decoded['goods_remark']); } if (!empty($decoded['service_remark']) && is_string($decoded['service_remark'])) { $topServiceRemark = trim($decoded['service_remark']); } if (!empty($decoded['extras']) && is_array($decoded['extras'])) { $topExtras = $decoded['extras']; } if (!empty($decoded['goods_list']) && is_array($decoded['goods_list'])) { $parsedGoodsList = $decoded['goods_list']; } } else { $goodsImages = array_merge($goodsImages, $normalizeMediaList((string)$order['goods_images'])); } } // 解析 parsedGoodsList 中的多媒体、增项与备注,以便补充到 order_goods 行 $parsedMetaByIdx = []; foreach ($parsedGoodsList as $pIdx => $pgItem) { if (!is_array($pgItem)) { continue; } $itemImgs = []; if (!empty($pgItem['images'])) { $itemImgs = array_merge($itemImgs, $normalizeMediaList($pgItem['images'])); } if (!empty($pgItem['goods_images'])) { $itemImgs = array_merge($itemImgs, $normalizeMediaList($pgItem['goods_images'])); } if (!empty($pgItem['goods_image'])) { $itemImgs = array_merge($itemImgs, $normalizeMediaList([$pgItem['goods_image']])); } foreach ($itemImgs as $imUrl) { if (!in_array($imUrl, $goodsImages, true)) { $goodsImages[] = $imUrl; } } $itemEnvImgs = []; if (!empty($pgItem['envImages'])) { $itemEnvImgs = array_merge($itemEnvImgs, $normalizeMediaList($pgItem['envImages'])); } if (!empty($pgItem['env_images'])) { $itemEnvImgs = array_merge($itemEnvImgs, $normalizeMediaList($pgItem['env_images'])); } foreach ($itemEnvImgs as $envUrl) { if (!in_array($envUrl, $envImages, true)) { $envImages[] = $envUrl; } } $itemVids = []; if (!empty($pgItem['videos'])) { $itemVids = array_merge($itemVids, $normalizeMediaList($pgItem['videos'])); } if (!empty($pgItem['goods_videos'])) { $itemVids = array_merge($itemVids, $normalizeMediaList($pgItem['goods_videos'])); } foreach ($itemVids as $vdUrl) { if (!in_array($vdUrl, $goodsVideos, true)) { $goodsVideos[] = $vdUrl; } } $gRem = isset($pgItem['goods_remark']) && is_string($pgItem['goods_remark']) ? trim($pgItem['goods_remark']) : (isset($pgItem['remark']) && is_string($pgItem['remark']) ? trim($pgItem['remark']) : ''); $sRem = isset($pgItem['service_remark']) && is_string($pgItem['service_remark']) ? trim($pgItem['service_remark']) : ''; $itemExtras = []; if (!empty($pgItem['extras']) && is_array($pgItem['extras'])) { foreach ($pgItem['extras'] as $ex) { if (is_array($ex) && (!isset($ex['num']) || (int)$ex['num'] > 0)) { $itemExtras[] = $ex; $topExtras[] = $ex; } } } if ($gRem !== '' && $topGoodsRemark === '') { $topGoodsRemark = $gRem; } if ($sRem !== '' && $topServiceRemark === '') { $topServiceRemark = $sRem; } $parsedMetaByIdx[$pIdx] = [ 'images' => $itemImgs, 'goods_videos' => $itemVids, 'goods_remark' => $gRem, 'service_remark' => $sRem, 'extras' => $itemExtras, ]; } $orderGoodsRows = Db::name('order_goods')->where('order_id', $orderId)->order('id asc')->select(); $goodsList = []; if ($orderGoodsRows) { foreach ($orderGoodsRows as $rIdx => $og) { $gImg = !empty($og['goods_image']) ? (strpos($og['goods_image'], 'http') === 0 ? $og['goods_image'] : cdnurl($og['goods_image'], true)) : ''; $meta = isset($parsedMetaByIdx[$rIdx]) ? $parsedMetaByIdx[$rIdx] : [ 'images' => $gImg !== '' ? [$gImg] : [], 'goods_videos' => [], 'goods_remark' => '', 'service_remark' => '', 'extras' => [], ]; $rowExtras = $meta['extras']; $addonRaw = (string)$og['addon_services']; if (empty($rowExtras) && $addonRaw !== '' && $addonRaw[0] === '[') { $decAddon = json_decode($addonRaw, true); if (is_array($decAddon)) { $rowExtras = $decAddon; if (empty($topExtras)) { $topExtras = $decAddon; } } } $goodsList[] = [ 'id' => (int)$og['id'], 'service_id' => (int)$og['service_id'], 'goods_name' => (string)$og['goods_name'], 'goods_model' => (string)$og['goods_model'], 'goods_num' => (int)$og['goods_num'] ?: 1, 'goods_image' => $gImg ?: (!empty($meta['images']) ? $meta['images'][0] : ''), 'images' => $meta['images'], 'goods_videos' => $meta['goods_videos'], 'goods_remark' => $meta['goods_remark'], 'service_remark' => $meta['service_remark'], 'extras' => $rowExtras, 'addon_services' => $addonRaw, 'base_price' => (float)$og['base_price'], ]; } } elseif ($parsedGoodsList) { foreach ($parsedGoodsList as $idx => $pg) { if (!is_array($pg)) { continue; } $meta = isset($parsedMetaByIdx[$idx]) ? $parsedMetaByIdx[$idx] : [ 'images' => [], 'goods_videos' => [], 'goods_remark' => '', 'service_remark' => '', 'extras' => [], ]; $rawImg = ''; if (!empty($pg['goods_image']) && is_string($pg['goods_image'])) { $rawImg = trim($pg['goods_image']); } elseif (!empty($pg['image']) && is_string($pg['image'])) { $rawImg = trim($pg['image']); } $firstMetaImg = !empty($meta['images']) ? $meta['images'][0] : ''; $addonText = ''; if (!empty($pg['addon_services'])) { $addonText = is_array($pg['addon_services']) ? json_encode($pg['addon_services'], JSON_UNESCAPED_UNICODE) : (string)$pg['addon_services']; } elseif (!empty($meta['extras'])) { $addonText = json_encode($meta['extras'], JSON_UNESCAPED_UNICODE); } $gNameVal = ''; foreach (['custom_name', 'goods_name', 'item_name', 'name'] as $nk) { if (!empty($pg[$nk]) && is_string($pg[$nk])) { $gNameVal = trim($pg[$nk]); break; } } $gModelVal = ''; foreach (['spec_name', 'goods_model', 'spec'] as $mk) { if (!empty($pg[$mk]) && is_string($pg[$mk])) { $gModelVal = trim($pg[$mk]); break; } } $goodsList[] = [ 'id' => $idx + 1, 'service_id' => (int)($pg['service_id'] ?? $order['service_id']), 'goods_name' => $gNameVal !== '' ? $gNameVal : ($order['service_name'] ?: '服务商品'), 'goods_model' => $gModelVal, 'goods_num' => max(1, (int)($pg['goods_num'] ?? ($pg['num'] ?? 1))), 'goods_image' => $rawImg !== '' ? (strpos($rawImg, 'http') === 0 ? $rawImg : cdnurl($rawImg, true)) : $firstMetaImg, 'images' => $meta['images'], 'goods_videos' => $meta['goods_videos'], 'goods_remark' => $meta['goods_remark'], 'service_remark' => $meta['service_remark'], 'extras' => $meta['extras'], 'addon_services' => $addonText, 'base_price' => (float)($pg['base_price'] ?? ($pg['price'] ?? 0)), ]; } } $rawServiceImg = $order['service_image'] ?: ($service ? $service['image'] : ''); $serviceImageUrl = $rawServiceImg ? (strpos($rawServiceImg, 'http') === 0 ? $rawServiceImg : cdnurl($rawServiceImg, true)) : ''; $contactDisplay = $isMyOrder ? ($order['contact'] ?: '业主客户') : ($order['contact'] ? mb_substr($order['contact'], 0, 1) . '客户' : '业主客户'); $prefix = trim($order['province'] . $order['city'] . $order['district']); $fullAddr = ($prefix !== '' && strpos($address, $order['district']) === false && strpos($address, $order['city']) === false) ? ($prefix . $address) : $address; // 按订单锁定的多维抽佣快照计算预计收益 $estCalc = CommissionService::calcByOrder($order); $commissionRate = $estCalc['commission_rate']; $estCommission = $estCalc['commission']; $estMasterFee = $estCalc['settle_amount']; $data = [ 'id' => (int)$order['id'], 'order_no' => $order['order_no'], 'price_type' => $order['price_type'], 'price_type_text' => $order['price_type'] === 'fixed' ? '一口价' : ($order['price_type'] === 'quote' ? '师傅竞价' : ($order['price_type'] === 'custom' ? '用户定价' : '标准价')), 'status' => (int)$order['status'], 'status_text' => OrderStatus::label($order['status']), 'total_amount' => (float)$order['total_amount'], 'base_amount' => (float)$order['base_amount'], 'floor_amount' => (float)($order['floor_amount'] ?? 0), 'urgent_amount' => (float)($order['urgent_amount'] ?? 0), 'pay_amount' => (float)($order['pay_amount'] ?? 0), 'commission_rate' => $commissionRate, 'commission' => $estCommission, 'master_fee' => $estMasterFee, 'user_price' => $order['price_type'] === 'custom' ? (float)$order['base_amount'] : (float)$order['total_amount'], 'service_name' => $order['service_name'] ?: ($service ? $service['name'] : '家庭维修服务'), 'service_image' => $serviceImageUrl, 'goods_images' => $goodsImages, 'env_images' => $envImages, 'goods_videos' => $goodsVideos, 'goods_remark' => $topGoodsRemark, 'service_remark' => $topServiceRemark !== '' ? $topServiceRemark : (string)($order['remark'] ?: ''), 'extras' => $topExtras, 'goods_list' => $goodsList, 'goods_model' => $order['goods_model'] ?: '标准规格', 'goods_num' => (int)$order['goods_num'] ?: 1, 'floor' => (int)($order['floor'] ?? 0), 'has_elevator' => (int)($order['has_elevator'] ?? 0), 'urgent' => (int)($order['urgent'] ?? 0), 'cat_name' => $category ? $category['name'] : '通用', 'service_type_tag' => $order['price_type'] === 'fixed' ? '一口价' : ($order['price_type'] === 'custom' ? '用户定价' : '维修'), 'remark' => $order['remark'] ?: '', 'express_no' => (string)($order['express_no'] ?? ''), 'express_company' => (string)($order['express_company'] ?? ''), 'goods_arrived' => (int)($order['goods_arrived'] ?? 1), 'qual_req' => (string)($order['qual_req'] ?? ''), 'master_req' => (string)($order['master_req'] ?? ''), 'contact' => $contactDisplay, 'mobile' => $mobile, 'is_my_order' => $isMyOrder, 'province' => $order['province'], 'city' => $order['city'], 'district' => $order['district'], 'full_address' => $fullAddr, 'lng' => $order['lng'], 'lat' => $order['lat'], 'distance_km' => $dist, 'appoint_time' => (int)$order['appoint_time'], 'appoint_time_text' => $order['appoint_time'] ? date('Y-m-d H:i', (int)$order['appoint_time']) : '接单后与业主预约', 'createtime' => (int)$order['createtime'], 'createtime_text' => date('Y-m-d H:i', (int)$order['createtime']), 'quote_count' => $quoteCount, 'remaining_quote_slots' => $remainingQuoteSlots, 'countdown_seconds' => $countdownSeconds, 'my_quote' => $myQuote ? [ 'id' => (int)$myQuote['id'], 'amount' => (float)$myQuote['amount'], 'description' => $myQuote['description'], 'status' => $myQuote['status'], ] : null, 'can_grab' => ($order['price_type'] !== 'quote' && (int)$order['status'] === OrderStatus::PENDING_ACCEPT && (int)$order['master_id'] === 0 && (int)$order['user_id'] !== $this->uid()), 'can_quote' => ($order['price_type'] === 'quote' && in_array((int)$order['status'], [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true) && (int)$order['master_id'] === 0 && !$myQuote && (int)$order['user_id'] !== $this->uid()), ]; // 本人已接工单:附带完整履约节点、增项补差、完工取证、结算入账与操作轨迹 if ($isMyOrder) { $acceptTs = (int)($order['accept_time'] ?? 0); $contactTs = (int)($order['contact_time'] ?? 0); $arriveTs = (int)($order['arrive_time'] ?? 0); $startTs = (int)($order['start_time'] ?? 0); $finishTs = (int)($order['finish_time'] ?? 0); $verifyTs = (int)($order['verify_time'] ?? 0); $contactRemain = ($acceptTs > 0 && $contactTs <= 0) ? max(0, ($acceptTs + 7200) - $now) : 0; $diffImg = !empty($order['diff_image']) ? (strpos((string)$order['diff_image'], 'http') === 0 ? (string)$order['diff_image'] : cdnurl((string)$order['diff_image'], true)) : ''; $mediaRows = Db::name('order_media')->where('order_id', $orderId)->order('id asc')->select(); $finishImages = []; $damageRemark = ''; foreach ($mediaRows as $md) { $mUrl = !empty($md['url']) ? (strpos((string)$md['url'], 'http') === 0 ? (string)$md['url'] : cdnurl((string)$md['url'], true)) : ''; if ($mUrl !== '') { $finishImages[] = [ 'id' => (int)$md['id'], 'stage' => (string)$md['stage'], 'url' => $mUrl, 'watermark' => (string)$md['watermark'], 'remark' => (string)$md['remark'], ]; } if ($damageRemark === '' && !empty($md['remark'])) { $damageRemark = (string)$md['remark']; } } $settleRow = Db::name('settle')->where('order_id', $orderId)->order('id desc')->find(); if ($settleRow) { $commissionRate = (float)$settleRow['commission_rate']; $estCommission = (float)$settleRow['commission']; $estMasterFee = (float)$settleRow['settle_amount']; } $logRows = Db::name('order_log')->where('order_id', $orderId)->order('id desc')->select(); $logs = []; foreach ($logRows as $lg) { $logs[] = [ 'id' => (int)$lg['id'], 'status' => (int)$lg['status'], 'status_text' => OrderStatus::label((int)$lg['status']), 'remark' => (string)$lg['remark'], 'operator_type' => (string)$lg['operator_type'], 'time_text' => $lg['createtime'] ? date('m-d H:i', (int)$lg['createtime']) : '', ]; } $data['accept_time'] = $acceptTs; $data['accept_time_text'] = $acceptTs > 0 ? date('Y-m-d H:i', $acceptTs) : ''; $data['contact_time'] = $contactTs; $data['contact_time_text'] = $contactTs > 0 ? date('Y-m-d H:i', $contactTs) : ''; $data['contact_remain'] = $contactRemain; $data['arrive_time'] = $arriveTs; $data['arrive_time_text'] = $arriveTs > 0 ? date('Y-m-d H:i', $arriveTs) : ''; $data['start_time'] = $startTs; $data['start_time_text'] = $startTs > 0 ? date('Y-m-d H:i', $startTs) : ''; $data['finish_time'] = $finishTs; $data['finish_time_text'] = $finishTs > 0 ? date('Y-m-d H:i', $finishTs) : ''; $data['verify_time'] = $verifyTs; $data['verify_time_text'] = $verifyTs > 0 ? date('Y-m-d H:i', $verifyTs) : ''; $data['diff_amount'] = (float)($order['diff_amount'] ?? 0); $data['diff_status'] = (int)($order['diff_status'] ?? 0); $data['diff_remark'] = (string)($order['diff_remark'] ?? ''); $data['diff_image'] = $diffImg; $data['settle_status'] = (int)($order['settle_status'] ?? 0); $data['settle_amount'] = (int)($order['settle_status'] ?? 0) === 1 ? (float)$order['settle_amount'] : $estMasterFee; $data['commission_rate'] = $commissionRate; $data['commission'] = $estCommission; $data['master_fee'] = $data['settle_amount']; $data['finish_images'] = $finishImages; $data['damage_remark'] = $damageRemark; $data['logs'] = $logs; } $this->success('', $data); } /** * 获取技能字典树与师傅当前选定状态 * @ApiMethod (GET) */ public function skillDict() { $master = $this->masterInfo(); $masterId = $master ? (int)$master['id'] : 0; // 1. 获取所有正常启用的字典项 $dictList = Db::name('skill_dict') ->where('status', 'normal') ->order('weigh desc, id asc') ->select(); // 2. 按大类分组 $groups = []; $categoryOrder = ['安装维修', '建筑装修', '物流运输', '汽车服务', '家政服务', '便民服务']; foreach ($categoryOrder as $cat) { $groups[$cat] = []; } foreach ($dictList as $item) { $cat = $item['category']; if (!isset($groups[$cat])) { $groups[$cat] = []; } $groups[$cat][] = [ 'id' => (int)$item['id'], 'name' => $item['name'], 'category' => $item['category'], 'source' => $item['source'], ]; } $tree = []; foreach ($groups as $catName => $items) { if (!empty($items)) { $tree[] = [ 'category' => $catName, 'items' => $items ]; } } // 3. 当前师傅已勾选的技能(对齐标准字典项,历史类目名自动映射标准名,非字典项归入补充技能) $dictNameSet = []; foreach ($dictList as $dRow) { if (!empty($dRow['name'])) { $dictNameSet[(string)$dRow['name']] = true; } } $legacyAliasMap = [ '家具安装' => '家具', '卫浴洁具' => '卫浴', '灯具电路' => '灯具', '锁具安防' => '锁具', '窗帘安装' => '窗帘', '墙纸壁画' => '墙纸/墙面', '墙纸/壁画' => '墙纸/墙面', '净水设备' => '净水器', '智能晾衣架' => '晾衣架', '门窗安装' => '门窗', '家电安装' => '家电', '电视投影' => '电视/投影', '淋浴房安装' => '淋浴房', '商用厨电' => '更多安装维修', '健身器材' => '更多安装维修', ]; $selectedNames = []; $customText = ''; if ($masterId > 0) { $mySkills = Db::name('master_skill')->where('master_id', $masterId)->select(); $customArr = []; foreach ($mySkills as $ms) { $sName = trim((string)(!empty($ms['skill_name']) ? $ms['skill_name'] : '')); if ($sName === '') { continue; } if ((int)$ms['is_custom'] === 1 && !isset($dictNameSet[$sName])) { if (!in_array($sName, $customArr, true)) { $customArr[] = $sName; } } elseif (isset($dictNameSet[$sName])) { if (!in_array($sName, $selectedNames, true)) { $selectedNames[] = $sName; } } elseif (isset($legacyAliasMap[$sName]) && isset($dictNameSet[$legacyAliasMap[$sName]])) { $mapped = $legacyAliasMap[$sName]; if (!in_array($mapped, $selectedNames, true)) { $selectedNames[] = $mapped; } } else { if (!in_array($sName, $customArr, true)) { $customArr[] = $sName; } } } $customText = implode(' ', $customArr); } $this->success('ok', [ 'tree' => $tree, 'selected_skills' => $selectedNames, 'custom_text' => $customText, ]); } /** * 保存师傅服务技能与自定义输入技能 * @ApiMethod (POST) */ public function saveSkills() { $master = $this->requireMaster(); $masterId = (int)$master['id']; // 同时兼容 skill_ids(ID 数组/逗号串/JSON串)与 selected_skills(名称数组/逗号串/JSON串) $parseStrList = function ($raw) { if (empty($raw)) { return []; } if (is_string($raw)) { $dec = json_decode(htmlspecialchars_decode(trim($raw)), true); $arr = is_array($dec) ? $dec : explode(',', $raw); } elseif (is_array($raw)) { $arr = $raw; } else { return []; } $out = []; foreach ($arr as $v) { if (is_array($v)) { continue; } $s = trim((string)$v, " \t\n\r\0\x0B\"'[]"); if ($s !== '' && !in_array($s, $out, true)) { $out[] = $s; } } return $out; }; $rawSkillIds = $this->request->post('skill_ids', null, null); $skillIds = []; foreach ($parseStrList($rawSkillIds) as $sidStr) { $sidInt = (int)$sidStr; if ($sidInt > 0 && !in_array($sidInt, $skillIds, true)) { $skillIds[] = $sidInt; } } $selectedSkills = $parseStrList($this->request->post('selected_skills', null, null)); $customText = trim((string)$this->request->post('custom_text', '')); // 若传入了 skill_ids,关联 fa_skill_dict -> fa_skill_category 补全标准名称 $dictById = []; if (!empty($skillIds)) { $idDictRows = Db::name('skill_dict')->alias('d') ->join('skill_category sc', 'sc.id = d.category_id', 'LEFT') ->field('d.id,d.category_id,d.category,d.name,sc.name as std_category_name') ->where('d.id', 'in', $skillIds) ->select(); foreach ($idDictRows as $idr) { $dictById[(int)$idr['id']] = $idr; if (!empty($idr['name']) && !in_array($idr['name'], $selectedSkills, true)) { $selectedSkills[] = (string)$idr['name']; } } } // 1. 处理自定义输入的技能 $customNames = []; if ($customText !== '') { $parts = preg_split('/[,,\s]+/u', $customText); foreach ($parts as $p) { $p = trim($p); if ($p !== '' && !in_array($p, $customNames, true) && !in_array($p, $selectedSkills, true)) { $customNames[] = $p; } } } if (empty($selectedSkills) && empty($customNames)) { $this->error('请至少选择或输入一项服务技能'); } // 读取服务类目表 fa_service_cat 建立多策略匹配(精确匹配 -> 斜杠词根匹配 -> 行业语义映射 -> 原有入驻类目兜底) $catRows = Db::name('service_cat')->field('id,name')->where('status', 'normal')->select(); $catExactMap = []; $catTokens = []; $otherCatId = 0; foreach ($catRows as $cr) { $cid = (int)$cr['id']; $cname = trim((string)$cr['name']); if ($cname === '') { continue; } $catExactMap[$cname] = $cid; if ($cname === '其它' || $cname === '其他') { $otherCatId = $cid; } foreach (preg_split('/[\/、\s]+/u', $cname) as $tk) { $tk = trim($tk); if (mb_strlen($tk, 'UTF-8') >= 2) { $catTokens[$tk] = $cid; } } } $semanticKeywords = [ '墙纸' => '墙纸/壁画', '壁画' => '墙纸/壁画', '墙面' => '墙纸/壁画', '洁具' => '卫浴', '马桶' => '卫浴', '花洒' => '卫浴', '强弱电' => '灯具', '热水器' => '家电', '空调' => '家电', '洗衣机' => '家电', '冰箱' => '家电', '家电清洗' => '家电', '油烟机' => '商用厨电', '厨电' => '商用厨电', '木工' => '家具', '送装' => '家具', '家具养护' => '家具', '铝工' => '门窗', '铝合金' => '门窗', '跑步机' => '健身器材', ]; $resolveCatId = function ($skillName, $skillCategoryName, $fallbackCatId = 0) use ($catExactMap, $catTokens, $semanticKeywords, $otherCatId) { $skillName = trim((string)$skillName); $skillCategoryName = trim((string)$skillCategoryName); if (isset($catExactMap[$skillName])) { return $catExactMap[$skillName]; } if (isset($catExactMap[$skillCategoryName])) { return $catExactMap[$skillCategoryName]; } foreach (preg_split('/[\/、\s()()]+/u', $skillName) as $part) { $part = trim($part); if ($part !== '' && isset($catTokens[$part])) { return $catTokens[$part]; } } foreach ($catTokens as $tk => $cid) { if (mb_strpos($skillName, $tk) !== false || mb_strpos($skillCategoryName, $tk) !== false) { return $cid; } } foreach ($semanticKeywords as $kw => $targetCatName) { if (mb_strpos($skillName, $kw) !== false && isset($catExactMap[$targetCatName])) { return $catExactMap[$targetCatName]; } } if ($fallbackCatId > 0) { return $fallbackCatId; } return $otherCatId > 0 ? $otherCatId : 0; }; // 读取该师傅原有已绑定的有效服务类目(保留入驻时选定的基础 cat_id,杜绝重存后丢失为 0) $oldSkillRows = Db::name('master_skill')->where('master_id', $masterId)->where('cat_id', '>', 0)->select(); $oldCatBySkillName = []; $oldBaseCatRows = []; $primaryFallbackCatId = 0; foreach ($oldSkillRows as $osr) { $ocid = (int)$osr['cat_id']; if ($primaryFallbackCatId === 0 && $ocid > 0) { $primaryFallbackCatId = $ocid; } if (!empty($osr['skill_name'])) { $oldCatBySkillName[(string)$osr['skill_name']] = $ocid; } if ((int)$osr['skill_id'] === 0 && (int)$osr['is_custom'] === 0) { $oldBaseCatRows[$ocid] = $osr; } } $dictMap = []; if (!empty($selectedSkills)) { $dictRows = Db::name('skill_dict')->alias('d') ->join('skill_category sc', 'sc.id = d.category_id', 'LEFT') ->field('d.id,d.category_id,d.category,d.name,sc.name as std_category_name') ->where('d.name', 'in', $selectedSkills) ->select(); foreach ($dictRows as $dr) { $dictMap[$dr['name']] = $dr; } } $now = time(); $coveredCatIds = []; Db::startTrans(); try { Db::name('master_skill')->where('master_id', $masterId)->delete(); foreach ($selectedSkills as $sName) { $dict = isset($dictMap[$sName]) ? $dictMap[$sName] : null; $catName = $dict ? (!empty($dict['std_category_name']) ? (string)$dict['std_category_name'] : (string)$dict['category']) : '服务技能'; $prevCat = isset($oldCatBySkillName[$sName]) ? (int)$oldCatBySkillName[$sName] : $primaryFallbackCatId; $matchedCatId = $resolveCatId($sName, $catName, $prevCat); if ($matchedCatId > 0) { $coveredCatIds[$matchedCatId] = true; } Db::name('master_skill')->insert([ 'master_id' => $masterId, 'cat_id' => $matchedCatId, 'skill_id' => $dict ? (int)$dict['id'] : 0, 'skill_name' => mb_substr($sName, 0, 50), 'category' => mb_substr($catName, 0, 50), 'is_custom' => 0, 'cert_image' => '', 'status' => 'normal', 'createtime' => $now, ]); } foreach ($customNames as $cName) { $exist = Db::name('skill_dict')->where('name', $cName)->find(); if (!$exist) { $skillDictId = (int)Db::name('skill_dict')->insertGetId([ 'category_id' => 7, 'category' => '其他技能', 'name' => mb_substr($cName, 0, 50), 'source' => 'custom', 'master_id' => $masterId, 'weigh' => 0, 'status' => 'normal', 'createtime' => $now, 'updatetime' => $now, ]); } else { $skillDictId = (int)$exist['id']; } $prevCat = isset($oldCatBySkillName[$cName]) ? (int)$oldCatBySkillName[$cName] : $primaryFallbackCatId; $customCatId = $resolveCatId($cName, '其他技能', $prevCat); if ($customCatId > 0) { $coveredCatIds[$customCatId] = true; } Db::name('master_skill')->insert([ 'master_id' => $masterId, 'cat_id' => $customCatId, 'skill_id' => $skillDictId, 'skill_name' => mb_substr($cName, 0, 50), 'category' => '其他技能', 'is_custom' => 1, 'cert_image' => '', 'status' => 'normal', 'createtime' => $now, ]); } // 保留师傅入驻审核时已绑定但未被细分字典覆盖的基础服务大类记录,确保接单大厅类目过滤不丢失 foreach ($oldBaseCatRows as $baseCid => $baseRow) { if (!isset($coveredCatIds[$baseCid])) { Db::name('master_skill')->insert([ 'master_id' => $masterId, 'cat_id' => (int)$baseCid, 'skill_id' => 0, 'skill_name' => (string)$baseRow['skill_name'], 'category' => (string)($baseRow['category'] ?: '安装维修'), 'is_custom' => 0, 'cert_image' => (string)($baseRow['cert_image'] ?? ''), 'status' => 'normal', 'createtime' => $now, ]); } } Db::commit(); } catch (\Exception $e) { Db::rollback(); \think\Log::error('[Master::saveSkills] ' . $e->getMessage()); $this->error('保存技能失败:' . $e->getMessage()); return; } $this->success('服务技能保存成功'); } /** * 技能证书字典与当前师傅上传状态列表 * @ApiMethod (GET) */ public function certs() { $master = $this->masterInfo(); $masterId = $master ? (int)$master['id'] : 0; $dictList = Db::name('cert_dict') ->field('id,cert_code,cert_name,sample_type,need_expire,weigh') ->where('status', 'normal') ->order('weigh desc, id asc') ->select(); $myRecords = []; if ($masterId > 0) { $myRecords = Db::name('master_cert') ->field('id,cert_dict_id,cert_code,cert_name,is_custom,cert_image,expire_date,is_long_term,status,audit_msg,audittime,createtime,updatetime') ->where('master_id', $masterId) ->order('id desc') ->select(); } $stdMap = []; $customItems = []; foreach ($myRecords as $rec) { if ((int)$rec['is_custom'] === 1 || (string)$rec['cert_code'] === 'other') { $customItems[] = $this->formatMasterCertRow($rec); } else { $code = (string)$rec['cert_code']; if (!isset($stdMap[$code])) { $stdMap[$code] = $this->formatMasterCertRow($rec); } } } $items = []; $otherEntry = null; foreach ($dictList as $d) { $code = (string)$d['cert_code']; if ($code === 'other') { $otherEntry = [ 'dict_id' => (int)$d['id'], 'cert_code' => $code, 'cert_name' => (string)$d['cert_name'], 'sample_type' => (string)$d['sample_type'], 'need_expire' => (int)$d['need_expire'], ]; continue; } $uploaded = $stdMap[$code] ?? null; $items[] = [ 'dict_id' => (int)$d['id'], 'cert_code' => $code, 'cert_name' => (string)$d['cert_name'], 'sample_type' => (string)$d['sample_type'], 'need_expire' => (int)$d['need_expire'], 'status' => $uploaded ? $uploaded['status'] : 'none', 'status_text' => $uploaded ? $uploaded['status_text'] : '未上传', 'cert_id' => $uploaded ? $uploaded['id'] : 0, 'cert_image' => $uploaded ? $uploaded['cert_image'] : '', 'expire_date' => $uploaded ? $uploaded['expire_date'] : '', 'is_long_term' => $uploaded ? $uploaded['is_long_term'] : 0, 'audit_msg' => $uploaded ? $uploaded['audit_msg'] : '', 'updatetime' => $uploaded ? $uploaded['updatetime'] : 0, ]; } $this->success('', [ 'notice' => '请上传本人真实的技能证书信息。技能证书审核通过后,才能接商家指定资质要求的订单。', 'is_master_applied' => $master ? 1 : 0, 'verify_status' => $master ? (string)$master['verify_status'] : '', 'items' => $items, 'other_entry' => $otherEntry ?: [ 'dict_id' => 0, 'cert_code' => 'other', 'cert_name' => '其他证书', 'sample_type' => 'qualification', 'need_expire' => 1, ], 'custom_items' => $customItems, 'summary' => $this->buildMasterCertSummary($masterId), ]); } /** * 单个技能证书详情与示例图说明 * @ApiMethod (GET) */ public function certDetail() { $certCode = trim((string)$this->request->get('cert_code', '')); $certId = (int)$this->request->get('cert_id', 0); $master = $this->masterInfo(); $masterId = $master ? (int)$master['id'] : 0; $record = null; if ($certId > 0) { if ($masterId <= 0) { $this->error('请先完成师傅入驻认证', ['need_apply' => 1], 2001); } $row = Db::name('master_cert') ->field('id,master_id,cert_dict_id,cert_code,cert_name,is_custom,cert_image,expire_date,is_long_term,status,audit_msg,audittime,createtime,updatetime') ->where('id', $certId) ->find(); if (!$row || (int)$row['master_id'] !== $masterId) { $this->error('证书记录不存在或无权查看', null, 403); } $record = $this->formatMasterCertRow($row); if ($certCode === '') { $certCode = (string)$row['cert_code']; } } if ($certCode === '') { $this->error('缺少证书类型参数'); } $dict = Db::name('cert_dict') ->field('id,cert_code,cert_name,sample_type,need_expire') ->where('cert_code', $certCode) ->where('status', 'normal') ->find(); if (!$dict) { $this->error('不支持的证书类型'); } if ($record === null && $masterId > 0 && $certCode !== 'other') { $row = Db::name('master_cert') ->field('id,master_id,cert_dict_id,cert_code,cert_name,is_custom,cert_image,expire_date,is_long_term,status,audit_msg,audittime,createtime,updatetime') ->where('master_id', $masterId) ->where('cert_code', $certCode) ->order('id desc') ->find(); if ($row) { $record = $this->formatMasterCertRow($row); } } $this->success('', [ 'dict' => [ 'id' => (int)$dict['id'], 'cert_code' => (string)$dict['cert_code'], 'cert_name' => (string)$dict['cert_name'], 'sample_type' => (string)$dict['sample_type'], 'need_expire' => (int)$dict['need_expire'], 'is_other' => $dict['cert_code'] === 'other' ? 1 : 0, ], 'record' => $record, 'sample_tip' => '下图仅用于示例展示,证件有效期、签发机关(审核机构)以您的真实证件为准。', 'sample_images' => $this->resolveCertSamples((string)$dict['sample_type'], (string)$dict['cert_name']), ]); } /** * 提交或重新上传技能证书 * @ApiMethod (POST) */ public function saveCert() { $master = $this->masterInfo(); if (!$master) { $this->error('请先完成师傅入驻认证', ['need_apply' => 1], 2001); } if ((string)$master['verify_status'] === 'reject') { $this->error('您的实名认证未通过,请先修改实名资料', ['verify_status' => 'reject'], 2003); } $masterId = (int)$master['id']; $certCode = trim((string)$this->request->post('cert_code', '')); $certId = (int)$this->request->post('cert_id', 0); $customName = trim((string)$this->request->post('custom_name', '')); $certImage = trim((string)$this->request->post('cert_image', '')); $expireDate = trim((string)$this->request->post('expire_date', '')); $isLongTerm = (int)$this->request->post('is_long_term', 0) === 1 ? 1 : 0; if ($certCode === '') { $this->error('请选择技能证书类型'); } $dict = Db::name('cert_dict') ->field('id,cert_code,cert_name,need_expire') ->where('cert_code', $certCode) ->where('status', 'normal') ->find(); if (!$dict) { $this->error('技能证书类型不存在或已停用'); } $isCustom = ($certCode === 'other') ? 1 : 0; if ($isCustom === 1) { if ($customName === '') { $this->error('请填写自定义证书名称'); } $customName = preg_replace('/[<>"\'`]/u', '', $customName); $nameLen = mb_strlen($customName, 'UTF-8'); if ($nameLen < 2 || $nameLen > 40) { $this->error('证书名称长度需在 2 至 40 个字符之间'); } $finalCertName = $customName; } else { $finalCertName = (string)$dict['cert_name']; } if ($certImage === '' || mb_strlen($certImage, 'UTF-8') > 255) { $this->error('请上传有效的证书照片'); } $isValidImageUrl = (strpos($certImage, '..') === false) && ((bool)preg_match('#^/[a-zA-Z0-9_-]+/[^\s<>"\']+$#', $certImage) || (bool)preg_match('#^https?://[^\s<>"\']+$#i', $certImage)); if (!$isValidImageUrl) { $this->error('证书照片地址格式无效,请重新上传'); } $finalExpireDate = null; if ($isLongTerm === 0) { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $expireDate, $m)) { $this->error('请选择证件有效期截止日期'); } if (!checkdate((int)$m[2], (int)$m[3], (int)$m[1])) { $this->error('证件有效期日期格式不正确'); } if ($expireDate < date('Y-m-d')) { $this->error('该证书已过有效期限,请上传有效期内的证件'); } $finalExpireDate = $expireDate; } $existingRow = null; if ($isCustom === 1) { if ($certId > 0) { $existingRow = Db::name('master_cert') ->field('id,master_id,is_custom') ->where('id', $certId) ->find(); if (!$existingRow || (int)$existingRow['master_id'] !== $masterId || (int)$existingRow['is_custom'] !== 1) { $this->error('无权修改该证书记录', null, 403); } } else { $existingRow = Db::name('master_cert') ->field('id,master_id') ->where('master_id', $masterId) ->where('is_custom', 1) ->where('cert_name', $finalCertName) ->find(); } } else { $existingRow = Db::name('master_cert') ->field('id,master_id') ->where('master_id', $masterId) ->where('cert_code', $certCode) ->where('is_custom', 0) ->order('id desc') ->find(); } $now = time(); $saveData = [ 'master_id' => $masterId, 'cert_dict_id' => (int)$dict['id'], 'cert_code' => $certCode, 'cert_name' => $finalCertName, 'is_custom' => $isCustom, 'cert_image' => $certImage, 'expire_date' => $finalExpireDate, 'is_long_term' => $isLongTerm, 'status' => 'pending', 'audit_msg' => '', 'audit_admin_id' => 0, 'audittime' => null, 'updatetime' => $now, ]; Db::startTrans(); try { if ($existingRow) { $targetId = (int)$existingRow['id']; Db::name('master_cert') ->where('id', $targetId) ->where('master_id', $masterId) ->update($saveData); } else { $saveData['createtime'] = $now; $targetId = (int)Db::name('master_cert')->insertGetId($saveData); } $this->syncMasterCertImagesColumn($masterId); Db::commit(); } catch (\Exception $e) { Db::rollback(); \think\Log::error('[Master::saveCert] ' . $e->getMessage()); $this->error('提交证书失败,请稍后重试'); } $savedRow = Db::name('master_cert') ->field('id,cert_dict_id,cert_code,cert_name,is_custom,cert_image,expire_date,is_long_term,status,audit_msg,audittime,createtime,updatetime') ->where('id', $targetId) ->find(); $this->success('提交成功,已进入审核队列', [ 'record' => $savedRow ? $this->formatMasterCertRow($savedRow) : null, 'summary' => $this->buildMasterCertSummary($masterId), ]); } /** * 删除本人上传的技能证书记录 * @ApiMethod (POST) */ public function deleteCert() { $master = $this->masterInfo(); if (!$master) { $this->error('请先完成师傅入驻认证', ['need_apply' => 1], 2001); } $masterId = (int)$master['id']; $certId = (int)$this->request->post('cert_id', 0); if ($certId <= 0) { $this->error('缺少证书参数'); } $row = Db::name('master_cert') ->field('id,master_id') ->where('id', $certId) ->find(); if (!$row || (int)$row['master_id'] !== $masterId) { $this->error('证书记录不存在或无权操作', null, 403); } Db::startTrans(); try { Db::name('master_cert') ->where('id', $certId) ->where('master_id', $masterId) ->delete(); $this->syncMasterCertImagesColumn($masterId); Db::commit(); } catch (\Exception $e) { Db::rollback(); \think\Log::error('[Master::deleteCert] ' . $e->getMessage()); $this->error('删除失败,请稍后重试'); } $this->success('已删除该证书记录', [ 'summary' => $this->buildMasterCertSummary($masterId), ]); } /** * 统计师傅技能证书各状态数量(供工作台与列表共用) */ private function buildMasterCertSummary($masterId) { $summary = [ 'total' => 0, 'passed' => 0, 'pending' => 0, 'rejected' => 0, ]; $masterId = (int)$masterId; if ($masterId <= 0) { return $summary; } $rows = Db::name('master_cert') ->field('status, COUNT(*) as cnt') ->where('master_id', $masterId) ->group('status') ->select(); foreach ($rows as $r) { $st = (string)($r['status'] ?? ''); $cnt = (int)($r['cnt'] ?? 0); $summary['total'] += $cnt; if ($st === 'pass') { $summary['passed'] += $cnt; } elseif ($st === 'pending') { $summary['pending'] += $cnt; } elseif ($st === 'reject') { $summary['rejected'] += $cnt; } } return $summary; } /** * 同步刷新 fa_master.cert_images 兼容字段 */ private function syncMasterCertImagesColumn($masterId) { $images = Db::name('master_cert') ->where('master_id', (int)$masterId) ->where('status', 'in', ['pass', 'pending']) ->order('id desc') ->column('cert_image'); $cleanImages = array_values(array_unique(array_filter(array_map('strval', $images ?: [])))); $json = !empty($cleanImages) ? json_encode($cleanImages, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : ''; Db::name('master')->where('id', (int)$masterId)->update([ 'cert_images' => mb_substr($json, 0, 1000), 'updatetime' => time(), ]); } /** * 统一格式化单条技能证书输出结构 */ private function formatMasterCertRow(array $row) { $statusMap = [ 'pending' => '审核中', 'pass' => '已通过', 'reject' => '已驳回', ]; $st = (string)($row['status'] ?? 'pending'); $img = (string)($row['cert_image'] ?? ''); return [ 'id' => (int)$row['id'], 'cert_dict_id' => (int)($row['cert_dict_id'] ?? 0), 'cert_code' => (string)($row['cert_code'] ?? ''), 'cert_name' => (string)($row['cert_name'] ?? ''), 'is_custom' => (int)($row['is_custom'] ?? 0), 'cert_image' => $img, 'cert_image_full' => $img !== '' ? cdnurl($img, true) : '', 'expire_date' => !empty($row['expire_date']) ? (string)$row['expire_date'] : '', 'is_long_term' => (int)($row['is_long_term'] ?? 0), 'status' => $st, 'status_text' => $statusMap[$st] ?? '未上传', 'audit_msg' => (string)($row['audit_msg'] ?? ''), 'audittime' => (int)($row['audittime'] ?? 0), 'createtime' => (int)($row['createtime'] ?? 0), 'updatetime' => (int)($row['updatetime'] ?? 0), ]; } /** * 按证书类型返回对应的规范示例图资源路径 */ private function resolveCertSamples($sampleType, $certName = '') { switch ($sampleType) { case 'driver': return [ [ 'title' => '驾驶证主页示例', 'url' => '/static/images/master/cert-sample-driver.png', ], ]; case 'health': return [ [ 'title' => '健康证明示例', 'url' => '/static/images/master/cert-sample-health.png', ], ]; case 'qualification': return [ [ 'title' => ($certName ?: '行业资格') . '证书示例', 'url' => '/static/images/master/cert-sample-qualification.png', ], ]; case 'special_card': default: return [ [ 'title' => '电子特种作业操作证示例', 'url' => '/static/images/master/cert-sample-special-card.png', ], [ 'title' => '纸质特种作业资格证示例', 'url' => '/static/images/master/cert-sample-qualification.png', ], ]; } } }