文件
yjyf/server/application/api/controller/Home.php

1628 行
69 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\api\controller;
use think\Db;
/**
* 首页 / 服务类目 / 服务项目 / 费用试算
*/
class Home extends Base
{
protected $noNeedLogin = ['index', 'feed', 'cats', 'services', 'detail', 'calc', 'specs', 'masterCount', 'orderCase', 'agreement', 'agreementRead', 'systemGallery'];
/**
* 首页聚合:类目 + 热门服务 + 我的角标
*/
public function index()
{
$rawCats = Db::name('service_cat')
->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,
]);
}
}