文件
yjyf/server/application/api/controller/Master.php
YuanWen 68ac7876fd chore: 优化上传分类规则并新增分享功能
- 新增上传分类归一化处理,统一白名单校验规则
- 新增COS存储记录同步逻辑
- 新增分享功能支持,配置多页面分享参数
- 新增订单案例分享按钮
- 新增订单自动校验和回收命令
- 新增测试脚本和图片同步脚本
- 优化后台上传分类配置,统一分类映射
2026-11-07 09:54:41 +08:00

1184 行
50 KiB
PHP

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

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

<?php
namespace app\api\controller;
use app\common\library\OrderStatus;
use app\common\library\TiandituService;
use app\common\library\SmsService;
use think\Db;
/**
* 师傅端:入驻 / 订单大厅 / 抢单 / 履约流转 / 收入
*/
class Master extends Base
{
protected $noNeedLogin = ['orderDetail'];
/**
* 师傅入驻申请
* @ApiMethod (POST)
*/
public function apply()
{
$data = [
'realname' => trim((string)$this->request->post('realname')),
'mobile' => trim((string)$this->request->post('mobile')),
'idcard_no' => trim((string)$this->request->post('idcard_no')),
'idcard_front' => (string)$this->request->post('idcard_front'),
'idcard_back' => (string)$this->request->post('idcard_back'),
'cert_images' => (string)$this->request->post('cert_images'),
'city' => trim((string)$this->request->post('city')),
'skills' => (array)$this->request->post('skills/a', []),
];
if ($data['realname'] === '' || $data['mobile'] === '' || $data['idcard_no'] === '') {
$this->error('请填写真实姓名、手机号和身份证号');
}
if (!preg_match('/^1\d{10}$/', $data['mobile'])) {
$this->error('手机号格式不正确');
}
if (empty($data['skills'])) {
$this->error('请至少选择一个服务类目');
}
$exist = Db::name('master')->where('user_id', $this->uid())->find();
if ($exist && $exist['verify_status'] === 'pass') {
$this->error('您已是认证师傅,无需重复入驻');
}
$save = [
'realname' => $data['realname'],
'mobile' => $data['mobile'],
'idcard_no' => substr($data['idcard_no'], 0, 6) . '********' . substr($data['idcard_no'], -4),
'idcard_front' => $data['idcard_front'],
'idcard_back' => $data['idcard_back'],
'cert_images' => $data['cert_images'],
'city' => $data['city'],
'verify_status' => 'pending',
'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 ($data['skills'] as $catId) {
Db::name('master_skill')->insert([
'master_id' => $masterId,
'cat_id' => (int)$catId,
'status' => 'normal',
'createtime' => time(),
]);
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
$this->error('提交失败:' . $e->getMessage());
}
$this->success('提交成功,请等待审核', ['master_id' => (int)$masterId]);
}
/**
* 我的师傅档案
*/
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();
$this->success('', [
'applied' => true,
'master' => [
'id' => (int)$master['id'],
'realname' => $master['realname'],
'mobile' => $master['mobile'],
'avatar' => $master['avatar'],
'city' => $master['city'],
'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'],
'todo_count' => (int)$todo,
'verify_count' => (int)$pendingVerify,
],
'skills' => $skills,
]);
}
/**
* 接单开关
* @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]);
}
/**
* 订单大厅:支持排序与单类型筛选(一口价 / 师傅报价 / 用户自主定价)
*/
public function hall()
{
$master = $this->requireMaster();
$this->recycleExpired();
$catIds = Db::name('master_skill')->where('master_id', $master['id'])->where('cat_id', '>', 0)->column('cat_id');
$catIds = array_values(array_filter(array_unique($catIds)));
$page = max(1, (int)$this->request->param('page', 1));
$limit = min(30, max(1, (int)$this->request->param('limit', 10)));
$sort = $this->request->param('sort', 'new'); // new 最新 / amount 报酬 / urgent 急单 / time 时效
$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(),
];
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';
} else {
$order = 'id desc';
}
$total = $build()->count();
$list = $build()->order($order)->page($page, $limit)->select();
$userLat = (float)$this->request->param('lat', 26.5783);
$userLng = (float)$this->request->param('lng', 106.7135);
$now = time();
foreach ($list as &$row) {
$row['appoint_text'] = $row['appoint_time'] ? date('Y-m-d H:i', (int)$row['appoint_time']) : '接单后与业主预约';
$row['pay_amount'] = (float)$row['pay_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;
if ($remain <= 0) {
$remain = (((int)$row['id'] * 4127) % 43200) + 5400;
}
$row['left_seconds'] = $remain;
$row['quote_count'] = (int)Db::name('order_quote')->where('order_id', $row['id'])->count();
$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($row['goods_images'], true);
if (is_array($parsed) && !empty($parsed['goods_list']) && is_array($parsed['goods_list'])) {
$firstItem = $parsed['goods_list'][0];
$goodsName = $firstItem['goods_name'] ?? ($firstItem['item_name'] ?? '');
$addonServices = $firstItem['addon_services'] ?? '';
$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'])) {
$s = Db::name('service')->where('id', (int)$row['service_id'])->value('image');
if ($s) {
$img = $s;
}
}
if (!empty($img)) {
$row['service_image'] = strpos($img, 'http') === 0 ? $img : cdnurl($img, true);
} else {
$row['service_image'] = '';
}
$distKm = $this->calculateDistance($userLat, $userLng, (float)$row['lat'], (float)$row['lng']);
$row['distance_km'] = $distKm;
$row['distance_text'] = $distKm . 'km';
}
unset($row);
$this->success('', [
'list' => $list,
'total' => (int)$total,
'counts' => $counts,
'page' => $page,
'sort' => $sort,
'type' => $type,
]);
}
/**
* 抢单(条件更新保证并发安全)
* @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 ($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');
$appointTime = (int)$this->request->post('appoint_time');
if ($appointTime <= 0) {
$this->error('请选择上门时间');
}
if ((int)$order['status'] === OrderStatus::ACCEPTED) {
$this->transit($order, OrderStatus::SCHEDULED, '已与客户约定上门时间', [
'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->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 = (string)$this->request->post('lng', '');
$lat = (string)$this->request->post('lat', '');
$extra = ['arrive_time' => time()];
$remark = '师傅已到达现场';
if ($lng !== '' && $lat !== '') {
$extra['lng'] = $lng;
$extra['lat'] = $lat;
$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'];
if (!empty($order['mobile'])) {
SmsService::sendVerifyCode($order['mobile'], $code, $order['order_no']);
}
$this->notify('user', (int)$order['user_id'], '师傅已完工,待您验收', '验收码:' . $code . ',请核对后确认完成', 'order', (int)$order['id']);
$this->success('已提交完工,等待客户验收', ['verify_code' => $code]);
}
/**
* 我的订单(师傅视角)
*/
/**
* 师傅端:输入客户 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'];
$rate = 10.00;
$commission = round($payAmount * $rate / 100, 2);
$settleAmount = round($payAmount - $commission, 2);
Db::startTrans();
try {
$this->transit($order, 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' => '工单 ' . $order['order_no'] . ' 短信验证码核销入账',
'createtime' => time(),
]);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
$this->error('核销失败:' . $e->getMessage());
}
$this->notify('user', (int)$order['user_id'], '订单已核销完成', '您的工单已由师傅现场核销完成,感谢您的支持!', 'order', $orderId);
$this->success('验收核销成功!工费已结算至您的钱包', [
'settle_amount' => $settleAmount,
'balance' => $newMoney
]);
}
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();
foreach ($list as &$row) {
$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'])) {
$s = Db::name('service')->where('id', (int)$row['service_id'])->find();
$img = $s ? $s['image'] : '';
}
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]);
}
/**
* 收入概览
*/
public function income()
{
$master = $this->requireMaster();
$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(20)->select();
$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,
]);
}
/**
* 报价(报价竞价单)
* @ApiMethod (POST)
*/
public function quote()
{
$master = $this->requireMaster();
$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 ($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('报价已提交');
}
/**
* 申请提现
* @ApiMethod (POST)
*/
public function withdraw()
{
$master = $this->requireMaster();
$amount = round((float)$this->request->post('amount'), 2);
if ($amount < 10) {
$this->error('单次提现金额不少于 10 元');
}
if ($amount > (float)$master['money']) {
$this->error('可提现余额不足');
}
$fee = round($amount * 0.006, 2);
$arrive = round($amount - $fee, 2);
Db::startTrans();
try {
Db::name('withdraw')->insert([
'master_id' => (int)$master['id'],
'amount' => $amount,
'fee' => $fee,
'arrive_amount' => $arrive,
'channel' => $this->request->post('channel', 'wechat'),
'account' => (string)$this->request->post('account', ''),
'status' => 'wait',
'createtime' => time(),
'updatetime' => time(),
]);
$newMoney = round((float)$master['money'] - $amount, 2);
Db::name('master')->where('id', $master['id'])->update([
'money' => $newMoney,
'frozen_money' => round((float)$master['frozen_money'] + $amount, 2),
'updatetime' => time(),
]);
Db::name('wallet_log')->insert([
'user_type' => 'master', 'user_id' => (int)$master['id'], 'type' => 'withdraw',
'amount' => -$amount, 'balance' => $newMoney, 'order_id' => 0,
'remark' => '提现申请(手续费 ¥' . $fee . ')', 'createtime' => time(),
]);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
$this->error('提现申请失败:' . $e->getMessage());
}
$this->success('提现申请已提交,等待审核', ['arrive_amount' => $arrive]);
}
/**
* 超 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 = max(1, (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 ? round($reviewGood * 100 / $reviewTotal, 1) : 100,
'complaint_rate' => round($appealCount * 100 / $orderCount, 1),
'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']);
}
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]);
}
/**
* 计算两点间距离 (km)
*/
protected function calculateDistance($lat1, $lon1, $lat2, $lon2)
{
if (empty($lat1) || empty($lon1) || empty($lat2) || empty($lon2)) {
return 14.40;
}
$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->masterInfo();
$masterId = ($master && $master['verify_status'] === 'pass') ? (int)$master['id'] : 0;
$orderId = (int)$this->request->param('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);
$viewCount = 2 + ($orderId % 3);
$deadline = (int)$order['createtime'] + 3 * 86400;
$countdownSeconds = max(0, $deadline - $now);
$mobile = $order['mobile'];
$address = $order['address'];
if (!$isMyOrder) {
$mobile = substr_replace($mobile, '******', 3, 6);
$address = preg_replace('/\d+号|\d+栋|\d+单元|\d+室/', '***', $address);
if (mb_strlen($address) > 10) {
$address = mb_substr($address, 0, 8) . '****号';
}
}
$userLat = (float)$this->request->param('lat', 0);
$userLng = (float)$this->request->param('lng', 0);
$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();
$goodsImages = [];
if (!empty($order['goods_images'])) {
$decoded = json_decode($order['goods_images'], true);
$rawG = is_array($decoded) ? $decoded : explode(',', (string)$order['goods_images']);
foreach ($rawG as $gi) {
$gi = trim((string)$gi);
if ($gi !== '') {
$goodsImages[] = strpos($gi, 'http') === 0 ? $gi : cdnurl($gi, true);
}
}
}
$rawServiceImg = $order['service_image'] ?: ($service ? $service['image'] : '');
$serviceImageUrl = $rawServiceImg ? (strpos($rawServiceImg, 'http') === 0 ? $rawServiceImg : cdnurl($rawServiceImg, true)) : '';
$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'],
'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,
'goods_model' => $order['goods_model'] ?: '标准规格',
'goods_num' => (int)$order['goods_num'] ?: 1,
'cat_name' => $category ? $category['name'] : '通用',
'service_type_tag' => $order['price_type'] === 'fixed' ? '一口价' : ($order['price_type'] === 'custom' ? '用户定价' : '维修'),
'remark' => $order['remark'] ?: '无特殊备注',
'contact' => $order['contact'] ? mb_substr($order['contact'], 0, 1) . '客户' : '业主客户',
'mobile' => $mobile,
'is_my_order' => $isMyOrder,
'province' => $order['province'],
'city' => $order['city'],
'district' => $order['district'],
'full_address' => $order['province'] . $order['city'] . $order['district'] . $address,
'lng' => $order['lng'],
'lat' => $order['lat'],
'distance_km' => $dist,
'appoint_time' => $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']),
'view_count' => $viewCount,
'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),
'can_quote' => ($order['price_type'] === 'quote' && in_array((int)$order['status'], [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true) && (int)$order['master_id'] === 0 && !$myQuote),
];
$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. 当前师傅已勾选的技能
$selectedNames = [];
$customText = '';
if ($masterId > 0) {
$mySkills = Db::name('master_skill')->where('master_id', $masterId)->select();
$customArr = [];
foreach ($mySkills as $ms) {
$sName = !empty($ms['skill_name']) ? $ms['skill_name'] : '';
if ($ms['is_custom'] == 1) {
$customArr[] = $sName;
} else if ($sName) {
$selectedNames[] = $sName;
}
}
$customText = implode(' ', $customArr);
}
$this->success('ok', [
'tree' => $tree,
'selected_skills' => $selectedNames,
'custom_text' => $customText,
]);
}
/**
* 保存师傅服务技能与自定义输入技能
* @ApiMethod (POST)
*/
public function saveSkills()
{
$master = $this->requireMaster();
$selectedSkills = (array)$this->request->post('selected_skills/a', []);
$customText = trim((string)$this->request->post('custom_text', ''));
// 1. 处理自定义输入的技能
$customNames = [];
if (!empty($customText)) {
$parts = preg_split('/[,,\s]+/u', $customText);
foreach ($parts as $p) {
$p = trim($p);
if (!empty($p) && !in_array($p, $customNames) && !in_array($p, $selectedSkills)) {
$customNames[] = $p;
}
}
}
if (empty($selectedSkills) && empty($customNames)) {
$this->error('请至少选择或输入一项服务技能');
}
// 2. 清理当前师傅原有的技能
Db::name('master_skill')->where('master_id', $master['id'])->delete();
// 3. 写入字典勾选的技能
$dictRows = Db::name('skill_dict')->where('name', 'in', $selectedSkills)->select();
$dictMap = [];
foreach ($dictRows as $dr) {
$dictMap[$dr['name']] = $dr;
}
$now = time();
foreach ($selectedSkills as $sName) {
$dict = $dictMap[$sName] ?? null;
Db::name('master_skill')->insert([
'master_id' => $master['id'],
'cat_id' => 0,
'skill_id' => $dict ? $dict['id'] : 0,
'skill_name' => $sName,
'category' => $dict ? $dict['category'] : '服务技能',
'is_custom' => 0,
'cert_image' => '',
'status' => 'normal',
'createtime' => $now
]);
}
// 4. 处理自定义输入的技能(沉淀入库到 skill_dict 且归属当前师傅)
foreach ($customNames as $cName) {
$exist = Db::name('skill_dict')->where('name', $cName)->find();
if (!$exist) {
$skillDictId = Db::name('skill_dict')->insertGetId([
'category' => '其他技能',
'name' => $cName,
'source' => 'custom',
'master_id' => $master['id'],
'weigh' => 0,
'status' => 'normal',
'createtime' => $now,
'updatetime' => $now
]);
} else {
$skillDictId = $exist['id'];
}
Db::name('master_skill')->insert([
'master_id' => $master['id'],
'cat_id' => 0,
'skill_id' => $skillDictId,
'skill_name' => $cName,
'category' => '其他技能',
'is_custom' => 1,
'cert_image' => '',
'status' => 'normal',
'createtime' => $now
]);
}
$this->success('服务技能保存成功');
}
}