Files
zyt/server/app/common/service/TencentImService.php
T
2026-09-09 15:47:48 +08:00

473 lines
18 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service;
/**
* 腾讯云IM服务类
* Class TencentImService
* @package app\common\service
*/
class TencentImService
{
private $sdkAppId;
private $secretKey;
private $adminIdentifier = 'administrator'; // 管理员账号
public function __construct()
{
$config = config('project.trtc');
$this->sdkAppId = $config['sdkAppId'];
$this->secretKey = $config['secretKey'];
\think\facade\Log::info('TencentImService初始化 - sdkAppId: ' . $this->sdkAppId . ', secretKey长度: ' . strlen($this->secretKey));
}
/**
* @notes 生成UserSig(使用腾讯云官方算法)
* @param string $userId
* @param int $expire
* @return string|false
*/
private function generateUserSig(string $userId, int $expire = 86400)
{
try {
$api = new TLSSigAPIv2($this->sdkAppId, $this->secretKey);
$userSig = $api->genUserSig($userId, $expire);
\think\facade\Log::info('UserSig生成成功 - userId: ' . $userId . ', sig长度: ' . strlen($userSig));
return $userSig;
} catch (\Exception $e) {
\think\facade\Log::error('生成UserSig失败: ' . $e->getMessage());
return false;
}
}
/**
* @notes 导入单个账号到IM
* @param string $userId 用户ID
* @param string $nick 昵称(可选)
* @param string $faceUrl 头像URL(可选)
* @return array|false
*/
public function importAccount(string $userId, string $nick = '', string $faceUrl = '')
{
try {
\think\facade\Log::info('开始导入IM账号 - userId: ' . $userId . ', nick: ' . $nick . ', sdkAppId: ' . $this->sdkAppId);
// 生成管理员UserSig
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
if (!$adminUserSig) {
throw new \Exception('生成管理员UserSig失败');
}
\think\facade\Log::info('管理员UserSig生成成功');
// 构建请求URL
$random = rand(0, 4294967295);
$url = sprintf(
'https://console.tim.qq.com/v4/im_open_login_svc/account_import?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
$this->sdkAppId,
$this->adminIdentifier,
urlencode($adminUserSig),
$random
);
\think\facade\Log::info('请求URL构建完成');
// 构建请求体
$data = [
'UserID' => $userId
];
if ($nick) {
$data['Nick'] = $nick;
}
if ($faceUrl) {
$data['FaceUrl'] = $faceUrl;
}
\think\facade\Log::info('请求数据: ' . json_encode($data, JSON_UNESCAPED_UNICODE));
// 发送请求
$result = $this->httpPost($url, json_encode($data));
\think\facade\Log::info('HTTP响应: ' . substr($result, 0, 500));
if (!$result) {
throw new \Exception('请求失败:无响应');
}
$response = json_decode($result, true);
if (!$response) {
throw new \Exception('响应解析失败:' . substr($result, 0, 200));
}
\think\facade\Log::info('响应解析成功: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
// 检查响应状态
if ($response['ActionStatus'] !== 'OK') {
$errorMsg = sprintf(
'导入账号失败 - ErrorCode: %s, ErrorInfo: %s',
$response['ErrorCode'] ?? 'unknown',
$response['ErrorInfo'] ?? '未知错误'
);
throw new \Exception($errorMsg);
}
return [
'success' => true,
'userId' => $userId,
'message' => '账号导入成功'
];
} catch (\Exception $e) {
$errorMsg = sprintf(
'导入IM账号失败 - userId: %s, error: %s',
$userId,
$e->getMessage()
);
\think\facade\Log::error($errorMsg);
return [
'success' => false,
'userId' => $userId,
'message' => $e->getMessage()
];
}
}
/**
* @notes 批量导入账号
* @param array $accounts 账号列表 [['userId' => 'xxx', 'nick' => 'xxx', 'faceUrl' => 'xxx'], ...]
* @return array
*/
public function batchImportAccounts(array $accounts): array
{
$results = [];
foreach ($accounts as $account) {
$userId = $account['userId'] ?? '';
$nick = $account['nick'] ?? '';
$faceUrl = $account['faceUrl'] ?? '';
if (!$userId) {
continue;
}
$result = $this->importAccount($userId, $nick, $faceUrl);
$results[] = $result;
}
return $results;
}
/**
* 只读检查一批账号是否已导入 IM;不自动导入,也不在一次调用里拆成多个请求。
* @see https://cloud.tencent.com/document/product/269/38417
* @return array{existing:array,missing:array}
* @throws \RuntimeException 单批最多 100 个不同账号;任何检查失败都保留错误码。
*/
public function checkAccounts(array $accounts): array
{
foreach ($accounts as $account) {
if (!is_string($account) || trim($account) === '') {
throw new \RuntimeException('IM账号查询参数必须是非空账号字符串');
}
}
$accounts = array_values(array_unique($accounts, SORT_STRING));
if ($accounts === []) {
return ['existing' => [], 'missing' => []];
}
if (count($accounts) > 100) {
throw new \RuntimeException('IM账号查询单批最多支持100个账号');
}
try {
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
if (!$adminUserSig) {
throw new \RuntimeException('生成管理员UserSig失败');
}
$url = sprintf(
'https://console.tim.qq.com/v4/im_open_login_svc/account_check?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
$this->sdkAppId,
$this->adminIdentifier,
urlencode($adminUserSig),
rand(0, 4294967295)
);
$data = ['CheckItem' => array_map(static function (string $account): array {
return ['UserID' => $account];
}, $accounts)];
$result = $this->httpPost($url, json_encode($data, JSON_THROW_ON_ERROR), 15);
if (!is_string($result) || $result === '') {
throw new \RuntimeException('IM账号查询接口无响应');
}
$response = json_decode($result, true);
if (!is_array($response) || !is_int($response['ErrorCode'] ?? null)) {
throw new \RuntimeException('IM账号查询响应格式非法或缺少ErrorCode');
}
$code = $response['ErrorCode'];
if (($response['ActionStatus'] ?? null) !== 'OK' || $code !== 0) {
$info = is_string($response['ErrorInfo'] ?? null) ? trim($response['ErrorInfo']) : '';
throw new \RuntimeException($info !== '' ? $info : 'IM账号查询失败:ErrorCode ' . $code, $code);
}
$items = $response['ResultItem'] ?? null;
if (!is_array($items) || array_values($items) !== $items) {
throw new \RuntimeException('IM账号查询响应缺少有效ResultItem列表');
}
$requested = array_fill_keys($accounts, true);
$statuses = [];
foreach ($items as $item) {
if (!is_array($item) || !is_string($item['UserID'] ?? null)) {
throw new \RuntimeException('IM账号查询结果缺少有效UserID');
}
$account = $item['UserID'];
if (!isset($requested[$account]) || isset($statuses[$account])) {
throw new \RuntimeException('IM账号查询结果包含未请求或重复的账号');
}
if (!is_int($item['ResultCode'] ?? null)) {
throw new \RuntimeException('IM账号查询结果缺少有效ResultCode');
}
if ($item['ResultCode'] !== 0) {
$info = is_string($item['ResultInfo'] ?? null) ? trim($item['ResultInfo']) : '';
throw new \RuntimeException(
$info !== '' ? $info : 'IM单个账号查询失败:ResultCode ' . $item['ResultCode'],
$item['ResultCode']
);
}
if (!in_array($item['AccountStatus'] ?? null, ['Imported', 'NotImported'], true)) {
throw new \RuntimeException('IM账号查询结果缺少有效AccountStatus');
}
$statuses[$account] = $item['AccountStatus'];
}
if (count($statuses) !== count($accounts)) {
throw new \RuntimeException('IM账号查询结果不完整,部分账号未返回检查结果');
}
$out = ['existing' => [], 'missing' => []];
foreach ($accounts as $account) {
$out[$statuses[$account] === 'Imported' ? 'existing' : 'missing'][] = $account;
}
return $out;
} catch (\RuntimeException $exception) {
throw $exception;
} catch (\Throwable $exception) {
throw new \RuntimeException($exception->getMessage(), (int)$exception->getCode(), $exception);
}
}
/**
* @notes 删除账号
* @param array $userIds 用户ID列表
* @return array|false
*/
public function deleteAccounts(array $userIds)
{
try {
// 生成管理员UserSig
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
if (!$adminUserSig) {
throw new \Exception('生成管理员UserSig失败');
}
// 构建请求URL
$random = rand(0, 4294967295);
$url = sprintf(
'https://console.tim.qq.com/v4/im_open_login_svc/account_delete?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
$this->sdkAppId,
$this->adminIdentifier,
urlencode($adminUserSig),
$random
);
// 构建请求体
$data = [
'DeleteItem' => array_map(function($userId) {
return ['UserID' => $userId];
}, $userIds)
];
// 发送请求
$result = $this->httpPost($url, json_encode($data));
if (!$result) {
throw new \Exception('请求失败');
}
$response = json_decode($result, true);
if (!$response) {
throw new \Exception('响应解析失败');
}
// 检查响应状态
if ($response['ActionStatus'] !== 'OK') {
throw new \Exception($response['ErrorInfo'] ?? '删除账号失败');
}
return [
'success' => true,
'message' => '账号删除成功'
];
} catch (\Exception $e) {
\think\facade\Log::error('删除IM账号失败', [
'userIds' => $userIds,
'error' => $e->getMessage()
]);
return [
'success' => false,
'message' => $e->getMessage()
];
}
}
/**
* 拉取单聊(C2C)漫游消息
* @see https://cloud.tencent.cn/document/product/269/42794
*
* @param string $operatorAccount 会话一方 UserID(如 doctor_1
* @param string $peerAccount 会话另一方 UserID(如 patient_2
* @return array{success:bool,msgList:array,complete:int,lastMsgKey:?string,lastMsgTime:?int,error:string,rawErrorCode:int}
*/
public function adminGetRoamMsg(
string $operatorAccount,
string $peerAccount,
int $maxCnt = 100,
int $minTime = 0,
int $maxTime = 4294967295,
?string $lastMsgKey = null,
?int $lastMsgTime = null
): array {
$empty = [
'success' => false,
'msgList' => [],
'complete' => 0,
'lastMsgKey' => null,
'lastMsgTime' => null,
'error' => '',
'rawErrorCode' => 0,
];
try {
$adminUserSig = $this->generateUserSig($this->adminIdentifier);
if (!$adminUserSig) {
$empty['error'] = '生成管理员UserSig失败';
return $empty;
}
$random = rand(0, 4294967295);
$url = sprintf(
'https://console.tim.qq.com/v4/openim/admin_getroammsg?sdkappid=%s&identifier=%s&usersig=%s&random=%s&contenttype=json',
$this->sdkAppId,
$this->adminIdentifier,
urlencode($adminUserSig),
$random
);
$data = [
'Operator_Account' => $operatorAccount,
'Peer_Account' => $peerAccount,
'MaxCnt' => $maxCnt,
'MinTime' => $minTime,
// 保留旧方法参数;续页时间必须写入 MaxTime,LastMsgTime 仅为响应字段。
'MaxTime' => $lastMsgTime ?? $maxTime,
];
if ($lastMsgKey !== null && $lastMsgKey !== '') {
$data['LastMsgKey'] = $lastMsgKey;
}
// 每个同步步骤只请求一页,超时后交由下一步骤重试。
$result = $this->httpPost($url, json_encode($data, JSON_THROW_ON_ERROR), 15);
if (!$result) {
$empty['error'] = 'IM接口无响应';
return $empty;
}
$response = json_decode($result, true);
if (!is_array($response)) {
$empty['error'] = 'IM响应解析失败';
return $empty;
}
$code = is_int($response['ErrorCode'] ?? null) ? $response['ErrorCode'] : -1;
$empty['rawErrorCode'] = $code;
if (($response['ActionStatus'] ?? '') !== 'OK' || $code !== 0) {
$errorInfo = is_string($response['ErrorInfo'] ?? null) ? trim($response['ErrorInfo']) : '';
$empty['error'] = $errorInfo !== '' ? $errorInfo : ('IM接口返回错误:ErrorCode ' . $code);
return $empty;
}
$msgList = $response['MsgList'] ?? null;
if (!is_array($msgList) || array_values($msgList) !== $msgList) {
$empty['error'] = 'IM响应消息列表 MsgList 非法';
return $empty;
}
if (!in_array($response['Complete'] ?? null, [0, 1], true)) {
$empty['error'] = 'IM响应分页状态 Complete 非法';
return $empty;
}
if (isset($response['MsgCnt']) && (!is_int($response['MsgCnt']) || $response['MsgCnt'] !== count($msgList))) {
$empty['error'] = 'IM响应消息条数 MsgCnt 与 MsgList 不一致';
return $empty;
}
if (isset($response['LastMsgTime']) && !is_int($response['LastMsgTime'])) {
$empty['error'] = 'IM响应游标 LastMsgTime 非法';
return $empty;
}
if (isset($response['LastMsgKey']) && !is_string($response['LastMsgKey'])) {
$empty['error'] = 'IM响应游标 LastMsgKey 非法';
return $empty;
}
return [
'success' => true,
'msgList' => $msgList,
'complete' => $response['Complete'],
'lastMsgKey' => $response['LastMsgKey'] ?? null,
'lastMsgTime' => $response['LastMsgTime'] ?? null,
'error' => '',
'rawErrorCode' => $code,
];
} catch (\Throwable $e) {
$empty['error'] = $e->getMessage();
return $empty;
}
}
/**
* @notes 发送HTTP POST请求
* @param string $url
* @param string $data
* @return string|false
*/
protected function httpPost(string $url, string $data, int $timeout = 10)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Content-Length: ' . strlen($data)
]);
$result = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
\think\facade\Log::error('HTTP请求失败', [
'url' => $url,
'error' => $error
]);
return false;
}
return $result;
}
}