field('id,name,icon') ->where('status', 'normal') ->order('weigh desc,id asc') ->select(); $cats = $this->formatCats($rawCats); // 热门服务多维推荐计算: // 维度1: 真实完工订单量 (fa_order status=8) // 维度2: 基础销量 sales // 维度3: 明码标价一口价优先 (fixed) // 维度4: 品类均衡覆盖 (避免单一类目倾斜,各高频类目精选优质项目) // 维度5: 后台置顶权重 weigh $orderCounts = Db::name('order') ->where('status', 8) ->group('service_id') ->column('count(*)', 'service_id'); $candidateServices = Db::name('service') ->alias('s') ->join('service_cat c', 's.cat_id = c.id', 'LEFT') ->field('s.id,s.cat_id,s.name,s.price_type,s.base_price,s.max_price,s.unit,s.image,s.sales,s.weigh,c.name as cat_name,c.icon as cat_icon') ->where('s.status', 'normal') ->where('s.cat_id', '<>', 15) // 排除“其它”类目 ->select(); // 综合打分 foreach ($candidateServices as &$cs) { $sid = (int)$cs['id']; $realOrders = isset($orderCounts[$sid]) ? (int)$orderCounts[$sid] : 0; $cs['real_orders'] = $realOrders; $cs['score'] = ($realOrders * 15) + ((int)$cs['sales'] * 2) + ($cs['price_type'] === 'fixed' ? 25 : 0) + (int)$cs['weigh']; // 推荐标签 if ($realOrders > 0) { $cs['tag'] = '热销爆款'; } elseif ($cs['price_type'] === 'fixed') { $cs['tag'] = '明码标价'; } else { $cs['tag'] = '好评优选'; } } unset($cs); // 按得分降序排列 usort($candidateServices, function($a, $b) { return $b['score'] <=> $a['score']; }); // 品类均衡采样:每个类目优先入选 1 个,最多 2 个,输出 6~8 项 $hotList = []; $catSeen = []; // 第一轮:每个类目选最热 1 项 foreach ($candidateServices as $cs) { $cid = (int)$cs['cat_id']; if (!isset($catSeen[$cid])) { $hotList[] = $cs; $catSeen[$cid] = 1; } if (count($hotList) >= 6) break; } // 若不足 6 项,允许热门类目补充第 2 项 if (count($hotList) < 6) { foreach ($candidateServices as $cs) { if (!in_array($cs['id'], array_column($hotList, 'id'), true)) { $hotList[] = $cs; } if (count($hotList) >= 6) break; } } // 组装大类 -> 具体服务项目二级联动映射表 $catServices = []; foreach ($candidateServices as $csItem) { $cidKey = (int)$csItem['cat_id']; if (!isset($catServices[$cidKey])) { $catServices[$cidKey] = []; } $catServices[$cidKey][] = [ 'id' => (int)$csItem['id'], 'cat_id' => $cidKey, 'name' => (string)$csItem['name'], 'base_price' => (float)$csItem['base_price'], 'unit' => (string)($csItem['unit'] ?: '件'), ]; } // 提取真实晒单覆盖区域(直接取自 fa_showcase 表,确保每个区域筛选均有真实晒单数据) $showcaseRegions = Db::name('showcase') ->where('status', 'normal') ->where('region', '<>', '') ->group('region') ->column('region'); $regions = array_values(array_unique(array_filter($showcaseRegions ?: []))); // 提取晒单表真实覆盖的服务项目(用于【服务动态】下“项目 ▼”联动过滤) $showcaseServiceRows = Db::name('showcase') ->field('service_id,service_name,cat_id,cat_name') ->where('status', 'normal') ->where('service_id', '>', 0) ->group('service_id,service_name,cat_id,cat_name') ->select(); $showcaseServices = []; foreach ($showcaseServiceRows as $ssr) { $showcaseServices[] = [ 'id' => (int)$ssr['service_id'], 'cat_id' => (int)$ssr['cat_id'], 'name' => (string)$ssr['service_name'], 'cat_name' => (string)$ssr['cat_name'], ]; } // 提取工项字典表 fa_service_type 真实启用工项(用于【热门推荐】下“工项 ▼”过滤) $rawTypeDict = Db::name('service_type') ->field('code,name') ->where('status', 'normal') ->where('code', '<>', 'other') ->order('weigh desc, id asc') ->select(); $serviceTypesDict = []; foreach ($rawTypeDict as $rtd) { $code = (string)$rtd['code']; if ($code === 'deliver_downstairs') { continue; } $label = (string)$rtd['name']; if ($code === 'deliver_install') { $label = '送货并安装'; } $serviceTypesDict[] = [ 'code' => $code, 'name' => $label, ]; } // 聚合在售服务的真实计价单位列表(彻底由数据库驱动,按商品数量降序) $unitRows = Db::name('service') ->field('unit, COUNT(*) AS cnt, MIN(id) AS min_id') ->where('status', 'normal') ->where('cat_id', '<>', 15) ->where('unit', '<>', '') ->group('unit') ->order('cnt desc, min_id asc') ->select(); $filterUnits = []; foreach ($unitRows as $ur) { $uName = trim((string)($ur['unit'] ?? '')); if ($uName !== '') { $filterUnits[] = [ 'label' => '按' . $uName . '计价', 'value' => $uName, ]; } } // 从 fa_config 读取价格筛选项 JSON 与晒单评价标签配置 $priceRangesRaw = Db::name('config')->where('name', 'home_price_ranges')->value('value'); if ($priceRangesRaw === null || trim((string)$priceRangesRaw) === '') { $priceRangesRaw = \think\Config::get('site.home_price_ranges'); } $filterPriceRanges = []; if (is_string($priceRangesRaw) && trim($priceRangesRaw) !== '') { $decodedPr = json_decode(htmlspecialchars_decode(trim($priceRangesRaw)), true); if (is_array($decodedPr)) { foreach ($decodedPr as $prItem) { if (is_array($prItem) && !empty($prItem['label']) && !empty($prItem['value'])) { $filterPriceRanges[] = [ 'label' => (string)$prItem['label'], 'value' => (string)$prItem['value'], ]; } } } } $tagCfg = $this->configList('review_tags'); $filterCaseTags = []; foreach ($tagCfg as $tc) { $filterCaseTags[] = [ 'label' => $tc, 'value' => $tc, ]; } $data = [ 'cats' => $cats, 'cat_services' => $catServices, 'showcase_services' => $showcaseServices, 'service_types_dict' => $serviceTypesDict, 'regions' => $regions, 'filter_options' => [ 'units' => $filterUnits, 'price_ranges' => $filterPriceRanges, 'case_tags' => $filterCaseTags, ], 'hot' => $this->formatServices($hotList), 'banners' => $this->banners(), 'assures' => $this->configList('home_assure'), 'tutorial' => $this->tutorial(), 'service_phone' => (string)(\think\Config::get('site.service_phone') ?: ''), 'badge' => ['coupon' => 0, 'unread' => 0], ]; if ($this->auth->isLogin()) { $uid = $this->uid(); $data['badge']['coupon'] = (int)Db::name('user_coupon')->where('user_id', $uid)->where('status', 'unused')->count(); $data['badge']['unread'] = (int)Db::name('message')->where('receive_type', 'user')->where('receive_id', $uid)->where('is_read', 0)->count(); } // 全平台用户主动晒单动态流(直接查询独立晒单表 fa_showcase,物理隔离订单隐私数据) $showcaseRows = Db::name('showcase') ->field('id,cat_id,cat_name,service_id,service_name,region,cover_image,user_name,score,tags,content,createtime') ->where('status', 'normal') ->order('createtime desc, id desc') ->limit(10) ->select(); $recentOrders = []; foreach ($showcaseRows as $sc) { $revTags = []; if (!empty($sc['tags'])) { $decTags = json_decode((string)$sc['tags'], true); $revTags = is_array($decTags) ? $decTags : array_values(array_filter(explode(',', (string)$sc['tags']))); } $recentOrders[] = [ 'id' => (int)$sc['id'], 'service_id' => (int)$sc['service_id'], 'service_name' => (string)$sc['service_name'], 'cat_name' => (string)$sc['cat_name'], 'cat_id' => (int)$sc['cat_id'], 'image_url' => $sc['cover_image'] ? cdnurl($sc['cover_image'], true) : '', 'status_text' => '业主晒单', 'location' => (string)$sc['region'], 'time_ago' => $this->timeAgo((int)$sc['createtime']), 'user_name' => (string)$sc['user_name'], 'review_score' => (int)$sc['score'], 'review_content' => (string)$sc['content'], 'review_tags' => array_slice($revTags, 0, 2), ]; } $data['recent_orders'] = $recentOrders; $this->success('', $data); } /** * 首页下方列表区域:分页、二级项目筛选、区域过滤与多维排序接口(支持【热门推荐】与【服务动态】双 Tab) */ public function feed() { $tab = trim((string)$this->request->param('tab', 'hot')); $catId = (int)$this->request->param('cat_id', 0); $serviceId = (int)$this->request->param('service_id', 0); $serviceType = trim((string)$this->request->param('service_type', '')); $region = trim((string)$this->request->param('region', '')); $sort = trim((string)$this->request->param('sort', 'default')); $priceRange = trim((string)$this->request->param('price_range', 'all')); $minPrice = $this->request->param('min_price', ''); $maxPrice = $this->request->param('max_price', ''); $unitFilter = trim((string)$this->request->param('unit', '')); $priceTypeFilter = trim((string)$this->request->param('price_type', '')); $tag = trim((string)$this->request->param('tag', '')); $onlyFixed = (int)$this->request->param('only_fixed', 0); $hasImage = (int)$this->request->param('has_image', 0); $page = max(1, (int)$this->request->param('page', 1)); $limit = min(20, max(1, (int)$this->request->param('limit', 6))); if ($tab === 'cases') { $buildQuery = function () use ($catId, $serviceId, $region, $tag, $hasImage, $priceRange, $minPrice, $maxPrice) { $q = Db::name('showcase') ->alias('sc') ->join('service s', 'sc.service_id = s.id', 'LEFT') ->field('sc.id,sc.cat_id,sc.cat_name,sc.service_id,sc.service_name,sc.region,sc.cover_image,sc.images,sc.user_name,sc.score,sc.tags,sc.content,sc.master_score,sc.createtime,s.base_price,s.sales') ->where('sc.status', 'normal'); if ($catId > 0) { $q->where('sc.cat_id', $catId); } if ($serviceId > 0) { $q->where('sc.service_id', $serviceId); } if ($region !== '' && $region !== 'all') { $q->where('sc.region', $region); } if ($tag !== '' && $tag !== 'all') { $q->where('sc.tags', 'like', '%' . $tag . '%'); } if ($hasImage === 1) { $q->where('sc.images', '<>', '')->where('sc.images', '<>', '[]'); } if ($minPrice !== '' && is_numeric($minPrice)) { $q->where('s.base_price', '>=', (float)$minPrice); } if ($maxPrice !== '' && is_numeric($maxPrice)) { $q->where('s.base_price', '<=', (float)$maxPrice); } if ($priceRange === 'under_80') { $q->where('s.base_price', '<', 80); } elseif ($priceRange === '80_120') { $q->where('s.base_price', '>=', 80)->where('s.base_price', '<=', 120); } elseif ($priceRange === '120_up') { $q->where('s.base_price', '>', 120); } elseif ($priceRange === '0_80') { $q->where('s.base_price', '<=', 80); } elseif ($priceRange === '80_150') { $q->where('s.base_price', '>', 80)->where('s.base_price', '<=', 150); } elseif ($priceRange === '150_up') { $q->where('s.base_price', '>', 150); } return $q; }; $total = (int)$buildQuery()->count(); $q = $buildQuery(); if ($sort === 'sales') { $q->order('s.sales desc, sc.createtime desc, sc.id desc'); } elseif ($sort === 'price_asc') { $q->order('s.base_price asc, sc.createtime desc, sc.id desc'); } elseif ($sort === 'price_desc') { $q->order('s.base_price desc, sc.createtime desc, sc.id desc'); } elseif ($sort === 'score') { $q->order('sc.score desc, sc.master_score desc, sc.createtime desc, sc.id desc'); } else { $q->order('sc.createtime desc, sc.id desc'); } $rows = $q->page($page, $limit)->select(); $list = []; foreach ($rows as $sc) { $revTags = []; if (!empty($sc['tags'])) { $decTags = json_decode((string)$sc['tags'], true); $revTags = is_array($decTags) ? $decTags : array_values(array_filter(explode(',', (string)$sc['tags']))); } $list[] = [ 'id' => (int)$sc['id'], 'service_id' => (int)$sc['service_id'], 'service_name' => (string)$sc['service_name'], 'cat_name' => (string)$sc['cat_name'], 'cat_id' => (int)$sc['cat_id'], 'image_url' => $sc['cover_image'] ? cdnurl($sc['cover_image'], true) : '', 'status_text' => '业主晒单', 'location' => (string)$sc['region'], 'time_ago' => $this->timeAgo((int)$sc['createtime']), 'user_name' => (string)$sc['user_name'], 'review_score' => (int)$sc['score'], 'review_content' => (string)$sc['content'], 'review_tags' => array_slice($revTags, 0, 3), ]; } $this->success('', [ 'list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit, 'has_more' => ($page * $limit) < $total, ]); } // tab === 'hot'(热门服务推荐分页 + 二级项目筛选 + 区域热度加权 + 排序) $orderQuery = Db::name('order')->where('status', 8); if ($region !== '' && $region !== 'all') { $orderQuery->where('district', $region); } $orderCounts = $orderQuery ->group('service_id') ->column('count(*)', 'service_id'); $query = Db::name('service') ->alias('s') ->join('service_cat c', 's.cat_id = c.id', 'LEFT') ->field('s.id,s.cat_id,s.name,s.price_type,s.service_types,s.base_price,s.max_price,s.unit,s.image,s.sales,s.weigh,c.name as cat_name,c.icon as cat_icon') ->where('s.status', 'normal') ->where('s.cat_id', '<>', 15); $candidateServices = $query->select(); // 计算真实订单与综合得分 foreach ($candidateServices as &$cs) { $sid = (int)$cs['id']; $realOrders = isset($orderCounts[$sid]) ? (int)$orderCounts[$sid] : 0; $cs['real_orders'] = $realOrders; $cs['total_sales'] = ($realOrders * 5) + (int)$cs['sales']; $cs['score'] = ($realOrders * 15) + ((int)$cs['sales'] * 2) + ($cs['price_type'] === 'fixed' ? 25 : 0) + (int)$cs['weigh']; if ($realOrders > 0) { $cs['tag'] = '热销爆款'; } elseif ($cs['price_type'] === 'fixed') { $cs['tag'] = '明码标价'; } else { $cs['tag'] = '好评优选'; } } unset($cs); // 首页【热门推荐】核心定位:精选各品类 Top 高频爆款(每类精选 Top 1~2 项,剔除低频长尾项,保证热门池精炼且筛选子集严格收敛) usort($candidateServices, function ($a, $b) { return $b['score'] <=> $a['score']; }); $curatedPool = []; $catQuota = []; foreach ($candidateServices as $csItem) { $cid = (int)$csItem['cat_id']; $salesVal = (int)$csItem['sales']; if ($salesVal < 145 && (int)$csItem['real_orders'] === 0) { continue; } $curCount = isset($catQuota[$cid]) ? $catQuota[$cid] : 0; if ($curCount < 2) { $curatedPool[] = $csItem; $catQuota[$cid] = $curCount + 1; } } $candidateServices = $curatedPool; // 1. 类目与指定商品过滤 if ($catId > 0) { $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($catId) { return (int)$r['cat_id'] === $catId; })); } if ($serviceId > 0) { $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($serviceId) { return (int)$r['id'] === $serviceId; })); } // 2. 计价单位与计价类型过滤 if ($unitFilter !== '' && $unitFilter !== 'all') { $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($unitFilter) { return (string)$r['unit'] === $unitFilter; })); } if ($priceTypeFilter !== '' && $priceTypeFilter !== 'all') { $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($priceTypeFilter) { return (string)$r['price_type'] === $priceTypeFilter; })); } if ($onlyFixed === 1) { $candidateServices = array_values(array_filter($candidateServices, function ($r) { return (string)$r['price_type'] === 'fixed'; })); } // 工项筛选与工项真实价格/名称联动 if ($serviceType !== '' && $serviceType !== 'all') { $matchedServices = []; foreach ($candidateServices as $csRow) { $types = []; if (!empty($csRow['service_types'])) { $dec = is_array($csRow['service_types']) ? $csRow['service_types'] : json_decode((string)$csRow['service_types'], true); if (is_array($dec)) { $types = $dec; } } foreach ($types as $st) { $code = isset($st['code']) ? (string)$st['code'] : ''; if ($code === $serviceType) { if (!empty($st['price']) && (float)$st['price'] > 0) { $csRow['base_price'] = (float)$st['price']; } if ($code !== 'install' && !empty($st['name'])) { $shortTypeName = (string)$st['name']; if ($code === 'deliver_install') { $shortTypeName = '送装'; } elseif ($code === 'clean') { $shortTypeName = '保洁'; } $csRow['name'] = $csRow['name'] . ' · ' . $shortTypeName; $csRow['tag'] = $shortTypeName . '专项'; } $matchedServices[] = $csRow; break; } } } $candidateServices = $matchedServices; } // 价格区间过滤(基于当前生效的 base_price 精准过滤) if ($minPrice !== '' && is_numeric($minPrice)) { $minVal = (float)$minPrice; $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($minVal) { return (float)$r['base_price'] >= $minVal; })); } if ($maxPrice !== '' && is_numeric($maxPrice)) { $maxVal = (float)$maxPrice; $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($maxVal) { return (float)$r['base_price'] <= $maxVal; })); } if ($priceRange !== '' && $priceRange !== 'all') { $candidateServices = array_values(array_filter($candidateServices, function ($r) use ($priceRange) { $bp = (float)$r['base_price']; if ($priceRange === 'under_80') return $bp < 80; if ($priceRange === '80_120') return $bp >= 80 && $bp <= 120; if ($priceRange === '120_up') return $bp > 120; if ($priceRange === '0_80') return $bp <= 80; if ($priceRange === '80_150') return $bp > 80 && $bp <= 150; if ($priceRange === '150_up') return $bp > 150; if ($priceRange === '0_50') return $bp <= 50; if ($priceRange === '50_100') return $bp > 50 && $bp <= 100; if ($priceRange === '100_up') return $bp > 100; return true; })); } if ($sort === 'sales') { usort($candidateServices, function ($a, $b) { if ($b['total_sales'] !== $a['total_sales']) { return $b['total_sales'] <=> $a['total_sales']; } return $b['score'] <=> $a['score']; }); } elseif ($sort === 'price_asc') { usort($candidateServices, function ($a, $b) { if ((float)$a['base_price'] != (float)$b['base_price']) { return (float)$a['base_price'] <=> (float)$b['base_price']; } return $b['score'] <=> $a['score']; }); } elseif ($sort === 'price_desc') { usort($candidateServices, function ($a, $b) { if ((float)$b['base_price'] != (float)$a['base_price']) { return (float)$b['base_price'] <=> (float)$a['base_price']; } return $b['score'] <=> $a['score']; }); } else { // 默认综合推荐:先按得分排序,若为全部分类则优先保证首屏品类均衡 usort($candidateServices, function ($a, $b) { return $b['score'] <=> $a['score']; }); if ($catId === 0) { $balanced = []; $catSeen = []; $usedIds = []; foreach ($candidateServices as $cs) { $cid = (int)$cs['cat_id']; if (!isset($catSeen[$cid])) { $balanced[] = $cs; $catSeen[$cid] = 1; $usedIds[$cs['id']] = true; } } foreach ($candidateServices as $cs) { if (!isset($usedIds[$cs['id']])) { $balanced[] = $cs; } } $candidateServices = $balanced; } } $total = count($candidateServices); $offset = ($page - 1) * $limit; $pagedSlice = array_slice($candidateServices, $offset, $limit); $this->success('', [ 'list' => $this->formatServices($pagedSlice), 'total' => $total, 'page' => $page, 'limit' => $limit, 'has_more' => ($page * $limit) < $total, ]); } /** * 全部类目 */ public function cats() { $rawCats = Db::name('service_cat') ->field('id,name,icon') ->where('status', 'normal') ->order('weigh desc,id asc') ->select(); $cats = $this->formatCats($rawCats); foreach ($cats as &$c) { $icon = (string)$c['icon']; if ($icon !== '') { if (strpos($icon, 'http') === 0) { $c['icon_url'] = $icon; } elseif (strpos($icon, '/') !== false || strpos($icon, '.') !== false) { $c['icon_url'] = cdnurl($icon, true); } else { $c['icon_url'] = ''; } } else { $c['icon_url'] = ''; } } unset($c); $this->success('', ['list' => $cats]); } /** * 类目下的服务列表 */ public function services() { $catId = (int)$this->request->param('cat_id'); $keyword = trim((string)$this->request->param('keyword')); $query = Db::name('service')->where('status', 'normal'); if ($catId > 0) { $query->where('cat_id', $catId); } if ($keyword !== '') { $specMatchingIds = Db::name('service_spec') ->where('status', 'normal') ->where('name|description', 'like', '%' . $keyword . '%') ->column('service_id'); $query->where(function($q) use ($keyword, $specMatchingIds) { $q->where('name', 'like', '%' . $keyword . '%'); if (!empty($specMatchingIds)) { $q->whereOr('id', 'in', $specMatchingIds); } }); } $list = $query->order('weigh desc,sales desc,id asc')->select(); $this->success('', ['list' => $this->formatServices($list)]); } /** * 服务商品规格列表 */ public function specs() { $serviceId = (int)$this->request->param('service_id'); if ($serviceId <= 0) { $this->error('请指定服务商品'); } $specs = Db::name('service_spec') ->where('service_id', $serviceId) ->where('status', 'normal') ->order('weigh desc, id asc') ->select(); $ret = []; foreach ($specs as $sp) { $spImg = isset($sp['image']) ? trim((string)$sp['image']) : ''; $ret[] = [ 'id' => (int)$sp['id'], 'service_id' => (int)$sp['service_id'], 'name' => $sp['name'], 'image_url' => $spImg !== '' ? (strpos($spImg, 'http') === 0 ? $spImg : cdnurl($spImg, true)) : '', 'description' => (string)$sp['description'], 'base_price' => (float)$sp['base_price'], ]; } $this->success('', ['list' => $ret]); } /** * 服务详情 */ public function detail() { $id = (int)$this->request->param('id'); $service = Db::name('service')->where('id', $id)->where('status', 'normal')->find(); if (!$service) { $this->error('服务不存在'); } $cat = Db::name('service_cat')->where('id', $service['cat_id'])->find(); $service['cat_name'] = $cat ? $cat['name'] : ''; $this->success('', ['detail' => $this->formatService($service)]); } /** * 区域师傅运力统计 */ public function masterCount() { $province = trim((string)$this->request->param('province', '')); $city = trim((string)$this->request->param('city', '')); $district = trim((string)$this->request->param('district', '')); $platformCount = (int)Db::name('master')->where('verify_status', 'pass')->count(); $total = $platformCount; $matchedLocal = false; if ($city !== '') { $shortCity = mb_strlen($city, 'UTF-8') > 2 ? preg_replace('/市$/u', '', $city) : $city; $cityCount = (int)Db::name('master') ->where('verify_status', 'pass') ->where('city', 'like', '%' . $shortCity . '%') ->count(); if ($cityCount > 0) { $total = $cityCount; $matchedLocal = true; } } $this->success('', [ 'count' => (int)$total, 'platform_count' => $platformCount, 'region' => trim($city . ' ' . $district), 'tip' => $matchedLocal ? "该地区可提供服务的师傅共 {$total} 位" : "平台认证可服务师傅共 {$total} 位", ]); } /** * 费用试算(基础价 + 楼层费 + 加急费 - 优惠) * 支持下单页实时切换:一口价 / 师傅报价 / 用户自主定价 */ public function calc() { $serviceId = (int)$this->request->param('service_id'); $goodsListRaw = $this->request->param('goods_list', $this->request->param('items', '', null), null); $goodsList = []; if (!empty($goodsListRaw)) { if (is_string($goodsListRaw)) { $rawStr = htmlspecialchars_decode($goodsListRaw); $decoded = json_decode($rawStr, true); if (is_array($decoded)) { $goodsList = $decoded; } } elseif (is_array($goodsListRaw)) { if (isset($goodsListRaw[0]) && is_string($goodsListRaw[0])) { $rawStr = htmlspecialchars_decode($goodsListRaw[0]); $decoded = json_decode($rawStr, true); $goodsList = is_array($decoded) ? $decoded : $goodsListRaw; } else { $goodsList = $goodsListRaw; } } } if ($serviceId <= 0 && !empty($goodsList) && is_array($goodsList)) { $first = reset($goodsList); if (is_array($first) && !empty($first['service_id'])) { $serviceId = (int)$first['service_id']; } } $service = null; if ($serviceId > 0) { $service = Db::name('service')->where('id', $serviceId)->where('status', 'normal')->find(); } try { $result = self::calcAmount($service, [ 'floor' => (int)$this->request->param('floor', 1), 'has_elevator' => (int)$this->request->param('has_elevator', 1), 'urgent' => (int)$this->request->param('urgent', 0), 'goods_num' => max(1, (int)$this->request->param('goods_num', $this->request->param('num', 1))), 'spec_id' => (int)$this->request->param('spec_id', 0), 'service_type' => trim((string)$this->request->param('service_type', '')), 'is_custom' => (int)$this->request->param('is_custom', 0), 'base_price' => $this->request->param('base_price', null), ], (int)$this->request->param('coupon_id', 0), $this->uid(), (string)$this->request->param('price_type', ''), (float)$this->request->param('user_price', 0), $goodsList); } catch (\InvalidArgumentException $e) { $this->error($e->getMessage()); return; } $this->success('', $result); } /** * 金额计算(下单与试算共用,保证前后端口径一致) * * @param array $service 服务行(可选) * @param array $params floor / has_elevator / urgent / goods_num / spec_id / service_type / is_custom / strict * @param int $couponId 用户优惠券 id * @param int $uid 用户 id * @param string $priceType 计价方式:fixed 一口价 / quote 师傅报价 / custom 用户自主定价 * @param float $userPrice 用户自主定价金额(总价,含楼层/加急) * @param array $goodsList 多组商品组合(含商品规格与增项拆旧等) */ public static function calcAmount($service, $params, $couponId = 0, $uid = 0, $priceType = '', $userPrice = 0, $goodsList = []) { $allowTypes = ['fixed', 'quote', 'range', 'custom']; $type = in_array($priceType, $allowTypes, true) ? $priceType : ($service ? $service['price_type'] : 'fixed'); $isFixedLike = in_array($type, ['fixed', 'range'], true); $isStrict = !empty($params['strict']); // 支持多商品组合与增项计算(一口价模式下强制从 fa_service 与 fa_service_spec 读取真实价格,严禁采信前端传入 base_price) $base = 0; if (!empty($goodsList) && is_array($goodsList)) { $serviceCache = []; $specCache = []; foreach ($goodsList as $g) { if (is_string($g)) { $rawG = htmlspecialchars_decode($g); $decoded = json_decode($rawG, true); if (is_array($decoded)) { $g = $decoded; } } if (!is_array($g)) { continue; } $sid = isset($g['service_id']) ? (int)$g['service_id'] : 0; $specId = isset($g['spec_id']) ? (int)$g['spec_id'] : 0; $reqServiceType = isset($g['service_type']) ? trim((string)$g['service_type']) : ''; $isCustom = !empty($g['is_custom']); $dbSvc = null; if ($sid > 0) { if (!array_key_exists($sid, $serviceCache)) { $serviceCache[$sid] = Db::name('service')->where('id', $sid)->where('status', 'normal')->find() ?: null; } $dbSvc = $serviceCache[$sid]; } if ($isFixedLike) { // 纯空占位行(下单页首次加载尚未选服务时试算)容忍返回 0;只要带了 is_custom、自填名称、自定义价格或处于创单严格校验则强制拦截 $hasCustomInput = $isCustom || !empty($g['custom_name']) || !empty($g['item_name']) || !empty($g['goods_name']) || (isset($g['base_price']) && (float)$g['base_price'] > 0); if ($sid <= 0) { if ($hasCustomInput || $isStrict) { throw new \InvalidArgumentException('自定义商品不支持一口价模式,请关联有效服务项目或切换为师傅报价/自主定价'); } continue; } if (!$dbSvc || $dbSvc['price_type'] === 'quote') { throw new \InvalidArgumentException('所选服务项目不存在或不支持一口价模式,请切换为师傅报价/自主定价'); } } $specPrice = 0.0; if ($specId > 0 && $dbSvc) { $cacheKey = $sid . ':' . $specId; if (!array_key_exists($cacheKey, $specCache)) { $specCache[$cacheKey] = Db::name('service_spec') ->where('id', $specId) ->where('service_id', $sid) ->where('status', 'normal') ->find() ?: null; } if (!empty($specCache[$cacheKey])) { $specPrice = (float)$specCache[$cacheKey]['base_price']; } } $itemPrice = 0.0; $dbAddonsMap = []; if ($dbSvc) { $svcTypes = !empty($dbSvc['service_types']) ? (json_decode((string)$dbSvc['service_types'], true) ?: []) : []; $defaultCode = 'install'; $matchedTypePrice = 0.0; foreach ($svcTypes as $st) { if (!empty($st['is_default']) && !empty($st['code'])) { $defaultCode = (string)$st['code']; } if ($reqServiceType !== '' && isset($st['code']) && (string)$st['code'] === $reqServiceType) { $matchedTypePrice = isset($st['price']) ? (float)$st['price'] : 0.0; } } // 若规格设置了专属一口价(>0)且当前为默认主服务,则优先采用规格专属价;否则采用所选主服务定价或商品默认价 $isDefaultService = ($reqServiceType === '' || $reqServiceType === $defaultCode || $reqServiceType === 'install'); if ($specPrice > 0 && $isDefaultService) { $itemPrice = $specPrice; } elseif ($matchedTypePrice > 0) { $itemPrice = $matchedTypePrice; } elseif ($specPrice > 0) { $itemPrice = $specPrice; } elseif ((float)$dbSvc['base_price'] > 0) { $itemPrice = (float)$dbSvc['base_price']; } $svcAddons = !empty($dbSvc['addons']) ? (json_decode((string)$dbSvc['addons'], true) ?: []) : []; foreach ($svcAddons as $ad) { if (!empty($ad['name']) && isset($ad['price']) && (float)$ad['price'] > 0) { $dbAddonsMap[(string)$ad['name']] = (float)$ad['price']; } if (!empty($ad['code']) && isset($ad['price']) && (float)$ad['price'] > 0) { $dbAddonsMap[(string)$ad['code']] = (float)$ad['price']; } } } if ($isFixedLike && $itemPrice <= 0) { throw new \InvalidArgumentException('所选服务规格暂未配置一口价,请切换为师傅报价或自主定价'); } $itemNum = isset($g['num']) ? max(1, (int)$g['num']) : (isset($g['goods_num']) ? max(1, (int)$g['goods_num']) : 1); $base += round($itemPrice * $itemNum, 2); if (!empty($g['extras']) && is_array($g['extras'])) { foreach ($g['extras'] as $ex) { $exNum = isset($ex['num']) ? max(0, (int)$ex['num']) : 0; if ($exNum <= 0) { continue; } $exPrice = 0.0; $exName = isset($ex['name']) ? (string)$ex['name'] : ''; $exCode = isset($ex['code']) ? (string)$ex['code'] : ''; if ($exName !== '' && isset($dbAddonsMap[$exName])) { $exPrice = $dbAddonsMap[$exName]; } elseif ($exCode !== '' && isset($dbAddonsMap[$exCode])) { $exPrice = $dbAddonsMap[$exCode]; } elseif (!$isFixedLike && isset($ex['price'])) { $exPrice = max(0, (float)$ex['price']); } $base += round($exPrice * $exNum, 2); } } } } elseif ($service) { if ($isFixedLike && $service['price_type'] === 'quote') { throw new \InvalidArgumentException('该服务项目不支持一口价模式,请切换为师傅报价或自主定价'); } $unitPrice = (float)$service['base_price']; $specId = isset($params['spec_id']) ? (int)$params['spec_id'] : 0; $reqServiceType = isset($params['service_type']) ? trim((string)$params['service_type']) : ''; $specPrice = 0.0; if ($specId > 0) { $spRow = Db::name('service_spec') ->where('id', $specId) ->where('service_id', (int)$service['id']) ->where('status', 'normal') ->find(); if ($spRow && (float)$spRow['base_price'] > 0) { $specPrice = (float)$spRow['base_price']; } } $svcTypes = !empty($service['service_types']) ? (json_decode((string)$service['service_types'], true) ?: []) : []; $defaultCode = 'install'; $matchedTypePrice = 0.0; foreach ($svcTypes as $st) { if (!empty($st['is_default']) && !empty($st['code'])) { $defaultCode = (string)$st['code']; } if ($reqServiceType !== '' && isset($st['code']) && (string)$st['code'] === $reqServiceType) { $matchedTypePrice = isset($st['price']) ? (float)$st['price'] : 0.0; } } $isDefaultService = ($reqServiceType === '' || $reqServiceType === $defaultCode || $reqServiceType === 'install'); if ($specPrice > 0 && $isDefaultService) { $unitPrice = $specPrice; } elseif ($matchedTypePrice > 0) { $unitPrice = $matchedTypePrice; } elseif ($specPrice > 0) { $unitPrice = $specPrice; } if ($isFixedLike && $unitPrice <= 0) { throw new \InvalidArgumentException('该服务项目暂未配置有效一口价,请切换为师傅报价或自主定价'); } $goodsNum = isset($params['goods_num']) ? max(1, (int)$params['goods_num']) : 1; $base = round($unitPrice * $goodsNum, 2); } elseif ($isFixedLike && (!empty($params['is_custom']) || $isStrict || (isset($params['base_price']) && (float)$params['base_price'] > 0))) { throw new \InvalidArgumentException('自定义商品不支持一口价模式,请关联有效服务项目或切换为师傅报价/自主定价'); } $floorAmount = 0; $urgentAmount = 0; if ($type === 'custom') { // 用户自主定价:出价即订单总价,已包含楼层费/加急费,师傅按此价接单 $base = max(0, round((float)$userPrice, 2)); $total = $base; } elseif ($type === 'quote') { // 师傅报价模式:金额由师傅后续竞价给出,下单时总价与应付为 0 $total = 0; $floorAmount = 0; $urgentAmount = 0; } else { $floor = max(0, (int)$params['floor']); $freeFloor = $service ? (int)$service['free_floor'] : 2; $floorFee = $service ? (float)$service['floor_fee'] : 10.0; if ((int)$params['has_elevator'] === 0 && $floor > $freeFloor) { $floorAmount = ($floor - $freeFloor) * $floorFee; } $urgentAmount = (int)$params['urgent'] === 1 ? round($base * 0.2, 2) : 0; $total = round($base + $floorAmount + $urgentAmount, 2); } $discount = 0; $couponInfo = null; if ($couponId > 0 && $uid > 0) { $uc = Db::name('user_coupon')->where('id', $couponId)->where('user_id', $uid)->where('status', 'unused')->find(); if ($uc) { $coupon = Db::name('coupon')->where('id', $uc['coupon_id'])->where('status', 'normal')->find(); $notExpired = $coupon && (int)$coupon['end_time'] === 0 ? true : ($coupon && $coupon['end_time'] > time()); if ($notExpired && $total >= (float)$coupon['min_amount']) { $discount = $coupon['type'] === 'cash' ? (float)$coupon['amount'] : round($total * (1 - (float)$coupon['discount']), 2); $discount = min($discount, $total); $couponInfo = ['id' => (int)$uc['id'], 'name' => $coupon['name']]; } } } return [ 'price_type' => $type, 'user_price' => $type === 'custom' ? round($base, 2) : 0, 'base_amount' => $type === 'quote' ? 0 : round($base, 2), 'floor_amount' => round($floorAmount, 2), 'urgent_amount' => round($urgentAmount, 2), 'total_amount' => $total, 'discount_amount' => round($discount, 2), 'pay_amount' => max(0, round($total - $discount, 2)), 'coupon' => $couponInfo, 'free_floor' => $type === 'custom' ? 0 : ($service ? (int)$service['free_floor'] : 0), 'floor_fee' => $type === 'custom' ? 0 : round($service ? (float)$service['floor_fee'] : 0.0, 2), ]; } /** * 首页轮播(后台 fa_banner 可管,支持活动 / 平台实力 / 服务主题) */ protected function banners() { $rows = Db::name('banner') ->field('id,badge,title,subtitle,date_text,theme,action_text,stats,image,link,weigh') ->where('status', 'normal') ->order('weigh desc,id desc') ->limit(8) ->select(); if (!$rows) { return []; } $themeText = [ 'promo' => '活动促销', 'brand' => '平台实力', 'service' => '服务主题', ]; $ret = []; foreach ($rows as $r) { $theme = isset($themeText[$r['theme']]) ? $r['theme'] : 'promo'; $stats = $this->parseBannerStats(isset($r['stats']) ? $r['stats'] : ''); $actionText = trim((string)$r['action_text']); if ($actionText === '' && $theme !== 'brand') { $actionText = '立即查看'; } $ret[] = [ 'id' => (int)$r['id'], 'badge' => $r['badge'], 'title' => $r['title'], 'subtitle' => $r['subtitle'], 'date_text' => $r['date_text'], 'theme' => $theme, 'theme_text' => $themeText[$theme], 'action_text' => $actionText, 'stats' => $stats, 'image' => $r['image'], 'link' => $r['link'], ]; } return $ret; } /** * 解析平台实力主题的统计 JSON */ protected function parseBannerStats($raw) { $raw = trim((string)$raw); if ($raw === '') { return []; } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return []; } $out = []; foreach ($decoded as $item) { if (!is_array($item) || !isset($item['value']) || !isset($item['label'])) { continue; } $out[] = [ 'value' => (string)$item['value'], 'label' => (string)$item['label'], ]; } return $out; } /** * 读取 fa_config 中的逗号分隔列表(完全由真实库配置与站点配置驱动) */ protected function configList($name) { $v = Db::name('config')->where('name', $name)->value('value'); if ($v === null || trim((string)$v) === '') { $v = \think\Config::get('site.' . $name); } if (!$v) { return []; } return array_values(array_filter(array_map('trim', explode(',', (string)$v)))); } /** * 首页教程卡(fa_config: 标题|副标题) */ protected function tutorial() { $v = Db::name('config')->where('name', 'home_tutorial')->value('value'); if ($v === null || trim((string)$v) === '') { $v = \think\Config::get('site.home_tutorial'); } if (!$v) { return ['title' => '', 'sub' => '']; } $parts = explode('|', (string)$v); return [ 'title' => trim($parts[0]), 'sub' => isset($parts[1]) ? trim($parts[1]) : '', ]; } /** * 列表格式化 */ public function formatServices($list) { if (empty($list)) { return []; } $serviceIds = array_column($list, 'id'); $specsMap = []; if (!empty($serviceIds)) { $specs = Db::name('service_spec') ->where('service_id', 'in', $serviceIds) ->where('status', 'normal') ->order('weigh desc, id asc') ->select(); foreach ($specs as $sp) { $sid = (int)$sp['service_id']; if (!isset($specsMap[$sid])) { $specsMap[$sid] = []; } $spImg = isset($sp['image']) ? trim((string)$sp['image']) : ''; $specsMap[$sid][] = [ 'id' => (int)$sp['id'], 'service_id' => $sid, 'name' => $sp['name'], 'image_url' => $spImg !== '' ? (strpos($spImg, 'http') === 0 ? $spImg : cdnurl($spImg, true)) : '', 'description' => (string)$sp['description'], 'base_price' => (float)$sp['base_price'], ]; } } $ret = []; foreach ($list as $row) { $formatted = $this->formatService($row); $formatted['specs'] = isset($specsMap[$row['id']]) ? $specsMap[$row['id']] : []; $ret[] = $formatted; } return $ret; } /** * 格式化类目列表(统一网络图片 URL) */ protected function formatCats($cats) { if (empty($cats)) return []; foreach ($cats as &$c) { $icon = (string)$c['icon']; if ($icon !== '') { if (strpos($icon, 'http') === 0) { $c['icon_url'] = $icon; } elseif (strpos($icon, '/') !== false || strpos($icon, '.') !== false) { $c['icon_url'] = cdnurl($icon, true); } else { $c['icon_url'] = ''; } } else { $c['icon_url'] = ''; } } unset($c); return $cats; } /** * 单项格式化(价格文案统一由此输出) */ public function formatService($row) { $type = $row['price_type']; $priceText = '¥' . (float)$row['base_price']; if ($type === 'range') { $priceText = '¥' . (float)$row['base_price'] . '-' . (float)$row['max_price'] . '/' . $row['unit']; } elseif ($type === 'quote') { $priceText = '¥' . (float)$row['base_price'] . ' · 师傅报价'; } // 解析主服务项目与增项服务(均与统一服务表 fa_service_type 实时联动校验) static $typeDictCache = null; if ($typeDictCache === null) { $typeDictCache = Db::name('service_type') ->where('status', 'normal') ->order('weigh desc, id asc') ->select(); } $activeTypeCodes = []; $activeTypeNames = []; foreach ($typeDictCache as $td) { $activeTypeCodes[$td['code']] = $td; $activeTypeNames[$td['name']] = $td; } $serviceTypes = []; if (!empty($row['service_types'])) { $serviceTypes = is_array($row['service_types']) ? $row['service_types'] : (json_decode($row['service_types'], true) ?: []); } $isOtherCat = ((int)$row['cat_id'] === 15); if (!empty($activeTypeCodes)) { $filteredTypes = []; foreach ($serviceTypes as $st) { $c = isset($st['code']) ? $st['code'] : ''; if ($c !== '' && isset($activeTypeCodes[$c])) { $dictSupport = isset($activeTypeCodes[$c]['support_fixed']) ? (int)$activeTypeCodes[$c]['support_fixed'] : 1; $stPrice = isset($st['price']) ? (float)$st['price'] : 0.0; if ($isOtherCat || $dictSupport === 0) { $st['support_fixed'] = 0; $st['price'] = 0; } else { $st['support_fixed'] = $stPrice > 0 ? 1 : 0; } $filteredTypes[] = $st; } } $serviceTypes = $filteredTypes; } else { foreach ($serviceTypes as &$stItem) { $stPrice = isset($stItem['price']) ? (float)$stItem['price'] : 0.0; if (!isset($stItem['support_fixed'])) { $stItem['support_fixed'] = (!$isOtherCat && $stPrice > 0) ? 1 : 0; } } unset($stItem); } // 解析增项服务(读取商品绑定的 addons JSON,并与统一服务表 fa_service_type 联动过滤) $addons = []; if (!empty($row['addons'])) { $addons = is_array($row['addons']) ? $row['addons'] : (json_decode($row['addons'], true) ?: []); } if (!empty($addons) && !empty($activeTypeCodes)) { $addons = array_values(array_filter($addons, function ($ad) use ($activeTypeCodes, $activeTypeNames) { $code = isset($ad['code']) ? $ad['code'] : ''; $name = isset($ad['name']) ? $ad['name'] : ''; return ($code !== '' && isset($activeTypeCodes[$code])) || ($name !== '' && isset($activeTypeNames[$name])); })); } $catIcon = isset($row['cat_icon']) && $row['cat_icon'] ? $row['cat_icon'] : ''; $img = $row['image'] ? $row['image'] : $catIcon; return [ 'id' => (int)$row['id'], 'cat_id' => (int)$row['cat_id'], 'name' => $row['name'], 'cat_name' => isset($row['cat_name']) ? $row['cat_name'] : '', 'image' => $row['image'], 'image_url' => $img ? cdnurl($img, true) : '', 'price_type' => $type, 'price_type_text' => $type === 'fixed' ? '一口价' : ($type === 'quote' ? '师傅报价' : '按面积计价'), 'base_price' => (float)$row['base_price'], 'max_price' => (float)$row['max_price'], 'unit' => $row['unit'], 'price_text' => $priceText, 'sales' => (int)$row['sales'], 'floor_fee' => isset($row['floor_fee']) ? (float)$row['floor_fee'] : 0, 'free_floor' => isset($row['free_floor']) ? (int)$row['free_floor'] : 0, 'need_model' => isset($row['need_model']) ? (int)$row['need_model'] : 1, 'description' => isset($row['description']) ? $row['description'] : '', 'tag' => isset($row['tag']) && $row['tag'] ? $row['tag'] : '明码标价', 'service_types' => $serviceTypes, 'addons' => $addons, ]; } /** * 相对时间计算(更具真实动态感) */ protected function timeAgo($time) { if ($time <= 0) return ''; $diff = time() - $time; if ($diff < 60) return '刚刚'; if ($diff < 3600) return floor($diff / 60) . '分钟前'; if ($diff < 86400) return floor($diff / 3600) . '小时前'; if ($diff < 86400 * 3) return floor($diff / 86400) . '天前'; return date('m-d', $time); } /** * 公开业主晒单详情(直接读取独立晒单表 fa_showcase,仅返回必要脱敏晒单信息) */ public function orderCase() { $id = (int)$this->request->param('id'); if ($id <= 0) { $this->error('晒单参数错误'); } // 优先按晒单主键 id 查询,兼容从本人订单详情跳转时传入的 order_id $sc = Db::name('showcase')->where('id', $id)->where('status', 'normal')->find(); if (!$sc) { $sc = Db::name('showcase')->where('order_id', $id)->where('status', 'normal')->find(); } if (!$sc) { $this->error('该晒单不存在或已取消公开'); } $service = Db::name('service')->where('id', (int)$sc['service_id'])->find(); $basePrice = $service ? (float)$service['base_price'] : 0.00; $imageUrl = $sc['cover_image'] ? cdnurl($sc['cover_image'], true) : ''; $tags = []; if (!empty($sc['tags'])) { $dec = json_decode((string)$sc['tags'], true); $tags = is_array($dec) ? $dec : array_values(array_filter(explode(',', (string)$sc['tags']))); } $rImgs = []; $mediaList = []; if (!empty($sc['images'])) { $decImgs = json_decode((string)$sc['images'], true); $rawImgArr = is_array($decImgs) ? $decImgs : array_values(array_filter(explode(',', (string)$sc['images']))); foreach ($rawImgArr as $idx => $imgItem) { $imgItem = trim((string)$imgItem); if ($imgItem !== '') { $fullUrl = cdnurl($imgItem, true); $rImgs[] = $fullUrl; $mediaList[] = [ 'id' => $idx + 1, 'type' => 'image', 'url' => $fullUrl, 'tag' => '晒单实拍', ]; } } } $dateText = !empty($sc['createtime']) ? date('Y-m-d', (int)$sc['createtime']) : ''; $catName = (string)$sc['cat_name']; $serviceName = (string)$sc['service_name']; $this->success('', [ 'order' => [ 'id' => (int)$sc['id'], 'service_id' => (int)$sc['service_id'], 'service_name' => $serviceName, 'cat_id' => (int)$sc['cat_id'], 'cat_name' => $catName, 'image_url' => $imageUrl, 'status_text' => '业主晒单', 'location' => (string)$sc['region'], 'finish_text' => $dateText, 'media' => $mediaList, ], 'master' => [ 'realname' => (string)$sc['master_name'], 'score' => (float)$sc['master_score'], ], 'review' => [ 'id' => (int)$sc['id'], 'score' => (int)$sc['score'], 'tags' => $tags, 'content' => (string)$sc['content'], 'images' => $rImgs, 'user_name' => (string)$sc['user_name'], 'user_avatar' => $sc['user_avatar'] ? cdnurl($sc['user_avatar'], true) : '', 'time_text' => $dateText, ], 'service_info' => [ 'cat_id' => (int)$sc['cat_id'], 'cat_name' => $catName, 'service_id' => (int)$sc['service_id'], 'service_name' => $serviceName, 'goods_name' => $serviceName, 'base_price' => $basePrice, ] ]); } /** * 平台协议与合规条款读取(优先读取后台「协议与隐私」实时配置,结合用户已读修订号返回首看变更标识) */ public function agreement() { $type = trim((string)$this->request->param('type', 'user_service')); $item = \app\common\library\AgreementService::getByType($type, $this->uid()); if (!$item) { $this->error('协议内容暂未配置或已下线'); } $this->success('', $item); } /** * 记录用户已首次查看指定修订版本的协议(后续打开不再标背景色) */ public function agreementRead() { if (false === $this->request->isPost()) { $this->error('请求方式错误'); } $type = trim((string)$this->request->post('type', 'privacy')); $revision = (int)$this->request->post('revision', 0); $res = \app\common\library\AgreementService::markUserRead($this->uid(), $type, $revision); $this->success('', $res); } /** * 官方系统图库(受下单所选类目控制,仅展示对应类目下的商品及参考图片;同源聚合系统图库表 + 细分规格图 + 商品主缩略图) */ public function systemGallery() { $catId = (int)$this->request->param('cat_id', 0); $serviceId = (int)$this->request->param('service_id', 0); $keyword = trim((string)$this->request->param('keyword', '')); $rawCats = Db::name('service_cat') ->field('id,name,icon') ->where('status', 'normal') ->where('id', '<>', 15) ->order('weigh desc,id asc') ->select(); $cats = $this->formatCats($rawCats); if ($serviceId > 0 && $catId <= 0) { $foundCatId = (int)Db::name('service')->where('id', $serviceId)->value('cat_id'); if ($foundCatId > 0 && $foundCatId !== 15) { $catId = $foundCatId; } } if ($catId === 0 && !empty($cats)) { $catId = (int)$cats[0]['id']; } $currentCatName = ''; foreach ($cats as $c) { if ((int)$c['id'] === $catId) { $currentCatName = (string)$c['name']; break; } } $svcQuery = Db::name('service') ->field('id,cat_id,name,image') ->where('status', 'normal') ->where('cat_id', '<>', 15); if ($catId > 0) { $svcQuery->where('cat_id', $catId); } $svcRows = $svcQuery->order('weigh desc,sales desc,id asc')->select(); $svcIds = array_column($svcRows, 'id'); $galleryMap = []; $specMap = []; if (!empty($svcIds)) { $gQuery = Db::name('system_gallery') ->where('service_id', 'in', $svcIds) ->where('status', 'normal'); if ($keyword !== '') { $gQuery->where('title', 'like', '%' . $keyword . '%'); } $gRows = $gQuery->order('weigh desc,id asc')->select(); foreach ($gRows as $gr) { $sid = (int)$gr['service_id']; $rawImg = trim((string)$gr['image']); if ($rawImg === '') { continue; } $galleryMap[$sid][] = [ 'id' => (int)$gr['id'], 'service_id' => $sid, 'spec_id' => (int)$gr['spec_id'], 'title' => (string)$gr['title'], 'image_url' => strpos($rawImg, 'http') === 0 ? $rawImg : cdnurl($rawImg, true), ]; } // 一次性批量查询所有关联商品的细分规格配图(严禁循环内查库) $spRows = Db::name('service_spec') ->field('id,service_id,name,image') ->where('service_id', 'in', $svcIds) ->where('status', 'normal') ->order('weigh desc,id asc') ->select(); foreach ($spRows as $sp) { $sid = (int)$sp['service_id']; $specMap[$sid][] = $sp; } } // 智能同源聚合:1. 系统图库表记录 -> 2. 细分规格独立配图 -> 3. 商品后台主缩略图兜底(单张不重复) $services = []; foreach ($svcRows as $sr) { $sid = (int)$sr['id']; $items = []; $seenImgUrls = []; $seenBasenames = []; static $isValidGalleryUrl = null; if ($isValidGalleryUrl === null) { $isValidGalleryUrl = static function (string $url): bool { $base = strstr($url, '?', true); $clean = strtolower(trim($base !== false ? $base : $url)); if ($clean === '' || substr($clean, -4) === '.svg' || strpos($clean, '/icon/') !== false) { return false; } return true; }; } $registerUniqueUrl = static function (string $url) use (&$seenImgUrls, &$seenBasenames, $isValidGalleryUrl): ?string { $u = trim($url); if ($u === '' || !$isValidGalleryUrl($u)) { return null; } $pathOnly = parse_url($u, PHP_URL_PATH); $fname = $pathOnly ? strtolower(basename($pathOnly)) : strtolower($u); if (isset($seenImgUrls[$u]) || ($fname !== '' && isset($seenBasenames[$fname]))) { return null; } $seenImgUrls[$u] = true; if ($fname !== '') { $seenBasenames[$fname] = true; } return $u; }; if (!empty($galleryMap[$sid])) { foreach ($galleryMap[$sid] as $gItem) { $u = $registerUniqueUrl((string)($gItem['image_url'] ?? '')); if ($u === null) { continue; } $gItem['image_url'] = $u; $items[] = $gItem; } } // 仅当系统图库未单独配置该商品图集时,回退读取商品细分规格参考图 if (empty($items) && !empty($specMap[$sid])) { foreach ($specMap[$sid] as $sp) { $spImg = !empty($sp['image']) ? trim((string)$sp['image']) : ''; if ($spImg === '' || !$isValidGalleryUrl($spImg)) { continue; } if ($keyword !== '' && mb_strpos((string)$sp['name'], $keyword) === false) { continue; } $fullSpUrl = strpos($spImg, 'http') === 0 ? $spImg : cdnurl($spImg, true); $u = $registerUniqueUrl($fullSpUrl); if ($u !== null) { $items[] = [ 'id' => 100000 + (int)$sp['id'], 'service_id' => $sid, 'spec_id' => (int)$sp['id'], 'title' => (string)$sp['name'], 'image_url' => $u, ]; } } } // 若后台仅在添加商品时上传了1张商品缩略图(未配额外图库与规格图),直接展示该商品主图 if (empty($items) && $keyword === '') { $defaultImg = trim((string)$sr['image']); if ($defaultImg !== '' && $isValidGalleryUrl($defaultImg)) { $fullDef = strpos($defaultImg, 'http') === 0 ? $defaultImg : cdnurl($defaultImg, true); if ($isValidGalleryUrl($fullDef)) { $items[] = [ 'id' => 200000 + $sid, 'service_id' => $sid, 'spec_id' => 0, 'title' => (string)$sr['name'], 'image_url' => $fullDef, ]; } } } if ($keyword !== '' && empty($items) && mb_strpos((string)$sr['name'], $keyword) === false) { continue; } if (!empty($items)) { $services[] = [ 'id' => $sid, 'cat_id' => (int)$sr['cat_id'], 'name' => (string)$sr['name'], 'count' => count($items), 'items' => $items, ]; } } $this->success('', [ 'cats' => $cats, 'current_cat_id' => $catId, 'current_cat_name' => $currentCatName, 'services' => $services, ]); } }