AiMeasurement.php 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  1. <?php
  2. namespace app\api\controller;
  3. use app\common\controller\Api;
  4. use app\common\service\AiMeasurementService;
  5. use app\common\service\BodyProfileService;
  6. use app\api\validate\BodyProfile as BodyProfileValidate;
  7. use think\Cache;
  8. /**
  9. * AI测量API控制器
  10. */
  11. class AiMeasurement extends Api
  12. {
  13. protected $noNeedLogin = [];
  14. protected $noNeedRight = '*';
  15. // 第三方AI服务配置
  16. private $thirdPartyApiConfig = [
  17. 'url' => 'http://85cg744gf528.vicp.fun:25085/get_bodysize',
  18. 'timeout' => 120,
  19. 'connect_timeout' => 30
  20. ];
  21. /**
  22. * 开始AI身体测量分析
  23. */
  24. public function startAnalysis()
  25. {
  26. $params = $this->request->post();
  27. // 验证必要参数
  28. if (empty($params['profile_id'])) {
  29. $this->error('档案ID不能为空');
  30. }
  31. if (empty($params['photos']) || !is_array($params['photos'])) {
  32. $this->error('请上传身体照片');
  33. }
  34. try {
  35. // 验证档案归属
  36. $profile = \app\common\model\BodyProfile::where('id', $params['profile_id'])
  37. ->where('user_id', $this->auth->id)
  38. ->find();
  39. if (!$profile) {
  40. $this->error('档案不存在');
  41. }
  42. // 检查是否有正在处理的任务
  43. $existingTask = \think\Db::table('fa_ai_measurement_task')
  44. ->where('profile_id', $params['profile_id'])
  45. ->where('status', 'in', [0, 1]) // 待处理或处理中
  46. ->find();
  47. if ($existingTask) {
  48. $this->error('该档案已有正在处理的AI测量任务,请稍后再试');
  49. }
  50. // 验证照片格式
  51. $requiredPhotos = ['front', 'side', 'back'];
  52. foreach ($requiredPhotos as $angle) {
  53. if (empty($params['photos'][$angle])) {
  54. $this->error("请上传{$angle}角度的身体照片");
  55. }
  56. }
  57. // 创建AI测量任务
  58. $taskData = [
  59. 'profile_id' => $params['profile_id'],
  60. 'user_id' => $this->auth->id,
  61. 'photos' => json_encode($params['photos']),
  62. 'params' => json_encode([
  63. 'gender' => $profile->gender,
  64. 'height' => $profile->height,
  65. 'weight' => $profile->weight
  66. ]),
  67. 'priority' => $params['priority'] ?? 5,
  68. 'status' => 0, // 待处理
  69. 'createtime' => time(),
  70. 'updatetime' => time()
  71. ];
  72. $taskId = \think\Db::table('fa_ai_measurement_task')->insertGetId($taskData);
  73. // 立即处理任务(也可以放到队列中异步处理)
  74. $this->processTask($taskId);
  75. $this->success('AI测量分析已开始', [
  76. 'task_id' => $taskId,
  77. 'estimated_time' => 30 // 预计处理时间(秒)
  78. ]);
  79. } catch (\Exception $e) {
  80. $this->error($e->getMessage());
  81. }
  82. }
  83. /**
  84. * 获取AI测量结果
  85. */
  86. public function getResult()
  87. {
  88. $taskId = $this->request->get('task_id/d');
  89. $profileId = $this->request->get('profile_id/d');
  90. if (!$taskId && !$profileId) {
  91. $this->error('任务ID或档案ID不能为空');
  92. }
  93. try {
  94. $query = \think\Db::table('fa_ai_measurement_task')
  95. ->where('user_id', $this->auth->id);
  96. if ($taskId) {
  97. $query->where('id', $taskId);
  98. } else {
  99. $query->where('profile_id', $profileId)->order('id DESC');
  100. }
  101. $task = $query->find();
  102. if (!$task) {
  103. $this->error('任务不存在');
  104. }
  105. // 根据任务状态返回不同结果
  106. switch ($task['status']) {
  107. case 0: // 待处理
  108. $this->success('任务排队中', [
  109. 'status' => 'pending',
  110. 'message' => '任务正在排队等待处理'
  111. ]);
  112. break;
  113. case 1: // 处理中
  114. $this->success('正在分析中', [
  115. 'status' => 'processing',
  116. 'message' => 'AI正在分析您的身体照片,请稍候...',
  117. 'progress' => $this->estimateProgress($task)
  118. ]);
  119. break;
  120. case 2: // 完成
  121. $result = json_decode($task['result'], true);
  122. $this->success('分析完成', [
  123. 'status' => 'completed',
  124. 'data' => $this->formatMeasurementResult($result, $task['profile_id'])
  125. ]);
  126. break;
  127. case 3: // 失败
  128. $this->success('分析失败', [
  129. 'status' => 'failed',
  130. 'message' => $task['error_message'] ?: '分析过程中出现错误',
  131. 'can_retry' => $task['attempts'] < $task['max_attempts']
  132. ]);
  133. break;
  134. default:
  135. $this->error('未知的任务状态');
  136. }
  137. } catch (\Exception $e) {
  138. $this->error($e->getMessage());
  139. }
  140. }
  141. /**
  142. * 保存AI测量结果
  143. */
  144. public function saveResult()
  145. {
  146. $params = $this->request->post();
  147. if (empty($params['task_id'])) {
  148. $this->error('任务ID不能为空');
  149. }
  150. try {
  151. // 获取任务信息
  152. $task = \think\Db::table('fa_ai_measurement_task')
  153. ->where('id', $params['task_id'])
  154. ->where('user_id', $this->auth->id)
  155. ->where('status', 2) // 只有完成的任务才能保存
  156. ->find();
  157. if (!$task) {
  158. $this->error('任务不存在或未完成');
  159. }
  160. $result = json_decode($task['result'], true);
  161. if (!$result || !isset($result['measurements'])) {
  162. $this->error('测量结果数据异常');
  163. }
  164. // 保存测量数据
  165. $measurement = AiMeasurementService::saveMeasurementResult(
  166. $task['profile_id'],
  167. $result['measurements'],
  168. json_decode($task['photos'], true),
  169. $result['confidence'] ?? null
  170. );
  171. $this->success('测量结果已保存', [
  172. 'measurement_id' => $measurement->id
  173. ]);
  174. } catch (\Exception $e) {
  175. $this->error($e->getMessage());
  176. }
  177. }
  178. /**
  179. * 重新分析
  180. */
  181. public function retryAnalysis()
  182. {
  183. $taskId = $this->request->post('task_id/d');
  184. if (!$taskId) {
  185. $this->error('任务ID不能为空');
  186. }
  187. try {
  188. $task = \think\Db::table('fa_ai_measurement_task')
  189. ->where('id', $taskId)
  190. ->where('user_id', $this->auth->id)
  191. ->where('status', 3) // 失败的任务
  192. ->find();
  193. if (!$task) {
  194. $this->error('任务不存在或不允许重试');
  195. }
  196. if ($task['attempts'] >= $task['max_attempts']) {
  197. $this->error('重试次数已达上限');
  198. }
  199. // 重置任务状态
  200. \think\Db::table('fa_ai_measurement_task')
  201. ->where('id', $taskId)
  202. ->update([
  203. 'status' => 0,
  204. 'error_message' => '',
  205. 'updatetime' => time()
  206. ]);
  207. // 重新处理任务
  208. $this->processTask($taskId);
  209. $this->success('已重新开始分析');
  210. } catch (\Exception $e) {
  211. $this->error($e->getMessage());
  212. }
  213. }
  214. /**
  215. * 获取测量字段配置
  216. */
  217. public function getMeasurementConfig()
  218. {
  219. $gender = $this->request->get('gender/d', 1);
  220. try {
  221. $config = AiMeasurementService::getMeasurementDisplayConfig($gender);
  222. $this->success('获取成功', $config);
  223. } catch (\Exception $e) {
  224. $this->error($e->getMessage());
  225. }
  226. }
  227. /**
  228. * 获取AI测量素材配置
  229. * @ApiMethod (GET)
  230. * @ApiParams (name="mode", type="string", required=false, description="模式:selfie(自拍)、helper(帮拍)、common(通用)、all(全部)")
  231. */
  232. public function getMaterialConfig()
  233. {
  234. $mode = $this->request->get('mode', 'all');
  235. // try {
  236. $config = [];
  237. switch ($mode) {
  238. case 'selfie':
  239. $config = $this->getSelfieConfig();
  240. break;
  241. case 'helper':
  242. $config = $this->getHelperConfig();
  243. break;
  244. case 'common':
  245. $config = $this->getCommonConfig();
  246. break;
  247. case 'all':
  248. default:
  249. $config = [
  250. 'selfie' => $this->getSelfieConfig(),
  251. 'helper' => $this->getHelperConfig(),
  252. 'common' => $this->getCommonConfig()
  253. ];
  254. break;
  255. }
  256. $this->success('获取成功', $config);
  257. // } catch (\Exception $e) {
  258. // $this->error($e->getMessage());
  259. // }
  260. }
  261. /**
  262. * 获取自拍模式配置
  263. */
  264. private function getSelfieConfig()
  265. {
  266. return [
  267. 'enabled' => config('site.ai_measure_selfie_enabled'),
  268. 'intro_image' => $this->formatFileUrl(config('site.ai_measure_selfie_intro_image')),
  269. // 引导教程
  270. 'tutorial' => [
  271. 'images' => $this->parseImages(config('site.ai_measure_selfie_tutorial_images')),
  272. 'video' => $this->formatFileUrl(config('site.ai_measure_selfie_tutorial_video'))
  273. ],
  274. // 陀螺仪检测
  275. 'gyroscope' => [
  276. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_gyro_voice')),
  277. 'example' => $this->formatFileUrl(config('site.ai_measure_selfie_gyro_example'))
  278. ],
  279. // 拍摄正面
  280. 'front_shooting' => [
  281. 'frame' => $this->formatFileUrl(config('site.ai_measure_selfie_front_frame')),
  282. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_front_voice')),
  283. 'demo' => $this->formatFileUrl(config('site.ai_measure_selfie_front_demo'))
  284. ],
  285. // 拍摄侧面
  286. 'side_shooting' => [
  287. 'frame' => $this->formatFileUrl(config('site.ai_measure_selfie_side_frame')),
  288. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_side_voice')),
  289. 'demo' => $this->formatFileUrl(config('site.ai_measure_selfie_side_demo'))
  290. ],
  291. // 拍摄正面侧平举
  292. 'arms_shooting' => [
  293. 'frame' => $this->formatFileUrl(config('site.ai_measure_selfie_arms_frame')),
  294. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_arms_voice')),
  295. 'demo' => $this->formatFileUrl(config('site.ai_measure_selfie_arms_demo'))
  296. ],
  297. // 拍摄过程素材
  298. 'process_materials' => [
  299. 'countdown_voice' => $this->formatFileUrl(config('site.ai_measure_selfie_countdown_voice')),
  300. 'timer_sound' => $this->formatFileUrl(config('site.ai_measure_selfie_timer_sound')),
  301. 'complete_sound' => $this->formatFileUrl(config('site.ai_measure_selfie_complete_sound')),
  302. 'next_voice' => $this->formatFileUrl(config('site.ai_measure_selfie_next_voice')),
  303. 'finish_voice' => $this->formatFileUrl(config('site.ai_measure_selfie_finish_voice'))
  304. ]
  305. ];
  306. }
  307. /**
  308. * 获取帮拍模式配置
  309. */
  310. private function getHelperConfig()
  311. {
  312. return [
  313. 'enabled' => config('site.ai_measure_helper_enabled'),
  314. 'intro_image' => $this->formatFileUrl(config('site.ai_measure_helper_intro_image')),
  315. // 引导教程
  316. 'tutorial' => [
  317. 'images' => $this->parseImages(config('site.ai_measure_helper_tutorial_images')),
  318. 'video' => $this->formatFileUrl(config('site.ai_measure_helper_tutorial_video'))
  319. ],
  320. // 陀螺仪检测
  321. 'gyroscope' => [
  322. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_gyro_voice')),
  323. 'example' => $this->formatFileUrl(config('site.ai_measure_helper_gyro_example'))
  324. ],
  325. // 拍摄正面
  326. 'front_shooting' => [
  327. 'frame' => $this->formatFileUrl(config('site.ai_measure_helper_front_frame')),
  328. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_front_voice')),
  329. 'demo' => $this->formatFileUrl(config('site.ai_measure_helper_front_demo'))
  330. ],
  331. // 拍摄侧面
  332. 'side_shooting' => [
  333. 'frame' => $this->formatFileUrl(config('site.ai_measure_helper_side_frame')),
  334. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_side_voice')),
  335. 'demo' => $this->formatFileUrl(config('site.ai_measure_helper_side_demo'))
  336. ],
  337. // 拍摄正面侧平举
  338. 'arms_shooting' => [
  339. 'frame' => $this->formatFileUrl(config('site.ai_measure_helper_arms_frame')),
  340. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_arms_voice')),
  341. 'demo' => $this->formatFileUrl(config('site.ai_measure_helper_arms_demo'))
  342. ],
  343. // 拍摄过程素材
  344. 'process_materials' => [
  345. 'countdown_voice' => cdnurl(config('site.ai_measure_helper_countdown_voice')),
  346. 'timer_sound' => $this->formatFileUrl(config('site.ai_measure_helper_timer_sound')),
  347. 'complete_sound' => $this->formatFileUrl(config('site.ai_measure_helper_complete_sound')),
  348. 'next_voice' => $this->formatFileUrl(config('site.ai_measure_helper_next_voice')),
  349. 'finish_voice' => $this->formatFileUrl(config('site.ai_measure_helper_finish_voice'))
  350. ]
  351. ];
  352. }
  353. /**
  354. * 获取通用配置
  355. */
  356. private function getCommonConfig()
  357. {
  358. return [
  359. 'privacy_notice' => config('site.ai_measure_privacy_notice'),
  360. 'accuracy_disclaimer' => config('site.ai_measure_accuracy_disclaimer'),
  361. 'measurement_types' => $this->parseMeasurementTypes(config('site.ai_measure_measurement_types')),
  362. 'ai_model_version' => config('site.ai_measure_ai_model_version'),
  363. 'feedback_enabled' => config('site.ai_measure_feedback_enabled'),
  364. 'debug_mode' => config('site.ai_measure_debug_mode'),
  365. 'log_level' => config('site.ai_measure_log_level')
  366. ];
  367. }
  368. /**
  369. * 格式化文件URL
  370. */
  371. private function formatFileUrl($url)
  372. {
  373. if (empty($url)) {
  374. return null;
  375. }
  376. return cdnurl($url);
  377. }
  378. /**
  379. * 解析图片集合
  380. */
  381. private function parseImages($images)
  382. {
  383. if (empty($images)) {
  384. return [];
  385. }
  386. // 如果是JSON格式的字符串,解析为数组
  387. if (is_string($images)) {
  388. $imageArray = json_decode($images, true);
  389. if (json_last_error() === JSON_ERROR_NONE && is_array($imageArray)) {
  390. $images = $imageArray;
  391. } else {
  392. // 如果不是JSON,可能是逗号分隔的字符串
  393. $images = explode(',', $images);
  394. }
  395. }
  396. if (!is_array($images)) {
  397. return [];
  398. }
  399. // 格式化每个图片URL
  400. return array_map(function($url) {
  401. return $this->formatFileUrl(trim($url));
  402. }, array_filter($images));
  403. }
  404. /**
  405. * 解析测量类型
  406. */
  407. private function parseMeasurementTypes($types)
  408. {
  409. if (empty($types)) {
  410. return [];
  411. }
  412. $typeLabels = [
  413. 'bust' => '胸围',
  414. 'waist' => '腰围',
  415. 'hips' => '臀围',
  416. 'shoulder' => '肩宽',
  417. 'sleeve' => '袖长',
  418. 'inseam' => '内缝长',
  419. 'outseam' => '外缝长',
  420. 'thigh' => '大腿围',
  421. 'neck' => '颈围'
  422. ];
  423. $typeArray = is_string($types) ? explode(',', $types) : $types;
  424. $result = [];
  425. foreach ($typeArray as $type) {
  426. $type = trim($type);
  427. if (isset($typeLabels[$type])) {
  428. $result[] = [
  429. 'key' => $type,
  430. 'label' => $typeLabels[$type]
  431. ];
  432. }
  433. }
  434. return $result;
  435. }
  436. /**
  437. * 直接调用第三方AI测量服务
  438. * @ApiMethod (POST)
  439. * @ApiParams (name="profile_id", type="integer", required=true, description="档案ID")
  440. * @ApiParams (name="photos", type="object", required=true, description="身体照片对象")
  441. * @ApiParams (name="photos.front", type="string", required=true, description="正面照片URL")
  442. * @ApiParams (name="photos.side", type="string", required=true, description="侧面照片URL")
  443. * @ApiParams (name="photos.back", type="string", required=true, description="背面照片URL")
  444. */
  445. public function measurementDirect()
  446. {
  447. $params = $this->request->post();
  448. // 验证必要参数
  449. if (empty($params['profile_id'])) {
  450. $this->error('档案ID不能为空');
  451. }
  452. // if (empty($params['photos']) || !is_array($params['photos'])) {
  453. // $this->error('请上传身体照片');
  454. // }
  455. // try {
  456. // 验证档案归属
  457. $profile = \app\common\model\BodyProfile::where('id', $params['profile_id'])
  458. ->where('user_id', $this->auth->id)
  459. ->find();
  460. if (!$profile) {
  461. $this->error('档案不存在');
  462. }
  463. // 验证照片格式
  464. // $requiredPhotos = ['front', 'side', 'back'];
  465. // foreach ($requiredPhotos as $angle) {
  466. // if (empty($params['photos'][$angle])) {
  467. // $this->error("请上传{$angle}角度的身体照片");
  468. // }
  469. // }
  470. // 直接使用档案的
  471. $photos = $profile->body_photos_text;
  472. // 安全调用第三方AI服务 - 确保身高为数字格式
  473. $heightCm = is_numeric($profile->height) ? floatval($profile->height) : 0;
  474. $measurements = $this->safeCallThirdPartyAiService(
  475. $photos,
  476. $heightCm
  477. );
  478. // echo "<pre>";
  479. // print_r($measurements);
  480. // echo "</pre>";
  481. // exit;
  482. // 处理结果
  483. // $result = [
  484. // 'measurements' => $measurements,
  485. // 'confidence' => $measurements['_confidence'] ?? 0.8,
  486. // 'warnings' => $measurements['_warnings'] ?? []
  487. // ];
  488. // 清理内部字段
  489. // unset($result['measurements']['_confidence']);
  490. // unset($result['measurements']['_warnings']);
  491. // 格式化结果用于展示
  492. //$formattedResult = $this->formatMeasurementResult($result, $params['profile_id']);
  493. $measurements['height'] = $profile->height;
  494. $measurements['weight'] = $profile->weight;
  495. $this->success('AI测量完成', $measurements);
  496. // } catch (\Exception $e) {
  497. // $this->error($e->getMessage());
  498. // }
  499. }
  500. /**
  501. * 调用第三方AI测量接口
  502. */
  503. private function callThirdPartyAiService($photos, $height)
  504. {
  505. // 第三方API配置
  506. $apiUrl = $this->thirdPartyApiConfig['url'];
  507. try {
  508. // 准备请求数据 - 确保身高为纯数字(厘米)
  509. $heightValue = is_numeric($height) ? floatval($height) : 0;
  510. $requestData = [
  511. 'height' => $heightValue
  512. ];
  513. // 处理照片数据 - 转换为base64格式
  514. if (isset($photos['front'])) {
  515. $requestData['image1'] = $this->convertImageToBase64($photos['front']);
  516. }
  517. if (isset($photos['side'])) {
  518. $requestData['image2'] = $this->convertImageToBase64($photos['side']);
  519. }
  520. if (isset($photos['back'])) {
  521. $requestData['image3'] = $this->convertImageToBase64($photos['back']);
  522. }
  523. // 记录请求日志(不包含图片数据)
  524. // $logData = [
  525. // 'url' => $apiUrl,
  526. // 'height' => $requestData['height'],
  527. // 'image_count' => count(array_filter([
  528. // isset($requestData['image1']),
  529. // isset($requestData['image2']),
  530. // isset($requestData['image3'])
  531. // ]))
  532. // ];
  533. // 记录请求日志(包含身高和图片base64数据的前50个字符)
  534. $logData = [
  535. 'url' => $apiUrl,
  536. 'height' => $heightValue . 'cm',
  537. 'image1_preview' => isset($requestData['image1']) ? substr($requestData['image1'], 0, 50) . '...' : null,
  538. 'image2_preview' => isset($requestData['image2']) ? substr($requestData['image2'], 0, 50) . '...' : null,
  539. 'image3_preview' => isset($requestData['image3']) ? substr($requestData['image3'], 0, 50) . '...' : null,
  540. 'request_data_size' => strlen(json_encode($requestData)) . ' bytes'
  541. ];
  542. \think\Log::info('Calling third party AI service: ' . json_encode($logData));
  543. // 发送POST请求
  544. $ch = curl_init();
  545. curl_setopt_array($ch, [
  546. CURLOPT_URL => $apiUrl,
  547. CURLOPT_POST => true,
  548. CURLOPT_POSTFIELDS => json_encode($requestData),
  549. CURLOPT_RETURNTRANSFER => true,
  550. CURLOPT_TIMEOUT => $this->thirdPartyApiConfig['timeout'],
  551. CURLOPT_CONNECTTIMEOUT => $this->thirdPartyApiConfig['connect_timeout'],
  552. CURLOPT_HTTPHEADER => [
  553. 'Content-Type: application/json',
  554. 'Accept: application/json'
  555. ],
  556. CURLOPT_SSL_VERIFYPEER => false,
  557. CURLOPT_SSL_VERIFYHOST => false
  558. ]);
  559. $response = curl_exec($ch);
  560. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  561. $error = curl_error($ch);
  562. curl_close($ch);
  563. if ($error) {
  564. throw new \Exception('请求第三方AI服务失败: ' . $error);
  565. }
  566. // 处理各种HTTP错误状态
  567. if ($httpCode >= 500) {
  568. throw new \Exception('第三方AI服务内部错误: HTTP ' . $httpCode);
  569. } elseif ($httpCode >= 400) {
  570. throw new \Exception('第三方AI服务请求错误: HTTP ' . $httpCode);
  571. } elseif ($httpCode !== 200) {
  572. throw new \Exception('第三方AI服务返回异常状态: HTTP ' . $httpCode);
  573. }
  574. \think\Log::info('Third party AI service response: ' .$response);
  575. // 检查响应内容
  576. if (empty($response)) {
  577. throw new \Exception('第三方AI服务返回空响应');
  578. }
  579. $result = json_decode($response, true);
  580. if (json_last_error() !== JSON_ERROR_NONE) {
  581. throw new \Exception('第三方AI服务返回数据格式错误');
  582. }
  583. // 记录API响应信息
  584. // $responseLog = [
  585. // 'http_code' => $httpCode,
  586. // 'response_size' => strlen($response) . ' bytes',
  587. // 'has_body_size' => isset($result['body_size']),
  588. // 'body_size_fields' => isset($result['body_size']) ? array_keys($result['body_size']) : [],
  589. // 'response_preview' => substr($response, 0, 200) . '...'
  590. // ];
  591. // 处理返回的测量数据
  592. // echo "<pre>";
  593. // print_r($result);
  594. // echo "</pre>";
  595. // exit;
  596. return $this->processMeasurementData($result);
  597. } catch (\Exception $e) {
  598. // 记录错误日志
  599. \think\Log::error('Third party AI service error: ' . $e->getMessage());
  600. throw $e;
  601. }
  602. // 如果执行到这里说明没有异常处理,直接返回处理结果
  603. }
  604. /**
  605. * 安全调用第三方AI服务(带异常处理和默认返回)
  606. */
  607. private function safeCallThirdPartyAiService($photos, $height)
  608. {
  609. try {
  610. return $this->callThirdPartyAiService($photos, $height);
  611. } catch (\Exception $e) {
  612. // 记录错误日志
  613. \think\Log::error('Third party AI service error, returning default data: ' . $e->getMessage());
  614. // 返回默认的空测量数据
  615. return $this->getDefaultMeasurementData();
  616. }
  617. }
  618. /**
  619. * 获取默认的空测量数据
  620. */
  621. private function getDefaultMeasurementData()
  622. {
  623. // 返回所有映射字段的空值(使用空字符串)
  624. return [
  625. 'chest'=>'', // 净胸围 → 胸围
  626. 'waist'=>'', // 净腰围 → 腰围
  627. 'hip'=>'', // 净臀围 → 实际臀围
  628. //'thigh', // 净腿根 → 大腿围
  629. 'knee'=>'', // 净膝围 → 膝围
  630. 'calf'=>'', // 净小腿围 → 小腿围
  631. 'arm_length'=>'', // 净手臂长 → 臂长
  632. 'wrist'=>'', // 净手腕围 → 手腕围
  633. 'pants_length'=>'', // 腿长 → 腿长
  634. 'belly_belt'=>'', // 净肚围 → 肚围
  635. 'shoulder_width'=>'', // 净肩宽 → 肩宽
  636. 'leg_root'=>'', // 净腿根 → 大腿围
  637. 'neck'=>'', // 净颈围 → 颈围
  638. 'inseam'=>'', // 内腿长 → 内腿长
  639. 'upper_arm'=>'', // 净上臂围 → 上臂围
  640. 'ankle'=>'', // 净脚踝围 → 脚踝围
  641. 'waist_lower'=>'', // 净小腹围 → 下腰围
  642. 'mid_waist'=>'', // 净中腰 → 中腰围
  643. '_confidence' => 0.0,
  644. '_warnings' => ['第三方AI服务暂时不可用,返回默认数据']
  645. ];
  646. }
  647. /**
  648. * 测试接口 - 返回模拟的第三方API测量数据
  649. * @ApiMethod (POST)
  650. * @ApiParams (name="profile_id", type="integer", required=true, description="档案ID")
  651. */
  652. public function testMeasurementData()
  653. {
  654. $params = $this->request->post();
  655. // 验证必要参数
  656. if (empty($params['profile_id'])) {
  657. $this->error('档案ID不能为空');
  658. }
  659. // 验证档案归属
  660. $profile = \app\common\model\BodyProfile::where('id', $params['profile_id'])
  661. ->where('user_id', $this->auth->id)
  662. ->find();
  663. if (!$profile) {
  664. $this->error('档案不存在');
  665. }
  666. // 模拟第三方API返回的数据
  667. $mockApiResult = [
  668. "body_size" => [
  669. "datuigen" => 56.973762220946064,
  670. "duwei" => 71.86294164495045,
  671. "jiankuan" => 44.99356951672863,
  672. "jiaohuai" => 20.995062499529606,
  673. "jingwei" => 36.973537078225604,
  674. "neitui" => 67.99506048261769,
  675. "shangbi" => 23.285375591374667,
  676. "shoubichang" => 61.1834335984307,
  677. "shouwanwei" => 16.0697059847192,
  678. "tuichang" => 73.9800462219755,
  679. "tunwei" => 90.08082593388505,
  680. "xiaofu" => 70.98010845587423,
  681. "xiaotuiwei" => 37.2761443409742,
  682. "xigai" => 34.990971006868364,
  683. "xiongwei" => 81.85738385794711,
  684. "yaowei" => 72.93800974219818,
  685. "zhongyao" => 70.99945416888724
  686. ],
  687. "confidence" => 0.85
  688. ];
  689. // 处理测量数据
  690. $measurements = $this->processMeasurementData($mockApiResult);
  691. // 处理结果
  692. $result = [
  693. 'measurements' => $measurements,
  694. 'confidence' => $measurements['_confidence'] ?? 0.8,
  695. 'warnings' => $measurements['_warnings'] ?? []
  696. ];
  697. // 清理内部字段
  698. unset($result['measurements']['_confidence']);
  699. unset($result['measurements']['_warnings']);
  700. // 格式化结果用于展示
  701. $formattedResult = $this->formatMeasurementResult($result, $params['profile_id']);
  702. $this->success('测试数据返回成功', [
  703. 'original_api_data' => $mockApiResult['body_size'],
  704. 'mapped_measurements' => $result['measurements'],
  705. 'formatted_result' => $formattedResult
  706. ]);
  707. }
  708. /**
  709. * 将图片URL转换为base64格式
  710. */
  711. private function convertImageToBase64($imageUrl)
  712. {
  713. try {
  714. // 如果已经是base64格式,直接返回
  715. if (strpos($imageUrl, 'data:image') === 0) {
  716. return $imageUrl;
  717. }
  718. // 如果是相对路径,转换为绝对路径
  719. if (strpos($imageUrl, 'http') !== 0) {
  720. $imageUrl = request()->domain() . $imageUrl;
  721. }
  722. // 获取图片数据
  723. $ch = curl_init();
  724. curl_setopt_array($ch, [
  725. CURLOPT_URL => $imageUrl,
  726. CURLOPT_RETURNTRANSFER => true,
  727. CURLOPT_TIMEOUT => 30,
  728. CURLOPT_CONNECTTIMEOUT => 10,
  729. CURLOPT_FOLLOWLOCATION => true,
  730. CURLOPT_SSL_VERIFYPEER => false,
  731. CURLOPT_SSL_VERIFYHOST => false,
  732. CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
  733. CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  734. CURLOPT_HTTPHEADER => [
  735. 'Accept: image/webp,image/apng,image/*,*/*;q=0.8',
  736. 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
  737. 'Cache-Control: no-cache',
  738. ]
  739. ]);
  740. $imageData = curl_exec($ch);
  741. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  742. $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
  743. $curlError = curl_error($ch);
  744. $effectiveUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
  745. curl_close($ch);
  746. // 详细的错误处理
  747. if ($curlError) {
  748. throw new \Exception("网络请求失败: {$curlError} (URL: {$imageUrl})");
  749. }
  750. if ($httpCode !== 200) {
  751. throw new \Exception("图片访问失败,HTTP状态码: {$httpCode} (URL: {$imageUrl})");
  752. }
  753. if (!$imageData || strlen($imageData) === 0) {
  754. throw new \Exception("图片数据为空 (URL: {$imageUrl})");
  755. }
  756. // 验证图片数据是否有效
  757. if (!@getimagesizefromstring($imageData)) {
  758. throw new \Exception("获取的数据不是有效的图片格式 (URL: {$imageUrl})");
  759. }
  760. // 确定MIME类型
  761. if (strpos($contentType, 'image/') === 0) {
  762. $mimeType = $contentType;
  763. } else {
  764. // 通过文件扩展名推断
  765. $extension = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
  766. $mimeTypes = [
  767. 'jpg' => 'image/jpeg',
  768. 'jpeg' => 'image/jpeg',
  769. 'png' => 'image/png',
  770. 'gif' => 'image/gif',
  771. 'webp' => 'image/webp',
  772. 'bmp' => 'image/bmp'
  773. ];
  774. $mimeType = $mimeTypes[$extension] ?? 'image/jpeg';
  775. }
  776. // 转换为base64
  777. $base64 = base64_encode($imageData);
  778. return "data:{$mimeType};base64,{$base64}";
  779. } catch (\Exception $e) {
  780. \think\Log::error('Convert image to base64 error: ' . $e->getMessage() . ' URL: ' . $imageUrl);
  781. throw new \Exception('图片转换失败: ' . $e->getMessage());
  782. }
  783. }
  784. /**
  785. * 处理第三方API返回的测量数据
  786. */
  787. private function processMeasurementData($apiResult)
  788. {
  789. // 根据第三方API的返回格式处理数据
  790. // 这里需要根据实际的API返回格式进行调整
  791. $measurements = [];
  792. try {
  793. // 假设API返回格式类似:
  794. // {
  795. // "status": "success",
  796. // "data": {
  797. // "chest": 95.5,
  798. // "waist": 75.2,
  799. // "hip": 98.7,
  800. // ...
  801. // },
  802. // "confidence": 0.85
  803. // }
  804. // 检查返回数据结构 - 可能直接包含body_size字段
  805. if (isset($apiResult['body_size'])) {
  806. $data = $apiResult['body_size'];
  807. $hasValidData = true;
  808. } elseif (isset($apiResult['status']) && $apiResult['status'] === 'success') {
  809. $data = $apiResult['data'] ?? [];
  810. $hasValidData = true;
  811. } else {
  812. // 尝试直接使用返回的数据
  813. $data = $apiResult;
  814. $hasValidData = !empty($data) && is_array($data);
  815. }
  816. if ($hasValidData) {
  817. // 映射字段名(根据第三方API返回的字段名进行映射)
  818. $fieldMapping = [
  819. 'xiongwei' => 'chest', // 净胸围 → 胸围
  820. 'yaowei' => 'waist', // 净腰围 → 腰围
  821. 'tunwei' => 'hip', // 净臀围 → 实际臀围
  822. //'datuigen' => 'thigh', // 净腿根 → 大腿围
  823. 'xigai' => 'knee', // 净膝围 → 膝围
  824. 'xiaotuiwei' => 'calf', // 净小腿围 → 小腿围
  825. 'shoubichang' => 'arm_length', // 净手臂长 → 臂长
  826. 'shouwanwei' => 'wrist', // 净手腕围 → 手腕围
  827. 'tuichang' => 'pants_length', // 腿长 → 腿长
  828. 'duwei' => 'belly_belt', // 净肚围 → 肚围
  829. 'jiankuan' => 'shoulder_width', // 净肩宽 → 肩宽
  830. 'datuigen' => 'leg_root', // 净腿根 → 大腿围
  831. 'jingwei' => 'neck', // 净颈围 → 颈围
  832. 'neitui' => 'inseam', // 内腿长 → 内腿长
  833. 'shangbi' => 'upper_arm', // 净上臂围 → 上臂围
  834. 'jiaohuai' => 'ankle', // 净脚踝围 → 脚踝围
  835. 'xiaofu' => 'waist_lower', // 净小腹围 → 下腰围
  836. 'zhongyao' => 'mid_waist', // 净中腰 → 中腰围
  837. ];
  838. foreach ($fieldMapping as $apiField => $localField) {
  839. if (isset($data[$apiField]) && is_numeric($data[$apiField])) {
  840. $measurements[$localField] = round(floatval($data[$apiField]), 1);
  841. }
  842. }
  843. // 设置置信度和警告信息
  844. $measurements['_confidence'] = $apiResult['confidence'] ?? 0.8;
  845. $measurements['_warnings'] = $apiResult['warnings'] ?? [];
  846. // 如果没有测量数据,添加默认警告
  847. if (count($measurements) === 2) { // 只有_confidence和_warnings
  848. $measurements['_warnings'][] = '第三方AI服务未返回有效的测量数据';
  849. }
  850. } else {
  851. // API返回错误
  852. $errorMsg = $apiResult['message'] ?? $apiResult['error'] ?? '第三方AI服务返回未知错误';
  853. throw new \Exception($errorMsg);
  854. }
  855. } catch (\Exception $e) {
  856. // 处理异常,返回错误信息
  857. $measurements['_confidence'] = 0;
  858. $measurements['_warnings'] = ['数据处理失败: ' . $e->getMessage()];
  859. }
  860. return $measurements;
  861. }
  862. /**
  863. * 处理AI测量任务
  864. */
  865. private function processTask($taskId)
  866. {
  867. try {
  868. // 更新任务状态为处理中
  869. \think\Db::table('fa_ai_measurement_task')
  870. ->where('id', $taskId)
  871. ->update([
  872. 'status' => 1,
  873. 'started_at' => time(),
  874. 'attempts' => \think\Db::raw('attempts + 1'),
  875. 'updatetime' => time()
  876. ]);
  877. // 获取任务详情
  878. $task = \think\Db::table('fa_ai_measurement_task')->where('id', $taskId)->find();
  879. $photos = json_decode($task['photos'], true);
  880. $params = json_decode($task['params'], true);
  881. // 安全调用第三方AI分析服务 - 确保身高为数字格式
  882. $heightCm = is_numeric($params['height']) ? floatval($params['height']) : 0;
  883. $measurements = $this->safeCallThirdPartyAiService(
  884. $photos,
  885. $heightCm
  886. );
  887. // 处理结果
  888. $result = [
  889. 'measurements' => $measurements,
  890. 'confidence' => $measurements['_confidence'] ?? 0.8,
  891. 'warnings' => $measurements['_warnings'] ?? []
  892. ];
  893. // 清理内部字段
  894. unset($result['measurements']['_confidence']);
  895. unset($result['measurements']['_warnings']);
  896. // 更新任务状态为完成
  897. \think\Db::table('fa_ai_measurement_task')
  898. ->where('id', $taskId)
  899. ->update([
  900. 'status' => 2,
  901. 'result' => json_encode($result),
  902. 'completed_at' => time(),
  903. 'updatetime' => time()
  904. ]);
  905. } catch (\Exception $e) {
  906. // 更新任务状态为失败
  907. \think\Db::table('fa_ai_measurement_task')
  908. ->where('id', $taskId)
  909. ->update([
  910. 'status' => 3,
  911. 'error_message' => $e->getMessage(),
  912. 'updatetime' => time()
  913. ]);
  914. }
  915. }
  916. /**
  917. * 估算处理进度
  918. */
  919. private function estimateProgress($task)
  920. {
  921. $startTime = $task['started_at'];
  922. $currentTime = time();
  923. $elapsedTime = $currentTime - $startTime;
  924. // 假设总处理时间为30秒
  925. $totalTime = 30;
  926. $progress = min(95, ($elapsedTime / $totalTime) * 100);
  927. return round($progress);
  928. }
  929. /**
  930. * 格式化测量结果用于展示
  931. */
  932. private function formatMeasurementResult($result, $profileId)
  933. {
  934. $profile = \app\common\model\BodyProfile::find($profileId);
  935. $measurements = $result['measurements'];
  936. // 获取显示配置
  937. $displayConfig = AiMeasurementService::getMeasurementDisplayConfig($profile->gender);
  938. // 格式化数据
  939. $formattedData = [
  940. 'profile' => [
  941. 'id' => $profile->id,
  942. 'name' => $profile->profile_name,
  943. 'gender' => $profile->gender,
  944. 'height' => $profile->height,
  945. 'weight' => $profile->weight
  946. ],
  947. 'measurements' => [],
  948. 'display_config' => $displayConfig,
  949. 'confidence' => $result['confidence'] ?? 0,
  950. 'warnings' => $result['warnings'] ?? []
  951. ];
  952. // 组织测量数据
  953. foreach ($displayConfig as $field => $config) {
  954. $value = isset($measurements[$field]) && $measurements[$field] > 0
  955. ? $measurements[$field]
  956. : null;
  957. $formattedData['measurements'][$field] = [
  958. 'label' => $config['label'],
  959. 'value' => $value,
  960. 'unit' => 'cm',
  961. 'position' => $config['position'],
  962. 'side' => $config['side']
  963. ];
  964. }
  965. // 添加基础数据表格
  966. $formattedData['basic_data'] = [
  967. ['label' => '身高', 'value' => $profile->height, 'unit' => 'cm'],
  968. ['label' => '体重', 'value' => $profile->weight, 'unit' => 'kg'],
  969. ];
  970. // 添加测量数据表格
  971. $tableData = [];
  972. $fields = array_keys($measurements);
  973. $chunks = array_chunk($fields, 2);
  974. foreach ($chunks as $chunk) {
  975. $row = [];
  976. foreach ($chunk as $field) {
  977. if (isset($displayConfig[$field])) {
  978. $row[] = [
  979. 'label' => $displayConfig[$field]['label'],
  980. 'value' => $measurements[$field] ?? null,
  981. 'unit' => 'cm'
  982. ];
  983. }
  984. }
  985. if (!empty($row)) {
  986. $tableData[] = $row;
  987. }
  988. }
  989. $formattedData['table_data'] = $tableData;
  990. return $formattedData;
  991. }
  992. }