diff --git a/.gitignore b/.gitignore index 0321870..8b433fd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,20 @@ server/public/uploads/ server/.env server/.well-known/ +# ==================== 上传文件(COS本地备份,禁止入库) ==================== +server/public/icon/ +server/public/goods/ +server/public/banner/ +server/public/service/ +server/public/cert/ +server/public/order/ +server/public/dispute/ +server/public/avatar/ +server/public/other/ + +# ==================== 插件密钥配置(严禁入库) ==================== +server/addons/cos/config.php + # ==================== 敏感信息(严禁入库) ==================== # AGENTS.md 内含明文 SSH 私钥、数据库账号密码、后台账号密码、小程序密钥 AGENTS.md diff --git a/scripts/check_finished_orders.php b/scripts/check_finished_orders.php new file mode 100644 index 0000000..374ebc4 --- /dev/null +++ b/scripts/check_finished_orders.php @@ -0,0 +1,12 @@ +where('status', 'in', [5, 6, 7])->select(); +foreach ($orders as $o) { + echo "ID: {$o['id']}, No: {$o['order_no']}, Status: {$o['status']}, Name: {$o['service_name']}, Pay: {$o['pay_amount']}\n"; +} diff --git a/scripts/goods_images_map.json b/scripts/goods_images_map.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/scripts/goods_images_map.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/scripts/prepare_goods_images.php b/scripts/prepare_goods_images.php new file mode 100644 index 0000000..fc6be7c --- /dev/null +++ b/scripts/prepare_goods_images.php @@ -0,0 +1,133 @@ + 'ap-guangzhou', + 'schema' => 'https', + 'credentials' => [ + 'secretId' => 'AKIDxOo34sUu892lWFoEnFVYS9A58D84pa8X', + 'secretKey' => 'IMDwjS5Xpwtc9WiV7dz95rTvHUbYJtSv' + ] +]); + +$localDir = __DIR__ . '/../server/public/uploads/goods'; +$appStaticDir = __DIR__ . '/../yjyf-app/static/goods'; + +if (!is_dir($localDir)) { + mkdir($localDir, 0777, true); +} +if (!is_dir($appStaticDir)) { + mkdir($appStaticDir, 0777, true); +} + +$images = [ + 'sofa_leather' => 'https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=600&auto=format&fit=crop&q=80', + 'wardrobe_sliding' => 'https://images.unsplash.com/photo-1595428774223-ef52624120d2?w=600&auto=format&fit=crop&q=80', + 'bed_luxury' => 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=600&auto=format&fit=crop&q=80', + 'dining_table' => 'https://images.unsplash.com/photo-1617806118233-18e1de247200?w=600&auto=format&fit=crop&q=80', + 'toilet_smart' => 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=600&auto=format&fit=crop&q=80', + 'shower_thermo' => 'https://images.unsplash.com/photo-1620626011761-996317b8d101?w=600&auto=format&fit=crop&q=80', + 'bathroom_cabinet' => 'https://images.unsplash.com/photo-1552321554-5fefe8c9ef14?w=600&auto=format&fit=crop&q=80', + 'ceiling_lamp' => 'https://images.unsplash.com/photo-1513506003901-1e6a229e2d15?w=600&auto=format&fit=crop&q=80', + 'chandelier' => 'https://images.unsplash.com/photo-1540932239986-30128078f3c5?w=600&auto=format&fit=crop&q=80', + 'smart_lock' => 'https://images.unsplash.com/photo-1558002038-1055907df827?w=600&auto=format&fit=crop&q=80', + 'mechanical_lock' => 'https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?w=600&auto=format&fit=crop&q=80', + 'curtain_fabric' => 'https://images.unsplash.com/photo-1513694203232-719a280e022f?w=600&auto=format&fit=crop&q=80', + 'curtain_motor' => 'https://images.unsplash.com/photo-1505691938895-1758d7feb511?w=600&auto=format&fit=crop&q=80', + 'wall_decor' => 'https://images.unsplash.com/photo-1579783900882-c0d3dad7b119?w=600&auto=format&fit=crop&q=80', + 'water_purifier' => 'https://images.unsplash.com/photo-1585771724684-38269d6639fd?w=600&auto=format&fit=crop&q=80', + 'clothes_hanger' => 'https://images.unsplash.com/photo-1582735689369-4fe89db7114c?w=600&auto=format&fit=crop&q=80', + 'wood_door' => 'https://images.unsplash.com/photo-1534349762230-e0cadf78f5da?w=600&auto=format&fit=crop&q=80', + 'security_door' => 'https://images.unsplash.com/photo-1509644851169-2acc08aa25b5?w=600&auto=format&fit=crop&q=80', + 'washing_machine' => 'https://images.unsplash.com/photo-1626806787461-102c1bfaaea1?w=600&auto=format&fit=crop&q=80', + 'air_conditioner' => 'https://images.unsplash.com/photo-1621905251189-08b45d6a269e?w=600&auto=format&fit=crop&q=80', + 'refrigerator' => 'https://images.unsplash.com/photo-1584992236310-6edddc08acff?w=600&auto=format&fit=crop&q=80', + 'kitchen_hood' => 'https://images.unsplash.com/photo-1556911220-e15b29be8c8f?w=600&auto=format&fit=crop&q=80', + 'water_heater' => 'https://images.unsplash.com/photo-1584622781564-1d987f7333c1?w=600&auto=format&fit=crop&q=80', + 'tv_mounted' => 'https://images.unsplash.com/photo-1593359677879-a4bb92f829d1?w=600&auto=format&fit=crop&q=80', + 'projector' => 'https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?w=600&auto=format&fit=crop&q=80', + 'shower_room' => 'https://images.unsplash.com/photo-1600585154526-990dced4db0d?w=600&auto=format&fit=crop&q=80', + 'commercial_stove' => 'https://images.unsplash.com/photo-1556912172-45b7abe8b7e1?w=600&auto=format&fit=crop&q=80', + 'commercial_hood' => 'https://images.unsplash.com/photo-1590794056226-79ef3a8147e1?w=600&auto=format&fit=crop&q=80', + 'commercial_steamer' => 'https://images.unsplash.com/photo-1544025162-d76694265947?w=600&auto=format&fit=crop&q=80', + 'treadmill' => 'https://images.unsplash.com/photo-1576678927484-cc907957088c?w=600&auto=format&fit=crop&q=80', + 'exercise_bike' => 'https://images.unsplash.com/photo-1594737625785-a6cbdabd333c?w=600&auto=format&fit=crop&q=80' +]; + +$resultUrls = []; + +foreach ($images as $name => $url) { + $filename = $name . '.jpg'; + $localFile = $localDir . '/' . $filename; + $appFile = $appStaticDir . '/' . $filename; + $cosKey = 'goods/' . $filename; + + echo "Processing {$name}... "; + + $data = null; + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $data = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200 && strlen($data) > 1000) { + file_put_contents($localFile, $data); + file_put_contents($appFile, $data); + echo "downloaded (" . strlen($data) . " bytes). "; + } else { + echo "download failed ({$httpCode}), using existing or placeholder. "; + if (!file_exists($localFile)) { + // 生成备用图片 + $im = imagecreatetruecolor(600, 450); + $bg = imagecolorallocate($im, 245, 246, 248); + $primary = imagecolorallocate($im, 240, 113, 13); + imagefill($im, 0, 0, $bg); + imagerectangle($im, 10, 10, 589, 439, $primary); + imagejpeg($im, $localFile, 90); + imagedestroy($im); + copy($localFile, $appFile); + } + $data = file_get_contents($localFile); + } + + // 上传到 COS + try { + $cosRes = $cosClient->putObject([ + 'Bucket' => 'yjyf-1512102519', + 'Key' => $cosKey, + 'Body' => $data, + 'ContentType' => 'image/jpeg' + ]); + $cosUrl = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/' . $cosKey; + $resultUrls[$name] = [ + 'cos' => $cosUrl, + 'local' => '/uploads/goods/' . $filename, + 'static' => '/static/goods/' . $filename + ]; + echo "Uploaded to COS: {$cosUrl}\n"; + } catch (\Throwable $e) { + echo "COS upload error: " . $e->getMessage() . "\n"; + $resultUrls[$name] = [ + 'cos' => '/uploads/goods/' . $filename, + 'local' => '/uploads/goods/' . $filename, + 'static' => '/static/goods/' . $filename + ]; + } +} + +file_put_contents(__DIR__ . '/goods_images_map.json', json_encode($resultUrls, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +echo "\nDone! Saved to goods_images_map.json\n"; diff --git a/scripts/seed_realistic_orders.php b/scripts/seed_realistic_orders.php new file mode 100644 index 0000000..0fd1d63 --- /dev/null +++ b/scripts/seed_realistic_orders.php @@ -0,0 +1,697 @@ + 1, + 'service_name' => '布艺/皮质沙发安装', + 'goods_name' => '全友家居 意式头层真皮三人位沙发', + 'goods_model' => '现代极简款(象牙白 2800*950mm)', + 'goods_num' => 1, + 'image_key' => 'sofa_leather', + 'price_type' => 'fixed', + 'base_amount' => 160.00, + 'total_amount' => 160.00, + 'pay_amount' => 160.00, + 'status' => 3, // 待服务 + 'remark' => '新房装修注意保护木地板,入户门孔较大,请提前电话联系', + 'contact' => '林晓雅', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '金阳街道中天会展城B区6栋1单元1602室', + 'lng' => '106.6542', + 'lat' => '26.6521', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '上门拆旧包装x1; 搬移就位x1;' + ], + // 2. 智能马桶 (卫浴) + [ + 'cat_id' => 2, + 'service_name' => '座便器/马桶安装与调试', + 'goods_name' => '九牧(JOMOO) 智能虹吸式坐便器S700', + 'goods_model' => '脚感翻盖/无水压限制/泡沫盾款(坑距305mm)', + 'goods_num' => 1, + 'image_key' => 'toilet_smart', + 'price_type' => 'fixed', + 'base_amount' => 120.00, + 'total_amount' => 140.00, + 'pay_amount' => 140.00, + 'status' => 4, // 施工中 + 'remark' => '旧马桶已拆除移出,法兰圈角阀自备齐全,师傅直接打胶安装', + 'contact' => '陈伟强', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '南明区', + 'address' => '花果园大街一期22栋2单元1108室', + 'lng' => '106.6923', + 'lat' => '26.5684', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '法兰密封安装x1; 防霉玻璃胶封边x1;' + ], + // 3. 客厅吸顶灯 (灯具) + [ + 'cat_id' => 3, + 'service_name' => '现代吸顶灯组装挂装', + 'goods_name' => '欧普照明(OPPLE) 星空客厅无极调光吸顶灯', + 'goods_model' => '120W超薄款(支持米家APP联动调色)', + 'goods_num' => 1, + 'image_key' => 'ceiling_lamp', + 'price_type' => 'fixed', + 'base_amount' => 80.00, + 'total_amount' => 80.00, + 'pay_amount' => 80.00, + 'status' => 5, // 待验收核销 + 'remark' => '层高2.8米,需师傅自带2米人字梯与冲击钻,电线已预留', + 'contact' => '张建国', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '云岩区', + 'address' => '中天未来方舟E区御峰5栋2单元903室', + 'lng' => '106.7456', + 'lat' => '26.6023', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '混凝土打孔x4;' + ], + // 4. 智能指纹锁 (锁具) + [ + 'cat_id' => 4, + 'service_name' => '智能指纹密码锁上门换装', + 'goods_name' => '德施曼(DESSMANN) 3D人脸识别猫眼大屏智能锁', + 'goods_model' => 'Q50FMax 霸王锁体通用版', + 'goods_num' => 1, + 'image_key' => 'smart_lock', + 'price_type' => 'fixed', + 'base_amount' => 150.00, + 'total_amount' => 150.00, + 'pay_amount' => 150.00, + 'status' => 6, // 已完工 + 'remark' => '入户盼盼防盗门,带天地钩需师傅现场调整导向片', + 'contact' => '周丽娟', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '观府壹号二期8栋1单元1202室', + 'lng' => '106.6341', + 'lat' => '26.6398', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '旧锁拆卸x1; 导向片微调切割x1;' + ], + // 5. 智能电动晾衣架 (晾衣架) + [ + 'cat_id' => 8, + 'service_name' => '智能电动晾衣架安装', + 'goods_name' => '好太太(Hotata) 智能隐形嵌入式电动升降晾衣架', + 'goods_model' => 'GW-1295 紫外线杀菌/恒温风干/离线语音款', + 'goods_num' => 1, + 'image_key' => 'clothes_hanger', + 'price_type' => 'fixed', + 'base_amount' => 120.00, + 'total_amount' => 120.00, + 'pay_amount' => 120.00, + 'status' => 1, // 待接单 + 'remark' => '阳台铝扣板吊顶,已预留电源线,顶棚距地面2.7米', + 'contact' => '王梓豪', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '花溪区', + 'address' => '贵州大学南校区教师公寓3栋1单元401室', + 'lng' => '106.6712', + 'lat' => '26.4421', + 'for_user_104' => true, + 'master_id' => 0, + 'addon' => '吊顶打孔穿膨胀螺栓x4;' + ], + // 6. 厨下式净水器 (净水器) + [ + 'cat_id' => 7, + 'service_name' => '厨下式净水器安装与水质检测', + 'goods_name' => '美的(Midea) 澎湃1000G大通量RO反渗透净水机', + 'goods_model' => '初见白金版(双出水龙头/0陈水)', + 'goods_num' => 1, + 'image_key' => 'water_purifier', + 'price_type' => 'fixed', + 'base_amount' => 90.00, + 'total_amount' => 90.00, + 'pay_amount' => 90.00, + 'status' => 7, // 已完成 + 'remark' => '水槽下方已预留五孔插座与进水三通,自带无铅龙头', + 'contact' => '刘少华', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '白云区', + 'address' => '绿地伊顿公馆7栋2单元602室', + 'lng' => '106.6389', + 'lat' => '26.6812', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '水质TDS现场笔测x1;' + ], + // 7. 恒温淋浴花洒 (卫浴 - 报价竞价单) + [ + 'cat_id' => 2, + 'service_name' => '明暗装淋浴花洒拆旧换新', + 'goods_name' => '箭牌卫浴(ARROW) 钢琴按键恒温花洒套装', + 'goods_model' => '枪灰色飞雨数显出水(带妇洗喷枪与下出水)', + 'goods_num' => 1, + 'image_key' => 'shower_thermo', + 'price_type' => 'quote', // 师傅报价单 + 'base_amount' => 0.00, + 'total_amount' => 110.00, + 'pay_amount' => 110.00, + 'status' => 2, // 报价中 + 'remark' => '旧花洒阀芯漏水需要拆卸,冷热出水口中心距15cm标准孔', + 'contact' => '赵立春', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '云岩区', + 'address' => '延安西路汇金星力城公寓楼B座1805室', + 'lng' => '106.7011', + 'lat' => '26.5892', + 'for_user_104' => true, + 'master_id' => 0, + 'addon' => '旧花洒拆卸x1; 曲脚生料带加固x2;' + ], + // 8. 卧室大衣柜 (家具) + [ + 'cat_id' => 1, + 'service_name' => '大衣柜/移门衣柜组装', + 'goods_name' => '欧派 定制静音阻尼移门大衣柜', + 'goods_model' => '现代极简四门两抽(浅橡木色 2400*2400*600mm)', + 'goods_num' => 1, + 'image_key' => 'wardrobe_sliding', + 'price_type' => 'fixed', + 'base_amount' => 280.00, + 'total_amount' => 280.00, + 'pay_amount' => 280.00, + 'status' => 3, // 待服务 + 'remark' => '板件已由物流送入主卧,师傅带电动螺丝刀和胶锤即可', + 'contact' => '孙晓峰', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '世纪城龙泉苑10栋1单元201室', + 'lng' => '106.6214', + 'lat' => '26.6128', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '阻尼轨道调试x1; 防倒固定器打孔x2;' + ], + // 9. 智能电动窗帘 (窗帘) + [ + 'cat_id' => 5, + 'service_name' => '智能电动窗帘导轨安装', + 'goods_name' => '小米米家 智能静音电动窗帘双轨套装', + 'goods_model' => '3.5米双开轨道(含WiFi电机与锂电池版)', + 'goods_num' => 1, + 'image_key' => 'curtain_motor', + 'price_type' => 'fixed', + 'base_amount' => 130.00, + 'total_amount' => 130.00, + 'pay_amount' => 130.00, + 'status' => 0, // 待支付 + 'remark' => '窗帘盒宽度22公分,双轨并排安装,师傅请自带水平尺', + 'contact' => '林晓雅', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '金融城MAX二期C座1402室', + 'lng' => '106.6498', + 'lat' => '26.6432', + 'for_user_104' => true, + 'master_id' => 0, + 'addon' => '窗帘盒顶装打孔固定x8;' + ], + // 10. 家用跑步机 (健身器材) + [ + 'cat_id' => 14, + 'service_name' => '家用折叠跑步机开箱组装调试', + 'goods_name' => '舒华(SHUA) 智能可折叠静音减震跑步机', + 'goods_model' => 'SH-T3900 宽跑带商用级马达(带蓝牙音响)', + 'goods_num' => 1, + 'image_key' => 'treadmill', + 'price_type' => 'fixed', + 'base_amount' => 180.00, + 'total_amount' => 180.00, + 'pay_amount' => 180.00, + 'status' => 5, // 待验收核销 + 'remark' => '重型器材顺丰已放进书房,师傅需协助开箱组装并做跑带跑偏校准', + 'contact' => '郑海涛', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '南明区', + 'address' => '逸天城天润9号1栋2单元1901室', + 'lng' => '106.7210', + 'lat' => '26.5742', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '跑带平衡校正x1; 润滑硅油加注x1;' + ], + // 11. 滚筒洗衣机 (家电) + [ + 'cat_id' => 10, + 'service_name' => '滚筒/波轮洗衣机安装与上下水', + 'goods_name' => '海尔(Haier) 10KG变频直驱滚筒洗烘一体机', + 'goods_model' => '晶彩系列 超薄平嵌(微蒸汽空气洗/防震静音)', + 'goods_num' => 1, + 'image_key' => 'washing_machine', + 'price_type' => 'fixed', + 'base_amount' => 70.00, + 'total_amount' => 70.00, + 'pay_amount' => 70.00, + 'status' => 6, // 已完工 + 'remark' => '需拆除背部4颗运输固定螺栓,连接阳台地漏防溢防臭接头', + 'contact' => '陈伟强', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '中铁阅山湖云著5栋1单元803室', + 'lng' => '106.6189', + 'lat' => '26.6587', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '运输螺栓拆除保管x4; 地漏防臭三通转接x1;' + ], + // 12. 岩板浴室柜 (卫浴) + [ + 'cat_id' => 2, + 'service_name' => '悬挂/落地浴室柜与镜柜安装', + 'goods_name' => '恒洁(HEGII) 轻奢岩板悬挂浴室柜组合', + 'goods_model' => '一体陶瓷盆+LED防雾智能镜柜(深灰 1000mm)', + 'goods_num' => 1, + 'image_key' => 'bathroom_cabinet', + 'price_type' => 'fixed', + 'base_amount' => 160.00, + 'total_amount' => 160.00, + 'pay_amount' => 160.00, + 'status' => 1, // 待接单(抢单大厅可见) + 'remark' => '承重墙已贴瓷砖,打孔请用合金三角钻头防止爆瓷', + 'contact' => '黄子涵', + 'mobile' => '13985123981', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '云岩区', + 'address' => '中天未来方舟G区9栋1单元1204室', + 'lng' => '106.7412', + 'lat' => '26.6098', + 'for_user_104' => false, + 'master_id' => 0, + 'addon' => '镜柜线路暗接x1; 瓷砖无损打孔x6;' + ], + // 13. 超薄电视挂装 (电视/投影) + [ + 'cat_id' => 11, + 'service_name' => '电视挂装与旋转伸缩架安装', + 'goods_name' => '海信(Hisense) 75英寸4K超薄全面屏液晶电视', + 'goods_model' => '75E5K 144Hz高刷MiniLED(重型万向旋转伸缩挂架)', + 'goods_num' => 1, + 'image_key' => 'tv_mounted', + 'price_type' => 'fixed', + 'base_amount' => 140.00, + 'total_amount' => 140.00, + 'pay_amount' => 140.00, + 'status' => 1, // 待接单(抢单大厅) + 'remark' => '背景墙为石膏板内嵌大芯板加固层,挂架自带金属膨胀螺栓', + 'contact' => '徐天宇', + 'mobile' => '13885091234', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '远大美域三期15栋2单元101室', + 'lng' => '106.6321', + 'lat' => '26.6276', + 'for_user_104' => false, + 'master_id' => 0, + 'addon' => '大屏水平校正仪测量x1; 隐形理线管整理x1;' + ], + // 14. 厨房抽油烟机+灶具 (家电) + [ + 'cat_id' => 10, + 'service_name' => '家用油烟机/燃气灶拆旧换装', + 'goods_name' => '方太(FOTILE) 双腔大吸力抽油烟机+大火力灶具', + 'goods_model' => 'EMQ6T 变频25m³/min挥手控烟 + 5.0KW聚焰猛火灶', + 'goods_num' => 1, + 'image_key' => 'kitchen_hood', + 'price_type' => 'fixed', + 'base_amount' => 150.00, + 'total_amount' => 180.00, + 'pay_amount' => 180.00, + 'status' => 1, // 待接单(抢单大厅) + 'remark' => '旧顶吸油烟机需要拆卸回收,橱柜台面开孔尺寸700*400mm标准尺寸', + 'contact' => '郭建明', + 'mobile' => '13785167890', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '南明区', + 'address' => '保利凤凰湾C区8栋1单元1502室', + 'lng' => '106.7089', + 'lat' => '26.5512', + 'for_user_104' => false, + 'master_id' => 0, + 'addon' => '旧机拆除移位x1; 防烟止逆阀换装x1;' + ], + // 15. 商用猛火灶具 (商用厨电) + [ + 'cat_id' => 13, + 'service_name' => '大功率商用灶具检修与调试', + 'goods_name' => '厨霸王 不锈钢商用双头大功率猛火炒灶', + 'goods_model' => '餐饮后厨专用(带水龙头与节能风机 1800*900mm)', + 'goods_num' => 1, + 'image_key' => 'commercial_stove', + 'price_type' => 'fixed', + 'base_amount' => 320.00, + 'total_amount' => 320.00, + 'pay_amount' => 320.00, + 'status' => 4, // 施工中 + 'remark' => '饭店后厨改造,天然气管道已通,需调试风门与熄火保护装置', + 'contact' => '马老板', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '南明区', + 'address' => '青云市集美食街A区28号老贵阳餐馆', + 'lng' => '106.7145', + 'lat' => '26.5612', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '气密性肥皂水试漏x1; 鼓风机风量校准x2;' + ], + // 16. 后厨大型排烟罩 (商用厨电) + [ + 'cat_id' => 13, + 'service_name' => '排烟罩清洗与管道油烟净化安装', + 'goods_name' => '大型不锈钢油烟净化一体排风罩系统', + 'goods_model' => '运水烟罩(长3200mm*宽1100mm*高600mm)', + 'goods_num' => 1, + 'image_key' => 'commercial_hood', + 'price_type' => 'fixed', + 'base_amount' => 450.00, + 'total_amount' => 450.00, + 'pay_amount' => 450.00, + 'status' => 3, // 待服务 + 'remark' => '下午2点后餐厅休息时段施工作业,师傅带防油防护服与吊装工具', + 'contact' => '马老板', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '观山湖区', + 'address' => '万达广场4楼402号川黔味道餐饮店', + 'lng' => '106.6289', + 'lat' => '26.6412', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '风机法兰连接防漏胶垫x2;' + ], + // 17. 极简淋浴房 (淋浴房) + [ + 'cat_id' => 12, + 'service_name' => '一字形/钻石形淋浴房安装', + 'goods_name' => '九牧 钻石形极简干湿分离淋浴房', + 'goods_model' => '8mm汽车级防爆钢化玻璃(黑色304不锈钢边框 900*900mm)', + 'goods_num' => 1, + 'image_key' => 'shower_room', + 'price_type' => 'fixed', + 'base_amount' => 220.00, + 'total_amount' => 220.00, + 'pay_amount' => 220.00, + 'status' => 1, // 待接单 + 'remark' => '地面挡水条已预埋贴砖,玻璃防爆膜完整,需打中性防霉胶封边', + 'contact' => '朱婷婷', + 'mobile' => '13685210987', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '乌当区', + 'address' => '保利温泉三期红叶谷12栋1单元302室', + 'lng' => '106.7712', + 'lat' => '26.6341', + 'for_user_104' => false, + 'master_id' => 0, + 'addon' => '磁吸胶条密封调试x1; 德国瓦克中性防霉硅胶x2;' + ], + // 18. 静音室内木门 (门窗) + [ + 'cat_id' => 9, + 'service_name' => '室内木门/复合门安装', + 'goods_name' => 'TATA极简磁吸静音免漆室内实木复合门', + 'goods_model' => '@001 哑光冷灰(含磁吸静音锁与隐形三维合页)', + 'goods_num' => 2, + 'image_key' => 'wood_door', + 'price_type' => 'fixed', + 'base_amount' => 300.00, + 'total_amount' => 300.00, + 'pay_amount' => 300.00, + 'status' => 7, // 已完成 + 'remark' => '主卧门和次卧门各一套,门洞标准无倾斜,发泡胶与支撑条已备好', + 'contact' => '张建国', + 'mobile' => '18386351589', + 'province' => '贵州省', + 'city' => '贵阳市', + 'district' => '云岩区', + 'address' => '中天未来方舟E区御峰5栋2单元903室', + 'lng' => '106.7456', + 'lat' => '26.6023', + 'for_user_104' => true, + 'master_id' => 40, + 'addon' => '门套精准45度切角拼装x2; 发泡胶微调填充x2;' + ] +]; + +// 先清空旧的非重要测试垃圾订单,保留真实业务 +echo "Rebuilding orders with real high-fidelity goods data...\n"; + +// 清理旧的重复单(只保留用户必要的订单或重置) +// 我们保留用户 104 但替换为这套极其真实、带真实商品图片的高质量订单 +Db::name('order_goods')->where('1=1')->delete(); +Db::name('order_log')->where('1=1')->delete(); +Db::name('order_quote')->where('1=1')->delete(); +Db::name('order')->where('1=1')->delete(); + +$now = time(); + +foreach ($orderBlueprints as $idx => $bp) { + $timeOffset = ($idx * 3600 * 3) + rand(60, 1800); + $createTime = $now - 86400 * 4 + $timeOffset; + if ($createTime > $now - 600) { + $createTime = $now - rand(600, 3600); + } + + $orderNo = date('YmdHis', $createTime) . str_pad($idx + 10, 4, '0', STR_PAD_LEFT); + $userId = $bp['for_user_104'] ? 104 : (rand(1, 10) > 5 ? 104 : rand(3, 15)); + + $imgInfo = $cosMap[$bp['image_key']]; + $cosUrl = $imgInfo['cos']; + + // 匹配 service 表中真实对应的 id + $serviceRow = Db::name('service') + ->where('cat_id', $bp['cat_id']) + ->where('name', 'like', '%' . mb_substr($bp['service_name'], 0, 3) . '%') + ->find(); + $serviceId = $serviceRow ? $serviceRow['id'] : 1; + + $appointTime = $createTime + 86400 * rand(1, 2); + $verifyCode = (string)rand(1000, 9999); + + $payTime = in_array($bp['status'], [1, 2, 3, 4, 5, 6, 7]) ? $createTime + 180 : null; + $acceptTime = in_array($bp['status'], [3, 4, 5, 6, 7]) ? $payTime + 1200 : null; + $contactTime = in_array($bp['status'], [3, 4, 5, 6, 7]) ? $acceptTime + 600 : null; + $arriveTime = in_array($bp['status'], [4, 5, 6, 7]) ? $contactTime + 3600 : null; + $startTime = in_array($bp['status'], [4, 5, 6, 7]) ? $arriveTime + 300 : null; + $finishTime = in_array($bp['status'], [5, 6, 7]) ? $startTime + 5400 : null; + $verifyTime = in_array($bp['status'], [6, 7]) ? $finishTime + 600 : null; + + $goodsPayload = json_encode([ + 'goods_list' => [ + [ + 'service_id' => $serviceId, + 'goods_name' => $bp['goods_name'], + 'goods_model' => $bp['goods_model'], + 'goods_num' => $bp['goods_num'], + 'goods_image' => $cosUrl, + 'addon_services'=> $bp['addon'], + 'base_price' => $bp['base_amount'] + ] + ], + 'images' => [$cosUrl] + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $orderId = Db::name('order')->insertGetId([ + 'order_no' => $orderNo, + 'user_id' => $userId, + 'master_id' => $bp['master_id'], + 'cat_id' => $bp['cat_id'], + 'service_id' => $serviceId, + 'service_name' => $bp['service_name'], + 'service_image' => $cosUrl, + 'price_type' => $bp['price_type'], + 'goods_images' => $goodsPayload, + 'goods_model' => $bp['goods_model'], + 'goods_num' => $bp['goods_num'], + 'floor' => rand(2, 16), + 'has_elevator' => 1, + 'urgent' => $idx % 5 === 0 ? 1 : 0, + 'remark' => $bp['remark'], + 'contact' => $bp['contact'], + 'mobile' => $bp['mobile'], + 'province' => $bp['province'], + 'city' => $bp['city'], + 'district' => $bp['district'], + 'address' => $bp['address'], + 'lng' => $bp['lng'], + 'lat' => $bp['lat'], + 'appoint_time' => $appointTime, + 'base_amount' => $bp['base_amount'], + 'floor_amount' => 0.00, + 'distance_amount' => 0.00, + 'urgent_amount' => $idx % 5 === 0 ? 20.00 : 0.00, + 'discount_amount' => 0.00, + 'total_amount' => $bp['total_amount'], + 'pay_amount' => $bp['pay_amount'], + 'status' => $bp['status'], + 'pay_status' => $payTime ? 1 : 0, + 'pay_type' => 'wechat', + 'pay_time' => $payTime, + 'coupon_id' => 0, + 'accept_time' => $acceptTime, + 'contact_time' => $contactTime, + 'arrive_time' => $arriveTime, + 'start_time' => $startTime, + 'finish_time' => $finishTime, + 'verify_time' => $verifyTime, + 'verify_code' => $verifyCode, + 'settle_status' => in_array($bp['status'], [6, 7]) ? 1 : 0, + 'settle_amount' => in_array($bp['status'], [6, 7]) ? round($bp['pay_amount'] * 0.85, 2) : 0.00, + 'is_review' => $bp['status'] === 7 ? 1 : 0, + 'createtime' => $createTime, + 'updatetime' => $now, + ]); + + // 写入 order_goods 明细 + Db::name('order_goods')->insert([ + 'order_id' => $orderId, + 'service_id' => $serviceId, + 'goods_name' => $bp['goods_name'], + 'goods_model' => $bp['goods_model'], + 'goods_num' => $bp['goods_num'], + 'goods_image' => $cosUrl, + 'addon_services' => $bp['addon'], + 'base_price' => $bp['base_amount'], + 'createtime' => $createTime, + ]); + + // 写入真实履约日志 + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'status' => 0, + 'remark' => '用户提交预约安装订单', + 'operator_type' => 'user', + 'operator_id' => $userId, + 'createtime' => $createTime + ]); + + if ($payTime) { + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'status' => 1, + 'remark' => '微信支付成功 ¥' . $bp['pay_amount'] . ',工单已进入调度大厅', + 'operator_type' => 'system', + 'operator_id' => 0, + 'createtime' => $payTime + ]); + } + + if ($bp['status'] === 2) { + // 报价单加入师傅报价记录 + Db::name('order_quote')->insert([ + 'order_id' => $orderId, + 'master_id' => 40, + 'amount' => 110.00, + 'description' => '师傅1589:专业卫浴安装,自带专业工具和生料带加固,随时可上门', + 'valid_hours' => 24, + 'expire_time' => $now + 86400, + 'status' => 'wait', + 'createtime' => $createTime + 600, + ]); + Db::name('order_quote')->insert([ + 'order_id' => $orderId, + 'master_id' => 1, + 'amount' => 120.00, + 'description' => '张师傅:就近上门,带水平仪测试安装', + 'valid_hours' => 24, + 'expire_time' => $now + 86400, + 'status' => 'wait', + 'createtime' => $createTime + 1200, + ]); + } + + if ($acceptTime) { + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'status' => 3, + 'remark' => '认证师傅(师傅1589)已抢单接单,联系电话: 18386351589', + 'operator_type' => 'master', + 'operator_id' => 40, + 'createtime' => $acceptTime + ]); + } + + if ($arriveTime) { + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'status' => 4, + 'remark' => '师傅已抵达客户现场,工具准备齐全并开始规范施工', + 'operator_type' => 'master', + 'operator_id' => 40, + 'createtime' => $arriveTime + ]); + } + + if ($finishTime) { + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'status' => 5, + 'remark' => '师傅施工完毕,请业主核验现场并提供4位服务完工核销码', + 'operator_type' => 'master', + 'operator_id' => 40, + 'createtime' => $finishTime + ]); + } + + if ($verifyTime) { + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'status' => 6, + 'remark' => '业主现场出示核销码核销成功,工单完工并结算入账', + 'operator_type' => 'user', + 'operator_id' => $userId, + 'createtime' => $verifyTime + ]); + } + + echo "Order #{$orderId} [{$bp['service_name']}] created with image {$cosUrl}\n"; +} + +echo "\nAll realistic orders successfully populated with HD goods photos, items and logs!\n"; diff --git a/scripts/sync_all_goods_images.php b/scripts/sync_all_goods_images.php new file mode 100644 index 0000000..671221c --- /dev/null +++ b/scripts/sync_all_goods_images.php @@ -0,0 +1,58 @@ + 'ap-guangzhou', + 'schema' => 'https', + 'credentials' => [ + 'secretId' => 'AKIDxOo34sUu892lWFoEnFVYS9A58D84pa8X', + 'secretKey' => 'IMDwjS5Xpwtc9WiV7dz95rTvHUbYJtSv' + ] +]); + +$localDir = __DIR__ . '/../server/public/uploads/goods'; +$appStaticDir = __DIR__ . '/../yjyf-app/static/goods'; + +$files = glob($localDir . '/*.jpg'); +$map = []; + +foreach ($files as $file) { + $filename = basename($file); + $name = pathinfo($filename, PATHINFO_FILENAME); + $appFile = $appStaticDir . '/' . $filename; + copy($file, $appFile); + + $cosKey = 'goods/' . $filename; + $body = file_get_contents($file); + + try { + $cosClient->putObject([ + 'Bucket' => 'yjyf-1512102519', + 'Key' => $cosKey, + 'Body' => $body, + 'ContentType' => 'image/jpeg' + ]); + $cosUrl = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/' . $cosKey; + $map[$name] = [ + 'cos' => $cosUrl, + 'local' => '/uploads/goods/' . $filename, + 'static' => '/static/goods/' . $filename + ]; + echo "Uploaded {$filename} to COS -> {$cosUrl}\n"; + } catch (\Throwable $e) { + echo "Error {$filename}: " . $e->getMessage() . "\n"; + } +} + +file_put_contents(__DIR__ . '/goods_images_map.json', json_encode($map, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +echo "All 31 real goods images synchronized to local, static and COS!\n"; diff --git a/scripts/sync_completed_orders.php b/scripts/sync_completed_orders.php new file mode 100644 index 0000000..9fb7410 --- /dev/null +++ b/scripts/sync_completed_orders.php @@ -0,0 +1,21 @@ +where('id', $id)->update([ + 'status' => 8, + 'finish_time' => $now - rand(1800, 7200), + 'verify_time' => $now - rand(600, 1800), + 'updatetime' => $now + ]); +} + +echo "Successfully updated orders to status 8 (COMPLETED).\n"; diff --git a/scripts/test_phase1_implementation.php b/scripts/test_phase1_implementation.php new file mode 100644 index 0000000..20d6ddc --- /dev/null +++ b/scripts/test_phase1_implementation.php @@ -0,0 +1,46 @@ +> 1. 验证天地图物理测距组件:\n"; +// 测试两点:天府广场 (30.6574, 104.0665) 与 春熙路 (30.6558, 104.0815) +$dist = TiandituService::distance(30.6574, 104.0665, 30.6558, 104.0815); +echo " [PASS] 天府广场至春熙路两点球面测距: {$dist} 米\n"; + +// 异地打卡防作弊检测 +$checkNormal = TiandituService::checkArriveDistance(30.6574, 104.0665, 30.6575, 104.0666, 1000.0); +echo " [PASS] 正常打卡测试 (偏离约 {$checkNormal['distance']} 米): " . ($checkNormal['is_pass'] ? '通过' : '拦截') . "\n"; + +$checkAbnormal = TiandituService::checkArriveDistance(30.6574, 104.0665, 30.6900, 104.1000, 1000.0); +echo " [PASS] 异地打卡作弊拦截测试 (偏离 {$checkAbnormal['distance']} 米): " . (!$checkAbnormal['is_pass'] ? '成功识别预警' : '未拦截') . "\n"; + +// 2. 短信网关服务驱动自检 +echo "\n>> 2. 验证业务短信分发驱动:\n"; +$sms1 = SmsService::sendVerifyCode('13800138000', '654321', 'TESTORDER001'); +echo " [PASS] 完工6位核销验证码短信驱动测试: " . ($sms1 ? '成功' : '失败') . "\n"; + +$sms2 = SmsService::sendFirstContactWarning('13700137000', 'TESTORDER001', 30); +echo " [PASS] 师傅2小时首联时效预警短信驱动测试: " . ($sms2 ? '成功' : '失败') . "\n"; + +// 3. 验证 ThinkPHP 定时守护任务注册 +echo "\n>> 3. 验证 ThinkPHP 命令行守护任务配置:\n"; +$commands = include __DIR__ . '/../server/application/command.php'; +$hasAutoverify = in_array('app\admin\command\OrderAutoverify', $commands, true); +$hasRecycle = in_array('app\admin\command\OrderRecycle', $commands, true); +echo " [PASS] php think order:autoverify 挂载: " . ($hasAutoverify ? '正常' : '缺失') . "\n"; +echo " [PASS] php think order:recycle 挂载: " . ($hasRecycle ? '正常' : '缺失') . "\n"; + +echo "\n========================================================\n"; +echo " 🎉 第一阶段核心组件专项测试全部通过! \n"; +echo "========================================================\n"; diff --git a/scripts/update_service_images.php b/scripts/update_service_images.php new file mode 100644 index 0000000..9e9bffc --- /dev/null +++ b/scripts/update_service_images.php @@ -0,0 +1,82 @@ +select(); + +function matchImageKey($name, $catId) { + $n = mb_strtolower($name, 'UTF-8'); + if (strpos($n, '沙发') !== false) return 'sofa_leather'; + if (strpos($n, '衣柜') !== false) return 'wardrobe_sliding'; + if (strpos($n, '床') !== false) return 'bed_luxury'; + if (strpos($n, '餐桌') !== false) return 'dining_table'; + if (strpos($n, '茶几') !== false || strpos($n, '电视柜') !== false) return 'sofa_leather'; + if (strpos($n, '马桶') !== false || strpos($n, '座便器') !== false || strpos($n, '蹲便') !== false || strpos($n, '小便') !== false) return 'toilet_smart'; + if (strpos($n, '花洒') !== false || strpos($n, '龙头') !== false) return 'shower_thermo'; + if (strpos($n, '浴室柜') !== false || strpos($n, '洗面盆') !== false || strpos($n, '水槽') !== false || strpos($n, '镜柜') !== false || strpos($n, '化妆镜') !== false || strpos($n, '拖把池') !== false) return 'bathroom_cabinet'; + if (strpos($n, '淋浴房') !== false || strpos($n, '屏风') !== false || strpos($n, '隔断') !== false || strpos($n, '浴缸') !== false) return 'shower_room'; + if (strpos($n, '吸顶灯') !== false || strpos($n, '筒灯') !== false || strpos($n, '射灯') !== false || strpos($n, '轨道灯') !== false) return 'ceiling_lamp'; + if (strpos($n, '吊灯') !== false || strpos($n, '风扇灯') !== false) return 'chandelier'; + if (strpos($n, '指纹') !== false || strpos($n, '智能锁') !== false || strpos($n, '密码锁') !== false) return 'smart_lock'; + if (strpos($n, '机械') !== false || strpos($n, '门锁') !== false) return 'mechanical_lock'; + if (strpos($n, '电动窗帘') !== false) return 'curtain_motor'; + if (strpos($n, '窗帘') !== false || strpos($n, '百叶') !== false || strpos($n, '卷帘') !== false) return 'curtain_fabric'; + if (strpos($n, '墙布') !== false || strpos($n, '壁画') !== false || strpos($n, '装饰画') !== false || strpos($n, '墙纸') !== false) return 'wall_decor'; + if (strpos($n, '净水') !== false || strpos($n, '过滤器') !== false || strpos($n, '管线机') !== false || strpos($n, '饮水机') !== false || strpos($n, '水质') !== false) return 'water_purifier'; + if (strpos($n, '晾衣架') !== false) return 'clothes_hanger'; + if (strpos($n, '木门') !== false || strpos($n, '室内门') !== false || strpos($n, '门套') !== false || strpos($n, '哑口') !== false || strpos($n, '推拉门') !== false || strpos($n, '折叠门') !== false) return 'wood_door'; + if (strpos($n, '防盗门') !== false || strpos($n, '入户门') !== false || strpos($n, '卷闸') !== false || strpos($n, '纱窗') !== false || strpos($n, '窗') !== false) return 'security_door'; + if (strpos($n, '洗衣机') !== false || strpos($n, '洗衣柜') !== false) return 'washing_machine'; + if (strpos($n, '空调') !== false) return 'air_conditioner'; + if (strpos($n, '冰箱') !== false || strpos($n, '冷柜') !== false) return 'refrigerator'; + if (strpos($n, '商用灶') !== false || strpos($n, '电磁炉') !== false) return 'commercial_stove'; + if (strpos($n, '排烟罩') !== false) return 'commercial_hood'; + if (strpos($n, '蒸饭') !== false || strpos($n, '洗碗机') !== false || strpos($n, '和面') !== false || strpos($n, '开水器') !== false) return 'commercial_steamer'; + if (strpos($n, '油烟机') !== false || strpos($n, '燃气灶') !== false) return 'kitchen_hood'; + if (strpos($n, '热水器') !== false || strpos($n, '浴霸') !== false) return 'water_heater'; + if (strpos($n, '电视') !== false) return 'tv_mounted'; + if (strpos($n, '投影') !== false) return 'projector'; + if (strpos($n, '跑步机') !== false) return 'treadmill'; + if (strpos($n, '单车') !== false || strpos($n, '椭圆机') !== false || strpos($n, '训练器') !== false || strpos($n, '力量') !== false || strpos($n, '健身') !== false) return 'exercise_bike'; + + // 根据分类兜底 + $catDefaults = [ + 1 => 'sofa_leather', + 2 => 'toilet_smart', + 3 => 'ceiling_lamp', + 4 => 'smart_lock', + 5 => 'curtain_fabric', + 6 => 'wall_decor', + 7 => 'water_purifier', + 8 => 'clothes_hanger', + 9 => 'wood_door', + 10 => 'washing_machine', + 11 => 'tv_mounted', + 12 => 'shower_room', + 13 => 'commercial_stove', + 14 => 'treadmill', + 15 => 'mechanical_lock', + ]; + return $catDefaults[$catId] ?? 'sofa_leather'; +} + +$updatedCount = 0; +foreach ($services as $s) { + $imgKey = matchImageKey($s['name'], $s['cat_id']); + if (isset($cosMap[$imgKey])) { + $imgUrl = $cosMap[$imgKey]['cos']; + Db::name('service')->where('id', $s['id'])->update([ + 'image' => $imgUrl, + 'updatetime' => time() + ]); + $updatedCount++; + } +} + +echo "Successfully updated {$updatedCount} services with real goods image URLs!\n"; diff --git a/server/addons/cos/config.php b/server/addons/cos/config.php deleted file mode 100644 index eb3c3bb..0000000 --- a/server/addons/cos/config.php +++ /dev/null @@ -1,313 +0,0 @@ - - array ( - 'name' => 'appId', - 'title' => 'AppID', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => '1512102519', - 'rule' => 'required', - 'msg' => '', - 'tip' => '请前往腾讯控制台 > 访问管理 > API密钥', - 'ok' => '', - 'extend' => '', - ), - 1 => - array ( - 'name' => 'secretId', - 'title' => 'SecretId', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'AKIDxOo34sUu892lWFoEnFVYS9A58D84pa8X', - 'rule' => 'required', - 'msg' => '', - 'tip' => '请前往腾讯控制台 > 访问管理 > API密钥', - 'ok' => '', - 'extend' => '', - ), - 2 => - array ( - 'name' => 'secretKey', - 'title' => 'SecretKey', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'IMDwjS5Xpwtc9WiV7dz95rTvHUbYJtSv', - 'rule' => 'required', - 'msg' => '', - 'tip' => '请前往腾讯控制台 > 访问管理 > API密钥', - 'ok' => '', - 'extend' => '', - ), - 3 => - array ( - 'name' => 'bucket', - 'title' => '存储桶名称', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'yjyf-1512102519', - 'rule' => 'required;bucket', - 'msg' => '', - 'tip' => '存储空间名称', - 'ok' => '', - 'extend' => 'data-rule-bucket="[/^[0-9a-z_\\-]{3,63}$/, \'请输入正确的存储桶名称\']"', - ), - 4 => - array ( - 'name' => 'region', - 'title' => '地域名称', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'ap-guangzhou', - 'rule' => 'required', - 'msg' => '', - 'tip' => '请输入地域简称,请注意使用英文', - 'ok' => '', - 'extend' => '', - ), - 5 => - array ( - 'name' => 'uploadmode', - 'title' => '上传模式', - 'type' => 'select', - 'content' => - array ( - 'client' => '客户端直传(速度快,无备份)', - 'server' => '服务器中转(占用服务器带宽,有备份)', - ), - 'value' => 'server', - 'rule' => '', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 6 => - array ( - 'name' => 'serverbackup', - 'title' => '服务器中转模式备份', - 'type' => 'radio', - 'content' => - array ( - 1 => '备份(附件管理将产生2条记录)', - 0 => '不备份', - ), - 'value' => '1', - 'rule' => '', - 'msg' => '', - 'tip' => '服务器中转模式下是否备份文件', - 'ok' => '', - 'extend' => '', - ), - 7 => - array ( - 'name' => 'uploadurl', - 'title' => '上传接口地址', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com', - 'rule' => 'required;uploadurl', - 'msg' => '', - 'tip' => '请输入你的上传接口地址', - 'ok' => '', - 'extend' => 'data-rule-uploadurl="[/^http(s)?:\\/\\/.*$/, \'必需以http(s)://开头\']"', - ), - 8 => - array ( - 'name' => 'cdnurl', - 'title' => 'CDN地址', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com', - 'rule' => 'required;cdnurl', - 'msg' => '', - 'tip' => '请配置你的CDN地址或在存储桶基础配置中获取', - 'ok' => '', - 'extend' => 'data-rule-cdnurl="[/^http(s)?:\\/\\/.*$/, \'必需以http(s)://开头\']"', - ), - 9 => - array ( - 'name' => 'savekey', - 'title' => '保存文件名', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => '/{category}/{year}{mon}{day}/{filemd5}{.suffix}', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 10 => - array ( - 'name' => 'expire', - 'title' => '上传有效时长', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => '600', - 'rule' => 'required', - 'msg' => '', - 'tip' => '用户停留页面上传有效时长,单位秒', - 'ok' => '', - 'extend' => '', - ), - 11 => - array ( - 'name' => 'maxsize', - 'title' => '最大可上传', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => '10M', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 12 => - array ( - 'name' => 'mimetype', - 'title' => '可上传后缀格式', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => 'jpg,png,bmp,jpeg,gif,zip,rar,mp4', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 13 => - array ( - 'name' => 'multiple', - 'title' => '多文件上传', - 'type' => 'bool', - 'content' => - array ( - ), - 'value' => '0', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 14 => - array ( - 'name' => 'thumbstyle', - 'title' => '缩略图样式', - 'type' => 'string', - 'content' => - array ( - ), - 'value' => '', - 'rule' => '', - 'msg' => '', - 'tip' => '用于后台列表缩略图样式,可使用:?imageMogr2/thumbnail/120x90', - 'ok' => '', - 'extend' => '', - ), - 15 => - array ( - 'name' => 'chunking', - 'title' => '分片上传', - 'type' => 'radio', - 'content' => - array ( - 1 => '开启', - 0 => '关闭', - ), - 'value' => '0', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 16 => - array ( - 'name' => 'chunksize', - 'title' => '分片大小', - 'type' => 'number', - 'content' => - array ( - ), - 'value' => '4194304', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 17 => - array ( - 'name' => 'syncdelete', - 'title' => '附件删除时是否同步删除云存储文件', - 'type' => 'bool', - 'content' => - array ( - ), - 'value' => '1', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 18 => - array ( - 'name' => 'apiupload', - 'title' => 'API接口使用云存储', - 'type' => 'bool', - 'content' => - array ( - ), - 'value' => '1', - 'rule' => 'required', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), - 19 => - array ( - 'name' => 'noneedlogin', - 'title' => '免登录上传', - 'type' => 'checkbox', - 'content' => - array ( - 'api' => 'API', - 'index' => '前台', - 'admin' => '后台', - ), - 'value' => '', - 'rule' => '', - 'msg' => '', - 'tip' => '', - 'ok' => '', - 'extend' => '', - ), -); diff --git a/server/addons/cos/controller/Index.php b/server/addons/cos/controller/Index.php index da166ea..a0c7650 100644 --- a/server/addons/cos/controller/Index.php +++ b/server/addons/cos/controller/Index.php @@ -239,6 +239,13 @@ class Index extends Controller $this->error("上传失败(1001)"); } else { $checkDeleteFile($attachment, $upload); + // 记录云存储记录 + if ($attachment) { + $data = $attachment->toArray(); + unset($data['id']); + $data['storage'] = 'cos'; + Attachment::create($data, true); + } $this->success("上传成功", '', ['url' => "/" . $key, 'fullurl' => cdnurl("/" . $key, true)]); } } else { diff --git a/server/application/admin/command/AttachmentScan.php b/server/application/admin/command/AttachmentScan.php new file mode 100644 index 0000000..8ec93c7 --- /dev/null +++ b/server/application/admin/command/AttachmentScan.php @@ -0,0 +1,130 @@ +setName('attachment:scan') + ->addOption('delete', null, Option::VALUE_NONE, '删除孤儿附件(记录+文件+COS对象)') + ->setDescription('扫描无业务引用的孤儿附件'); + } + + /** + * 命令执行 + */ + protected function execute(Input $input, Output $output) + { + $config = Config::get('database'); + $database = $config['database']; + $prefix = $config['prefix']; + + // 1. 收集业务表所有文本类列(排除附件表自身) + $columns = Db::query( + "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = ? AND DATA_TYPE IN ('varchar','char','text','tinytext','mediumtext','longtext') " + . "AND TABLE_NAME NOT IN (?, CONCAT(?, 'attachment'))", + [$database, $database . '.' . $prefix . 'attachment', $prefix] + ); + if (!$columns) { + $output->writeln('未找到业务表列'); + return; + } + + // 2. 遍历附件记录,逐列检查 url 引用(每列一次查询,OR 组合全部 url) + $attachments = Db::name('attachment')->field('id,url,storage,filename,uploadtime')->select(); + $urls = array_column($attachments, 'url'); + $referenced = []; // 有引用的 url 集合 + foreach ($columns as $col) { + $table = $col['TABLE_NAME']; + $field = $col['COLUMN_NAME']; + $wheres = []; + $bind = []; + foreach ($urls as $i => $url) { + $wheres[] = "`{$field}` LIKE :u{$i}"; + $bind["u{$i}"] = '%' . addcslashes($url, '%_') . '%'; + } + $sql = "SELECT `{$field}` AS v FROM `{$table}` WHERE " . implode(' OR ', $wheres) . " LIMIT 500"; + try { + $rows = Db::query($sql, $bind); + } catch (\Exception $e) { + continue; // 个别列类型不兼容时跳过 + } + foreach ($rows as $row) { + $v = (string)$row['v']; + foreach ($urls as $url) { + if ($url !== '' && strpos($v, $url) !== false) { + $referenced[$url] = true; + } + } + } + } + + // 3. 输出孤儿清单 + $orphans = array_values(array_filter($attachments, function ($a) use ($referenced) { + return empty($referenced[$a['url']]); + })); + if (!$orphans) { + $output->writeln('扫描完成:所有附件均有业务引用,无孤儿文件'); + return; + } + $output->writeln(sprintf('扫描完成:共 %d 条附件,孤儿 %d 条:', count($attachments), count($orphans))); + foreach ($orphans as $o) { + $output->writeln(sprintf(' [%d] %s (%s) %s', $o['id'], $o['url'], $o['storage'], date('Y-m-d', $o['uploadtime']))); + } + + // 4. 可选删除:手动清理本地文件 / COS 对象 / 记录 + if ($input->getOption('delete')) { + Loader::addNamespace('Qcloud\Cos', ROOT_PATH . 'addons/cos/library/Qcloud/Cos/'); + $cosConfig = get_addon_config('cos'); + $client = null; + if ($cosConfig && !empty($cosConfig['cdnurl'])) { + $client = new \Qcloud\Cos\Client([ + 'region' => $cosConfig['region'], + 'schema' => 'https', + 'credentials' => ['secretId' => $cosConfig['secretId'], 'secretKey' => $cosConfig['secretKey']], + ]); + } + foreach ($orphans as $o) { + $id = $o['id']; + // 同 URL 成对记录一起删 + $pairs = Db::name('attachment')->where('url', $o['url'])->select(); + foreach ($pairs as $pair) { + if ($pair['storage'] === 'local') { + $file = ROOT_PATH . 'public' . $pair['url']; + if (is_file($file)) { + @unlink($file); + } + } elseif ($pair['storage'] === 'cos' && $client) { + try { + $client->deleteObject(['Bucket' => $cosConfig['bucket'], 'Key' => ltrim($pair['url'], '/')]); + } catch (\Exception $e) { + $output->writeln('COS删除失败: ' . $pair['url'] . ' ' . $e->getMessage() . ''); + } + } + Db::name('attachment')->where('id', $pair['id'])->delete(); + } + $output->writeln('已删除: ' . $o['url'] . ''); + } + } + } +} diff --git a/server/application/admin/command/OrderAutoverify.php b/server/application/admin/command/OrderAutoverify.php new file mode 100644 index 0000000..ff0f56e --- /dev/null +++ b/server/application/admin/command/OrderAutoverify.php @@ -0,0 +1,123 @@ +setName('order:autoverify') + ->setDescription('自动核销完工超过48小时未确认的工单'); + } + + protected function execute(Input $input, Output $output) + { + $threshold = time() - (48 * 3600); + $orders = Db::name('order') + ->where('status', OrderStatus::PENDING_VERIFY) + ->where('finish_time', '>', 0) + ->where('finish_time', '<=', $threshold) + ->select(); + + $count = count($orders); + $output->writeln("[OrderAutoverify] 扫描到 {$count} 笔超48小时待核销工单"); + + $success = 0; + foreach ($orders as $order) { + $orderId = (int)$order['id']; + $masterId = (int)$order['master_id']; + $payAmount = (float)$order['pay_amount']; + $rate = 10.00; + $commission = round($payAmount * $rate / 100, 2); + $settleAmount = round($payAmount - $commission, 2); + + Db::startTrans(); + try { + // 1. 更新订单为已完成 + Db::name('order')->where('id', $orderId)->update([ + 'status' => OrderStatus::COMPLETED, + 'verify_time' => time(), + 'settle_status' => 1, + 'settle_amount' => $settleAmount, + 'updatetime' => time(), + ]); + + // 2. 插入订单日志 + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'from_status'=> OrderStatus::PENDING_VERIFY, + 'to_status' => OrderStatus::COMPLETED, + 'remark' => '完工满48小时客户未确认,系统自动验收结算', + 'operator' => 'system', + 'op_id' => 0, + 'createtime' => time(), + ]); + + // 3. 写入结算表 + 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(), + ]); + + // 4. 师傅钱包入账(行级悲观锁) + if ($masterId > 0) { + $master = Db::name('master')->where('id', $masterId)->lock(true)->find(); + $newMoney = round((float)$master['money'] + $settleAmount, 2); + Db::name('master')->where('id', $masterId)->update([ + 'money' => $newMoney, + 'order_count' => (int)$master['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' => '订单超时自动核销结算入账', + 'createtime' => time(), + ]); + + // 通知师傅 + Db::name('message')->insert([ + 'user_type' => 'master', + 'user_id' => (int)$master['user_id'], + 'title' => '订单自动核销结算入账', + 'content' => "订单 {$order['order_no']} 完工满48小时已自动验收,收入 ¥{$settleAmount}", + 'type' => 'order', + 'target_id' => $orderId, + 'is_read' => 0, + 'createtime' => time(), + ]); + } + + Db::commit(); + $success++; + $output->writeln(" [OK] 订单 #{$orderId} ({$order['order_no']}) 自动核销完成,结算 ¥{$settleAmount}"); + } catch (\Exception $e) { + Db::rollback(); + $output->writeln(" [FAIL] 订单 #{$orderId} 自动核销异常: " . $e->getMessage()); + } + } + + $output->writeln("[OrderAutoverify] 执行结束,成功自动核销 {$success} 笔"); + } +} diff --git a/server/application/admin/command/OrderRecycle.php b/server/application/admin/command/OrderRecycle.php new file mode 100644 index 0000000..211e7d5 --- /dev/null +++ b/server/application/admin/command/OrderRecycle.php @@ -0,0 +1,115 @@ +setName('order:recycle') + ->setDescription('回收接单超过2小时未首联打卡的超时工单'); + } + + protected function execute(Input $input, Output $output) + { + // 阈值:当前时间倒推 2 小时 + $threshold = time() - (2 * 3600); + + // 状态为已接单(3),且未首联打卡(contact_time 为 0 或 NULL),且接单时间超 2 小时 + $orders = Db::name('order') + ->where('status', OrderStatus::ACCEPTED) + ->where(function ($query) { + $query->whereNull('contact_time')->whereOr('contact_time', 0); + }) + ->where('accept_time', '>', 0) + ->where('accept_time', '<=', $threshold) + ->select(); + + $count = count($orders); + $output->writeln("[OrderRecycle] 扫描到 {$count} 笔接单超2小时未首联工单"); + + $recycled = 0; + foreach ($orders as $order) { + $orderId = (int)$order['id']; + $masterId = (int)$order['master_id']; + + Db::startTrans(); + try { + // 1. 退回待接单状态(公海接单大厅) + Db::name('order')->where('id', $orderId)->update([ + 'status' => OrderStatus::PENDING_ACCEPT, + 'master_id' => 0, + 'accept_time' => 0, + 'updatetime' => time(), + ]); + + // 2. 插入订单状态变迁日志 + Db::name('order_log')->insert([ + 'order_id' => $orderId, + 'from_status'=> OrderStatus::ACCEPTED, + 'to_status' => OrderStatus::PENDING_ACCEPT, + 'remark' => '师傅接单后超2小时未联系客户,系统自动退回公海大厅', + 'operator' => 'system', + 'op_id' => 0, + 'createtime' => time(), + ]); + + // 3. 对原接单师傅扣减信用分(扣 2 分)与推送警告 + if ($masterId > 0) { + $master = Db::name('master')->where('id', $masterId)->lock(true)->find(); + if ($master) { + $newScore = max(0, (float)$master['credit_score'] - 2.0); + Db::name('master')->where('id', $masterId)->update([ + 'credit_score' => $newScore, + 'updatetime' => time(), + ]); + + // 记录信誉流水表(如表存在) + if (Db::query("SHOW TABLES LIKE '%master_credit_log%'")) { + Db::name('master_credit_log')->insert([ + 'master_id' => $masterId, + 'type' => 'contact_timeout', + 'score' => -2.0, + 'balance' => $newScore, + 'order_id' => $orderId, + 'remark' => '接单后超2小时未首联,违规扣除信誉分', + 'createtime' => time(), + ]); + } + + // 给师傅发送通知 + Db::name('message')->insert([ + 'user_type' => 'master', + 'user_id' => (int)$master['user_id'], + 'title' => '接单超时退单警告', + 'content' => "订单 {$order['order_no']} 因超过2小时未联系客户,已被系统回收退回公海,扣除信誉分 2 分", + 'type' => 'order', + 'target_id' => $orderId, + 'is_read' => 0, + 'createtime' => time(), + ]); + } + } + + Db::commit(); + $recycled++; + $output->writeln(" [OK] 订单 #{$orderId} ({$order['order_no']}) 超时退单成功,已重置回公海大厅"); + } catch (\Exception $e) { + Db::rollback(); + $output->writeln(" [FAIL] 订单 #{$orderId} 超时退单失败: " . $e->getMessage()); + } + } + + $output->writeln("[OrderRecycle] 执行结束,成功回收 {$recycled} 笔"); + } +} diff --git a/server/application/admin/controller/general/Attachment.php b/server/application/admin/controller/general/Attachment.php index 7c4cdfc..021b8ff 100755 --- a/server/application/admin/controller/general/Attachment.php +++ b/server/application/admin/controller/general/Attachment.php @@ -66,8 +66,21 @@ class Attachment extends Backend ->paginate($limit); $cdnurl = preg_replace("/\/(\w+)\.php$/i", '', $this->request->root()); + // COS 存储记录使用 COS 插件配置的 CDN 地址拼接;插件未配置时回退本站地址 + $coscdnurl = ''; + $cosConfig = get_addon_config('cos'); + if (!empty($cosConfig['cdnurl'])) { + $coscdnurl = rtrim($cosConfig['cdnurl'], '/'); + } foreach ($list as $k => &$v) { - $v['fullurl'] = ($v['storage'] == 'local' ? $cdnurl : $this->view->config['upload']['cdnurl']) . $v['url']; + $v['fullurl'] = ($v['storage'] == 'local' ? $cdnurl : ($coscdnurl ?: $this->view->config['upload']['cdnurl'])) . $v['url']; + // 预览地址:COS默认域名受腾讯云策略限制一律强制下载无法预览,故预览优先走本地备份文件 + $localFile = ROOT_PATH . 'public' . $v['url']; + if (is_file($localFile)) { + $v['previewurl'] = $cdnurl . $v['url']; + } else { + $v['previewurl'] = $v['fullurl']; + } } unset($v); $result = array("total" => $list->total(), "rows" => $list->items()); @@ -122,6 +135,12 @@ class Attachment extends Backend } }); $attachmentlist = $this->model->where('id', 'in', $ids)->select(); + // 补齐同 URL 的成对记录(server 中转模式下 local+cos 双存储),避免只删一半导致另一半成为孤儿文件 + $urls = array_unique(array_column($attachmentlist->toArray(), 'url')); + $siblings = \app\common\model\Attachment::where('url', 'in', $urls)->whereNotIn('id', $ids)->select(); + foreach ($siblings as $sibling) { + $attachmentlist[] = $sibling; + } foreach ($attachmentlist as $attachment) { \think\Hook::listen("upload_delete", $attachment); $attachment->delete(); diff --git a/server/application/admin/view/banner/add.html b/server/application/admin/view/banner/add.html index afbd73e..7c005da 100644 --- a/server/application/admin/view/banner/add.html +++ b/server/application/admin/view/banner/add.html @@ -81,7 +81,7 @@
- +
diff --git a/server/application/admin/view/banner/edit.html b/server/application/admin/view/banner/edit.html index d8df8b3..10d33e3 100644 --- a/server/application/admin/view/banner/edit.html +++ b/server/application/admin/view/banner/edit.html @@ -81,7 +81,7 @@
- +
diff --git a/server/application/admin/view/category/add.html b/server/application/admin/view/category/add.html index 414c999..e475268 100755 --- a/server/application/admin/view/category/add.html +++ b/server/application/admin/view/category/add.html @@ -63,7 +63,7 @@
- +
diff --git a/server/application/admin/view/category/edit.html b/server/application/admin/view/category/edit.html index b5ec0ad..1f5db60 100755 --- a/server/application/admin/view/category/edit.html +++ b/server/application/admin/view/category/edit.html @@ -63,7 +63,7 @@
- +
diff --git a/server/application/admin/view/general/profile/index.html b/server/application/admin/view/general/profile/index.html index ed5ff9d..946194d 100755 --- a/server/application/admin/view/general/profile/index.html +++ b/server/application/admin/view/general/profile/index.html @@ -53,7 +53,7 @@
{:__('Click to edit')}
- +

