AccountService.php 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073
  1. <?php
  2. namespace App\Services\Coach;
  3. use App\Models\CoachUser;
  4. use App\Enums\OrderStatus;
  5. use App\Models\MemberUser;
  6. use App\Models\CoachLocation;
  7. use App\Models\CoachSchedule;
  8. use Illuminate\Support\Facades\DB;
  9. use App\Enums\TechnicianAuthStatus;
  10. use App\Enums\TechnicianWorkStatus;
  11. use Illuminate\Support\Facades\Log;
  12. use App\Enums\TechnicianLocationType;
  13. use Illuminate\Support\Facades\Cache;
  14. use Illuminate\Support\Facades\Redis;
  15. use App\Traits\LocationValidationTrait;
  16. class AccountService
  17. {
  18. use LocationValidationTrait;
  19. private const CACHE_KEY_PREFIX = 'coach_info_';
  20. private const CACHE_TTL = 300; // 5分钟
  21. /**
  22. * 提交技师基本信息
  23. * 包括个人基础资料的提交和审核
  24. *
  25. * 业务流程:
  26. * 1. 验证技师信息存在性
  27. * 2. 检查是否有待审核的记录
  28. * 3. 处理生活照片数据
  29. * 4. 创建新的基本信息记录
  30. * 5. 清除相关缓存
  31. *
  32. * 注意事项:
  33. * - 同一时间只能有一条待审核记录
  34. * - 审核不通过可以重新提交
  35. * - 头像和生活照片不限制格式
  36. * - 除性别和手机号外,其他字段均为可选
  37. * - 手机号会进行脱敏处理
  38. *
  39. * @param User $user 当前认证用户
  40. * @param array $data 基本信息数据,包含:
  41. * - nickname: string|null 昵称(可选)
  42. * - avatar: string|null 头像图片(可选)
  43. * - life_photos: array|null 生活照片数组(可选)
  44. * - gender: string 性别(1:男 2:女)
  45. * - mobile: string 手机号
  46. * - birthday: string|null 出生日期(可选)
  47. * - work_years: int|null 工作年限(可选)
  48. * - intention_city: string|null 意向城市(可选)
  49. * - introduction: string|null 个人简介(可选)
  50. * @return array 返回结果,包含:
  51. * - message: string 提示信息
  52. * @throws \Exception 当验证失败或保存失败时抛出异常
  53. */
  54. public function submitBaseInfo($user, array $data)
  55. {
  56. // 开启数据库事务,确保数据一致性
  57. DB::beginTransaction();
  58. try {
  59. // 验证技师信息是否存在,不存在则抛出404异常
  60. abort_if(!$user->coach, 404, '技师信息不存在');
  61. // 检查是否有待审核的记录,避免重复提交
  62. // 如果存在待审核记录,则抛出422异常
  63. $pendingRecord = $this->hasPendingRecord($user->coach, 'info');
  64. abort_if($pendingRecord, 422, '已有待审核的基本信息记录');
  65. // 处理生活照片数据:将数组转换为JSON格式存储
  66. // 如果没有生活照片数据,则保持原样
  67. if (isset($data['life_photos'])) {
  68. // 使用array_values确保数组索引连续
  69. $data['life_photos'] = json_encode(array_values($data['life_photos']));
  70. }
  71. // 创建新的基本信息记录,设置为待审核状态
  72. // 合并用户提交的数据和审核状态
  73. $user->coach->infoRecords()->create(array_merge($data, [
  74. 'state' => TechnicianAuthStatus::AUDITING->value, // 设置为待审核状态
  75. ]));
  76. // 清除技师信息缓存,确保数据一致性
  77. // 避免用户获取到旧的缓存数据
  78. $this->clearCoachCache($user->coach->id);
  79. // 提交事务,确保所有操作成功
  80. DB::commit();
  81. // 返回成功结果
  82. return ['message' => '基本信息提交成功'];
  83. } catch (\Exception $e) {
  84. // 发生异常时回滚事务,确保数据一致性
  85. DB::rollBack();
  86. // 向上抛出异常,由调用方处理
  87. throw $e;
  88. }
  89. }
  90. /**
  91. * 提交技师资质信息
  92. * 包括资质证书照片、营业执照和健康证照片的提交和审核
  93. *
  94. * 业务流程:
  95. * 1. 验证技师信息存在性
  96. * 2. 检查是否有待审核的记录
  97. * 3. 创建新的资质审核记录
  98. * 4. 清除相关缓存
  99. *
  100. * 注意事项:
  101. * - 同一时间只能有一条待审核记录
  102. * - 审核不通过可以重新提交
  103. * - 所有图片数据不限制格式
  104. *
  105. * @param User $user 当前认证用户
  106. * @param array $data 资质信息数据,包含:
  107. * - qual_type: int 资质类型(1:初级 2:中级 3:高级)
  108. * - qual_photo: string 资质证书照片
  109. * - business_license: string 营业执照照片
  110. * - health_cert: string 健康证照片
  111. * @return array 返回结果,包含:
  112. * - message: string 提示信息
  113. * - data: array 详细数据
  114. * - record_id: int 记录ID
  115. * - state: int 状态值
  116. * - state_text: string 状态文本
  117. * @throws \Exception 当验证失败或保存失败时抛出异常
  118. */
  119. public function submitQualification($user, array $data)
  120. {
  121. // 开启数据库事务
  122. DB::beginTransaction();
  123. try {
  124. // 验证技师信息是否存在
  125. abort_if(!$user->coach, 404, '技师信息不存在');
  126. // 检查是否有待审核的记录,避免重复提交
  127. $pendingRecord = $this->hasPendingRecord($user->coach, 'qual');
  128. abort_if($pendingRecord, 422, '已有待审核的资质信息记录');
  129. // 创建新的资质审核记录,设置为待审核状态
  130. $record = $user->coach->qualRecords()->create(array_merge($data, [
  131. 'state' => TechnicianAuthStatus::AUDITING->value,
  132. ]));
  133. // 清除技师信息缓存,确保数据一致性
  134. $this->clearCoachCache($user->coach->id);
  135. // 提交事务
  136. DB::commit();
  137. // 返回成功结果
  138. return [
  139. 'message' => '资质信息提交成功',
  140. 'data' => [
  141. 'record_id' => $record->id,
  142. 'state' => TechnicianAuthStatus::AUDITING->value,
  143. 'state_text' => TechnicianAuthStatus::fromValue(TechnicianAuthStatus::AUDITING->value)->label(),
  144. ]
  145. ];
  146. } catch (\Exception $e) {
  147. // 发生异常时回滚事务
  148. DB::rollBack();
  149. throw $e;
  150. }
  151. }
  152. /**
  153. * 提交实名认证信息
  154. * 包括姓名(可选)、身份证号(可选)和三张身份证照片的提交和审核
  155. *
  156. * 业务流程:
  157. * 1. 验证技师信息存在性
  158. * 2. 检查是否有待审核的记录
  159. * 3. 创建新的实名认证记录
  160. * 4. 清除相关缓存
  161. *
  162. * 注意事项:
  163. * - 同一时间只能有一条待审核记录
  164. * - 审核不通过可以重新提交
  165. * - 所有图片数据不限制格式
  166. * - 姓名和身份证号为可选字段
  167. * - 敏感信息会进行脱敏处理
  168. *
  169. * @param User $user 当前认证用户
  170. * @param array $data 实名认证数据,包含:
  171. * - real_name: string|null 真实姓名(可选)
  172. * - id_card: string|null 身份证号(可选)
  173. * - id_card_front_photo: string 身份证正面照片
  174. * - id_card_back_photo: string 身份证反面照片
  175. * - id_card_hand_photo: string 手持身份证照片
  176. * @return array 返回结果
  177. * @throws \Exception 当验证失败或保存失败时抛出异常
  178. */
  179. public function submitRealName($user, array $data)
  180. {
  181. // 开启数据库事务
  182. DB::beginTransaction();
  183. try {
  184. // 验证技师信息是否存在
  185. abort_if(!$user->coach, 404, '技师信息不存在');
  186. // 检查是否有待审核的记录,避免重复提交
  187. $pendingRecord = $this->hasPendingRecord($user->coach, 'real');
  188. abort_if($pendingRecord, 422, '已有待审核的实名认证信息');
  189. // 创建新的实名认证记录,设置为待审核状态
  190. $user->coach->realRecords()->create(array_merge($data, [
  191. 'state' => TechnicianAuthStatus::AUDITING->value,
  192. ]));
  193. // 清除技师信息缓存,确保数据一致性
  194. $this->clearCoachCache($user->coach->id);
  195. // 提交事务
  196. DB::commit();
  197. // 返回成功结果
  198. return ['message' => '实名认证信息提交成功'];
  199. } catch (\Exception $e) {
  200. // 发生异常时回滚事务
  201. DB::rollBack();
  202. throw $e;
  203. }
  204. }
  205. /**
  206. * 获取技师信息
  207. * 包括基本信息、资质信息和实名认证信息
  208. *
  209. * 业务流程:
  210. * 1. 验证用户和技师信息存在性
  211. * 2. 尝试从缓存获取数据
  212. * 3. 如果缓存不存在,从数据库获取并缓存
  213. *
  214. * 注意事项:
  215. * - 所有图片数据不限制格式
  216. * - 敏感信息会进行脱敏处理
  217. * - 使用缓存提高性能
  218. *
  219. * @param User $user 当前认证用户
  220. * @return array 返回技师所有信息,包含:
  221. * - base_info: array|null 基本信息
  222. * - qualification: array|null 资质信息
  223. * - real_name: array|null 实名认证信息
  224. * @throws \Exception 当验证失败时抛出异常
  225. */
  226. public function getCoachInfo($user)
  227. {
  228. // 验证用户和技师信息
  229. abort_if(!$user, 404, '用户不存在');
  230. abort_if(!$user->coach, 404, '技师信息不存在');
  231. // 尝试从缓存获取数据
  232. return Cache::remember(
  233. self::CACHE_KEY_PREFIX . $user->coach->id,
  234. self::CACHE_TTL,
  235. function () use ($user) {
  236. // 缓存不存在时,从数据库获取
  237. return $this->fetchCoachInfo($user->coach);
  238. }
  239. );
  240. }
  241. /**
  242. * 获取技师详细信息
  243. * 从数据库获取最新的认证记录信息
  244. *
  245. * @param CoachUser $coach 技师对象
  246. * @return array 格式化后的技师信息
  247. */
  248. private function fetchCoachInfo($coach)
  249. {
  250. // 获取最新的各类认证记录
  251. $baseInfo = $coach->infoRecords()->latest()->first();
  252. $qualification = $coach->qualRecords()->latest()->first();
  253. $realName = $coach->realRecords()->latest()->first();
  254. // 格式化并返回数据
  255. return [
  256. 'base_info' => $baseInfo ? $this->formatBaseInfo($baseInfo) : null,
  257. 'qualification' => $qualification ? $this->formatQualification($qualification) : null,
  258. 'real_name' => $realName ? $this->formatRealName($realName) : null,
  259. ];
  260. }
  261. /**
  262. * 格式化基本信息
  263. * 处理技师基本信息的展示格式
  264. *
  265. * @param object $info 基本信息记录对象
  266. * @return array 格式化后的基本信息
  267. */
  268. private function formatBaseInfo($info)
  269. {
  270. // 返回格式化后的基本信息,包含状态文本
  271. return [
  272. 'nickname' => $info->nickname,
  273. 'avatar' => $info->avatar, // 支持任意格式的图片数据
  274. 'life_photos' => json_decode($info->life_photos, true) ?? [], // 生活照片数组
  275. 'gender' => $info->gender,
  276. 'mobile' => $this->maskMobile($info->mobile), // 手机号脱敏处理
  277. 'birthday' => $info->birthday,
  278. 'work_years' => $info->work_years,
  279. 'intention_city' => $info->intention_city,
  280. 'introduction' => $info->introduction,
  281. 'state' => $info->state,
  282. 'state_text' => TechnicianAuthStatus::fromValue($info->state)->label(),
  283. 'audit_remark' => $info->audit_remark,
  284. ];
  285. }
  286. /**
  287. * 格式化资质信息
  288. * 处理技师资质信息的展示格式
  289. *
  290. * @param object $qual 资质记录对象
  291. * @return array 格式化后的资质信息
  292. */
  293. private function formatQualification($qual)
  294. {
  295. // 资质类型文本映射
  296. $qualTypeMap = [
  297. 1 => '初级按摩师',
  298. 2 => '中级按摩师',
  299. 3 => '高级按摩师',
  300. ];
  301. // 返回格式化后的资质信息,包含状态文本
  302. return [
  303. 'qual_type' => $qual->qual_type,
  304. 'qual_type_text' => $qualTypeMap[$qual->qual_type] ?? '未知类型', // 添加类型文本
  305. 'qual_photo' => $qual->qual_photo, // 支持任意格式的图片数据
  306. 'business_license' => $qual->business_license, // 支持任意格式的图片数据
  307. 'health_cert' => $qual->health_cert, // 支持任意格式的图片数据
  308. 'state' => $qual->state,
  309. 'state_text' => TechnicianAuthStatus::fromValue($qual->state)->label(),
  310. 'audit_remark' => $qual->audit_remark,
  311. ];
  312. }
  313. /**
  314. * 格式化实名信息
  315. * 处理技师实名认证信息的展示格式
  316. *
  317. * @param object $real 实名认证记录对象
  318. * @return array 格式化后的实名信息
  319. */
  320. private function formatRealName($real)
  321. {
  322. // 返回格式化后的实名信息,包含状态文本和脱敏处理
  323. return [
  324. 'real_name' => $real->real_name,
  325. 'id_card' => $this->maskIdCard($real->id_card), // 身份证号脱敏处理
  326. 'id_card_front_photo' => $real->id_card_front_photo, // 支持任意格式的图片数据
  327. 'id_card_back_photo' => $real->id_card_back_photo, // 支持任意格式的图片数据
  328. 'id_card_hand_photo' => $real->id_card_hand_photo, // 支持任意格式的图片数据
  329. 'state' => $real->state,
  330. 'state_text' => TechnicianAuthStatus::fromValue($real->state)->label(),
  331. 'audit_remark' => $real->audit_remark,
  332. ];
  333. }
  334. /**
  335. * 手机号脱敏
  336. * 将手机号中间4位替换为****
  337. *
  338. * @param string $mobile 原始手机号
  339. * @return string 脱敏后的手机号
  340. */
  341. private function maskMobile($mobile)
  342. {
  343. return substr_replace($mobile, '****', 3, 4);
  344. }
  345. /**
  346. * 身份证号脱敏
  347. * 将身份证号中间8位替换为****
  348. *
  349. * @param string|null $idCard 原始身份证号
  350. * @return string|null 脱敏后的身份证号
  351. */
  352. private function maskIdCard($idCard)
  353. {
  354. if (!$idCard) {
  355. return null;
  356. }
  357. return substr_replace($idCard, '****', 6, 8);
  358. }
  359. /**
  360. * 清除技师信息缓存
  361. */
  362. private function clearCoachCache($coachId)
  363. {
  364. Cache::forget(self::CACHE_KEY_PREFIX . $coachId);
  365. }
  366. /**
  367. * 设置定位信息
  368. * 支持设置当前位置和常用位置,包含地理位置和行政区划信息
  369. *
  370. * 业务流程:
  371. * 1. 验证经纬度参数
  372. * 2. 验证位置类型
  373. * 3. 保存到Redis的地理位置数据结构
  374. * 4. 同步保存到数据库
  375. *
  376. * @param int $coachId 技师ID
  377. * @param float $latitude 纬度
  378. * @param float $longitude 经度
  379. * @param int $type 位置类型 (current:1|common:2)
  380. * @param array $locationInfo 位置信息,包含:
  381. * - province: string|null 省份
  382. * - city: string|null 城市
  383. * - district: string|null 区县
  384. * - address: string|null 详细地址
  385. * - adcode: string|null 行政区划代码
  386. * @return bool 返回缓存更新结果
  387. * @throws \Exception 当验证失败或保存失败时抛出异常
  388. */
  389. public function setLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value, array $locationInfo = [])
  390. {
  391. // 使用事务确保数据一致性
  392. return DB::transaction(function () use ($coachId, $latitude, $longitude, $type, $locationInfo) {
  393. // 验证经纬度的有效性(-90≤纬度≤90,-180≤经度≤180)
  394. $this->validateCoordinates($latitude, $longitude);
  395. // 验证位置类型是否为有效值(1:当前位置 2:常用位置)
  396. $this->validateLocationType($type);
  397. // 格式化并验证位置信息(省市区、地址、行政区划代码)
  398. $formattedLocation = $this->formatLocationInfo($locationInfo);
  399. // 更新Redis地理位置缓存,用于实时位置查询
  400. $result = $this->updateLocationCache($coachId, $longitude, $latitude, $type);
  401. // 同步更新数据库,保存历史位置记录
  402. CoachLocation::updateOrCreate(
  403. // 查询条件:根据技师ID和位置类型确定唯一记录
  404. ['coach_id' => $coachId, 'type' => $type],
  405. // 更新数据:合并基础位置信息和格式化后的地址信息
  406. array_merge([
  407. 'latitude' => $latitude,
  408. 'longitude' => $longitude,
  409. ], $formattedLocation)
  410. );
  411. return $result;
  412. });
  413. }
  414. /**
  415. * 获取技师位置信息
  416. *
  417. * @param int $userId 用户ID
  418. * @return array 位置信息
  419. */
  420. public function getLocation($userId)
  421. {
  422. try {
  423. // 改进:直接使用 coach 模型
  424. $user = MemberUser::find($userId);
  425. abort_if(! $user, 404, '用户不存在');
  426. abort_if(! $user->coach, 404, '技师信息不存在');
  427. // 获取常用位置信息
  428. $location = $user->coach->locations()
  429. ->where('type', TechnicianLocationType::COMMON->value)
  430. ->first();
  431. $result = [
  432. 'address' => $location ? $location->location : null,
  433. ];
  434. // 记录日志
  435. Log::info('获取技师常用位置信息成功', [
  436. 'coach_id' => $user->coach->id,
  437. 'location' => $result,
  438. ]);
  439. return $result;
  440. } catch (\Exception $e) {
  441. Log::error('获取技师常用位置信息异常', [
  442. 'coach_id' => $user->coach->id ?? null,
  443. 'error' => $e->getMessage(),
  444. 'file' => $e->getFile(),
  445. 'line' => $e->getLine(),
  446. ]);
  447. throw $e;
  448. }
  449. }
  450. /**
  451. * 设置技师排班时间(每天通用)
  452. *
  453. * @param int $userId 技师用户ID
  454. * @param array $timeRanges 时间段数组 格式: [
  455. * ['start_time' => '09:00', 'end_time' => '12:00'],
  456. * ['start_time' => '14:00', 'end_time' => '18:00']
  457. * ]
  458. *
  459. * @throws \Exception
  460. */
  461. public function setSchedule(int $userId, array $timeRanges): array
  462. {
  463. return DB::transaction(function () use ($userId, $timeRanges) {
  464. try {
  465. // 获取技师信息
  466. $user = MemberUser::with(['coach'])->findOrFail($userId);
  467. $coach = $user->coach;
  468. abort_if(! $coach, 404, '技师信息不存在');
  469. // 验证并排序时间段
  470. $sortedRanges = $this->validateAndSortTimeRanges($timeRanges);
  471. // 创建或更新排班记录
  472. $schedule = CoachSchedule::updateOrCreate(
  473. [
  474. 'coach_id' => $coach->id,
  475. ],
  476. [
  477. 'time_ranges' => json_encode($sortedRanges),
  478. 'state' => 1,
  479. ]
  480. );
  481. // 更新Redis缓存
  482. $this->updateScheduleCache($coach->id, $sortedRanges);
  483. // 记录日志
  484. Log::info('技师排班设置成功', [
  485. 'coach_id' => $coach->id,
  486. 'time_ranges' => $sortedRanges,
  487. ]);
  488. return [
  489. 'status' => true,
  490. 'message' => '排班设置成功',
  491. 'data' => [
  492. 'coach_id' => $coach->id,
  493. 'time_ranges' => $sortedRanges,
  494. 'updated_at' => $schedule->updated_at->toDateTimeString(),
  495. ],
  496. ];
  497. } catch (\Exception $e) {
  498. Log::error('技师排班设置败', [
  499. 'user_id' => $userId,
  500. 'time_ranges' => $timeRanges,
  501. 'error' => $e->getMessage(),
  502. 'trace' => $e->getTraceAsString(),
  503. ]);
  504. throw $e;
  505. }
  506. });
  507. }
  508. /**
  509. * 验证并排序时间段
  510. */
  511. private function validateAndSortTimeRanges(array $timeRanges): array
  512. {
  513. // 验证时间段数组
  514. abort_if(empty($timeRanges), 400, '必须至少设置一个时间段');
  515. // 验证每个时间段格式并转换为分钟数进行比较
  516. $ranges = collect($timeRanges)->map(function ($range) {
  517. abort_if(
  518. ! isset($range['start_time'], $range['end_time']),
  519. 400,
  520. '时间段格式错误'
  521. );
  522. // 验证时间格式
  523. foreach (['start_time', 'end_time'] as $field) {
  524. abort_if(
  525. ! preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $range[$field]),
  526. 400,
  527. '时间格式错,应为HH:mm格式'
  528. );
  529. }
  530. // 转换为分钟数便于比较
  531. $startMinutes = $this->timeToMinutes($range['start_time']);
  532. $endMinutes = $this->timeToMinutes($range['end_time']);
  533. // 验证时间先后
  534. abort_if(
  535. $startMinutes >= $endMinutes,
  536. 400,
  537. "时间段 {$range['start_time']}-{$range['end_time']} 结束时间必须大于开始时间"
  538. );
  539. return [
  540. 'start_time' => $range['start_time'],
  541. 'end_time' => $range['end_time'],
  542. 'start_minutes' => $startMinutes,
  543. 'end_minutes' => $endMinutes,
  544. ];
  545. })
  546. ->sortBy('start_minutes')
  547. ->values();
  548. // 验证时间段是否重叠
  549. $ranges->each(function ($range, $index) use ($ranges) {
  550. if ($index > 0) {
  551. $prevRange = $ranges[$index - 1];
  552. abort_if(
  553. $range['start_minutes'] <= $prevRange['end_minutes'],
  554. 400,
  555. "时间段 {$prevRange['start_time']}-{$prevRange['end_time']} 和 " .
  556. "{$range['start_time']}-{$range['end_time']} 之间存在重叠"
  557. );
  558. }
  559. });
  560. // 返回排序后的时间,只保留需要的字段
  561. return $ranges->map(function ($range) {
  562. return [
  563. 'start_time' => $range['start_time'],
  564. 'end_time' => $range['end_time'],
  565. ];
  566. })->toArray();
  567. }
  568. /**
  569. * 将时间转换为分钟数
  570. */
  571. private function timeToMinutes(string $time): int
  572. {
  573. [$hours, $minutes] = explode(':', $time);
  574. return (int) $hours * 60 + (int) $minutes;
  575. }
  576. /**
  577. * 更新Redis缓存
  578. */
  579. private function updateScheduleCache(int $coachId, array $timeRanges): void
  580. {
  581. try {
  582. $cacheKey = "coach:schedule:{$coachId}";
  583. $cacheData = [
  584. 'updated_at' => now()->toDateTimeString(),
  585. 'time_ranges' => $timeRanges,
  586. ];
  587. Redis::setex($cacheKey, 86400, json_encode($cacheData));
  588. // 清除相关的可预约时间段缓存
  589. $this->clearTimeSlotCache($coachId);
  590. } catch (\Exception $e) {
  591. Log::error('更新排班缓存失败', [
  592. 'coach_id' => $coachId,
  593. 'error' => $e->getMessage(),
  594. ]);
  595. // 缓存更新失败不影响主流程
  596. }
  597. }
  598. /**
  599. * 清除可预约时间段缓存
  600. */
  601. public function clearTimeSlotCache(int $coachId): void
  602. {
  603. try {
  604. $pattern = "coach:timeslots:{$coachId}:*";
  605. $keys = Redis::keys($pattern);
  606. if (! empty($keys)) {
  607. Redis::del($keys);
  608. }
  609. } catch (\Exception $e) {
  610. Log::error('清除时间段缓存失败', [
  611. 'coach_id' => $coachId,
  612. 'error' => $e->getMessage(),
  613. ]);
  614. }
  615. }
  616. /**
  617. * 更改技师工作状态
  618. *
  619. * @param int $userId 用户ID
  620. * @param int $status 状态(1:休息中 2:工作中)
  621. */
  622. public function updateWorkStatus(int $userId, int $status): array
  623. {
  624. DB::beginTransaction();
  625. try {
  626. // 获取技师信息
  627. $user = MemberUser::with(['coach', 'coach.infoRecords', 'coach.qualRecords', 'coach.realRecords'])
  628. ->findOrFail($userId);
  629. $coach = $user->coach;
  630. abort_if(! $coach, 404, '技师信息不存在');
  631. // 验证状态值
  632. abort_if(! in_array($status, [1, 2]), 400, '无效的状态值');
  633. // 验证技师认证状态
  634. $this->validateCoachStatus($coach);
  635. // 获取当前时间是否在排班时间内
  636. $isInSchedule = $this->checkScheduleTime($coach->id);
  637. $currentStatus = $coach->work_status;
  638. $newStatus = $status;
  639. // 如果要切换到休息状态
  640. if ($status === 1) {
  641. // 验证当前状态是否允许切换到休息
  642. $this->validateRestStatus($currentStatus);
  643. $newStatus = TechnicianWorkStatus::REST->value;
  644. }
  645. // 如果要切换到工作状态
  646. elseif ($status === 2) {
  647. // 验证是否在排班时间内
  648. abort_if(! $isInSchedule, 422, '当前时间不在排班时间内,无法切换到工作状态');
  649. // 检查是否有进行中的订单
  650. $hasActiveOrder = $coach->orders()
  651. ->whereIn('state', [
  652. OrderStatus::ACCEPTED->value, // 已接单
  653. OrderStatus::DEPARTED->value, // 已出发
  654. OrderStatus::ARRIVED->value, // 已到达
  655. OrderStatus::SERVING->value, // 服务中
  656. ])
  657. ->exists();
  658. // 根据是否有进行中订单决定状态
  659. $newStatus = $hasActiveOrder ?
  660. TechnicianWorkStatus::BUSY->value :
  661. TechnicianWorkStatus::FREE->value;
  662. }
  663. // 如果状态没有变,则不需要更新
  664. if ($currentStatus === $newStatus) {
  665. DB::rollBack();
  666. return [
  667. 'status' => true,
  668. 'message' => '状态未发生变化',
  669. 'data' => [
  670. 'work_status' => $newStatus,
  671. 'work_status_text' => TechnicianWorkStatus::fromValue($newStatus)->label(),
  672. 'updated_at' => now()->toDateTimeString(),
  673. ],
  674. ];
  675. }
  676. // 更新状态
  677. $coach->work_status = $newStatus;
  678. $coach->save();
  679. // 更新Redis缓存
  680. $this->updateWorkStatusCache($coach->id, $newStatus);
  681. DB::commit();
  682. // 记录日志
  683. Log::info('技师工作状态更新成功', [
  684. 'coach_id' => $coach->id,
  685. 'old_status' => $currentStatus,
  686. 'new_status' => $newStatus,
  687. 'updated_at' => now()->toDateTimeString(),
  688. 'in_schedule' => $isInSchedule,
  689. ]);
  690. return [
  691. 'status' => true,
  692. 'message' => '状态更新成功',
  693. 'data' => [
  694. 'work_status' => $newStatus,
  695. 'work_status_text' => TechnicianWorkStatus::fromValue($newStatus)->label(),
  696. 'updated_at' => now()->toDateTimeString(),
  697. ],
  698. ];
  699. } catch (\Exception $e) {
  700. DB::rollBack();
  701. Log::error('技师工作状态更新失败', [
  702. 'user_id' => $userId,
  703. 'status' => $status,
  704. 'error' => $e->getMessage(),
  705. 'trace' => $e->getTraceAsString(),
  706. ]);
  707. throw $e;
  708. }
  709. }
  710. /**
  711. * 验证技师认证状态
  712. */
  713. private function validateCoachStatus($coach): void
  714. {
  715. // 验证基本信息认证
  716. $baseInfo = $coach->info;
  717. abort_if(
  718. ! $baseInfo || $baseInfo->state !== TechnicianAuthStatus::PASSED->value,
  719. 422,
  720. '基本信息未认证通过'
  721. );
  722. // 验证资质认证
  723. $qualification = $coach->qual;
  724. abort_if(
  725. ! $qualification || $qualification->state !== TechnicianAuthStatus::PASSED->value,
  726. 422,
  727. '资质信息未认证通过'
  728. );
  729. // 验证实名认证
  730. $realName = $coach->real;
  731. abort_if(
  732. ! $realName || $realName->state !== TechnicianAuthStatus::PASSED->value,
  733. 422,
  734. '实名信息未认证通过'
  735. );
  736. }
  737. /**
  738. * 验证是否可以切换到休息状态
  739. */
  740. private function validateRestStatus(int $currentStatus): void
  741. {
  742. // 只有在空闲或忙碌状态下才能改为休息状态
  743. abort_if(! in_array($currentStatus, [
  744. TechnicianWorkStatus::FREE->value,
  745. TechnicianWorkStatus::BUSY->value,
  746. ]), 422, '当前状态不能更改为休息状态');
  747. }
  748. /**
  749. * 检查当前时间是否在排班时间内
  750. */
  751. private function checkScheduleTime(int $coachId): bool
  752. {
  753. try {
  754. $schedule = CoachSchedule::where('coach_id', $coachId)
  755. ->where('state', 1)
  756. ->first();
  757. if (! $schedule) {
  758. return false;
  759. }
  760. $timeRanges = json_decode($schedule->time_ranges, true);
  761. if (empty($timeRanges)) {
  762. return false;
  763. }
  764. $currentTime = now()->format('H:i');
  765. foreach ($timeRanges as $range) {
  766. if ($currentTime >= $range['start_time'] && $currentTime <= $range['end_time']) {
  767. return true;
  768. }
  769. }
  770. return false;
  771. } catch (\Exception $e) {
  772. Log::error('检查排班时间异常', [
  773. 'coach_id' => $coachId,
  774. 'error' => $e->getMessage(),
  775. ]);
  776. return false;
  777. }
  778. }
  779. /**
  780. * 更新工作状态缓存
  781. */
  782. private function updateWorkStatusCache(int $coachId, int $status): void
  783. {
  784. try {
  785. $cacheKey = "coach:work_status:{$coachId}";
  786. $cacheData = [
  787. 'status' => $status,
  788. 'updated_at' => now()->toDateTimeString(),
  789. ];
  790. Redis::setex($cacheKey, 86400, json_encode($cacheData));
  791. } catch (\Exception $e) {
  792. Log::error('更新工作状态缓存失败', [
  793. 'coach_id' => $coachId,
  794. 'error' => $e->getMessage(),
  795. ]);
  796. // 缓存更新失败不影响主流程
  797. }
  798. }
  799. /**
  800. * 获取技师工作状态
  801. *
  802. * @param int $coachId 技师ID
  803. */
  804. public function getWorkStatus(int $coachId): array
  805. {
  806. try {
  807. // 验证技师信息
  808. $coach = CoachUser::find($coachId);
  809. abort_if(! $coach, 404, '技师不存在');
  810. // 直接获取技师信息里的work_status
  811. $workStatus = $coach->work_status;
  812. return [
  813. 'work_status' => $workStatus,
  814. 'work_status_text' => TechnicianWorkStatus::fromValue($workStatus)->label(),
  815. 'updated_at' => now()->toDateTimeString(),
  816. ];
  817. } catch (\Exception $e) {
  818. Log::error('获取技师工作状态失败', [
  819. 'coach_id' => $coachId,
  820. 'error' => $e->getMessage(),
  821. 'trace' => $e->getTraceAsString(),
  822. ]);
  823. throw $e;
  824. }
  825. }
  826. /**
  827. * 获取技师排班信息
  828. *
  829. * @param int $userId 用户ID
  830. */
  831. public function getSchedule(int $userId): array
  832. {
  833. try {
  834. // 获取技师信息
  835. $user = MemberUser::with(['coach'])->findOrFail($userId);
  836. $coach = $user->coach;
  837. abort_if(! $coach, 404, '技师信息不存在');
  838. // 先尝试从缓存获取
  839. $cacheKey = "coach:schedule:{$coach->id}";
  840. $cached = Redis::get($cacheKey);
  841. if ($cached) {
  842. return json_decode($cached, true);
  843. }
  844. // 缓存不存在,从数据库获取
  845. $schedule = CoachSchedule::where('coach_id', $coach->id)
  846. ->where('state', 1)
  847. ->first();
  848. $result = [
  849. 'time_ranges' => $schedule ? json_decode($schedule->time_ranges, true) : [],
  850. 'updated_at' => $schedule ? $schedule->updated_at->toDateTimeString() : now()->toDateTimeString(),
  851. ];
  852. // 写入缓存
  853. Redis::setex($cacheKey, 86400, json_encode($result));
  854. // 记录日志
  855. Log::info('获取技师排班信息成功', [
  856. 'coach_id' => $coach->id,
  857. 'schedule' => $result,
  858. ]);
  859. return $result;
  860. } catch (\Exception $e) {
  861. Log::error('获取技师排班信息失败', [
  862. 'user_id' => $userId,
  863. 'error' => $e->getMessage(),
  864. 'trace' => $e->getTraceAsString(),
  865. ]);
  866. throw $e;
  867. }
  868. }
  869. /**
  870. * 验证技师基础信息
  871. *
  872. * @param User $user 用户对象
  873. * @param bool $throwError 是否抛出异常
  874. * @return bool
  875. */
  876. private function validateBasicCoach($user, bool $throwError = true): bool
  877. {
  878. if (!$user || !$user->coach) {
  879. if ($throwError) {
  880. abort_if(!$user, 404, '用户不存在');
  881. abort_if(!$user->coach, 404, '技师信息不存在');
  882. }
  883. return false;
  884. }
  885. return true;
  886. }
  887. /**
  888. * 检查是否存在审核记录
  889. *
  890. * @param CoachUser $coach 技师对象
  891. * @param string $type 记录类型(info|qual|real)
  892. * @return bool
  893. */
  894. private function hasPendingRecord($coach, string $type): bool
  895. {
  896. $method = match ($type) {
  897. 'info' => 'infoRecords',
  898. 'qual' => 'qualRecords',
  899. 'real' => 'realRecords',
  900. default => throw new \InvalidArgumentException('Invalid record type')
  901. };
  902. return $coach->{$method}()
  903. ->where('state', TechnicianAuthStatus::AUDITING->value)
  904. ->exists();
  905. }
  906. /**
  907. * 格式化位置信息
  908. * 过滤和验证位置相关字段
  909. *
  910. * @param array $locationInfo 原始位置信息
  911. * @return array 格式化后的位置信息
  912. * @throws \Exception 当行政区划代码格式无效时抛出异常
  913. */
  914. private function formatLocationInfo(array $locationInfo): array
  915. {
  916. // 定义允许的字段列表,确保数据安全性
  917. $allowedFields = [
  918. 'province', // 省份
  919. 'city', // 城市
  920. 'district', // 区县
  921. 'address', // 详细地址
  922. 'adcode' // 行政区划代码
  923. ];
  924. // 过滤并验证字段:
  925. // 1. 只保留允许的字段
  926. // 2. 移除空值
  927. $formatted = array_filter(
  928. array_intersect_key($locationInfo, array_flip($allowedFields)),
  929. function ($value) {
  930. return !is_null($value) && $value !== '';
  931. }
  932. );
  933. // 验证行政区划代码格式(6位数字)
  934. if (isset($formatted['adcode'])) {
  935. abort_if(!preg_match('/^\d{6}$/', $formatted['adcode']), 422, '无效的行政区划代码');
  936. }
  937. return $formatted;
  938. }
  939. /**
  940. * 更新位置缓存
  941. * 处理Redis地理位置数据结构的更新
  942. * 使用Redis的GEOADD命令存储地理位置信息
  943. *
  944. * @param int $coachId 技师ID
  945. * @param float $longitude 经度
  946. * @param float $latitude 纬度
  947. * @param int $type 位置类型
  948. * @return bool 操作是否成功
  949. */
  950. private function updateLocationCache(int $coachId, float $longitude, float $latitude, int $type): bool
  951. {
  952. // 生成缓存键:技师ID_位置类型
  953. $key = $coachId . '_' . $type;
  954. // 使用Redis的GEOADD命令添加地理位置信息
  955. // 参数顺序:key longitude latitude member
  956. return Redis::geoadd('coach_locations', $longitude, $latitude, $key);
  957. }
  958. }