AccountService.php 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357
  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. * 4. 如果是已通过状态,创建新记录并复制字段
  1097. *
  1098. * @param CoachUser $coach 技师对象
  1099. * @param array $data 待更新的数据,可包含:
  1100. * - nickname: ?string 昵称
  1101. * - gender: ?int 性别(1:男 2:女)
  1102. * - mobile: ?string 手机号
  1103. * - code: ?string 验证码(修改手机号时必填)
  1104. * @return array 返回结果
  1105. */
  1106. public function updateBasicInfo(CoachUser $coach, array $data): array
  1107. {
  1108. return DB::transaction(function () use ($coach, $data) {
  1109. // 获取最新的非拒绝记录
  1110. $latestRecord = $coach->infoRecords()
  1111. ->where('state', '<>', TechnicianAuthStatus::REJECTED->value)
  1112. ->latest()
  1113. ->first();
  1114. abort_if(!$latestRecord, 404, '未找到有效的基础信息记录');
  1115. // 如果要修改手机号,验证验证码
  1116. if (isset($data['mobile'])) {
  1117. $this->verifyMobileCode($data['mobile'], $data['code']);
  1118. }
  1119. // 提取要更新的字段
  1120. $updateFields = array_intersect_key($data, [
  1121. 'nickname' => '',
  1122. 'gender' => '',
  1123. 'mobile' => '',
  1124. ]);
  1125. // 如果没有要更新的字段,直接返回
  1126. abort_if(empty($updateFields), 422, '没有需要更新的字段');
  1127. // 如果最新记录是待审核状态,直接更新
  1128. if ($latestRecord->state === TechnicianAuthStatus::AUDITING->value) {
  1129. $latestRecord->update($updateFields);
  1130. return [
  1131. 'message' => '基础信息修改申请已更新',
  1132. 'record_id' => $latestRecord->id,
  1133. 'updated_fields' => array_keys($updateFields)
  1134. ];
  1135. }
  1136. // 创建新的审核记录,复制其他字段
  1137. $newRecord = $coach->infoRecords()->create(array_merge([
  1138. 'avatar' => $latestRecord->avatar,
  1139. 'life_photos' => $latestRecord->life_photos,
  1140. 'gender' => $latestRecord->gender,
  1141. 'mobile' => $latestRecord->mobile,
  1142. 'birthday' => $latestRecord->birthday,
  1143. 'work_years' => $latestRecord->work_years,
  1144. 'intention_city' => $latestRecord->intention_city,
  1145. 'introduction' => $latestRecord->introduction,
  1146. 'state' => TechnicianAuthStatus::AUDITING->value,
  1147. ], $updateFields)); // 使用新的字段值覆盖
  1148. return [
  1149. 'message' => '基础信息修改申请已提交',
  1150. 'record_id' => $newRecord->id,
  1151. 'updated_fields' => array_keys($updateFields)
  1152. ];
  1153. });
  1154. }
  1155. /**
  1156. * 验证手机验证码
  1157. *
  1158. * @param string $mobile 手机号
  1159. * @param string $code 验证码
  1160. * @throws \Illuminate\Http\Exceptions\HttpResponseException
  1161. */
  1162. private function verifyMobileCode(string $mobile, string $code): void
  1163. {
  1164. // 缓存键
  1165. $cacheKey = "sms_code:update:{$mobile}";
  1166. // 获取缓存中的验证码
  1167. $cacheCode = Redis::get($cacheKey);
  1168. // 验证码不存在或不匹配
  1169. abort_if(!$cacheCode || $cacheCode !== $code, 422, '验证码错误或已过期');
  1170. // 验证成功后删除验证码
  1171. Redis::del($cacheKey);
  1172. }
  1173. /**
  1174. * 发送验证码
  1175. *
  1176. * 业务流程:
  1177. * 1. 检查手机号是否已被使用
  1178. * 2. 生成验证码
  1179. * 3. 保存到 Redis
  1180. * 4. 发送验证码
  1181. *
  1182. * @param array $data 包含:
  1183. * - mobile: string 手机号
  1184. * - type: string 验证码类型
  1185. * @return array 返回结果
  1186. */
  1187. public function sendVerifyCode(array $data): array
  1188. {
  1189. // 检查手机号是否已被使用
  1190. $exists = MemberUser::where('mobile', $data['mobile'])
  1191. ->whereHas('coach')
  1192. ->exists();
  1193. abort_if($exists, 422, '该手机号已被使用');
  1194. // 生成6位随机验证码
  1195. $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
  1196. // 验证码有效期(5分钟)
  1197. $expireTime = 300;
  1198. // 缓存键
  1199. $cacheKey = "sms_code:{$data['type']}:{$data['mobile']}";
  1200. // 保存到Redis,设置5分钟过期
  1201. Redis::setex($cacheKey, $expireTime, $code);
  1202. return [
  1203. 'code' => $code,
  1204. 'mobile' => substr_replace($data['mobile'], '****', 3, 4),
  1205. 'expire_time' => $expireTime
  1206. ];
  1207. }
  1208. }