AiMeasurement.php 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150
  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. // 引导教程
  269. 'tutorial' => [
  270. 'images' => $this->parseImages(config('site.ai_measure_selfie_tutorial_images')),
  271. 'video' => $this->formatFileUrl(config('site.ai_measure_selfie_tutorial_video'))
  272. ],
  273. // 陀螺仪检测
  274. 'gyroscope' => [
  275. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_gyro_voice')),
  276. 'example' => $this->formatFileUrl(config('site.ai_measure_selfie_gyro_example'))
  277. ],
  278. // 拍摄正面
  279. 'front_shooting' => [
  280. 'frame' => $this->formatFileUrl(config('site.ai_measure_selfie_front_frame')),
  281. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_front_voice')),
  282. 'demo' => $this->formatFileUrl(config('site.ai_measure_selfie_front_demo'))
  283. ],
  284. // 拍摄侧面
  285. 'side_shooting' => [
  286. 'frame' => $this->formatFileUrl(config('site.ai_measure_selfie_side_frame')),
  287. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_side_voice')),
  288. 'demo' => $this->formatFileUrl(config('site.ai_measure_selfie_side_demo'))
  289. ],
  290. // 拍摄正面侧平举
  291. 'arms_shooting' => [
  292. 'frame' => $this->formatFileUrl(config('site.ai_measure_selfie_arms_frame')),
  293. 'voice' => $this->formatFileUrl(config('site.ai_measure_selfie_arms_voice')),
  294. 'demo' => $this->formatFileUrl(config('site.ai_measure_selfie_arms_demo'))
  295. ],
  296. // 拍摄过程素材
  297. 'process_materials' => [
  298. 'countdown_voice' => $this->formatFileUrl(config('site.ai_measure_selfie_countdown_voice')),
  299. 'timer_sound' => $this->formatFileUrl(config('site.ai_measure_selfie_timer_sound')),
  300. 'complete_sound' => $this->formatFileUrl(config('site.ai_measure_selfie_complete_sound')),
  301. 'next_voice' => $this->formatFileUrl(config('site.ai_measure_selfie_next_voice')),
  302. 'finish_voice' => $this->formatFileUrl(config('site.ai_measure_selfie_finish_voice'))
  303. ]
  304. ];
  305. }
  306. /**
  307. * 获取帮拍模式配置
  308. */
  309. private function getHelperConfig()
  310. {
  311. return [
  312. 'enabled' => config('site.ai_measure_helper_enabled'),
  313. // 引导教程
  314. 'tutorial' => [
  315. 'images' => $this->parseImages(config('site.ai_measure_helper_tutorial_images')),
  316. 'video' => $this->formatFileUrl(config('site.ai_measure_helper_tutorial_video'))
  317. ],
  318. // 陀螺仪检测
  319. 'gyroscope' => [
  320. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_gyro_voice')),
  321. 'example' => $this->formatFileUrl(config('site.ai_measure_helper_gyro_example'))
  322. ],
  323. // 拍摄正面
  324. 'front_shooting' => [
  325. 'frame' => $this->formatFileUrl(config('site.ai_measure_helper_front_frame')),
  326. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_front_voice')),
  327. 'demo' => $this->formatFileUrl(config('site.ai_measure_helper_front_demo'))
  328. ],
  329. // 拍摄侧面
  330. 'side_shooting' => [
  331. 'frame' => $this->formatFileUrl(config('site.ai_measure_helper_side_frame')),
  332. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_side_voice')),
  333. 'demo' => $this->formatFileUrl(config('site.ai_measure_helper_side_demo'))
  334. ],
  335. // 拍摄正面侧平举
  336. 'arms_shooting' => [
  337. 'frame' => $this->formatFileUrl(config('site.ai_measure_helper_arms_frame')),
  338. 'voice' => $this->formatFileUrl(config('site.ai_measure_helper_arms_voice')),
  339. 'demo' => $this->formatFileUrl(config('site.ai_measure_helper_arms_demo'))
  340. ],
  341. // 拍摄过程素材
  342. 'process_materials' => [
  343. 'countdown_voice' => cdnurl(config('site.ai_measure_helper_countdown_voice')),
  344. 'timer_sound' => $this->formatFileUrl(config('site.ai_measure_helper_timer_sound')),
  345. 'complete_sound' => $this->formatFileUrl(config('site.ai_measure_helper_complete_sound')),
  346. 'next_voice' => $this->formatFileUrl(config('site.ai_measure_helper_next_voice')),
  347. 'finish_voice' => $this->formatFileUrl(config('site.ai_measure_helper_finish_voice'))
  348. ]
  349. ];
  350. }
  351. /**
  352. * 获取通用配置
  353. */
  354. private function getCommonConfig()
  355. {
  356. return [
  357. 'privacy_notice' => config('site.ai_measure_privacy_notice'),
  358. 'accuracy_disclaimer' => config('site.ai_measure_accuracy_disclaimer'),
  359. 'measurement_types' => $this->parseMeasurementTypes(config('site.ai_measure_measurement_types')),
  360. 'ai_model_version' => config('site.ai_measure_ai_model_version'),
  361. 'feedback_enabled' => config('site.ai_measure_feedback_enabled'),
  362. 'debug_mode' => config('site.ai_measure_debug_mode'),
  363. 'log_level' => config('site.ai_measure_log_level')
  364. ];
  365. }
  366. /**
  367. * 格式化文件URL
  368. */
  369. private function formatFileUrl($url)
  370. {
  371. if (empty($url)) {
  372. return null;
  373. }
  374. return cdnurl($url);
  375. }
  376. /**
  377. * 解析图片集合
  378. */
  379. private function parseImages($images)
  380. {
  381. if (empty($images)) {
  382. return [];
  383. }
  384. // 如果是JSON格式的字符串,解析为数组
  385. if (is_string($images)) {
  386. $imageArray = json_decode($images, true);
  387. if (json_last_error() === JSON_ERROR_NONE && is_array($imageArray)) {
  388. $images = $imageArray;
  389. } else {
  390. // 如果不是JSON,可能是逗号分隔的字符串
  391. $images = explode(',', $images);
  392. }
  393. }
  394. if (!is_array($images)) {
  395. return [];
  396. }
  397. // 格式化每个图片URL
  398. return array_map(function($url) {
  399. return $this->formatFileUrl(trim($url));
  400. }, array_filter($images));
  401. }
  402. /**
  403. * 解析测量类型
  404. */
  405. private function parseMeasurementTypes($types)
  406. {
  407. if (empty($types)) {
  408. return [];
  409. }
  410. $typeLabels = [
  411. 'bust' => '胸围',
  412. 'waist' => '腰围',
  413. 'hips' => '臀围',
  414. 'shoulder' => '肩宽',
  415. 'sleeve' => '袖长',
  416. 'inseam' => '内缝长',
  417. 'outseam' => '外缝长',
  418. 'thigh' => '大腿围',
  419. 'neck' => '颈围'
  420. ];
  421. $typeArray = is_string($types) ? explode(',', $types) : $types;
  422. $result = [];
  423. foreach ($typeArray as $type) {
  424. $type = trim($type);
  425. if (isset($typeLabels[$type])) {
  426. $result[] = [
  427. 'key' => $type,
  428. 'label' => $typeLabels[$type]
  429. ];
  430. }
  431. }
  432. return $result;
  433. }
  434. /**
  435. * 直接调用第三方AI测量服务
  436. * @ApiMethod (POST)
  437. * @ApiParams (name="profile_id", type="integer", required=true, description="档案ID")
  438. * @ApiParams (name="photos", type="object", required=true, description="身体照片对象")
  439. * @ApiParams (name="photos.front", type="string", required=true, description="正面照片URL")
  440. * @ApiParams (name="photos.side", type="string", required=true, description="侧面照片URL")
  441. * @ApiParams (name="photos.back", type="string", required=true, description="背面照片URL")
  442. */
  443. public function measurementDirect()
  444. {
  445. $params = $this->request->post();
  446. // 验证必要参数
  447. if (empty($params['profile_id'])) {
  448. $this->error('档案ID不能为空');
  449. }
  450. // if (empty($params['photos']) || !is_array($params['photos'])) {
  451. // $this->error('请上传身体照片');
  452. // }
  453. // try {
  454. // 验证档案归属
  455. $profile = \app\common\model\BodyProfile::where('id', $params['profile_id'])
  456. ->where('user_id', $this->auth->id)
  457. ->find();
  458. if (!$profile) {
  459. $this->error('档案不存在');
  460. }
  461. // 验证照片格式
  462. // $requiredPhotos = ['front', 'side', 'back'];
  463. // foreach ($requiredPhotos as $angle) {
  464. // if (empty($params['photos'][$angle])) {
  465. // $this->error("请上传{$angle}角度的身体照片");
  466. // }
  467. // }
  468. // 直接使用档案的
  469. $photos = $profile->body_photos_text;
  470. // 安全调用第三方AI服务 - 确保身高为数字格式
  471. $heightCm = is_numeric($profile->height) ? floatval($profile->height) : 0;
  472. $measurements = $this->safeCallThirdPartyAiService(
  473. $photos,
  474. $heightCm
  475. );
  476. // echo "<pre>";
  477. // print_r($measurements);
  478. // echo "</pre>";
  479. // exit;
  480. // 处理结果
  481. // $result = [
  482. // 'measurements' => $measurements,
  483. // 'confidence' => $measurements['_confidence'] ?? 0.8,
  484. // 'warnings' => $measurements['_warnings'] ?? []
  485. // ];
  486. // 清理内部字段
  487. // unset($result['measurements']['_confidence']);
  488. // unset($result['measurements']['_warnings']);
  489. // 格式化结果用于展示
  490. //$formattedResult = $this->formatMeasurementResult($result, $params['profile_id']);
  491. $measurements['height'] = $profile->height;
  492. $measurements['weight'] = $profile->weight;
  493. $this->success('AI测量完成', $measurements);
  494. // } catch (\Exception $e) {
  495. // $this->error($e->getMessage());
  496. // }
  497. }
  498. /**
  499. * 调用第三方AI测量接口
  500. */
  501. private function callThirdPartyAiService($photos, $height)
  502. {
  503. // 第三方API配置
  504. $apiUrl = $this->thirdPartyApiConfig['url'];
  505. try {
  506. // 准备请求数据 - 确保身高为纯数字(厘米)
  507. $heightValue = is_numeric($height) ? floatval($height) : 0;
  508. $requestData = [
  509. 'height' => $heightValue
  510. ];
  511. // 处理照片数据 - 转换为base64格式
  512. if (isset($photos['front'])) {
  513. $requestData['image1'] = $this->convertImageToBase64($photos['front']);
  514. }
  515. if (isset($photos['side'])) {
  516. $requestData['image2'] = $this->convertImageToBase64($photos['side']);
  517. }
  518. if (isset($photos['back'])) {
  519. $requestData['image3'] = $this->convertImageToBase64($photos['back']);
  520. }
  521. // 记录请求日志(不包含图片数据)
  522. // $logData = [
  523. // 'url' => $apiUrl,
  524. // 'height' => $requestData['height'],
  525. // 'image_count' => count(array_filter([
  526. // isset($requestData['image1']),
  527. // isset($requestData['image2']),
  528. // isset($requestData['image3'])
  529. // ]))
  530. // ];
  531. // 记录请求日志(包含身高和图片base64数据的前50个字符)
  532. $logData = [
  533. 'url' => $apiUrl,
  534. 'height' => $heightValue . 'cm',
  535. 'image1_preview' => isset($requestData['image1']) ? substr($requestData['image1'], 0, 50) . '...' : null,
  536. 'image2_preview' => isset($requestData['image2']) ? substr($requestData['image2'], 0, 50) . '...' : null,
  537. 'image3_preview' => isset($requestData['image3']) ? substr($requestData['image3'], 0, 50) . '...' : null,
  538. 'request_data_size' => strlen(json_encode($requestData)) . ' bytes'
  539. ];
  540. \think\Log::info('Calling third party AI service: ' . json_encode($logData));
  541. // 发送POST请求
  542. $ch = curl_init();
  543. curl_setopt_array($ch, [
  544. CURLOPT_URL => $apiUrl,
  545. CURLOPT_POST => true,
  546. CURLOPT_POSTFIELDS => json_encode($requestData),
  547. CURLOPT_RETURNTRANSFER => true,
  548. CURLOPT_TIMEOUT => $this->thirdPartyApiConfig['timeout'],
  549. CURLOPT_CONNECTTIMEOUT => $this->thirdPartyApiConfig['connect_timeout'],
  550. CURLOPT_HTTPHEADER => [
  551. 'Content-Type: application/json',
  552. 'Accept: application/json'
  553. ],
  554. CURLOPT_SSL_VERIFYPEER => false,
  555. CURLOPT_SSL_VERIFYHOST => false
  556. ]);
  557. $response = curl_exec($ch);
  558. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  559. $error = curl_error($ch);
  560. curl_close($ch);
  561. if ($error) {
  562. throw new \Exception('请求第三方AI服务失败: ' . $error);
  563. }
  564. // 处理各种HTTP错误状态
  565. if ($httpCode >= 500) {
  566. throw new \Exception('第三方AI服务内部错误: HTTP ' . $httpCode);
  567. } elseif ($httpCode >= 400) {
  568. throw new \Exception('第三方AI服务请求错误: HTTP ' . $httpCode);
  569. } elseif ($httpCode !== 200) {
  570. throw new \Exception('第三方AI服务返回异常状态: HTTP ' . $httpCode);
  571. }
  572. \think\Log::info('Third party AI service response: ' .$response);
  573. // 检查响应内容
  574. if (empty($response)) {
  575. throw new \Exception('第三方AI服务返回空响应');
  576. }
  577. $result = json_decode($response, true);
  578. if (json_last_error() !== JSON_ERROR_NONE) {
  579. throw new \Exception('第三方AI服务返回数据格式错误');
  580. }
  581. // 记录API响应信息
  582. // $responseLog = [
  583. // 'http_code' => $httpCode,
  584. // 'response_size' => strlen($response) . ' bytes',
  585. // 'has_body_size' => isset($result['body_size']),
  586. // 'body_size_fields' => isset($result['body_size']) ? array_keys($result['body_size']) : [],
  587. // 'response_preview' => substr($response, 0, 200) . '...'
  588. // ];
  589. // 处理返回的测量数据
  590. // echo "<pre>";
  591. // print_r($result);
  592. // echo "</pre>";
  593. // exit;
  594. return $this->processMeasurementData($result);
  595. } catch (\Exception $e) {
  596. // 记录错误日志
  597. \think\Log::error('Third party AI service error: ' . $e->getMessage());
  598. throw $e;
  599. }
  600. // 如果执行到这里说明没有异常处理,直接返回处理结果
  601. }
  602. /**
  603. * 安全调用第三方AI服务(带异常处理和默认返回)
  604. */
  605. private function safeCallThirdPartyAiService($photos, $height)
  606. {
  607. try {
  608. return $this->callThirdPartyAiService($photos, $height);
  609. } catch (\Exception $e) {
  610. // 记录错误日志
  611. \think\Log::error('Third party AI service error, returning default data: ' . $e->getMessage());
  612. // 返回默认的空测量数据
  613. return $this->getDefaultMeasurementData();
  614. }
  615. }
  616. /**
  617. * 获取默认的空测量数据
  618. */
  619. private function getDefaultMeasurementData()
  620. {
  621. // 返回所有映射字段的空值(使用空字符串)
  622. return [
  623. 'chest'=>'', // 净胸围 → 胸围
  624. 'waist'=>'', // 净腰围 → 腰围
  625. 'hip'=>'', // 净臀围 → 实际臀围
  626. //'thigh', // 净腿根 → 大腿围
  627. 'knee'=>'', // 净膝围 → 膝围
  628. 'calf'=>'', // 净小腿围 → 小腿围
  629. 'arm_length'=>'', // 净手臂长 → 臂长
  630. 'wrist'=>'', // 净手腕围 → 手腕围
  631. 'pants_length'=>'', // 腿长 → 腿长
  632. 'belly_belt'=>'', // 净肚围 → 肚围
  633. 'shoulder_width'=>'', // 净肩宽 → 肩宽
  634. 'leg_root'=>'', // 净腿根 → 大腿围
  635. 'neck'=>'', // 净颈围 → 颈围
  636. 'inseam'=>'', // 内腿长 → 内腿长
  637. 'upper_arm'=>'', // 净上臂围 → 上臂围
  638. 'ankle'=>'', // 净脚踝围 → 脚踝围
  639. 'waist_lower'=>'', // 净小腹围 → 下腰围
  640. 'mid_waist'=>'', // 净中腰 → 中腰围
  641. '_confidence' => 0.0,
  642. '_warnings' => ['第三方AI服务暂时不可用,返回默认数据']
  643. ];
  644. }
  645. /**
  646. * 测试接口 - 返回模拟的第三方API测量数据
  647. * @ApiMethod (POST)
  648. * @ApiParams (name="profile_id", type="integer", required=true, description="档案ID")
  649. */
  650. public function testMeasurementData()
  651. {
  652. $params = $this->request->post();
  653. // 验证必要参数
  654. if (empty($params['profile_id'])) {
  655. $this->error('档案ID不能为空');
  656. }
  657. // 验证档案归属
  658. $profile = \app\common\model\BodyProfile::where('id', $params['profile_id'])
  659. ->where('user_id', $this->auth->id)
  660. ->find();
  661. if (!$profile) {
  662. $this->error('档案不存在');
  663. }
  664. // 模拟第三方API返回的数据
  665. $mockApiResult = [
  666. "body_size" => [
  667. "datuigen" => 56.973762220946064,
  668. "duwei" => 71.86294164495045,
  669. "jiankuan" => 44.99356951672863,
  670. "jiaohuai" => 20.995062499529606,
  671. "jingwei" => 36.973537078225604,
  672. "neitui" => 67.99506048261769,
  673. "shangbi" => 23.285375591374667,
  674. "shoubichang" => 61.1834335984307,
  675. "shouwanwei" => 16.0697059847192,
  676. "tuichang" => 73.9800462219755,
  677. "tunwei" => 90.08082593388505,
  678. "xiaofu" => 70.98010845587423,
  679. "xiaotuiwei" => 37.2761443409742,
  680. "xigai" => 34.990971006868364,
  681. "xiongwei" => 81.85738385794711,
  682. "yaowei" => 72.93800974219818,
  683. "zhongyao" => 70.99945416888724
  684. ],
  685. "confidence" => 0.85
  686. ];
  687. // 处理测量数据
  688. $measurements = $this->processMeasurementData($mockApiResult);
  689. // 处理结果
  690. $result = [
  691. 'measurements' => $measurements,
  692. 'confidence' => $measurements['_confidence'] ?? 0.8,
  693. 'warnings' => $measurements['_warnings'] ?? []
  694. ];
  695. // 清理内部字段
  696. unset($result['measurements']['_confidence']);
  697. unset($result['measurements']['_warnings']);
  698. // 格式化结果用于展示
  699. $formattedResult = $this->formatMeasurementResult($result, $params['profile_id']);
  700. $this->success('测试数据返回成功', [
  701. 'original_api_data' => $mockApiResult['body_size'],
  702. 'mapped_measurements' => $result['measurements'],
  703. 'formatted_result' => $formattedResult
  704. ]);
  705. }
  706. /**
  707. * 将图片URL转换为base64格式
  708. */
  709. private function convertImageToBase64($imageUrl)
  710. {
  711. try {
  712. // 如果已经是base64格式,直接返回
  713. if (strpos($imageUrl, 'data:image') === 0) {
  714. return $imageUrl;
  715. }
  716. // 如果是相对路径,转换为绝对路径
  717. if (strpos($imageUrl, 'http') !== 0) {
  718. $imageUrl = request()->domain() . $imageUrl;
  719. }
  720. // 获取图片数据
  721. $ch = curl_init();
  722. curl_setopt_array($ch, [
  723. CURLOPT_URL => $imageUrl,
  724. CURLOPT_RETURNTRANSFER => true,
  725. CURLOPT_TIMEOUT => 30,
  726. CURLOPT_CONNECTTIMEOUT => 10,
  727. CURLOPT_FOLLOWLOCATION => true,
  728. CURLOPT_SSL_VERIFYPEER => false,
  729. CURLOPT_SSL_VERIFYHOST => false,
  730. CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
  731. CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  732. CURLOPT_HTTPHEADER => [
  733. 'Accept: image/webp,image/apng,image/*,*/*;q=0.8',
  734. 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8',
  735. 'Cache-Control: no-cache',
  736. ]
  737. ]);
  738. $imageData = curl_exec($ch);
  739. $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  740. $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
  741. $curlError = curl_error($ch);
  742. $effectiveUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
  743. curl_close($ch);
  744. // 详细的错误处理
  745. if ($curlError) {
  746. throw new \Exception("网络请求失败: {$curlError} (URL: {$imageUrl})");
  747. }
  748. if ($httpCode !== 200) {
  749. throw new \Exception("图片访问失败,HTTP状态码: {$httpCode} (URL: {$imageUrl})");
  750. }
  751. if (!$imageData || strlen($imageData) === 0) {
  752. throw new \Exception("图片数据为空 (URL: {$imageUrl})");
  753. }
  754. // 验证图片数据是否有效
  755. if (!@getimagesizefromstring($imageData)) {
  756. throw new \Exception("获取的数据不是有效的图片格式 (URL: {$imageUrl})");
  757. }
  758. // 确定MIME类型
  759. if (strpos($contentType, 'image/') === 0) {
  760. $mimeType = $contentType;
  761. } else {
  762. // 通过文件扩展名推断
  763. $extension = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
  764. $mimeTypes = [
  765. 'jpg' => 'image/jpeg',
  766. 'jpeg' => 'image/jpeg',
  767. 'png' => 'image/png',
  768. 'gif' => 'image/gif',
  769. 'webp' => 'image/webp',
  770. 'bmp' => 'image/bmp'
  771. ];
  772. $mimeType = $mimeTypes[$extension] ?? 'image/jpeg';
  773. }
  774. // 转换为base64
  775. $base64 = base64_encode($imageData);
  776. return "data:{$mimeType};base64,{$base64}";
  777. } catch (\Exception $e) {
  778. \think\Log::error('Convert image to base64 error: ' . $e->getMessage() . ' URL: ' . $imageUrl);
  779. throw new \Exception('图片转换失败: ' . $e->getMessage());
  780. }
  781. }
  782. /**
  783. * 处理第三方API返回的测量数据
  784. */
  785. private function processMeasurementData($apiResult)
  786. {
  787. // 根据第三方API的返回格式处理数据
  788. // 这里需要根据实际的API返回格式进行调整
  789. $measurements = [];
  790. try {
  791. // 假设API返回格式类似:
  792. // {
  793. // "status": "success",
  794. // "data": {
  795. // "chest": 95.5,
  796. // "waist": 75.2,
  797. // "hip": 98.7,
  798. // ...
  799. // },
  800. // "confidence": 0.85
  801. // }
  802. // 检查返回数据结构 - 可能直接包含body_size字段
  803. if (isset($apiResult['body_size'])) {
  804. $data = $apiResult['body_size'];
  805. $hasValidData = true;
  806. } elseif (isset($apiResult['status']) && $apiResult['status'] === 'success') {
  807. $data = $apiResult['data'] ?? [];
  808. $hasValidData = true;
  809. } else {
  810. // 尝试直接使用返回的数据
  811. $data = $apiResult;
  812. $hasValidData = !empty($data) && is_array($data);
  813. }
  814. if ($hasValidData) {
  815. // 映射字段名(根据第三方API返回的字段名进行映射)
  816. $fieldMapping = [
  817. 'xiongwei' => 'chest', // 净胸围 → 胸围
  818. 'yaowei' => 'waist', // 净腰围 → 腰围
  819. 'tunwei' => 'hip', // 净臀围 → 实际臀围
  820. //'datuigen' => 'thigh', // 净腿根 → 大腿围
  821. 'xigai' => 'knee', // 净膝围 → 膝围
  822. 'xiaotuiwei' => 'calf', // 净小腿围 → 小腿围
  823. 'shoubichang' => 'arm_length', // 净手臂长 → 臂长
  824. 'shouwanwei' => 'wrist', // 净手腕围 → 手腕围
  825. 'tuichang' => 'pants_length', // 腿长 → 腿长
  826. 'duwei' => 'belly_belt', // 净肚围 → 肚围
  827. 'jiankuan' => 'shoulder_width', // 净肩宽 → 肩宽
  828. 'datuigen' => 'leg_root', // 净腿根 → 大腿围
  829. 'jingwei' => 'neck', // 净颈围 → 颈围
  830. 'neitui' => 'inseam', // 内腿长 → 内腿长
  831. 'shangbi' => 'upper_arm', // 净上臂围 → 上臂围
  832. 'jiaohuai' => 'ankle', // 净脚踝围 → 脚踝围
  833. 'xiaofu' => 'waist_lower', // 净小腹围 → 下腰围
  834. 'zhongyao' => 'mid_waist', // 净中腰 → 中腰围
  835. ];
  836. foreach ($fieldMapping as $apiField => $localField) {
  837. if (isset($data[$apiField]) && is_numeric($data[$apiField])) {
  838. $measurements[$localField] = round(floatval($data[$apiField]), 1);
  839. }
  840. }
  841. // 设置置信度和警告信息
  842. $measurements['_confidence'] = $apiResult['confidence'] ?? 0.8;
  843. $measurements['_warnings'] = $apiResult['warnings'] ?? [];
  844. // 如果没有测量数据,添加默认警告
  845. if (count($measurements) === 2) { // 只有_confidence和_warnings
  846. $measurements['_warnings'][] = '第三方AI服务未返回有效的测量数据';
  847. }
  848. } else {
  849. // API返回错误
  850. $errorMsg = $apiResult['message'] ?? $apiResult['error'] ?? '第三方AI服务返回未知错误';
  851. throw new \Exception($errorMsg);
  852. }
  853. } catch (\Exception $e) {
  854. // 处理异常,返回错误信息
  855. $measurements['_confidence'] = 0;
  856. $measurements['_warnings'] = ['数据处理失败: ' . $e->getMessage()];
  857. }
  858. return $measurements;
  859. }
  860. /**
  861. * 处理AI测量任务
  862. */
  863. private function processTask($taskId)
  864. {
  865. try {
  866. // 更新任务状态为处理中
  867. \think\Db::table('fa_ai_measurement_task')
  868. ->where('id', $taskId)
  869. ->update([
  870. 'status' => 1,
  871. 'started_at' => time(),
  872. 'attempts' => \think\Db::raw('attempts + 1'),
  873. 'updatetime' => time()
  874. ]);
  875. // 获取任务详情
  876. $task = \think\Db::table('fa_ai_measurement_task')->where('id', $taskId)->find();
  877. $photos = json_decode($task['photos'], true);
  878. $params = json_decode($task['params'], true);
  879. // 安全调用第三方AI分析服务 - 确保身高为数字格式
  880. $heightCm = is_numeric($params['height']) ? floatval($params['height']) : 0;
  881. $measurements = $this->safeCallThirdPartyAiService(
  882. $photos,
  883. $heightCm
  884. );
  885. // 处理结果
  886. $result = [
  887. 'measurements' => $measurements,
  888. 'confidence' => $measurements['_confidence'] ?? 0.8,
  889. 'warnings' => $measurements['_warnings'] ?? []
  890. ];
  891. // 清理内部字段
  892. unset($result['measurements']['_confidence']);
  893. unset($result['measurements']['_warnings']);
  894. // 更新任务状态为完成
  895. \think\Db::table('fa_ai_measurement_task')
  896. ->where('id', $taskId)
  897. ->update([
  898. 'status' => 2,
  899. 'result' => json_encode($result),
  900. 'completed_at' => time(),
  901. 'updatetime' => time()
  902. ]);
  903. } catch (\Exception $e) {
  904. // 更新任务状态为失败
  905. \think\Db::table('fa_ai_measurement_task')
  906. ->where('id', $taskId)
  907. ->update([
  908. 'status' => 3,
  909. 'error_message' => $e->getMessage(),
  910. 'updatetime' => time()
  911. ]);
  912. }
  913. }
  914. /**
  915. * 估算处理进度
  916. */
  917. private function estimateProgress($task)
  918. {
  919. $startTime = $task['started_at'];
  920. $currentTime = time();
  921. $elapsedTime = $currentTime - $startTime;
  922. // 假设总处理时间为30秒
  923. $totalTime = 30;
  924. $progress = min(95, ($elapsedTime / $totalTime) * 100);
  925. return round($progress);
  926. }
  927. /**
  928. * 格式化测量结果用于展示
  929. */
  930. private function formatMeasurementResult($result, $profileId)
  931. {
  932. $profile = \app\common\model\BodyProfile::find($profileId);
  933. $measurements = $result['measurements'];
  934. // 获取显示配置
  935. $displayConfig = AiMeasurementService::getMeasurementDisplayConfig($profile->gender);
  936. // 格式化数据
  937. $formattedData = [
  938. 'profile' => [
  939. 'id' => $profile->id,
  940. 'name' => $profile->profile_name,
  941. 'gender' => $profile->gender,
  942. 'height' => $profile->height,
  943. 'weight' => $profile->weight
  944. ],
  945. 'measurements' => [],
  946. 'display_config' => $displayConfig,
  947. 'confidence' => $result['confidence'] ?? 0,
  948. 'warnings' => $result['warnings'] ?? []
  949. ];
  950. // 组织测量数据
  951. foreach ($displayConfig as $field => $config) {
  952. $value = isset($measurements[$field]) && $measurements[$field] > 0
  953. ? $measurements[$field]
  954. : null;
  955. $formattedData['measurements'][$field] = [
  956. 'label' => $config['label'],
  957. 'value' => $value,
  958. 'unit' => 'cm',
  959. 'position' => $config['position'],
  960. 'side' => $config['side']
  961. ];
  962. }
  963. // 添加基础数据表格
  964. $formattedData['basic_data'] = [
  965. ['label' => '身高', 'value' => $profile->height, 'unit' => 'cm'],
  966. ['label' => '体重', 'value' => $profile->weight, 'unit' => 'kg'],
  967. ];
  968. // 添加测量数据表格
  969. $tableData = [];
  970. $fields = array_keys($measurements);
  971. $chunks = array_chunk($fields, 2);
  972. foreach ($chunks as $chunk) {
  973. $row = [];
  974. foreach ($chunk as $field) {
  975. if (isset($displayConfig[$field])) {
  976. $row[] = [
  977. 'label' => $displayConfig[$field]['label'],
  978. 'value' => $measurements[$field] ?? null,
  979. 'unit' => 'cm'
  980. ];
  981. }
  982. }
  983. if (!empty($row)) {
  984. $tableData[] = $row;
  985. }
  986. }
  987. $formattedData['table_data'] = $tableData;
  988. return $formattedData;
  989. }
  990. }