AccountService.php 44 KB

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