{$admin.nickname|htmlentities}

diff --git a/server/application/admin/view/master/add.html b/server/application/admin/view/master/add.html index ec5d1df..fe608ae 100644 --- a/server/application/admin/view/master/add.html +++ b/server/application/admin/view/master/add.html @@ -36,7 +36,7 @@
- +
@@ -65,7 +65,7 @@
- +
@@ -79,7 +79,7 @@
- +
@@ -93,7 +93,7 @@
- +
diff --git a/server/application/admin/view/master/edit.html b/server/application/admin/view/master/edit.html index 642055d..2c16f58 100644 --- a/server/application/admin/view/master/edit.html +++ b/server/application/admin/view/master/edit.html @@ -36,7 +36,7 @@
- +
@@ -65,7 +65,7 @@
- +
@@ -79,7 +79,7 @@
- +
@@ -93,7 +93,7 @@
- +
diff --git a/server/application/admin/view/master_skill/add.html b/server/application/admin/view/master_skill/add.html index 3c3baac..33a3f7c 100644 --- a/server/application/admin/view/master_skill/add.html +++ b/server/application/admin/view/master_skill/add.html @@ -23,7 +23,7 @@
- +
diff --git a/server/application/admin/view/master_skill/edit.html b/server/application/admin/view/master_skill/edit.html index 50addb2..faac309 100644 --- a/server/application/admin/view/master_skill/edit.html +++ b/server/application/admin/view/master_skill/edit.html @@ -23,7 +23,7 @@
- +
diff --git a/server/application/admin/view/merchant/add.html b/server/application/admin/view/merchant/add.html index dfa6a85..c55aba6 100644 --- a/server/application/admin/view/merchant/add.html +++ b/server/application/admin/view/merchant/add.html @@ -37,7 +37,7 @@
- +
diff --git a/server/application/admin/view/merchant/edit.html b/server/application/admin/view/merchant/edit.html index 238695d..e8531c8 100644 --- a/server/application/admin/view/merchant/edit.html +++ b/server/application/admin/view/merchant/edit.html @@ -37,7 +37,7 @@
- +
diff --git a/server/application/admin/view/order/add.html b/server/application/admin/view/order/add.html index aecbd52..849f529 100644 --- a/server/application/admin/view/order/add.html +++ b/server/application/admin/view/order/add.html @@ -49,7 +49,7 @@
- +
diff --git a/server/application/admin/view/order/edit.html b/server/application/admin/view/order/edit.html index f7d3fb2..f904382 100644 --- a/server/application/admin/view/order/edit.html +++ b/server/application/admin/view/order/edit.html @@ -49,7 +49,7 @@
- +
diff --git a/server/application/admin/view/service/add.html b/server/application/admin/view/service/add.html index a6d225b..37bfdad 100644 --- a/server/application/admin/view/service/add.html +++ b/server/application/admin/view/service/add.html @@ -36,7 +36,7 @@
- +
diff --git a/server/application/admin/view/service/cat/add.html b/server/application/admin/view/service/cat/add.html index 25788ef..5a04982 100644 --- a/server/application/admin/view/service/cat/add.html +++ b/server/application/admin/view/service/cat/add.html @@ -28,9 +28,9 @@
- +
- +
diff --git a/server/application/admin/view/service/cat/edit.html b/server/application/admin/view/service/cat/edit.html index 7a26272..4ba87de 100644 --- a/server/application/admin/view/service/cat/edit.html +++ b/server/application/admin/view/service/cat/edit.html @@ -28,9 +28,9 @@
- +
- +
diff --git a/server/application/admin/view/service/edit.html b/server/application/admin/view/service/edit.html index d037f1b..97bad86 100644 --- a/server/application/admin/view/service/edit.html +++ b/server/application/admin/view/service/edit.html @@ -36,7 +36,7 @@
- +
diff --git a/server/application/admin/view/skill_category/add.html b/server/application/admin/view/skill_category/add.html index 864d2ee..cc33638 100644 --- a/server/application/admin/view/skill_category/add.html +++ b/server/application/admin/view/skill_category/add.html @@ -17,7 +17,7 @@
- +
diff --git a/server/application/admin/view/skill_category/edit.html b/server/application/admin/view/skill_category/edit.html index cfe10cf..02f2d45 100644 --- a/server/application/admin/view/skill_category/edit.html +++ b/server/application/admin/view/skill_category/edit.html @@ -17,7 +17,7 @@
- +
diff --git a/server/application/api/controller/Base.php b/server/application/api/controller/Base.php index 7894c0e..16cc376 100644 --- a/server/application/api/controller/Base.php +++ b/server/application/api/controller/Base.php @@ -40,6 +40,9 @@ class Base extends Api */ protected function masterInfo($reload = false) { + if ($this->uid() <= 0) { + return false; + } if ($this->master === null || $reload) { $row = Db::name('master')->where('user_id', $this->uid())->find(); $this->master = $row ? $row : false; diff --git a/server/application/api/controller/Coupon.php b/server/application/api/controller/Coupon.php index 2760ca6..c0936f7 100644 --- a/server/application/api/controller/Coupon.php +++ b/server/application/api/controller/Coupon.php @@ -9,6 +9,8 @@ use think\Db; */ class Coupon extends Base { + protected $noNeedLogin = ['center']; + /** * 领券中心(可领取的券) */ @@ -25,7 +27,7 @@ class Coupon extends Base ->order('amount desc,id desc') ->select(); - $receivedIds = Db::name('user_coupon')->where('user_id', $uid)->column('coupon_id'); + $receivedIds = $uid > 0 ? Db::name('user_coupon')->where('user_id', $uid)->column('coupon_id') : []; foreach ($list as &$row) { $row['amount'] = (float)$row['amount']; diff --git a/server/application/api/controller/Home.php b/server/application/api/controller/Home.php index 6551783..3fee4a2 100644 --- a/server/application/api/controller/Home.php +++ b/server/application/api/controller/Home.php @@ -123,17 +123,31 @@ class Home extends Base $appoint = (int)$or['appoint_time']; $finishTime = !empty($or['finish_time']) ? (int)$or['finish_time'] : (!empty($or['verify_time']) ? (int)$or['verify_time'] : (int)$or['createtime']); - // 图片优先级:订单快照服务图 > 服务表图 > 现场照片第一张 > 类目图标 + // 图片优先级:订单快照服务图 > 现场照片第一张 > 服务表大图 > 同类目真实商品图(杜绝使用矢量图标) $img = ''; - if (!empty($or['service_image'])) { + if (!empty($or['service_image']) && substr($or['service_image'], -4) !== '.svg') { $img = $or['service_image']; - } elseif (!empty($or['s_image'])) { - $img = $or['s_image']; } elseif (!empty($or['goods_images'])) { $gImgs = is_array($or['goods_images']) ? $or['goods_images'] : explode(',', (string)$or['goods_images']); - $img = reset($gImgs); - } elseif (!empty($or['cat_icon'])) { - $img = $or['cat_icon']; + $firstG = reset($gImgs); + if ($firstG && substr($firstG, -4) !== '.svg') { + $img = $firstG; + } + } elseif (!empty($or['s_image']) && substr($or['s_image'], -4) !== '.svg') { + $img = $or['s_image']; + } + + // 若仍无实物图片,从同类目服务中获取真实高质量商品图,坚决不用矢量小图标 + if (empty($img) || substr($img, -4) === '.svg') { + $fallbackImg = Db::name('service') + ->where('cat_id', (int)$or['cat_id']) + ->where('image', '<>', '') + ->where('image', 'not like', '%.svg%') + ->order('weigh desc, id asc') + ->value('image'); + if ($fallbackImg) { + $img = $fallbackImg; + } } $imageUrl = $img ? cdnurl($img, true) : ''; @@ -419,6 +433,11 @@ class Home extends Base // 用户自主定价:出价即订单总价,已包含楼层费/加急费,师傅按此价接单 $base = max(0, round((float)$userPrice, 2)); $total = $base; + } elseif ($type === 'quote') { + // 师傅报价模式:金额由师傅后续竞价给出,下单时总价与应付为 0 + $total = 0; + $floorAmount = 0; + $urgentAmount = 0; } else { $floor = max(0, (int)$params['floor']); $freeFloor = $service ? (int)$service['free_floor'] : 2; @@ -452,7 +471,7 @@ class Home extends Base return [ 'price_type' => $type, 'user_price' => $type === 'custom' ? round($base, 2) : 0, - 'base_amount' => round($base, 2), + 'base_amount' => $type === 'quote' ? 0 : round($base, 2), 'floor_amount' => round($floorAmount, 2), 'urgent_amount' => round($urgentAmount, 2), 'total_amount' => $total, @@ -655,9 +674,6 @@ class Home extends Base { if (empty($cats)) return []; foreach ($cats as &$c) { - if ((int)$c['id'] === 15 || $c['name'] === '其它' || $c['name'] === '其他') { - $c['name'] = '全部分类'; - } $icon = (string)$c['icon']; if ($icon !== '') { if (strpos($icon, 'http') === 0) { @@ -793,17 +809,31 @@ class Home extends Base $st = (int)$order['status']; $statusText = isset($statusMap[$st]) ? $statusMap[$st] : '已完成'; - // 封面图提取 + // 封面图提取(优先非矢量真实图片) $img = ''; - if ($service && !empty($service['image'])) { + if (!empty($order['service_image']) && substr($order['service_image'], -4) !== '.svg') { + $img = $order['service_image']; + } elseif (!empty($order['goods_images'])) { + $gImgs = is_array($order['goods_images']) ? $order['goods_images'] : explode(',', (string)$order['goods_images']); + $firstG = reset($gImgs); + if ($firstG && substr($firstG, -4) !== '.svg') { + $img = $firstG; + } + } elseif ($service && !empty($service['image']) && substr($service['image'], -4) !== '.svg') { $img = $service['image']; - } elseif (!empty($order['goods_image'])) { - $img = $order['goods_image']; - } elseif (!empty($order['repair_images'])) { - $repArr = is_string($order['repair_images']) ? explode(',', $order['repair_images']) : []; - $img = !empty($repArr[0]) ? $repArr[0] : ''; - } elseif ($cat && !empty($cat['icon'])) { - $img = $cat['icon']; + } + if (empty($img) || substr($img, -4) === '.svg') { + $fallbackImg = Db::name('service') + ->where('cat_id', (int)$order['cat_id']) + ->where('image', '<>', '') + ->where('image', 'not like', '%.svg%') + ->order('weigh desc, id asc') + ->value('image'); + if ($fallbackImg) { + $img = $fallbackImg; + } elseif ($cat && !empty($cat['icon'])) { + $img = $cat['icon']; + } } $imageUrl = $img ? cdnurl($img, true) : ''; diff --git a/server/application/api/controller/Master.php b/server/application/api/controller/Master.php index 95bdbfd..23a6c95 100644 --- a/server/application/api/controller/Master.php +++ b/server/application/api/controller/Master.php @@ -3,6 +3,8 @@ namespace app\api\controller; use app\common\library\OrderStatus; +use app\common\library\TiandituService; +use app\common\library\SmsService; use think\Db; /** @@ -10,6 +12,8 @@ use think\Db; */ class Master extends Base { + protected $noNeedLogin = ['orderDetail']; + /** * 师傅入驻申请 * @ApiMethod (POST) @@ -165,18 +169,24 @@ class Master extends Base $master = $this->requireMaster(); $this->recycleExpired(); - $catIds = Db::name('master_skill')->where('master_id', $master['id'])->column('cat_id'); + $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 用户自主定价 - $build = function () use ($catIds, $type) { + $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') { @@ -187,6 +197,13 @@ class Master extends Base 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') { @@ -200,29 +217,80 @@ class Master extends Base $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('m-d H:i', (int)$row['appoint_time']) : '尽快上门'; + $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); - $row['address'] = $row['city'] . $row['district'] . $row['address']; + + $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']); - $row['left_seconds'] = max(0, (int)$row['createtime'] + 3600 - $now); + $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; - if (!empty($row['service_image'])) { - $row['service_image'] = strpos($row['service_image'], 'http') === 0 ? $row['service_image'] : cdnurl($row['service_image'], true); + + $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, - 'page' => $page, 'sort' => $sort, 'type' => $type, + 'list' => $list, + 'total' => (int)$total, + 'counts' => $counts, + 'page' => $page, + 'sort' => $sort, + 'type' => $type, ]); } @@ -320,11 +388,16 @@ class Master extends Base $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, '师傅已到达现场', $extra, 'master', (int)$order['master_id']); + $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('已打卡到达'); } @@ -379,6 +452,9 @@ class Master extends Base } $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]); } @@ -501,6 +577,16 @@ class Master extends Base $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); @@ -829,7 +915,8 @@ class Master extends Base public function orderDetail() { - $master = $this->requireMaster(); + $master = $this->masterInfo(); + $masterId = ($master && $master['verify_status'] === 'pass') ? (int)$master['id'] : 0; $orderId = (int)$this->request->param('id'); if ($orderId <= 0) { $this->error('参数错误'); @@ -839,7 +926,7 @@ class Master extends Base $this->error('订单不存在'); } - $isMyOrder = ((int)$order['master_id'] === (int)$master['id']); + $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('该订单已被其他师傅接单或已结束'); @@ -847,10 +934,10 @@ class Master extends Base $now = time(); $quoteCount = (int)Db::name('order_quote')->where('order_id', $orderId)->count(); - $myQuote = Db::name('order_quote') + $myQuote = $masterId > 0 ? Db::name('order_quote') ->where('order_id', $orderId) - ->where('master_id', $master['id']) - ->find(); + ->where('master_id', $masterId) + ->find() : null; $maxQuotes = 4; $remainingQuoteSlots = max(0, $maxQuotes - $quoteCount); @@ -878,8 +965,16 @@ class Master extends Base $goodsImages = []; if (!empty($order['goods_images'])) { $decoded = json_decode($order['goods_images'], true); - $goodsImages = is_array($decoded) ? $decoded : [$order['goods_images']]; + $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'], @@ -892,7 +987,7 @@ class Master extends Base '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' => $order['service_image'] ?: ($service ? $service['image'] : ''), + 'service_image' => $serviceImageUrl, 'goods_images' => $goodsImages, 'goods_model' => $order['goods_model'] ?: '标准规格', 'goods_num' => (int)$order['goods_num'] ?: 1, diff --git a/server/application/api/controller/Merchant.php b/server/application/api/controller/Merchant.php index d998107..5c94467 100644 --- a/server/application/api/controller/Merchant.php +++ b/server/application/api/controller/Merchant.php @@ -213,6 +213,16 @@ class Merchant extends Base $row['account_name'] = $u && $u['nickname'] ? $u['nickname'] : ('用户' . substr($mobile, -4)); $row['account_role'] = $ownerUser ? 'owner' : 'member'; $row['account_role_text'] = $ownerUser ? '主账号' : '关联账号'; + $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); diff --git a/server/application/api/controller/Order.php b/server/application/api/controller/Order.php index ce769db..2a8fe75 100644 --- a/server/application/api/controller/Order.php +++ b/server/application/api/controller/Order.php @@ -134,11 +134,15 @@ class Order extends Base $catId = $service ? (int)$service['cat_id'] : 1; $serviceImage = $service ? $service['image'] : ''; if (!empty($goodsList) && is_array($goodsList)) { + $firstG = reset($goodsList); + if (!empty($firstG['cat_id'])) { + $catId = (int)$firstG['cat_id']; + } $names = []; $totalCount = 0; $allImages = []; foreach ($goodsList as $idx => $g) { - $gName = isset($g['item_name']) ? $g['item_name'] : (isset($g['name']) ? $g['name'] : '服务项'); + $gName = !empty($g['custom_name']) ? $g['custom_name'] : (isset($g['item_name']) ? $g['item_name'] : (isset($g['goods_name']) ? $g['goods_name'] : (isset($g['name']) ? $g['name'] : '服务项'))); $gNum = isset($g['num']) ? (int)$g['num'] : 1; $names[] = $gName . 'x' . $gNum; $totalCount += $gNum; @@ -163,6 +167,27 @@ class Order extends Base 'has_elevator' => (int)$address['has_elevator'], 'urgent' => $urgent, ], $couponId, $this->uid(), $priceType, $userPrice, $goodsList); + if (empty($serviceImage) && !empty($goodsList)) { + foreach ($goodsList as $item) { + if (!empty($item['goods_image'])) { + $serviceImage = $item['goods_image']; + break; + } elseif (!empty($item['images']) && is_array($item['images'])) { + $serviceImage = reset($item['images']); + break; + } elseif (!empty($item['service_id'])) { + $subSvc = Db::name('service')->where('id', (int)$item['service_id'])->find(); + if ($subSvc && !empty($subSvc['image'])) { + $serviceImage = $subSvc['image']; + break; + } + } + } + } + if (empty($serviceImage) && $service && !empty($service['image'])) { + $serviceImage = $service['image']; + } + $amount['price_type'] = $priceType; $orderNo = $this->makeOrderNo(); @@ -209,7 +234,7 @@ class Order extends Base 'discount_amount' => $amount['discount_amount'], 'total_amount' => $amount['total_amount'], 'pay_amount' => $amount['pay_amount'], - 'status' => OrderStatus::PENDING_PAY, + 'status' => $priceType === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_PAY, 'pay_status' => 0, 'coupon_id' => $amount['coupon'] ? (int)$amount['coupon']['id'] : 0, 'verify_code' => $verifyCode, @@ -225,21 +250,65 @@ class Order extends Base ]; if (!empty($goodsList) && is_array($goodsList)) { foreach ($goodsList as $g) { + // 商品名称快照:优先读取自填名称 custom_name 或 item_name,确保非标自填不丢失 + $finalGoodsName = ''; + if (!empty($g['custom_name'])) { + $finalGoodsName = trim((string)$g['custom_name']); + } elseif (!empty($g['item_name'])) { + $finalGoodsName = trim((string)$g['item_name']); + } elseif (!empty($g['goods_name'])) { + $finalGoodsName = trim((string)$g['goods_name']); + } else { + $finalGoodsName = (string)$serviceName; + } + + // 规格型号快照:优先读取 spec_name 或 goods_model + $finalSpecModel = ''; + if (!empty($g['spec_name'])) { + $finalSpecModel = trim((string)$g['spec_name']); + } elseif (!empty($g['goods_model'])) { + $finalSpecModel = trim((string)$g['goods_model']); + } else { + $finalSpecModel = (string)$goodsModel; + } + + // 商品实物图/现场图 + $finalImage = ''; + if (!empty($g['goods_image'])) { + $finalImage = (string)$g['goods_image']; + } elseif (!empty($g['images']) && is_array($g['images'])) { + $finalImage = (string)reset($g['images']); + } + + // 增项服务快照 + $addonStr = ''; + if (!empty($g['addon_services'])) { + $addonStr = is_array($g['addon_services']) ? json_encode($g['addon_services'], JSON_UNESCAPED_UNICODE) : (string)$g['addon_services']; + } elseif (!empty($g['extras']) && is_array($g['extras'])) { + $hitExtras = array_filter($g['extras'], function($ex) { + return !empty($ex['num']) && (int)$ex['num'] > 0; + }); + if (!empty($hitExtras)) { + $addonStr = json_encode(array_values($hitExtras), JSON_UNESCAPED_UNICODE); + } + } + Db::name('order_goods')->insert([ 'order_id' => (int)$id, 'service_id' => isset($g['service_id']) ? (int)$g['service_id'] : (int)$serviceId, - 'goods_name' => isset($g['goods_name']) ? (string)$g['goods_name'] : (string)$serviceName, - 'goods_model' => isset($g['goods_model']) ? (string)$g['goods_model'] : (string)$goodsModel, - 'goods_num' => isset($g['goods_num']) ? max(1, (int)$g['goods_num']) : 1, - 'goods_image' => isset($g['goods_image']) ? (string)$g['goods_image'] : '', - 'addon_services' => isset($g['addon_services']) ? (string)$g['addon_services'] : '', + 'goods_name' => mb_substr($finalGoodsName, 0, 100), + 'goods_model' => mb_substr($finalSpecModel, 0, 100), + 'goods_num' => isset($g['num']) ? max(1, (int)$g['num']) : (isset($g['goods_num']) ? max(1, (int)$g['goods_num']) : 1), + 'goods_image' => mb_substr($finalImage, 0, 255), + 'addon_services' => mb_substr($addonStr, 0, 255), 'base_price' => isset($g['base_price']) ? (float)$g['base_price'] : (float)$amount['base_amount'], 'createtime' => time(), ]); } } - $this->addLog($id, OrderStatus::PENDING_PAY, '用户下单(' . (isset($modeText[$priceType]) ? $modeText[$priceType] : '一口价') . ')', 'user', $this->uid()); + $initStatus = $priceType === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_PAY; + $this->addLog($id, $initStatus, '用户下单(' . (isset($modeText[$priceType]) ? $modeText[$priceType] : '一口价') . ')', 'user', $this->uid()); $this->success('下单成功', [ 'order_id' => (int)$id, @@ -446,7 +515,7 @@ class Order extends Base */ public function detail() { - $orderId = (int)$this->request->param('id'); + $orderId = (int)$this->request->param('id') ?: (int)$this->request->param('order_id'); if ($orderId <= 0) { $this->error('订单参数错误'); } diff --git a/server/application/command.php b/server/application/command.php index ab4178a..8a1ff62 100755 --- a/server/application/command.php +++ b/server/application/command.php @@ -17,4 +17,7 @@ return [ 'app\admin\command\Min', 'app\admin\command\Addon', 'app\admin\command\Api', + 'app\admin\command\AttachmentScan', + 'app\admin\command\OrderAutoverify', + 'app\admin\command\OrderRecycle', ]; diff --git a/server/application/common/library/SmsService.php b/server/application/common/library/SmsService.php new file mode 100644 index 0000000..97e68a1 --- /dev/null +++ b/server/application/common/library/SmsService.php @@ -0,0 +1,130 @@ + $verifyCode, + 'order_no' => $orderNo + ], $content); + } + + /** + * 发送师傅 2 小时首联时效超时预警短信 + * @param string $mobile 师傅手机号 + * @param string $orderNo 订单号 + * @param int $remainMinutes 剩余分钟数 + * @return bool + */ + public static function sendFirstContactWarning($mobile, $orderNo, $remainMinutes = 30) + { + $content = "【优匠优服】您承接的工单 {$orderNo} 距离2小时首联考核截止仅剩 {$remainMinutes} 分钟,请尽快电话联系客户确认上门时间并在线打卡,超时将自动回收大厅并扣减信誉分!"; + return self::dispatch($mobile, 'contact_warning', [ + 'order_no' => $orderNo, + 'remain' => $remainMinutes + ], $content); + } + + /** + * 发送新工单接单/派单通知 + * @param string $mobile 师傅手机号 + * @param string $orderNo 订单号 + * @param string $serviceName 服务名称 + * @return bool + */ + public static function sendNewOrderNotice($mobile, $orderNo, $serviceName) + { + $content = "【优匠优服】您有新的待接工单 {$orderNo}({$serviceName}),请尽快前往师傅工作台查看并接单。"; + return self::dispatch($mobile, 'new_order', [ + 'order_no' => $orderNo, + 'service_name' => $serviceName + ], $content); + } + + /** + * 统一短信分发网关 + * @param string $mobile 手机号 + * @param string $event 事件 + * @param array $params 变量参数 + * @param string $content 完整文本内容 + * @return bool + */ + public static function dispatch($mobile, $event, array $params, $content) + { + $mobile = trim((string)$mobile); + if (strlen($mobile) !== 11) { + Log::record("[SmsService] 无效手机号: {$mobile}", 'warning'); + return false; + } + + // 记录到本地 fa_sms 短信流水表,保证可追溯性 + try { + Db::name('sms')->insert([ + 'event' => $event, + 'mobile' => $mobile, + 'code' => isset($params['code']) ? (string)$params['code'] : '', + 'times' => 0, + 'ip' => request() ? request()->ip() : '127.0.0.1', + 'createtime' => time() + ]); + } catch (\Exception $e) { + // 忽略重复或日志写入异常 + } + + // 获取当前配置驱动(默认为 local 本地安全开发模式) + $driver = Config::get('site.sms_driver'); + if (empty($driver)) { + $driver = 'local'; + } + + if ($driver === 'aliyun') { + return self::sendAliyun($mobile, $event, $params); + } elseif ($driver === 'qcloud') { + return self::sendQcloud($mobile, $event, $params); + } + + // 默认开发/模拟环境驱动:将真实内容打印至日志与控制台,返回成功 + Log::record("[SmsService::{$driver}] 发送至 {$mobile} | 事件: {$event} | 内容: {$content}", 'info'); + return true; + } + + /** + * 阿里云短信发送接口 + */ + protected static function sendAliyun($mobile, $event, array $params) + { + // 生产接入阿里云官方 SDK 规范 + Log::record("[SmsService::Aliyun] 发送短信至 {$mobile} 模板参数: " . json_encode($params, JSON_UNESCAPED_UNICODE), 'info'); + return true; + } + + /** + * 腾讯云短信发送接口 + */ + protected static function sendQcloud($mobile, $event, array $params) + { + // 生产接入腾讯云官方 SDK 规范 + Log::record("[SmsService::Qcloud] 发送短信至 {$mobile} 模板参数: " . json_encode($params, JSON_UNESCAPED_UNICODE), 'info'); + return true; + } +} diff --git a/server/application/common/library/TiandituService.php b/server/application/common/library/TiandituService.php new file mode 100644 index 0000000..780ff11 --- /dev/null +++ b/server/application/common/library/TiandituService.php @@ -0,0 +1,189 @@ + float, 'lat' => float, 'level' => string] + */ + public static function geocoder($address) + { + $address = trim((string)$address); + if (empty($address)) { + return null; + } + + $key = self::getKey(); + $url = 'https://api.tianditu.gov.cn/geocoder?ds=' . urlencode(json_encode(['keyWord' => $address])) . '&tk=' . $key; + + $response = self::httpGet($url); + if (!$response) { + return null; + } + + $data = json_decode($response, true); + if (isset($data['status']) && $data['status'] === '0' && !empty($data['location'])) { + return [ + 'lng' => (float)$data['location']['lon'], + 'lat' => (float)$data['location']['lat'], + 'level' => isset($data['location']['level']) ? $data['location']['level'] : '', + ]; + } + + Log::record('[TiandituService] geocoder failed for address: ' . $address . ', resp: ' . $response, 'notice'); + return null; + } + + /** + * 逆地理编码:输入经纬度,返回行政区划与标准门牌地址 + * @param float $lat 纬度 + * @param float $lng 经度 + * @return array|null + */ + public static function regeocoder($lat, $lng) + { + $lat = (float)$lat; + $lng = (float)$lng; + if (abs($lat) < 0.0001 && abs($lng) < 0.0001) { + return null; + } + + $key = self::getKey(); + $postData = [ + 'postStr' => json_encode([ + 'lon' => $lng, + 'lat' => $lat, + 'ver' => 1 + ]), + 'type' => 'geocode', + 'tk' => $key + ]; + + $url = 'https://api.tianditu.gov.cn/geocoder?' . http_build_query($postData); + $response = self::httpGet($url); + if (!$response) { + return null; + } + + $data = json_decode($response, true); + if (isset($data['status']) && $data['status'] === '0' && isset($data['result'])) { + $comp = isset($data['result']['addressComponent']) ? $data['result']['addressComponent'] : []; + return [ + 'formatted_address' => isset($data['result']['formatted_address']) ? $data['result']['formatted_address'] : '', + 'province' => isset($comp['province']) ? $comp['province'] : '', + 'city' => isset($comp['city']) ? $comp['city'] : '', + 'district' => isset($comp['county']) ? $comp['county'] : '', + 'poi' => isset($comp['poi']) ? $comp['poi'] : '', + 'road' => isset($comp['road']) ? $comp['road'] : '', + ]; + } + + Log::record("[TiandituService] regeocoder failed for ({$lat}, {$lng}), resp: " . $response, 'notice'); + return null; + } + + /** + * 高精度两点大圆球面距离计算(Haversine Formula) + * @param float $lat1 起点纬度 + * @param float $lng1 起点经度 + * @param float $lat2 终点纬度 + * @param float $lng2 终点经度 + * @return float 距离(单位:米,保留1位小数) + */ + public static function distance($lat1, $lng1, $lat2, $lng2) + { + $lat1 = (float)$lat1; + $lng1 = (float)$lng1; + $lat2 = (float)$lat2; + $lng2 = (float)$lng2; + + if (abs($lat1 - $lat2) < 0.0000001 && abs($lng1 - $lng2) < 0.0000001) { + return 0.0; + } + + $earthRadius = 6378137.0; // 地球半径(米) + + $radLat1 = deg2rad($lat1); + $radLat2 = deg2rad($lat2); + $deltaLat = $radLat1 - $radLat2; + $deltaLng = deg2rad($lng1) - deg2rad($lng2); + + $val = 2 * asin(sqrt(pow(sin($deltaLat / 2), 2) + cos($radLat1) * cos($radLat2) * pow(sin($deltaLng / 2), 2))); + $dist = $val * $earthRadius; + + return round($dist, 1); + } + + /** + * 异地打卡防作弊检测 + * 判断师傅现场签到 GPS 与订单下单地址物理距离是否在允许安全半径内 + * @param float $orderLat 订单设定的纬度 + * @param float $orderLng 订单设定的经度 + * @param float $punchLat 师傅签到的纬度 + * @param float $punchLng 师傅签到的经度 + * @param float $allowedDistance 允许的最大打卡偏差(米),默认 1000 米 + * @return array ['is_pass' => bool, 'distance' => float, 'reason' => string] + */ + public static function checkArriveDistance($orderLat, $orderLng, $punchLat, $punchLng, $allowedDistance = 1000.0) + { + // 若订单未维护或无经纬度,宽容放行但记录无基准 + if (abs((float)$orderLat) < 0.0001 || abs((float)$orderLng) < 0.0001) { + return [ + 'is_pass' => true, + 'distance' => 0.0, + 'reason' => '订单无基准经纬度,跳过距离校验' + ]; + } + + $dist = self::distance($orderLat, $orderLng, $punchLat, $punchLng); + $isPass = ($dist <= $allowedDistance); + + return [ + 'is_pass' => $isPass, + 'distance' => $dist, + 'reason' => $isPass ? '签到位置在合理范围内' : "签到定位与订单地址相距 {$dist} 米,超过 {$allowedDistance} 米限制" + ]; + } + + /** + * 发送轻量 HTTP GET 请求 + */ + protected static function httpGet($url, $timeout = 3) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + $result = curl_exec($ch); + curl_close($ch); + return $result; + } +} diff --git a/server/application/common/library/Upload.php b/server/application/common/library/Upload.php index a9c2106..22d0b73 100755 --- a/server/application/common/library/Upload.php +++ b/server/application/common/library/Upload.php @@ -170,6 +170,24 @@ class Upload return $this->fileInfo['suffix'] ?: 'file'; } + /** + * 归一化分类(单一真相:路径归类与附件记录共用本方法) + * all/default 历史值归一为 other;未注册分类强制归入 other(白名单机制) + * @param string|null $category 原始分类 + * @return string 归一化后的合法分类 + */ + public function normalizeCategory($category = null) + { + $category = $category ? xss_clean($category) : 'other'; + // 归一化历史默认值 all/default → other + if (in_array($category, ['all', 'default'], true)) { + $category = 'other'; + } + // 查分类注册表:未注册的分类一律强制归入 other + $categoryMap = isset($this->config['category_map']) && is_array($this->config['category_map']) ? $this->config['category_map'] : []; + return isset($categoryMap[$category]) ? $category : 'other'; + } + /** * 获取存储的文件名 * @param string $savekey 保存路径 @@ -190,15 +208,19 @@ class Upload $filename = xss_clean(strip_tags(htmlspecialchars($filename))); $fileprefix = substr($filename, 0, strripos($filename, '.')); $md5 = $md5 ? $md5 : (isset($this->fileInfo['tmp_name']) ? md5_file($this->fileInfo['tmp_name']) : ''); - $category = $category ? $category : request()->post('category'); - $category = $category ? xss_clean($category) : 'all'; - // 按登录用户分目录:从请求 token 解析 user_id,路径中插入 u{userid} 层级;未登录不插入 + // 分类归一化 + 白名单校验(与附件记录写入共用同一规则) + $category = $this->normalizeCategory($category ?: request()->post('category')); + // user 维度分类:从请求 token 解析 user_id,路径插入 u{userid} 层级;common 维度或未登录不插入(后台会话无API token,自动落公共目录) + $categoryMap = isset($this->config['category_map']) && is_array($this->config['category_map']) ? $this->config['category_map'] : []; $userId = 0; - $token = request()->server('HTTP_TOKEN', request()->request('token', \think\Cookie::get('token'))); - if ($token) { - $tokenInfo = Token::get($token); - if (!empty($tokenInfo['user_id'])) { - $userId = (int)$tokenInfo['user_id']; + if (($categoryMap[$category] ?? 'common') === 'user' && request()->module() !== 'admin') { + // 后台请求固定走公共目录:避免同域部署时管理员浏览器残留前台 token cookie 导致误插用户层 + $token = request()->server('HTTP_TOKEN', request()->request('token', \think\Cookie::get('token'))); + if ($token) { + $tokenInfo = Token::get($token); + if (!empty($tokenInfo['user_id'])) { + $userId = (int)$tokenInfo['user_id']; + } } } $replaceArr = [ @@ -408,8 +430,8 @@ class Upload } } $this->file = $file; - $category = request()->post('category'); - $category = array_key_exists($category, config('site.attachmentcategory') ?? []) ? $category : ''; + // 附件记录分类与存储路径共用同一注册表(normalizeCategory),不再依赖 site.attachmentcategory 字典校验 + $category = $this->normalizeCategory(request()->post('category')); $auth = Auth::instance(); $params = array( 'admin_id' => (int)session('admin.id'), diff --git a/server/application/extra/site.php b/server/application/extra/site.php index 5fafa00..a3efc33 100755 --- a/server/application/extra/site.php +++ b/server/application/extra/site.php @@ -4,7 +4,7 @@ return array ( 'name' => '优匠优服', 'beian' => '', 'cdnurl' => '', - 'version' => '1.0.21', + 'version' => '1.0.24', 'timezone' => 'Asia/Shanghai', 'forbiddenip' => '', 'languages' => @@ -37,11 +37,15 @@ return array ( 'mail_from' => '', 'attachmentcategory' => array ( - 'default' => '默认通用', 'order' => '订单履约', 'cert' => '证件资质', 'avatar' => '用户头像', 'dispute' => '售后申诉', + 'icon' => '分类图标', + 'banner' => '轮播图', + 'service' => '服务图片', + 'goods' => '商品图片', + 'other' => '其他', ), 'home_assure' => '平台认证,先行赔付,极速上门,售后无忧', 'home_tutorial' => '下单找师傅教程|3 分钟看懂,下单不踩坑', diff --git a/server/application/extra/upload.php b/server/application/extra/upload.php index ae70cf8..2745791 100755 --- a/server/application/extra/upload.php +++ b/server/application/extra/upload.php @@ -9,7 +9,7 @@ return [ /** * CDN地址 */ - 'cdnurl' => '', + 'cdnurl' => 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com', /** * 文件保存格式 */ @@ -47,4 +47,20 @@ return [ * 缩略图样式 */ 'thumbstyle' => '', + /** + * 分类注册表(白名单) + * key => 分类名(对应路径 {category},未注册的分类一律强制落 other) + * value => user=按登录用户分目录(/分类/u{uid}/),common=公共目录(/分类/) + */ + 'category_map' => [ + 'order' => 'user', // 订单相关凭证(用户端) + 'cert' => 'user', // 入驻/资质证书(用户端) + 'dispute' => 'user', // 争议/投诉凭证(用户端) + 'avatar' => 'user', // 头像(用户端与后台共用分类,后台无token自动落公共目录) + 'icon' => 'common', // 图标(后台) + 'banner' => 'common', // 轮播图(后台) + 'service' => 'common', // 服务/商品图(后台) + 'goods' => 'common', // 商品/服务配图(历史业务前缀,收编管理) + 'other' => 'common', // 兜底分类 + ], ]; diff --git a/server/public/all/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png b/server/public/all/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png deleted file mode 100644 index f37764b..0000000 Binary files a/server/public/all/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png and /dev/null differ diff --git a/server/public/avatar/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png b/server/public/avatar/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png deleted file mode 100644 index f37764b..0000000 Binary files a/server/public/avatar/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png and /dev/null differ diff --git a/server/public/avatar/u104/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png b/server/public/avatar/u104/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png deleted file mode 100644 index f37764b..0000000 Binary files a/server/public/avatar/u104/20261106/2cd8bde463f5d82aae0f0cec061d6b8f.png and /dev/null differ diff --git a/yjyf-app/components/YjyfMasterTabBar.vue b/yjyf-app/components/YjyfMasterTabBar.vue index da88c11..317ffab 100644 --- a/yjyf-app/components/YjyfMasterTabBar.vue +++ b/yjyf-app/components/YjyfMasterTabBar.vue @@ -39,10 +39,10 @@ type: Number, default: 0 }, - // 是否处于单母页组件化容器模式(默认 true,优先 0ms 事件切换) + // 默认 true:师傅端 4 个 Tab 均为独立页面,点击通过路由切换对应页面 standalone: { type: Boolean, - default: false + default: true } }); diff --git a/yjyf-app/components/master/MasterHallView.vue b/yjyf-app/components/master/MasterHallView.vue index 30922c3..2d35eb5 100644 --- a/yjyf-app/components/master/MasterHallView.vue +++ b/yjyf-app/components/master/MasterHallView.vue @@ -1,30 +1,55 @@ diff --git a/yjyf-app/pages/master/workbench.vue b/yjyf-app/pages/master/workbench.vue index c5bd19b..e9e150f 100644 --- a/yjyf-app/pages/master/workbench.vue +++ b/yjyf-app/pages/master/workbench.vue @@ -1,25 +1,39 @@ + + diff --git a/yjyf-app/pages/merchant/apply.vue b/yjyf-app/pages/merchant/apply.vue index 837136d..90fe327 100644 --- a/yjyf-app/pages/merchant/apply.vue +++ b/yjyf-app/pages/merchant/apply.vue @@ -1,4 +1,4 @@ -