Files
cunkebao_v3/Server/application/chukebao/controller/CustomerServiceController.php
Manus AI f6025724ea feat: 四端工作手机链路与触客宝发消息安全门禁
统一设备/微信解析与 BFF 发消息正规流程(Resolver、142-P1、sendStatus 判定),并补齐超管映射、触客宝乐观 UI 与验收文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 19:41:21 +08:00

542 lines
23 KiB
PHP
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\chukebao\controller;
use app\chukebao\service\CompanyWechatScopeService;
use app\common\service\workphone\WorkphoneDisplayWechatService;
use app\common\service\workphone\WorkphoneProviderService;
use app\common\util\WorkPhoneSDK;
use library\ResponseHelper;
use think\Db;
class CustomerServiceController extends BaseController
{
/**
* 客服评估总览(§二 · 头像 Popover 数据源)
* GET /v1/kefu/stats/overview
* 返回 { totalCustomers, activeCustomers, assignedCount }
*/
public function statsOverview()
{
$accountId = (int)$this->getUserInfo('s2_accountId');
$userId = (int)$this->getUserInfo('id');
$companyId = (int)$this->getUserInfo('companyId');
$accountIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds($companyId, $accountId);
$total = 0;
$active = 0;
// 当前分配:该客服管辖的微信号数量(稳健、非 0
$assigned = count($accountIds);
if (!empty($accountIds)) {
// 总客户数(独立 try
try {
$total = (int)Db::table('s2_wechat_friend')
->whereIn('wechatAccountId', $accountIds)
->where('isDeleted', 0)
->count();
} catch (\Exception $e) {
}
// 活跃客户:已通过好友(多字段兜底,独立 try避免整体为 0
foreach (['isPassed', 'isPass', 'passed'] as $col) {
try {
$active = (int)Db::table('s2_wechat_friend')
->whereIn('wechatAccountId', $accountIds)
->where('isDeleted', 0)
->where($col, 1)
->count();
if ($active > 0) {
break;
}
} catch (\Exception $e) {
// 该列不存在,尝试下一个
}
}
// 仍为 0 时退化为近 30 天新增createTime 兼容秒/毫秒)
if ($active === 0) {
try {
$sec = time() - 30 * 86400;
$active = (int)Db::table('s2_wechat_friend')
->whereIn('wechatAccountId', $accountIds)
->where('isDeleted', 0)
->where('createTime', '>=', $sec)
->count();
} catch (\Exception $e) {
}
}
}
return ResponseHelper::success([
'totalCustomers' => $total,
'activeCustomers' => $active,
'assignedCount' => $assigned,
'kefuId' => $userId,
]);
}
public function getList(){
$accountId = $this->getUserInfo('s2_accountId');
$userId = $this->getUserInfo('id');
$companyId = $this->getUserInfo('companyId');
if (empty($accountId)){
return ResponseHelper::error('请先登录');
}
$deviceList = $this->getProjectDeviceCustomerList((int)$companyId, (int)$userId);
if (!empty($deviceList)) {
return ResponseHelper::success($deviceList);
}
// 侧栏真源:与存客宝 ck_device 绑定一致;有项目设备链时不混入客服个人 IM 号(避免 2 台设备却显示 3 个号)
$accountIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds((int)$companyId, (int)$accountId);
if (empty($accountIds)) {
return ResponseHelper::success([]);
}
// CKB-123 / TKB-128列表前按 provider 真源节流刷新方案A SDK心跳/方案B S2镜像再归零陈旧 alive
// 与存客宝同一真源,禁止 S2 假在线覆盖 ck_device触客宝设备态 123.5 对齐
try {
\app\common\service\workphone\WorkphoneDeviceAliveHelper::refreshThrottled((int)$companyId);
\app\common\service\workphone\WorkphoneDeviceAliveHelper::zeroStaleAlive((int)$companyId);
} catch (\Throwable $e) {
}
$list = Db::table('s2_wechat_account')->alias('wa')
->join(['s2_device' => 'd'],'wa.currentDeviceId = d.id','LEFT')
->whereIn('wa.id',$accountIds)
->order('wa.id desc')
->group('wa.id')
->field([
'wa.*',
'd.imei',
'd.memo as deviceMemoFromDevice',
'd.extra',
'd.alive as s2DeviceAlive',
])
->select();
$ckDevicesByImei = [];
try {
$imeis = [];
foreach ($list as $row) {
if (!empty($row['imei'])) {
$imeis[] = $row['imei'];
}
}
if (!empty($imeis)) {
$ckRows = Db::name('device')
->where('companyId', $companyId)
->where('deleteTime', 0)
->whereIn('imei', array_unique($imeis))
->field('imei,alive,memo,model,brand,extra')
->select();
foreach ($ckRows as $ck) {
$ckDevicesByImei[$ck['imei']] = $ck;
}
}
} catch (\Exception $e) {
// ck device 表不可用时仍返回 S2 列表
}
foreach ($list as $k=>&$v){
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : '';
$v['updateTime'] = !empty($v['updateTime']) ? date('Y-m-d H:i:s',$v['updateTime']) : '';
$v['labels'] = json_decode($v['labels'] ?? '', true) ?: [];
$momentsSetting = Db::name('kf_moments_settings')->where(['userId' => $userId,'companyId' => $companyId,'wechatId' =>$v['id']])->find();
$v['momentsMax'] = !empty($momentsSetting['max']) ? $momentsSetting['max'] : 5;
$v['momentsNum'] = !empty($momentsSetting['sendNum']) ? $momentsSetting['sendNum'] : 0;
$v['deviceExtra'] = json_decode($v['extra'],true) ?: [];
$v['deviceExtra']['imei'] = $v['imei'];
$memoFromDevice = $v['deviceMemoFromDevice'] ?? ($v['deviceMemo'] ?? '');
$v['deviceExtra']['memo'] = $memoFromDevice ?: ($v['deviceExtra']['memo'] ?? '');
$imei = $v['imei'] ?? '';
if ($imei && isset($ckDevicesByImei[$imei])) {
$ck = $ckDevicesByImei[$imei];
$ckExtra = !empty($ck['extra']) ? (json_decode($ck['extra'], true) ?: []) : [];
if (!empty($ck['memo'])) {
$v['deviceExtra']['memo'] = $ck['memo'];
}
if (!empty($ck['model']) || !empty($ck['brand'])) {
$v['deviceExtra']['market_name'] = trim(($ck['brand'] ?? '') . ' ' . ($ck['model'] ?? ''));
}
if (isset($ck['alive'])) {
$v['deviceAlive'] = (int)$ck['alive'];
}
if (isset($ckExtra['battery'])) {
$v['deviceExtra']['battery'] = $ckExtra['battery'];
}
}
// 禁止 s2_device.alive 覆盖 ck_device曾导致真机离线仍显示在线
$s2StaleOnline = ((int)($v['s2DeviceAlive'] ?? 0) === 1)
&& ((int)($v['deviceAlive'] ?? 0) === 0);
if ($s2StaleOnline) {
$v['deviceAliveHint'] = 'hardware_offline';
}
$v['isOnline'] = ((int)($v['deviceAlive'] ?? 0) === 1)
|| ((int)($v['wechatAlive'] ?? 0) === 1)
|| ((int)($v['keFuAlive'] ?? 0) === 1);
unset(
$v['accountUserName'],
$v['accountRealName'],
$v['accountNickname'],
$v['extra'],
$v['imei'],
$v['deviceMemo'],
$v['deviceMemoFromDevice'],
$v['s2DeviceAlive'],
);
}
unset($v);
usort($list, function ($a, $b) {
$oa = !empty($a['isOnline']) ? 1 : 0;
$ob = !empty($b['isOnline']) ? 1 : 0;
if ($oa !== $ob) {
return $ob - $oa;
}
return strcmp($a['nickname'] ?? '', $b['nickname'] ?? '');
});
return ResponseHelper::success($list);
}
/**
* 触客宝侧栏按设备为核心输出:设备是主对象,微信只是当前挂载状态。
*/
private function getProjectDeviceCustomerList(int $companyId, int $userId): array
{
if ($companyId <= 0) {
return [];
}
try {
\app\common\service\workphone\WorkphoneDeviceAliveHelper::refreshThrottled($companyId);
\app\common\service\workphone\WorkphoneDeviceAliveHelper::zeroStaleAlive($companyId);
} catch (\Throwable $e) {
}
try {
$devices = Db::name('device')
->where('companyId', $companyId)
->where('deleteTime', 0)
->field('id,imei,memo,alive,brand,model,extra,createTime,updateTime')
->order('alive desc,id desc')
->select();
} catch (\Throwable $e) {
return [];
}
if (empty($devices)) {
return [];
}
$providerSvc = new WorkphoneProviderService();
$deviceControlProvider = $providerSvc->getProvider($companyId);
$sdkMap = $providerSvc->getDeviceMap($companyId);
$result = [];
foreach ($devices as $device) {
$deviceId = (int)($device['id'] ?? 0);
$imei = (string)($device['imei'] ?? '');
$sdkDeviceId = (string)($sdkMap[$deviceId] ?? '');
$boundWechats = $this->loadDeviceBoundWechats($companyId, $deviceId, $imei);
// 142 四端同源:与存客宝列表/详情共用 WorkphoneDisplayWechatService 决策树
$display = WorkphoneDisplayWechatService::resolveForDevice($companyId, $deviceId, $imei);
$profileSource = (string)($display['workphoneProfileSource'] ?? '');
if ($sdkDeviceId === '' && !empty($display['workphoneMapHint'])) {
$sdkDeviceId = (string)($display['workphoneDeviceId'] ?? '');
} else {
$sdkDeviceId = (string)($display['workphoneDeviceId'] ?? $sdkDeviceId);
}
$currentHookWechat = null;
if ($profileSource === 'websocket/hook' && !empty($display['wechatId']) && !empty($boundWechats)) {
$currentHookWechat = [
'wechatId' => (string)$display['wechatId'],
'nickname' => (string)($display['nickname'] ?? ''),
'totalFriend' => isset($display['totalFriend']) ? (int)$display['totalFriend'] : 0,
's2AccountId' => (int)($display['s2AccountId'] ?? 0),
];
$business = $this->resolveS2FallbackWechat($boundWechats);
$bizWxid = (string)($business['account']['wechatId'] ?? '');
if ($bizWxid !== '' && $bizWxid !== (string)$display['wechatId']) {
$display = array_merge($display, [
'wechatId' => $bizWxid,
'nickname' => trim((string)($business['account']['nickname'] ?? '')) ?: $bizWxid,
'alias' => (string)($business['account']['alias'] ?? ''),
'avatar' => (string)($business['account']['avatar'] ?? ''),
'totalFriend' => isset($business['account']['totalFriend']) && is_numeric($business['account']['totalFriend'])
? (int)$business['account']['totalFriend'] : ($display['totalFriend'] ?? null),
's2AccountId' => (int)($business['account']['id'] ?? 0),
'workphoneProfileSource' => 's2_business_override',
]);
$profileSource = 's2_business_override';
}
}
$wechatId = (string)($display['wechatId'] ?? '');
$account = $wechatId !== '' ? $this->findWechatAccountByWechatId($wechatId) : [];
if (empty($account) && $wechatId === '' && !empty($boundWechats)) {
$fallback = $this->resolveS2FallbackWechat($boundWechats);
if (!empty($fallback['account']['wechatId'])) {
$account = $fallback['account'];
$wechatId = (string)$account['wechatId'];
$profileSource = (string)$fallback['source'];
}
}
$accountId = (int)($account['id'] ?? ($display['s2AccountId'] ?? 0));
$deviceAlive = (int)($device['alive'] ?? 0);
$wechatLoggedIn = $wechatId !== '';
$displayNickname = trim((string)($display['nickname'] ?? ''));
$extra = json_decode((string)($device['extra'] ?? ''), true) ?: [];
$extra['imei'] = $imei;
$extra['memo'] = (string)($device['memo'] ?? '');
$extra['market_name'] = trim((string)($device['brand'] ?? '') . ' ' . (string)($device['model'] ?? '')) ?: ($extra['market_name'] ?? '');
if ($sdkDeviceId !== '') {
$extra['sdkDeviceId'] = $sdkDeviceId;
}
$momentsSetting = $accountId > 0
? Db::name('kf_moments_settings')->where(['userId' => $userId,'companyId' => $companyId,'wechatId' => $accountId])->find()
: null;
$row = array_merge($account ?: [], [
'id' => $accountId > 0 ? $accountId : -$deviceId,
'wechatId' => $wechatId,
'nickname' => $wechatLoggedIn
? ($displayNickname !== '' ? $displayNickname : (string)($account['nickname'] ?? $wechatId))
: '未登录微信',
'alias' => $wechatLoggedIn ? (string)($display['alias'] ?? ($account['alias'] ?? '')) : '',
'avatar' => $wechatLoggedIn ? (string)($display['avatar'] ?? ($account['avatar'] ?? '')) : '',
'totalFriend' => isset($display['totalFriend']) && is_numeric($display['totalFriend'])
? (int)$display['totalFriend']
: (int)($account['totalFriend'] ?? 0),
'deviceAccountId' => (int)($account['deviceAccountId'] ?? 0),
'currentDeviceId' => $deviceId,
'deviceAlive' => $deviceAlive,
'wechatAlive' => $wechatLoggedIn ? 1 : 0,
'keFuAlive' => 0,
'isOnline' => $deviceAlive === 1,
'apiIsOnline' => $deviceAlive === 1,
'deviceExtra' => $extra,
'deviceMemo' => (string)($device['memo'] ?? ''),
'deviceStatusText' => $sdkDeviceId === ''
? '未配置工作手机映射'
: ($deviceAlive === 1
? ($wechatLoggedIn ? '工作手机在线·微信已登录' : '工作手机在线·微信未登录')
: ($wechatLoggedIn ? '微信曾登录·设备离线' : '设备离线·微信未登录')),
'workphoneDeviceId' => $sdkDeviceId,
'workphoneProfileSource' => $profileSource,
'deviceControlProvider' => $deviceControlProvider,
'messageProvider' => 's2_legacy',
'currentWechat' => $wechatLoggedIn ? [
'wechatId' => $wechatId,
'nickname' => $displayNickname !== '' ? $displayNickname : (string)($account['nickname'] ?? ''),
'totalFriend' => (int)($display['totalFriend'] ?? ($account['totalFriend'] ?? 0)),
's2AccountId' => $accountId,
] : null,
'currentHookWechat' => $currentHookWechat,
'attribution' => $this->buildAttribution($account),
'boundWechats' => $boundWechats,
'boundWechatCount' => count($boundWechats),
'labels' => !empty($account['labels']) ? (json_decode((string)$account['labels'], true) ?: []) : [],
'momentsMax' => !empty($momentsSetting['max']) ? $momentsSetting['max'] : 5,
'momentsNum' => !empty($momentsSetting['sendNum']) ? $momentsSetting['sendNum'] : 0,
'createTime' => !empty($device['createTime']) ? date('Y-m-d H:i:s', (int)$device['createTime']) : '',
'updateTime' => !empty($device['updateTime']) ? date('Y-m-d H:i:s', (int)$device['updateTime']) : '',
]);
unset($row['extra']);
$result[] = $row;
}
return $result;
}
private function configuredSdkDeviceMap(int $companyId): array
{
try {
$raw = Db::name('system_config')
->where('configKey', 'workphone_sdk_device_map:' . $companyId)
->value('configValue');
} catch (\Throwable $e) {
$raw = '';
}
$decoded = json_decode((string)$raw, true);
if (!is_array($decoded)) {
return [];
}
$map = [];
foreach ($decoded as $left => $right) {
$leftText = trim((string)$left);
$rightText = trim((string)$right);
if ($leftText === '' || $rightText === '') {
continue;
}
if (ctype_digit($leftText)) {
$map[(int)$leftText] = $rightText;
}
if (ctype_digit($rightText)) {
$map[(int)$rightText] = $leftText;
}
}
return $map;
}
private function loadCurrentWechatFromWorkphone(int $companyId, string $sdkDeviceId): array
{
try {
$sdk = WorkPhoneSDK::forCompany($companyId);
$profileResp = $sdk->getProfile($sdkDeviceId, 'wechat');
$profile = $profileResp['data']['profile']
?? $profileResp['data']['data']['profile']
?? $profileResp['profile']
?? [];
if (!is_array($profile)) {
$profile = [];
}
$wechatId = trim((string)($profile['wxid'] ?? ($profile['wechat_id'] ?? '')));
if ($wechatId === '') {
return [];
}
$contactsResp = $sdk->getContacts($sdkDeviceId, 'wechat', 1, 0);
$contactsData = $contactsResp['data']['data']
?? $contactsResp['data']
?? $contactsResp;
$totalFriend = $contactsData['total_count']
?? $contactsData['raw_total_count']
?? $contactsData['count']
?? null;
return [
'wechatId' => $wechatId,
'nickname' => trim((string)($profile['nickname'] ?? '')),
'totalFriend' => is_numeric($totalFriend) ? (int)$totalFriend : null,
];
} catch (\Throwable $e) {
return [];
}
}
private function findWechatAccountByWechatId(string $wechatId): array
{
if ($wechatId === '') {
return [];
}
try {
$row = Db::table('s2_wechat_account')->where('wechatId', $wechatId)->find();
return is_array($row) ? $row : [];
} catch (\Throwable $e) {
return [];
}
}
private function loadDeviceBoundWechats(int $companyId, int $deviceId, string $imei): array
{
$wechatIds = [];
try {
$wechatIds = Db::name('device_wechat_login')
->where('deviceId', $deviceId)
->where('companyId', $companyId)
->group('wechatId')
->column('wechatId') ?: [];
} catch (\Throwable $e) {
$wechatIds = [];
}
try {
if ($imei !== '') {
$more = Db::table('s2_wechat_account')
->where('imei', $imei)
->column('wechatId') ?: [];
$wechatIds = array_merge($wechatIds, $more);
}
} catch (\Throwable $e) {
}
$wechatIds = array_values(array_unique(array_filter(array_map('strval', $wechatIds))));
if (empty($wechatIds)) {
return [];
}
try {
return Db::table('s2_wechat_account')
->whereIn('wechatId', $wechatIds)
->field('id,wechatId,nickname,alias,avatar,totalFriend,wechatAlive,currentDeviceId,deviceAccountId')
->select() ?: [];
} catch (\Throwable $e) {
return array_map(function ($wechatId) {
return ['wechatId' => $wechatId];
}, $wechatIds);
}
}
/**
* 142-P0-API1 · Hook 空时的 S2 回退08 文档决策树第 3/4 步):
* 活跃会话 top1s2_wechat_message 最近一条的 wechatAccountId→ boundWechats[0]
*
* @return array{account: array, source: string}
*/
private function resolveS2FallbackWechat(array $boundWechats): array
{
$byId = [];
foreach ($boundWechats as $row) {
$id = (int)($row['id'] ?? 0);
if ($id > 0) {
$byId[$id] = $row;
}
}
if (!empty($byId)) {
try {
$activeId = (int)Db::table('s2_wechat_message')
->whereIn('wechatAccountId', array_keys($byId))
->order('id desc')
->limit(1)
->value('wechatAccountId');
if ($activeId > 0 && isset($byId[$activeId])) {
return ['account' => $byId[$activeId], 'source' => 's2_session_fallback'];
}
} catch (\Throwable $e) {
// 消息表不可用时退化为 boundWechats[0]
}
}
$first = $boundWechats[0] ?? [];
if (!empty($first['wechatId'])) {
return ['account' => $first, 'source' => 's2_bound_fallback'];
}
return ['account' => [], 'source' => ''];
}
/** 142-P0-API1 · 业务归属s2_wechat_account.deviceAccountId → ck_users法定归属客服 */
private function buildAttribution(array $account): ?array
{
$ownerS2AccountId = (int)($account['deviceAccountId'] ?? 0);
if ($ownerS2AccountId <= 0) {
return null;
}
$ownerUsername = '';
$ownerDisplayName = '';
try {
$owner = Db::name('users')
->where('s2_accountId', $ownerS2AccountId)
->where('deleteTime', 0)
->field('account,username')
->find();
if (is_array($owner)) {
$ownerUsername = (string)($owner['account'] ?? '');
$ownerDisplayName = (string)($owner['username'] ?? '');
}
} catch (\Throwable $e) {
}
return [
'ownerS2AccountId' => $ownerS2AccountId,
'ownerUsername' => $ownerUsername,
'ownerDisplayName' => $ownerDisplayName,
];
}
}