AccountService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. <?php
  2. namespace App\Services\Coach;
  3. use App\Enums\TechnicianAuthStatus;
  4. use App\Enums\TechnicianLocationType;
  5. use App\Models\CoachSchedule;
  6. use App\Models\MemberUser;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Log;
  10. use Illuminate\Support\Facades\Redis;
  11. class AccountService
  12. {
  13. private const CACHE_KEY_PREFIX = 'coach_info_';
  14. private const CACHE_TTL = 300; // 5分钟
  15. /**
  16. * 提交技师基本信息
  17. */
  18. public function submitBaseInfo($user, array $data)
  19. {
  20. DB::beginTransaction();
  21. try {
  22. $this->setTransactionConfig();
  23. abort_if(! $user->coach, 404, '技师信息不存在');
  24. // 检查是否有待审核的记录
  25. $pendingRecord = $user->coach->infoRecords()
  26. ->where('state', TechnicianAuthStatus::AUDITING->value)
  27. ->exists();
  28. abort_if($pendingRecord, 422, '已有待审核的基本信息记录');
  29. // 创建技师信息
  30. $record = $user->coach->infoRecords()->create(array_merge($data, [
  31. 'state' => TechnicianAuthStatus::AUDITING->value,
  32. ]));
  33. // 清除技师信息缓存
  34. $this->clearCoachCache($user->coach->id);
  35. DB::commit();
  36. $this->logInfo('技师提交基本信息成功', $user, $data);
  37. return ['message' => '基本信息提交成功'];
  38. } catch (\Exception $e) {
  39. DB::rollBack();
  40. $this->logError('提交技师基本信息失败', $user, $data, $e);
  41. throw $e;
  42. }
  43. }
  44. /**
  45. * 提交技师资质信息
  46. */
  47. public function submitQualification($user, array $data)
  48. {
  49. DB::beginTransaction();
  50. try {
  51. $this->setTransactionConfig();
  52. abort_if(! $user->coach, 404, '技师信息不存在');
  53. // 检查是否有待审核的记录
  54. $pendingRecord = $user->coach->qualRecords()
  55. ->where('state', TechnicianAuthStatus::AUDITING->value)
  56. ->exists();
  57. abort_if($pendingRecord, 422, '已有待审核的资质信息记录');
  58. // 创建资质信息
  59. $record = $user->coach->qualRecords()->create(array_merge($data, [
  60. 'state' => TechnicianAuthStatus::AUDITING->value,
  61. ]));
  62. // 清除技师信息缓存
  63. $this->clearCoachCache($user->coach->id);
  64. DB::commit();
  65. $this->logInfo('技师提交资质信息成功', $user, $data);
  66. return ['message' => '资质信息提交成功'];
  67. } catch (\Exception $e) {
  68. DB::rollBack();
  69. $this->logError('提交技师资质信息失败', $user, $data, $e);
  70. throw $e;
  71. }
  72. }
  73. /**
  74. * 提交实名认证信息
  75. */
  76. public function submitRealName($user, array $data)
  77. {
  78. DB::beginTransaction();
  79. try {
  80. $this->setTransactionConfig();
  81. abort_if(! $user->coach, 404, '技师信息不存在');
  82. // 检查是否有待审核的记录
  83. $pendingRecord = $user->coach->realRecords()
  84. ->where('state', TechnicianAuthStatus::AUDITING->value)
  85. ->exists();
  86. abort_if($pendingRecord, 422, '已有待审核的实名认证信息');
  87. // 创建实名认证信息
  88. $record = $user->coach->realRecords()->create(array_merge($data, [
  89. 'state' => TechnicianAuthStatus::AUDITING->value,
  90. ]));
  91. // 清除技师信息缓存
  92. $this->clearCoachCache($user->coach->id);
  93. DB::commit();
  94. $this->logInfo('技师提交实名认证信息成功', $user, $this->maskSensitiveData($data));
  95. return ['message' => '实名认证信息提交成功'];
  96. } catch (\Exception $e) {
  97. DB::rollBack();
  98. $this->logError('提交实名认证信息失败', $user, $this->maskSensitiveData($data), $e);
  99. throw $e;
  100. }
  101. }
  102. /**
  103. * 获取技师信息
  104. */
  105. public function getCoachInfo($user)
  106. {
  107. try {
  108. abort_if(! $user, 404, '用户不存在');
  109. abort_if(! $user->coach, 404, '技师信息不存在');
  110. return Cache::remember(
  111. self::CACHE_KEY_PREFIX.$user->coach->id,
  112. self::CACHE_TTL,
  113. function () use ($user) {
  114. return $this->fetchCoachInfo($user->coach);
  115. }
  116. );
  117. } catch (\Exception $e) {
  118. $this->logError('获取技师信息失败', $user, [], $e);
  119. throw $e;
  120. }
  121. }
  122. /**
  123. * 设置事务配置
  124. */
  125. private function setTransactionConfig()
  126. {
  127. DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');
  128. DB::statement('SET SESSION innodb_lock_wait_timeout=10');
  129. }
  130. /**
  131. * 记录信息日志
  132. */
  133. private function logInfo(string $message, $user, array $data)
  134. {
  135. Log::info($message, [
  136. 'user_id' => $user->id,
  137. 'coach_id' => $user->coach->id,
  138. 'data' => $data,
  139. 'ip' => request()->ip(),
  140. 'timestamp' => now()->toDateTimeString(),
  141. ]);
  142. }
  143. /**
  144. * 记录错误日志
  145. */
  146. private function logError(string $message, $user, array $data, \Exception $e)
  147. {
  148. Log::error($message, [
  149. 'user_id' => $user->id,
  150. 'coach_id' => $user->coach->id ?? null,
  151. 'data' => $data,
  152. 'error' => $e->getMessage(),
  153. 'file' => $e->getFile(),
  154. 'line' => $e->getLine(),
  155. 'ip' => request()->ip(),
  156. 'timestamp' => now()->toDateTimeString(),
  157. ]);
  158. }
  159. /**
  160. * 获取技师详细信息
  161. */
  162. private function fetchCoachInfo($coach)
  163. {
  164. $baseInfo = $coach->infoRecords()->latest()->first();
  165. $qualification = $coach->qualRecords()->latest()->first();
  166. $realName = $coach->realRecords()->latest()->first();
  167. return [
  168. 'base_info' => $baseInfo ? $this->formatBaseInfo($baseInfo) : null,
  169. 'qualification' => $qualification ? $this->formatQualification($qualification) : null,
  170. 'real_name' => $realName ? $this->formatRealName($realName) : null,
  171. ];
  172. }
  173. /**
  174. * 格式化基本信息
  175. */
  176. private function formatBaseInfo($info)
  177. {
  178. return [
  179. 'nickname' => $info->nickname,
  180. 'avatar' => $info->avatar,
  181. 'gender' => $info->gender,
  182. 'mobile' => $this->maskMobile($info->mobile),
  183. 'birthday' => $info->birthday,
  184. 'work_years' => $info->work_years,
  185. 'intention_city' => $info->intention_city,
  186. 'introduction' => $info->introduction,
  187. 'state' => $info->state,
  188. 'state_text' => TechnicianAuthStatus::fromValue($info->state)->label(),
  189. 'audit_remark' => $info->audit_remark,
  190. ];
  191. }
  192. /**
  193. * 格式化资质信息
  194. */
  195. private function formatQualification($qual)
  196. {
  197. return [
  198. 'qual_type' => $qual->qual_type,
  199. 'qual_no' => $qual->qual_no,
  200. 'qual_photo' => $qual->qual_photo,
  201. 'valid_start' => $qual->valid_start,
  202. 'valid_end' => $qual->valid_end,
  203. 'state' => $qual->state,
  204. 'state_text' => TechnicianAuthStatus::fromValue($qual->state)->label(),
  205. 'audit_remark' => $qual->audit_remark,
  206. ];
  207. }
  208. /**
  209. * 格式化实名信息
  210. */
  211. private function formatRealName($real)
  212. {
  213. return [
  214. 'real_name' => $real->real_name,
  215. 'id_card' => $this->maskIdCard($real->id_card),
  216. 'id_card_front_photo' => $real->id_card_front_photo,
  217. 'id_card_back_photo' => $real->id_card_back_photo,
  218. 'id_card_hand_photo' => $real->id_card_hand_photo,
  219. 'state' => $real->state,
  220. 'state_text' => TechnicianAuthStatus::fromValue($real->state)->label(),
  221. 'audit_remark' => $real->audit_remark,
  222. ];
  223. }
  224. /**
  225. * 手机号脱敏
  226. */
  227. private function maskMobile($mobile)
  228. {
  229. return substr_replace($mobile, '****', 3, 4);
  230. }
  231. /**
  232. * 身份证号脱敏
  233. */
  234. private function maskIdCard($idCard)
  235. {
  236. return substr_replace($idCard, '****', 6, 8);
  237. }
  238. /**
  239. * 敏感数据脱敏
  240. */
  241. private function maskSensitiveData(array $data)
  242. {
  243. if (isset($data['id_card'])) {
  244. $data['id_card'] = $this->maskIdCard($data['id_card']);
  245. }
  246. if (isset($data['mobile'])) {
  247. $data['mobile'] = $this->maskMobile($data['mobile']);
  248. }
  249. return $data;
  250. }
  251. /**
  252. * 清除技师信息缓存
  253. */
  254. private function clearCoachCache($coachId)
  255. {
  256. Cache::forget(self::CACHE_KEY_PREFIX.$coachId);
  257. }
  258. /**
  259. * 设置定位信息
  260. *
  261. * @param int $coachId 技师ID
  262. * @param float $latitude 纬度
  263. * @param float $longitude 经度
  264. * @param int $type 位置类型 (current:1|common:2)
  265. * @return bool
  266. *
  267. * @throws \Exception
  268. */
  269. public function setLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value)
  270. {
  271. DB::beginTransaction();
  272. try {
  273. // 验证经纬度参数
  274. if (! is_numeric($latitude) || ! is_numeric($longitude)) {
  275. throw new \Exception('无效的经纬度坐标');
  276. }
  277. // 验证位置类型
  278. if (! in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value])) {
  279. throw new \Exception('无效的位置类型');
  280. }
  281. // 生成Redis键
  282. $key = $coachId.'_'.$type;
  283. // 将位置信息写入Redis
  284. $result = Redis::geoadd('coach_locations', $longitude, $latitude, $key);
  285. // 同时写入数据库保存历史记录
  286. DB::table('coach_locations')->updateOrInsert(
  287. ['coach_id' => $coachId, 'type' => $type],
  288. [
  289. 'latitude' => $latitude,
  290. 'longitude' => $longitude,
  291. 'updated_at' => now(),
  292. ]
  293. );
  294. DB::commit();
  295. Log::info('技师位置信息设置成功', [
  296. 'coach_id' => $coachId,
  297. 'type' => $type,
  298. 'latitude' => $latitude,
  299. 'longitude' => $longitude,
  300. ]);
  301. return $result;
  302. } catch (\Exception $e) {
  303. DB::rollBack();
  304. Log::error('技师位置信息设置异常', [
  305. 'coach_id' => $coachId,
  306. 'latitude' => $latitude,
  307. 'longitude' => $longitude,
  308. 'type' => $type,
  309. 'error' => $e->getMessage(),
  310. 'file' => $e->getFile(),
  311. 'line' => $e->getLine(),
  312. ]);
  313. throw $e;
  314. }
  315. }
  316. /**
  317. * 获取技师位置信息
  318. *
  319. * @param int $userId 用户ID
  320. * @return array 位置信息
  321. */
  322. public function getLocation($userId)
  323. {
  324. try {
  325. // 改进:直接使用 coach 模型
  326. $user = MemberUser::find($userId);
  327. abort_if(! $user, 404, '用户不存在');
  328. abort_if(! $user->coach, 404, '技师信息不存在');
  329. // 获取常用位置信息
  330. $location = $user->coach->locations()
  331. ->where('type', TechnicianLocationType::COMMON->value)
  332. ->first();
  333. $result = [
  334. 'address' => $location ? $location->location : null,
  335. ];
  336. // 记录日志
  337. Log::info('获取技师常用位置信息成功', [
  338. 'coach_id' => $user->coach->id,
  339. 'location' => $result,
  340. ]);
  341. return $result;
  342. } catch (\Exception $e) {
  343. Log::error('获取技师常用位置信息异常', [
  344. 'coach_id' => $user->coach->id ?? null,
  345. 'error' => $e->getMessage(),
  346. 'file' => $e->getFile(),
  347. 'line' => $e->getLine(),
  348. ]);
  349. throw $e;
  350. }
  351. }
  352. /**
  353. * 设置技师排班时间(每天通用)
  354. *
  355. * @param int $userId 技师用户ID
  356. * @param array $timeRanges 时间段数组 格式: [
  357. * ['start_time' => '09:00', 'end_time' => '12:00'],
  358. * ['start_time' => '14:00', 'end_time' => '18:00']
  359. * ]
  360. *
  361. * @throws \Exception
  362. */
  363. public function setSchedule(int $userId, array $timeRanges): array
  364. {
  365. return DB::transaction(function () use ($userId, $timeRanges) {
  366. try {
  367. // 获取技师信息
  368. $user = MemberUser::with(['coach'])->findOrFail($userId);
  369. $coach = $user->coach;
  370. abort_if(! $coach, 404, '技师信息不存在');
  371. // 验证并排序时间段
  372. $sortedRanges = $this->validateAndSortTimeRanges($timeRanges);
  373. // 创建或更新排班记录
  374. $schedule = CoachSchedule::updateOrCreate(
  375. [
  376. 'coach_id' => $coach->id,
  377. ],
  378. [
  379. 'time_ranges' => json_encode($sortedRanges),
  380. 'state' => 1,
  381. ]
  382. );
  383. // 更新Redis缓存
  384. $this->updateScheduleCache($coach->id, $sortedRanges);
  385. // 记录日志
  386. Log::info('技师排班设置成功', [
  387. 'coach_id' => $coach->id,
  388. 'time_ranges' => $sortedRanges,
  389. ]);
  390. return [
  391. 'status' => true,
  392. 'message' => '排班设置成功',
  393. 'data' => [
  394. 'coach_id' => $coach->id,
  395. 'time_ranges' => $sortedRanges,
  396. 'updated_at' => $schedule->updated_at->toDateTimeString(),
  397. ],
  398. ];
  399. } catch (\Exception $e) {
  400. Log::error('技师排班设置失败', [
  401. 'user_id' => $userId,
  402. 'time_ranges' => $timeRanges,
  403. 'error' => $e->getMessage(),
  404. 'trace' => $e->getTraceAsString(),
  405. ]);
  406. throw $e;
  407. }
  408. });
  409. }
  410. /**
  411. * 验证并排序时间段
  412. */
  413. private function validateAndSortTimeRanges(array $timeRanges): array
  414. {
  415. // 验证时间段数组
  416. abort_if(empty($timeRanges), 400, '必须至少设置一个时间段');
  417. // 验证每个时间段格式并转换为分钟数进行比较
  418. $ranges = collect($timeRanges)->map(function ($range) {
  419. abort_if(! isset($range['start_time'], $range['end_time']),
  420. 400, '时间段格式错误');
  421. // 验证时间格式
  422. foreach (['start_time', 'end_time'] as $field) {
  423. abort_if(! preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $range[$field]),
  424. 400, '时间格式错误,应为HH:mm格式');
  425. }
  426. // 转换为分钟数便于比较
  427. $startMinutes = $this->timeToMinutes($range['start_time']);
  428. $endMinutes = $this->timeToMinutes($range['end_time']);
  429. // 验证时间先后
  430. abort_if($startMinutes >= $endMinutes,
  431. 400, "时间段 {$range['start_time']}-{$range['end_time']} 结束时间必须大于开始时间");
  432. return [
  433. 'start_time' => $range['start_time'],
  434. 'end_time' => $range['end_time'],
  435. 'start_minutes' => $startMinutes,
  436. 'end_minutes' => $endMinutes,
  437. ];
  438. })
  439. ->sortBy('start_minutes')
  440. ->values();
  441. // 验证时间段是否重叠
  442. $ranges->each(function ($range, $index) use ($ranges) {
  443. if ($index > 0) {
  444. $prevRange = $ranges[$index - 1];
  445. abort_if($range['start_minutes'] <= $prevRange['end_minutes'],
  446. 400, "时间段 {$prevRange['start_time']}-{$prevRange['end_time']} 和 ".
  447. "{$range['start_time']}-{$range['end_time']} 之间存在重叠");
  448. }
  449. });
  450. // 返回排序后的时间段,只保留需要的字段
  451. return $ranges->map(function ($range) {
  452. return [
  453. 'start_time' => $range['start_time'],
  454. 'end_time' => $range['end_time'],
  455. ];
  456. })->toArray();
  457. }
  458. /**
  459. * 将时间转换为分钟数
  460. */
  461. private function timeToMinutes(string $time): int
  462. {
  463. [$hours, $minutes] = explode(':', $time);
  464. return (int) $hours * 60 + (int) $minutes;
  465. }
  466. /**
  467. * 更新Redis缓存
  468. */
  469. private function updateScheduleCache(int $coachId, array $timeRanges): void
  470. {
  471. try {
  472. $cacheKey = "coach:schedule:{$coachId}";
  473. $cacheData = [
  474. 'updated_at' => now()->toDateTimeString(),
  475. 'time_ranges' => $timeRanges,
  476. ];
  477. Redis::setex($cacheKey, 86400, json_encode($cacheData));
  478. // 清除相关的可预约时间段缓存
  479. $this->clearTimeSlotCache($coachId);
  480. } catch (\Exception $e) {
  481. Log::error('更新排班缓存失败', [
  482. 'coach_id' => $coachId,
  483. 'error' => $e->getMessage(),
  484. ]);
  485. // 缓存更新失败不影响主流程
  486. }
  487. }
  488. /**
  489. * 清除可预约时间段缓存
  490. */
  491. private function clearTimeSlotCache(int $coachId): void
  492. {
  493. try {
  494. $pattern = "coach:timeslots:{$coachId}:*";
  495. $keys = Redis::keys($pattern);
  496. if (! empty($keys)) {
  497. Redis::del($keys);
  498. }
  499. } catch (\Exception $e) {
  500. Log::error('清除时间段缓存失败', [
  501. 'coach_id' => $coachId,
  502. 'error' => $e->getMessage(),
  503. ]);
  504. }
  505. }
  506. }