AccountService.php 36 KB

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