// +---------------------------------------------------------------------- declare(strict_types=1); namespace app\services\other; use app\services\BaseServices; use think\facade\Log; /** * 隐私小号服务类 (AX模式) * Class PrivacyPhoneServices * @package app\services\other */ class PrivacyPhoneServices extends BaseServices { /** * 标准化AX小号:从后往前截取11位手机号 * @param string $number * @return string */ private function normalizeAxNumber(string $number): string { if ($number === '') { return ''; } return strlen($number) > 11 ? substr($number, -11) : $number; } /** * 日志上下文转字符串(ThinkPHP日志上下文仅支持占位符替换) * @param mixed $value * @return string */ private function toLogString($value): string { if (is_string($value)) { return $value; } $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return $json === false ? var_export($value, true) : $json; } /** * API地址 */ private string $apiUrl = 'https://api.mixcom.cn'; /** * 获取AppKey * @return string */ public function getAppKey(): string { return env('PRIVACY_PHONE.APPKEY', ''); } /** * 获取AppSecret * @return string */ public function getAppSecret(): string { return env('PRIVACY_PHONE.APPSECRET', ''); } /** * 检查功能是否开启 * @return bool */ public function isEnabled(): bool { return (bool)env('PRIVACY_PHONE.ENABLE', false); } /** * 生成签名 * @param array $params * @return string */ private function generateSign(array $params): string { $secret = $this->getAppSecret(); if (empty($secret)) { throw new \Exception('隐私小号AppSecret未配置'); } $params = array_change_key_case($params, CASE_LOWER); foreach ($params as $key => $val) { if ($key == 'c' || $key == 'a' || $key == 'sign') { unset($params[$key]); } if ($val === '') { unset($params[$key]); } } ksort($params); $data = ''; foreach ($params as $key => $val) { $data .= $val; } $data .= $secret; return strtolower(sha1($data . sha1($secret))); } /** * 发送POST请求 * @param string $url * @param array $params * @return array */ private function post(string $url, array $params): array { $params['time'] = time(); $params['appkey'] = $this->getAppKey(); $params['sign'] = $this->generateSign($params); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $params); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); $result = [ 'http_code' => $httpCode, 'response' => $response, 'error' => $error, 'decoded' => json_decode($response, true) ?? [] ]; Log::info('隐私小号API请求 url={url} params={params} result={result}', [ 'url' => $url, 'params' => $this->toLogString($params), 'result' => $this->toLogString($result), ]); return $result; } /** * 获取可用AX小号 * @param string $regionCode 区号(可选) * @return array */ public function getAxNumber(string $regionCode = ''): array { if (!$this->isEnabled()) { return []; } $params = []; if ($regionCode) { $params['region_code'] = $regionCode; } $url = $this->apiUrl . '/virtual/getaxnumber'; $result = $this->post($url, $params); if ($result['http_code'] !== 200) { throw new \Exception('获取隐私小号失败:HTTP ' . $result['http_code']); } $data = $result['decoded']; if (empty($data['success']) || $data['code'] != 200 || empty($data['data'])) { throw new \Exception('获取隐私小号失败:' . ($data['msg'] ?? '未知错误')); } return $data['data']; } /** * 获取第一个可用小号 * @return string|null */ public function getFirstAxNumber(): ?string { $numbers = $this->getAxNumber(); return $numbers[0]['number'] ?? null; } /** * AX绑定 * @param string $number 隐私小号 * @param string $aParty A号码(用户手机号) * @param array $options 可选参数 * @return array */ public function axBind(string $number, string $aParty, array $options = []): array { if (!$this->isEnabled()) { throw new \Exception('隐私小号功能未开启'); } $params = array_merge([ 'number' => $number, 'aparty' => $aParty, 'recording' => 0, 'recordhinttone' => '', 'display_call' => 0, 'callerhinttone' => '', 'calleehinttone' => '', 'enddate' => 0, ], $options); $url = $this->apiUrl . '/virtual/axBind'; $result = $this->post($url, $params); if ($result['http_code'] !== 200) { throw new \Exception('AX绑定失败:HTTP ' . $result['http_code']); } $data = $result['decoded']; if (empty($data['success']) || $data['code'] != 200) { throw new \Exception('AX绑定失败:' . ($data['msg'] ?? '未知错误')); } return $data; } /** * AX解绑 * @param string $number 隐私小号 * @param string $aParty A号码(用户手机号) * @return array */ public function unAxBind(string $number, string $aParty): array { if (!$this->isEnabled()) { throw new \Exception('隐私小号功能未开启'); } $params = [ 'number' => $number, 'aparty' => $aParty, ]; $url = $this->apiUrl . '/virtual/unAxBind'; $result = $this->post($url, $params); if ($result['http_code'] !== 200) { throw new \Exception('AX解绑失败:HTTP ' . $result['http_code']); } $data = $result['decoded']; if (empty($data['success']) || $data['code'] != 200) { throw new \Exception('AX解绑失败:' . ($data['msg'] ?? '未知错误')); } return $data; } /** * 订单创建时绑定隐私手机号 * @param string $realPhone 用户真实手机号 * @return array ['user_phone' => 隐私号, 'bind_id' => 真实手机号, 'status' => 0] */ public function bindForOrder(string $realPhone): array { if (!$this->isEnabled()) { return []; } $result = [ 'user_phone' => '', 'bind_id' => '', 'status' => 0 ]; try { $number = $this->getFirstAxNumber(); if (!$number) { Log::error('获取隐私小号失败:无可用号码'); return $result; } $bindNumber = $this->normalizeAxNumber($number); if (strlen($bindNumber) !== 11) { Log::error('隐私小号格式异常 raw_number={raw_number} bind_number={bind_number}', [ 'raw_number' => $number, 'bind_number' => $bindNumber, ]); return $result; } $bindResult = $this->axBind($bindNumber, $realPhone); if (!empty($bindResult['success']) && $bindResult['code'] == 200) { $result = [ 'user_phone' => $bindNumber, 'bind_id' => $realPhone, 'status' => 1 ]; Log::info('隐私小号绑定成功 privacy_phone={privacy_phone} real_phone={real_phone}', [ 'privacy_phone' => $bindNumber, 'real_phone' => $realPhone, ]); } } catch (\Exception $e) { Log::error('隐私小号绑定异常 real_phone={real_phone} ax_number={ax_number} error={error}', [ 'real_phone' => $realPhone, 'ax_number' => $bindNumber ?? ($number ?? ''), 'error' => $e->getMessage(), ]); } return $result; } /** * 订单完成时解绑隐私手机号 * @param string $privacyPhone 隐私手机号(user_phone) * @param string $realPhone 真实手机号(private_bind_id) * @return bool */ public function unbindForOrder(string $privacyPhone, string $realPhone): bool { if (!$this->isEnabled()) { return false; } if (empty($privacyPhone)) { return false; } $unbindPhone = $this->normalizeAxNumber($privacyPhone); if (strlen($unbindPhone) !== 11) { Log::error('隐私小号格式异常 privacy_phone={privacy_phone} unbind_phone={unbind_phone}', [ 'privacy_phone' => $privacyPhone, 'unbind_phone' => $unbindPhone, ]); return false; } try { $result = $this->unAxBind($unbindPhone, $realPhone); if (!empty($result['success']) && $result['code'] == 200) { Log::info('隐私小号解绑成功 privacy_phone={privacy_phone} real_phone={real_phone}', [ 'privacy_phone' => $unbindPhone, 'real_phone' => $realPhone, ]); return true; } } catch (\Exception $e) { Log::error('隐私小号解绑异常 privacy_phone={privacy_phone} real_phone={real_phone} error={error}', [ 'privacy_phone' => $unbindPhone, 'real_phone' => $realPhone, 'error' => $e->getMessage(), ]); } return false; } }