文件

3297 行
142 KiB
PHP

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

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

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