## GitHub 同步说明
| 项 | 内容 |
|:---|:---|
| 仓库 | https://github.com/fnvtk/cunkebao_v3 |
| 分支 | develop |
| 方向 | 本地 → GitHub(单向 push) |
| 是否拉取远程 | 否(未 fetch / pull / merge) |
| 是否改本地文件 | 否(仅 git add/commit,未编辑任何源文件正文) |
| 基准 commit | 0643e78a |
| 变更规模 | 709 files, +26436 / -3407 lines |
## 目录变更统计
| 目录 | 文件数 | 说明 |
|:---|---:|:---|
| 开发文档/ | 404 | 四端进行中/已完成需求、接口文档、官网需求、部署脚本 |
| Server/ | 95 | 后端 API、迁移脚本、触客宝/超管/存客宝服务 |
| Cunkebao/ | 85 | 移动端 H5、设备绑定、客服、转号审批等 |
| Touchkebao/ | 68 | PC 工作台、AI 获客、算力中心、Glass UI |
| SuperAdmin/ | 33 | 超管项目中心、算力计费、内容库 |
| 官网/ | 19 | 新增存客宝官网静态页(index/pricing/solutions 等) |
| .cursor/ | 3 | Cursor 规则与 Skill |
| .obsidian/ | 2 | Obsidian 工作区配置 |
## 重点新增/更新(本地为准)
- 官网/: 完整静态站点(index、pricing、manual、solutions、api)
- 开发文档/1、需求/修改/*_进行中_20260530.md(存客宝/触客宝/AI数智员工)
- 开发文档/1、需求/官网需求.md
- 开发文档/5、接口/开发进度_接口文档.md 等接口文档迭代
- Touchkebao Glass UI 与 P0 收尾、通道获客算力
- SuperAdmin 算力计费规则、项目工作台
## 刻意未上传(本地保留)
- node_modules/
- .DS_Store、.smart-env/
- .开发文档_nested_git_backup/(旧嵌套 git 备份)
- **/__pycache__/
## 验收
GitHub 浏览: https://github.com/fnvtk/cunkebao_v3/tree/develop
Co-authored-by: Cursor <cursoragent@cursor.com>
1130 lines
43 KiB
PHP
1130 lines
43 KiB
PHP
<?php
|
||
|
||
namespace app\chukebao\controller;
|
||
|
||
use app\chukebao\service\CompanyWechatScopeService;
|
||
use app\common\model\TrafficPoolV2;
|
||
use app\common\model\TrafficPoolCompany;
|
||
use app\common\model\TrafficPoolTag;
|
||
use app\common\model\TrafficPoolTagDefine;
|
||
use library\ResponseHelper;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 模块 A · AI 获客 Webhook(需求一)
|
||
* 触客宝接收存客宝场景获客客资推送,合并键 companyId + userKey。
|
||
* 数据表:ck_ai_lead(首次调用懒建)。
|
||
*/
|
||
class AiLeadController extends BaseController
|
||
{
|
||
/**
|
||
* 同一触客宝账号(s2_accountId)可能在多 company 下有多条 ck_users;
|
||
* 流量分发推送的 kefuId 与当前登录 id 不一致时,仍应可见同账号客资。
|
||
*
|
||
* @return array{kefuIds:int[],companyIds:int[]}
|
||
*/
|
||
private function resolveKefuScope(int $companyId, int $kefuId): array
|
||
{
|
||
$kefuIds = [$kefuId];
|
||
$companyIds = [$companyId];
|
||
if ($kefuId <= 0) {
|
||
return [
|
||
'kefuIds' => $kefuIds,
|
||
'companyIds' => $companyIds,
|
||
];
|
||
}
|
||
$s2AccountId = (int)Db::name('users')->where('id', $kefuId)->value('s2_accountId');
|
||
if ($s2AccountId <= 0) {
|
||
return [
|
||
'kefuIds' => $kefuIds,
|
||
'companyIds' => $companyIds,
|
||
];
|
||
}
|
||
$related = Db::name('users')
|
||
->where('s2_accountId', $s2AccountId)
|
||
->field('id,companyId')
|
||
->select();
|
||
foreach ($related ?: [] as $row) {
|
||
$kefuIds[] = (int)$row['id'];
|
||
$companyIds[] = (int)$row['companyId'];
|
||
}
|
||
return [
|
||
'kefuIds' => array_values(array_unique(array_filter($kefuIds))),
|
||
'companyIds' => array_values(array_unique(array_filter($companyIds))),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* best-effort:将标签写入流量池标签表(ck_traffic_pool_tag)
|
||
*/
|
||
private function syncPoolTags(int $companyId, int $poolCompanyId, array $tags): int
|
||
{
|
||
if ($poolCompanyId <= 0 || empty($tags)) {
|
||
return 0;
|
||
}
|
||
$operatorId = (int)$this->getUserInfo('id');
|
||
$identifier = (string)Db::name('traffic_pool_company')
|
||
->where('id', $poolCompanyId)
|
||
->value('identifier');
|
||
if ($identifier === '') {
|
||
return 0;
|
||
}
|
||
$synced = 0;
|
||
foreach ($tags as $tagName) {
|
||
$tagName = trim((string)$tagName);
|
||
if ($tagName === '') {
|
||
continue;
|
||
}
|
||
try {
|
||
$define = TrafficPoolTagDefine::getOrCreateByName($tagName, $companyId);
|
||
if (!$define || empty($define->id)) {
|
||
continue;
|
||
}
|
||
$tag = TrafficPoolTag::addTag(
|
||
$poolCompanyId,
|
||
$identifier,
|
||
$companyId,
|
||
(int)$define->id,
|
||
TrafficPoolTag::SOURCE_MANUAL,
|
||
$operatorId
|
||
);
|
||
if ($tag) {
|
||
$synced++;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// best-effort:不阻断主流程
|
||
}
|
||
}
|
||
return $synced;
|
||
}
|
||
|
||
/**
|
||
* 绑定流量池(客户资料池同源):返回 poolCompanyId(= customer-pool/detail 的 poolId)
|
||
* best-effort,失败返回 0 不阻断入站。
|
||
*/
|
||
private static function ensurePoolId(int $companyId, array $body): int
|
||
{
|
||
try {
|
||
$phone = trim((string)($body['phone'] ?? ''));
|
||
$identifier = $phone !== ''
|
||
? $phone
|
||
: trim((string)($body['unionId'] ?? ($body['openId'] ?? '')));
|
||
if ($identifier === '') {
|
||
return 0;
|
||
}
|
||
$pool = TrafficPoolV2::findOrCreateByIdentifier($identifier, [
|
||
'nickname' => $body['nickname'] ?? '',
|
||
'avatar' => $body['avatar'] ?? '',
|
||
'mobile' => $phone,
|
||
]);
|
||
if (!$pool || empty($pool->id)) {
|
||
return 0;
|
||
}
|
||
$poolCompany = TrafficPoolCompany::findOrCreateByIdentifierAndCompany(
|
||
$identifier,
|
||
$companyId,
|
||
(int)$pool->id,
|
||
[
|
||
'realName' => $body['nickname'] ?? '',
|
||
'phone' => $phone,
|
||
]
|
||
);
|
||
return $poolCompany && !empty($poolCompany->id) ? (int)$poolCompany->id : 0;
|
||
} catch (\Throwable $e) {
|
||
return 0;
|
||
}
|
||
}
|
||
/** 懒建表,避免依赖迁移 */
|
||
private function ensureTable()
|
||
{
|
||
Db::execute("CREATE TABLE IF NOT EXISTS `ck_ai_lead` (
|
||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||
`companyId` int(11) NOT NULL DEFAULT 0,
|
||
`kefuId` int(11) NOT NULL DEFAULT 0,
|
||
`userKey` varchar(128) NOT NULL DEFAULT '',
|
||
`unionId` varchar(128) NOT NULL DEFAULT '',
|
||
`openId` varchar(128) NOT NULL DEFAULT '',
|
||
`phone` varchar(32) NOT NULL DEFAULT '',
|
||
`nickname` varchar(128) NOT NULL DEFAULT '',
|
||
`avatar` varchar(512) NOT NULL DEFAULT '',
|
||
`planName` varchar(128) NOT NULL DEFAULT '',
|
||
`tags` text NULL,
|
||
`poolKeywordsText` varchar(255) NOT NULL DEFAULT '',
|
||
`rfmLevel` varchar(8) NOT NULL DEFAULT '',
|
||
`rfmScore` int(11) NOT NULL DEFAULT 0,
|
||
`intention` varchar(32) NOT NULL DEFAULT '',
|
||
`behaviorTimeline` text NULL,
|
||
`wechatAdded` tinyint(1) NOT NULL DEFAULT 0,
|
||
`poolId` int(11) NOT NULL DEFAULT 0,
|
||
`status` varchar(16) NOT NULL DEFAULT 'unadded',
|
||
`isRead` tinyint(1) NOT NULL DEFAULT 0,
|
||
`createTime` int(11) NOT NULL DEFAULT 0,
|
||
`updateTime` int(11) NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_company_user` (`companyId`,`userKey`),
|
||
KEY `idx_kefu` (`kefuId`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
}
|
||
|
||
private static function decodeRow(array $row, ?array $poolMeta = null, array $products = []): array
|
||
{
|
||
$row['tags'] = !empty($row['tags']) ? (json_decode($row['tags'], true) ?: []) : [];
|
||
$row['behaviorTimeline'] = !empty($row['behaviorTimeline'])
|
||
? (json_decode($row['behaviorTimeline'], true) ?: [])
|
||
: [];
|
||
$row['wechatAdded'] = (bool)$row['wechatAdded'];
|
||
$row['updatedAt'] = (int)($row['updateTime'] ?? 0);
|
||
return self::enrichSortMeta($row, $poolMeta, $products);
|
||
}
|
||
|
||
/** @return array<int,array<string,mixed>> */
|
||
private static function loadPoolMetaMap(array $poolCompanyIds, int $companyId): array
|
||
{
|
||
$poolCompanyIds = array_values(array_unique(array_filter(array_map('intval', $poolCompanyIds))));
|
||
if (empty($poolCompanyIds)) {
|
||
return [];
|
||
}
|
||
$rows = Db::name('traffic_pool_company')->alias('tpc')
|
||
->join('traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
|
||
->whereIn('tpc.id', $poolCompanyIds)
|
||
->where('tpc.companyId', $companyId)
|
||
->field('tpc.id,tpc.remark,tpc.totalOrderCount,tpc.phone as poolPhone,tp.wechatId,tp.wechatAlias')
|
||
->select();
|
||
$map = [];
|
||
foreach ($rows ?: [] as $r) {
|
||
$map[(int)$r['id']] = $r;
|
||
}
|
||
return $map;
|
||
}
|
||
|
||
/** @return string[] */
|
||
private static function loadProjectProducts(int $companyId): array
|
||
{
|
||
$products = [];
|
||
$rows = Db::name('customer_acquisition_task')
|
||
->where('companyId', $companyId)
|
||
->where('deleteTime', 0)
|
||
->order('id desc')
|
||
->limit(30)
|
||
->column('sceneConf');
|
||
foreach ($rows ?: [] as $conf) {
|
||
$scene = is_array($conf) ? $conf : (json_decode((string)$conf, true) ?: []);
|
||
if (!is_array($scene)) {
|
||
continue;
|
||
}
|
||
foreach (['poolKeywords', 'productWords', 'products'] as $key) {
|
||
if (empty($scene[$key])) {
|
||
continue;
|
||
}
|
||
$items = $scene[$key];
|
||
if (!is_array($items)) {
|
||
$items = preg_split('/[,,、]/u', (string)$items);
|
||
}
|
||
foreach ($items as $item) {
|
||
$name = is_string($item) ? trim($item) : trim((string)($item['name'] ?? ''));
|
||
if ($name !== '') {
|
||
$products[] = $name;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (empty($products)) {
|
||
$products = ['一键宏', '魔兽一键宏', 'iOS一键宏'];
|
||
}
|
||
return array_values(array_unique($products));
|
||
}
|
||
|
||
/** 项目上下文(产品词 + 备注模板) */
|
||
public static function loadProjectContext(int $companyId): array
|
||
{
|
||
$remarkFormat = '';
|
||
$rows = Db::name('customer_acquisition_task')
|
||
->where('companyId', $companyId)
|
||
->where('deleteTime', 0)
|
||
->order('id desc')
|
||
->limit(10)
|
||
->column('sceneConf');
|
||
foreach ($rows ?: [] as $conf) {
|
||
$scene = is_array($conf) ? $conf : (json_decode((string)$conf, true) ?: []);
|
||
if (!empty($scene['remarkFormat']) && $remarkFormat === '') {
|
||
$remarkFormat = (string)$scene['remarkFormat'];
|
||
}
|
||
}
|
||
return [
|
||
'products' => self::loadProjectProducts($companyId),
|
||
'remarkFormat' => $remarkFormat !== '' ? $remarkFormat : '手机号+{product}',
|
||
];
|
||
}
|
||
|
||
private static function sanitizePlanName(string $planName): string
|
||
{
|
||
$parts = array_filter(array_map('trim', explode('|', $planName)));
|
||
$out = [];
|
||
foreach ($parts as $p) {
|
||
if ($p === '') {
|
||
continue;
|
||
}
|
||
if (preg_match('/webhook/i', $p)) {
|
||
continue;
|
||
}
|
||
if (preg_match('/^流量分发\s*\d/u', $p)) {
|
||
continue;
|
||
}
|
||
$out[] = $p;
|
||
}
|
||
return implode(' · ', $out);
|
||
}
|
||
|
||
private static function isValidProjectRemark(string $remark): bool
|
||
{
|
||
if ($remark === '') {
|
||
return false;
|
||
}
|
||
$invalid = ['-', '未填写', '被对方删除', '未命名'];
|
||
return !in_array($remark, $invalid, true);
|
||
}
|
||
|
||
/** 解析行为轨迹时间(m-d H:i 或标准时间串) */
|
||
private static function parseBehaviorTime(string $time): int
|
||
{
|
||
$time = trim($time);
|
||
if ($time === '') {
|
||
return 0;
|
||
}
|
||
if (preg_match('/^(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$/', $time, $m)) {
|
||
$year = (int)date('Y');
|
||
$ts = strtotime(sprintf('%d-%02d-%02d %02d:%02d:00', $year, (int)$m[1], (int)$m[2], (int)$m[3], (int)$m[4]));
|
||
return $ts ?: 0;
|
||
}
|
||
$ts = strtotime($time);
|
||
return $ts ?: 0;
|
||
}
|
||
|
||
/** 排序元数据 v2:买过/备注/产品/D0/联系方式 + 行为/触达/获客/RFM */
|
||
private static function enrichSortMeta(array $row, ?array $poolMeta = null, array $products = []): array
|
||
{
|
||
if ($poolMeta) {
|
||
$row['remark'] = trim((string)($poolMeta['remark'] ?? ''));
|
||
$wx = trim((string)($poolMeta['wechatId'] ?? ''));
|
||
$alias = trim((string)($poolMeta['wechatAlias'] ?? ''));
|
||
$row['wechatId'] = $wx !== '' ? $wx : $alias;
|
||
if (empty($row['phone']) && !empty($poolMeta['poolPhone'])) {
|
||
$row['phone'] = trim((string)$poolMeta['poolPhone']);
|
||
}
|
||
if ((int)($poolMeta['totalOrderCount'] ?? 0) > 0) {
|
||
$row['hasPurchased'] = true;
|
||
}
|
||
} else {
|
||
$row['remark'] = trim((string)($row['remark'] ?? ''));
|
||
$row['wechatId'] = trim((string)($row['wechatId'] ?? ''));
|
||
}
|
||
|
||
$phone = trim((string)($row['phone'] ?? ''));
|
||
$unionId = trim((string)($row['unionId'] ?? ''));
|
||
$openId = trim((string)($row['openId'] ?? ''));
|
||
$wechatId = trim((string)($row['wechatId'] ?? ''));
|
||
$hasWechatId = $wechatId !== '' || $unionId !== '' || $openId !== '';
|
||
$row['hasContactD0'] = $phone !== '' && $hasWechatId;
|
||
|
||
$tags = is_array($row['tags']) ? $row['tags'] : [];
|
||
if (empty($row['hasPurchased'])) {
|
||
$row['hasPurchased'] = false;
|
||
$purchasedKeywords = ['购买', '已购', '买过', '成交', '下单', '老客户', '付费'];
|
||
foreach ($tags as $tag) {
|
||
$tag = (string)$tag;
|
||
foreach ($purchasedKeywords as $kw) {
|
||
if ($tag !== '' && mb_strpos($tag, $kw) !== false) {
|
||
$row['hasPurchased'] = true;
|
||
break 2;
|
||
}
|
||
}
|
||
}
|
||
if (!$row['hasPurchased']) {
|
||
foreach ($row['behaviorTimeline'] ?? [] as $behavior) {
|
||
$action = (string)($behavior['action'] ?? '');
|
||
if ($action !== '' && preg_match('/下单|购买|成交/u', $action)) {
|
||
$row['hasPurchased'] = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$remark = trim((string)($row['remark'] ?? ''));
|
||
$row['hasProjectRemark'] = self::isValidProjectRemark($remark);
|
||
|
||
$matched = [];
|
||
$haystack = mb_strtolower(
|
||
($row['nickname'] ?? '') . ' ' . $remark . ' ' . ($row['poolKeywordsText'] ?? '') . ' ' . implode(' ', $tags)
|
||
);
|
||
foreach ($products as $prod) {
|
||
$prod = trim((string)$prod);
|
||
if ($prod !== '' && mb_stripos($haystack, $prod) !== false) {
|
||
$matched[] = $prod;
|
||
}
|
||
}
|
||
$row['matchedProducts'] = array_values(array_unique($matched));
|
||
$row['hasProductMatch'] = !empty($matched);
|
||
|
||
$lastBehaviorTs = 0;
|
||
foreach ($row['behaviorTimeline'] ?? [] as $behavior) {
|
||
$ts = self::parseBehaviorTime((string)($behavior['time'] ?? ''));
|
||
if ($ts > $lastBehaviorTs) {
|
||
$lastBehaviorTs = $ts;
|
||
}
|
||
}
|
||
$row['lastBehaviorTs'] = $lastBehaviorTs;
|
||
$row['lastTouchTs'] = max($lastBehaviorTs, (int)($row['updateTime'] ?? 0));
|
||
|
||
$tier = 0;
|
||
if (!empty($row['hasPurchased'])) {
|
||
$tier += 4;
|
||
}
|
||
if (!empty($row['hasProjectRemark'])) {
|
||
$tier += 3;
|
||
}
|
||
if (!empty($row['hasProductMatch'])) {
|
||
$tier += 2;
|
||
}
|
||
if (!empty($row['hasContactD0'])) {
|
||
$tier += 2;
|
||
}
|
||
if ($phone !== '' || $hasWechatId) {
|
||
$tier += 1;
|
||
}
|
||
$row['priorityTier'] = $tier;
|
||
|
||
if (!empty($row['createTime'])) {
|
||
$row['acquireTimeLabel'] = date('Y-m-d H:i', (int)$row['createTime']);
|
||
} else {
|
||
$row['acquireTimeLabel'] = '';
|
||
}
|
||
$row['planNameDisplay'] = self::sanitizePlanName((string)($row['planName'] ?? ''));
|
||
|
||
return $row;
|
||
}
|
||
|
||
/** AI 获客列表 · 同手机号/同人合并为一条(最新为主 · 轨迹/来源累加) */
|
||
private static function dedupeLeadRowsByContact(array $rows): array
|
||
{
|
||
$groups = [];
|
||
foreach ($rows as $row) {
|
||
$digits = preg_replace('/\D/', '', (string)($row['phone'] ?? ''));
|
||
if (strlen($digits) >= 11) {
|
||
$key = 'p:' . substr($digits, -11);
|
||
} else {
|
||
$uk = trim((string)($row['userKey'] ?? ''));
|
||
$key = $uk !== '' ? 'u:' . $uk : 'id:' . (string)($row['id'] ?? '');
|
||
}
|
||
$groups[$key][] = $row;
|
||
}
|
||
|
||
$merged = [];
|
||
foreach ($groups as $group) {
|
||
usort($group, function ($a, $b) {
|
||
return ((int)($b['updateTime'] ?? 0)) <=> ((int)($a['updateTime'] ?? 0));
|
||
});
|
||
$primary = $group[0];
|
||
$primary['mergedCount'] = count($group);
|
||
if (count($group) > 1) {
|
||
$plans = [];
|
||
$tags = is_array($primary['tags'] ?? null) ? $primary['tags'] : [];
|
||
$timeline = is_array($primary['behaviorTimeline'] ?? null) ? $primary['behaviorTimeline'] : [];
|
||
foreach ($group as $item) {
|
||
$pn = trim((string)($item['planName'] ?? ''));
|
||
if ($pn !== '') {
|
||
$plans[] = $pn;
|
||
}
|
||
if (!empty($item['tags']) && is_array($item['tags'])) {
|
||
$tags = array_merge($tags, $item['tags']);
|
||
}
|
||
if (!empty($item['behaviorTimeline']) && is_array($item['behaviorTimeline'])) {
|
||
$timeline = array_merge($timeline, $item['behaviorTimeline']);
|
||
}
|
||
}
|
||
$plans = array_values(array_unique(array_filter($plans)));
|
||
$primary['planName'] = implode('|', $plans);
|
||
$primary['tags'] = array_values(array_unique(array_filter(array_map('strval', $tags))));
|
||
$seen = [];
|
||
$dedupTimeline = [];
|
||
foreach ($timeline as $b) {
|
||
if (!is_array($b)) {
|
||
continue;
|
||
}
|
||
$sig = ($b['time'] ?? '') . '|' . ($b['action'] ?? '');
|
||
if (isset($seen[$sig])) {
|
||
continue;
|
||
}
|
||
$seen[$sig] = true;
|
||
$dedupTimeline[] = $b;
|
||
}
|
||
usort($dedupTimeline, function ($a, $b) {
|
||
return self::parseBehaviorTime((string)($b['time'] ?? ''))
|
||
<=> self::parseBehaviorTime((string)($a['time'] ?? ''));
|
||
});
|
||
$primary['behaviorTimeline'] = $dedupTimeline;
|
||
$primary['planNameDisplay'] = self::sanitizePlanName((string)$primary['planName']);
|
||
} else {
|
||
$primary['mergedCount'] = 1;
|
||
}
|
||
$merged[] = $primary;
|
||
}
|
||
|
||
return $merged;
|
||
}
|
||
|
||
/** Tab 计数(与列表同口径 · 按手机号去重后统计) */
|
||
private function buildStatusCountsDeduped(array $scope): array
|
||
{
|
||
$rows = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
})
|
||
->select();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$products = self::loadProjectProducts($companyId);
|
||
$poolMetaMap = self::loadPoolMetaMap(array_column($rows ?: [], 'poolId'), $companyId);
|
||
$decoded = array_map(function ($row) use ($poolMetaMap, $products) {
|
||
$meta = $poolMetaMap[(int)($row['poolId'] ?? 0)] ?? null;
|
||
return self::decodeRow($row, $meta, $products);
|
||
}, $rows ?: []);
|
||
$deduped = self::dedupeLeadRowsByContact($decoded);
|
||
|
||
$all = count($deduped);
|
||
$unadded = 0;
|
||
$added = 0;
|
||
$expired = 0;
|
||
foreach ($deduped as $r) {
|
||
if (!empty($r['wechatAdded'])) {
|
||
$added++;
|
||
} elseif (($r['status'] ?? '') === 'expired') {
|
||
$expired++;
|
||
} else {
|
||
$unadded++;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'all' => $all,
|
||
'unadded' => $unadded,
|
||
'added' => $added,
|
||
'expired' => $expired,
|
||
];
|
||
}
|
||
|
||
/** AI 获客列表排序:D0/买过优先 → 行为日期 → 最近触达 → 获客时间 → RFM */
|
||
private static function sortLeadRows(array $rows): array
|
||
{
|
||
usort($rows, function ($a, $b) {
|
||
$tierA = (int)($a['priorityTier'] ?? 0);
|
||
$tierB = (int)($b['priorityTier'] ?? 0);
|
||
if ($tierB !== $tierA) {
|
||
return $tierB <=> $tierA;
|
||
}
|
||
$cmp = ((int)($b['lastBehaviorTs'] ?? 0)) <=> ((int)($a['lastBehaviorTs'] ?? 0));
|
||
if ($cmp !== 0) {
|
||
return $cmp;
|
||
}
|
||
$cmp = ((int)($b['lastTouchTs'] ?? 0)) <=> ((int)($a['lastTouchTs'] ?? 0));
|
||
if ($cmp !== 0) {
|
||
return $cmp;
|
||
}
|
||
$cmp = ((int)($b['createTime'] ?? 0)) <=> ((int)($a['createTime'] ?? 0));
|
||
if ($cmp !== 0) {
|
||
return $cmp;
|
||
}
|
||
return ((int)($b['rfmScore'] ?? 0)) <=> ((int)($a['rfmScore'] ?? 0));
|
||
});
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 入站 Webhook(存客宝 Server 推送,非客服 JWT)
|
||
* POST /v1/kefu/ai-lead/webhook
|
||
*/
|
||
public function webhook()
|
||
{
|
||
$body = $this->request->param();
|
||
$res = self::ingest($body);
|
||
if (!empty($res['error'])) {
|
||
return ResponseHelper::error($res['error'], 400);
|
||
}
|
||
return ResponseHelper::success($res);
|
||
}
|
||
|
||
/**
|
||
* 核心入站逻辑(供 webhook 与存客宝 Server 内部直调)
|
||
* 合并键 companyId + userKey(unionId>openId>phone)。
|
||
* @return array { action,created|merged,leadId,poolId } 或 { error }
|
||
*/
|
||
public static function ingest(array $body): array
|
||
{
|
||
// 懒建表
|
||
Db::execute("CREATE TABLE IF NOT EXISTS `ck_ai_lead` (
|
||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||
`companyId` int(11) NOT NULL DEFAULT 0,
|
||
`kefuId` int(11) NOT NULL DEFAULT 0,
|
||
`userKey` varchar(128) NOT NULL DEFAULT '',
|
||
`unionId` varchar(128) NOT NULL DEFAULT '',
|
||
`openId` varchar(128) NOT NULL DEFAULT '',
|
||
`phone` varchar(32) NOT NULL DEFAULT '',
|
||
`nickname` varchar(128) NOT NULL DEFAULT '',
|
||
`avatar` varchar(512) NOT NULL DEFAULT '',
|
||
`planName` varchar(128) NOT NULL DEFAULT '',
|
||
`tags` text NULL,
|
||
`poolKeywordsText` varchar(255) NOT NULL DEFAULT '',
|
||
`rfmLevel` varchar(8) NOT NULL DEFAULT '',
|
||
`rfmScore` int(11) NOT NULL DEFAULT 0,
|
||
`intention` varchar(32) NOT NULL DEFAULT '',
|
||
`behaviorTimeline` text NULL,
|
||
`wechatAdded` tinyint(1) NOT NULL DEFAULT 0,
|
||
`poolId` int(11) NOT NULL DEFAULT 0,
|
||
`status` varchar(16) NOT NULL DEFAULT 'unadded',
|
||
`isRead` tinyint(1) NOT NULL DEFAULT 0,
|
||
`createTime` int(11) NOT NULL DEFAULT 0,
|
||
`updateTime` int(11) NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_company_user` (`companyId`,`userKey`),
|
||
KEY `idx_kefu` (`kefuId`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
|
||
$companyId = isset($body['companyId']) ? intval($body['companyId']) : 0;
|
||
$kefuId = isset($body['kefuId']) ? intval($body['kefuId']) : 0;
|
||
if ($companyId <= 0) {
|
||
return ['error' => 'companyId 缺失'];
|
||
}
|
||
|
||
$unionId = trim((string)($body['unionId'] ?? ''));
|
||
$openId = trim((string)($body['openId'] ?? ''));
|
||
$phone = trim((string)($body['phone'] ?? ''));
|
||
$userKey = $unionId ?: ($openId ?: $phone);
|
||
if ($userKey === '') {
|
||
return ['error' => 'userKey 缺失(需 unionId/openId/phone 任一)'];
|
||
}
|
||
|
||
// 同手机号并入一条(避免列表重复展示)
|
||
$exist = null;
|
||
if ($phone !== '') {
|
||
$exist = Db::name('ai_lead')
|
||
->where('companyId', $companyId)
|
||
->where('phone', $phone)
|
||
->find();
|
||
}
|
||
if (!$exist) {
|
||
$exist = Db::name('ai_lead')
|
||
->where('companyId', $companyId)
|
||
->where('userKey', $userKey)
|
||
->find();
|
||
}
|
||
|
||
$tags = $body['tags'] ?? [];
|
||
if (is_string($tags)) {
|
||
$tags = array_filter(array_map('trim', explode(',', $tags)));
|
||
}
|
||
$behavior = $body['behaviorTimeline'] ?? [];
|
||
if (!is_array($behavior)) {
|
||
$behavior = [];
|
||
}
|
||
$planName = trim((string)($body['planName'] ?? ''));
|
||
|
||
$now = time();
|
||
// 绑定流量池/客户资料池(同源),回填 poolId(poolCompanyId)
|
||
$poolId = self::ensurePoolId($companyId, $body);
|
||
|
||
if ($exist) {
|
||
$oldTags = !empty($exist['tags']) ? (json_decode($exist['tags'], true) ?: []) : [];
|
||
$mergedTags = array_values(array_unique(array_merge($oldTags, $tags)));
|
||
|
||
$oldTimeline = !empty($exist['behaviorTimeline'])
|
||
? (json_decode($exist['behaviorTimeline'], true) ?: [])
|
||
: [];
|
||
$mergedTimeline = array_merge($oldTimeline, $behavior);
|
||
if (count($mergedTimeline) > 50) {
|
||
$mergedTimeline = array_slice($mergedTimeline, -50);
|
||
}
|
||
|
||
$planList = !empty($exist['planName']) ? explode('|', $exist['planName']) : [];
|
||
if ($planName && !in_array($planName, $planList)) {
|
||
$planList[] = $planName;
|
||
}
|
||
|
||
$update = [
|
||
'tags' => json_encode($mergedTags, 256),
|
||
'behaviorTimeline' => json_encode($mergedTimeline, 256),
|
||
'planName' => implode('|', array_filter($planList)),
|
||
'updateTime' => $now,
|
||
];
|
||
foreach (['nickname', 'avatar', 'poolKeywordsText', 'rfmLevel', 'intention'] as $f) {
|
||
if (!empty($body[$f])) {
|
||
$update[$f] = $body[$f];
|
||
}
|
||
}
|
||
if (isset($body['rfmScore'])) {
|
||
$update['rfmScore'] = intval($body['rfmScore']);
|
||
}
|
||
if ($kefuId > 0) {
|
||
$update['kefuId'] = $kefuId;
|
||
}
|
||
// poolId:显式传入优先,否则用绑定结果回填(仅当原值为空)
|
||
if (isset($body['poolId'])) {
|
||
$update['poolId'] = intval($body['poolId']);
|
||
} elseif ($poolId > 0 && empty($exist['poolId'])) {
|
||
$update['poolId'] = $poolId;
|
||
}
|
||
Db::name('ai_lead')->where('id', $exist['id'])->update($update);
|
||
|
||
return [
|
||
'action' => 'merged',
|
||
'merged' => true,
|
||
'leadId' => (int)$exist['id'],
|
||
'poolId' => (int)($update['poolId'] ?? $exist['poolId']),
|
||
];
|
||
}
|
||
|
||
$insert = [
|
||
'companyId' => $companyId,
|
||
'kefuId' => $kefuId,
|
||
'userKey' => $userKey,
|
||
'unionId' => $unionId,
|
||
'openId' => $openId,
|
||
'phone' => $phone,
|
||
'nickname' => $body['nickname'] ?? '',
|
||
'avatar' => $body['avatar'] ?? '',
|
||
'planName' => $planName,
|
||
'tags' => json_encode(array_values($tags), 256),
|
||
'poolKeywordsText' => $body['poolKeywordsText'] ?? '',
|
||
'rfmLevel' => $body['rfmLevel'] ?? '',
|
||
'rfmScore' => isset($body['rfmScore']) ? intval($body['rfmScore']) : 0,
|
||
'intention' => $body['intention'] ?? '',
|
||
'behaviorTimeline' => json_encode($behavior, 256),
|
||
'wechatAdded' => 0,
|
||
'poolId' => isset($body['poolId']) ? intval($body['poolId']) : $poolId,
|
||
'status' => 'unadded',
|
||
'isRead' => 0,
|
||
'createTime' => $now,
|
||
'updateTime' => $now,
|
||
];
|
||
$leadId = Db::name('ai_lead')->insertGetId($insert);
|
||
|
||
return [
|
||
'action' => 'created',
|
||
'created' => true,
|
||
'leadId' => (int)$leadId,
|
||
'poolId' => (int)$insert['poolId'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 未读 AI 获客数量(铃铛/功能中心小红点)
|
||
* GET /v1/kefu/ai-lead/unread-count
|
||
*/
|
||
public function unreadCount()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$kefuId = (int)$this->getUserInfo('id');
|
||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||
$count = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
})
|
||
->where('isRead', 0)
|
||
->count();
|
||
return ResponseHelper::success(['count' => (int)$count]);
|
||
}
|
||
|
||
/**
|
||
* AI 获客列表
|
||
* GET /v1/kefu/ai-lead/list
|
||
*/
|
||
public function getList()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$kefuId = (int)$this->getUserInfo('id');
|
||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||
$page = $this->request->param('page', 1);
|
||
$limit = $this->request->param('limit', 20);
|
||
$status = $this->request->param('status', 'all');
|
||
$keyword = $this->request->param('keyword', '');
|
||
|
||
// 同 s2 账号跨 company 可见;kefuId=0 为未指定客服的公共客资
|
||
$query = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
});
|
||
$onlyUnread = $this->request->param('onlyUnread', '');
|
||
if ($onlyUnread === '1' || $onlyUnread === 'true') {
|
||
$query->where('isRead', 0);
|
||
}
|
||
if ($status === 'unadded') {
|
||
$query->where('wechatAdded', 0)->where('status', '<>', 'expired');
|
||
} elseif ($status === 'added') {
|
||
$query->where('wechatAdded', 1);
|
||
} elseif ($status === 'expired') {
|
||
$query->where('status', 'expired');
|
||
} elseif ($status !== 'all' && $status !== '') {
|
||
$query->where('status', '=', $status);
|
||
}
|
||
if ($keyword !== '') {
|
||
$like = '%' . $keyword . '%';
|
||
$poolIdsByRemark = Db::name('traffic_pool_company')
|
||
->where('companyId', $companyId)
|
||
->whereLike('remark', $like)
|
||
->column('id');
|
||
$query->where(function ($q) use ($like, $poolIdsByRemark) {
|
||
$q->whereLike('nickname|phone|unionId|openId', $like);
|
||
if (!empty($poolIdsByRemark)) {
|
||
$q->whereOr('poolId', 'in', $poolIdsByRemark);
|
||
}
|
||
});
|
||
}
|
||
$products = self::loadProjectProducts($companyId);
|
||
$allRows = $query->select();
|
||
$poolMetaMap = self::loadPoolMetaMap(array_column($allRows ?: [], 'poolId'), $companyId);
|
||
$allRows = array_map(function ($row) use ($poolMetaMap, $products) {
|
||
$meta = $poolMetaMap[(int)($row['poolId'] ?? 0)] ?? null;
|
||
return self::decodeRow($row, $meta, $products);
|
||
}, $allRows ?: []);
|
||
$allRows = self::dedupeLeadRowsByContact($allRows);
|
||
$allRows = self::sortLeadRows($allRows);
|
||
$total = count($allRows);
|
||
$offset = max(0, ((int)$page - 1) * (int)$limit);
|
||
$list = array_slice($allRows, $offset, (int)$limit);
|
||
|
||
$counts = $this->buildStatusCountsDeduped($scope);
|
||
|
||
return ResponseHelper::success([
|
||
'list' => $list,
|
||
'total' => $total,
|
||
'counts' => $counts,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* AI 获客详情
|
||
* GET /v1/kefu/ai-lead/detail?id= 或 ?poolId=
|
||
*/
|
||
public function getDetail()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$kefuId = (int)$this->getUserInfo('id');
|
||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||
$id = $this->request->param('id', 0);
|
||
$poolId = $this->request->param('poolId', 0);
|
||
|
||
$query = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
});
|
||
if ($id) {
|
||
$query->where('id', $id);
|
||
} elseif ($poolId) {
|
||
$query->where('poolId', $poolId);
|
||
} else {
|
||
return ResponseHelper::error('缺少 id 或 poolId', 400);
|
||
}
|
||
$row = $query->find();
|
||
if (!$row) {
|
||
return ResponseHelper::error('未找到该客资', 404);
|
||
}
|
||
$products = self::loadProjectProducts($companyId);
|
||
$meta = self::loadPoolMetaMap([(int)($row['poolId'] ?? 0)], $companyId);
|
||
$poolMeta = $meta[(int)($row['poolId'] ?? 0)] ?? null;
|
||
return ResponseHelper::success(self::decodeRow($row, $poolMeta, $products));
|
||
}
|
||
|
||
/** Tab 计数 */
|
||
private function buildStatusCounts(array $scope): array
|
||
{
|
||
$base = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
});
|
||
return [
|
||
'all' => (int)(clone $base)->count(),
|
||
'unadded' => (int)(clone $base)->where('wechatAdded', 0)->where('status', '<>', 'expired')->count(),
|
||
'added' => (int)(clone $base)->where('wechatAdded', 1)->count(),
|
||
'expired' => (int)(clone $base)->where('status', 'expired')->count(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 项目上下文(产品词 + 备注模板)
|
||
* GET /v1/kefu/project-context
|
||
*/
|
||
public function projectContext()
|
||
{
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
return ResponseHelper::success(self::loadProjectContext($companyId));
|
||
}
|
||
|
||
/**
|
||
* 加好友可选设备 + 今日剩余次数
|
||
* GET /v1/kefu/ai-lead/add-friend-devices
|
||
*/
|
||
public function addFriendDevices()
|
||
{
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$s2AccountId = (int)$this->getUserInfo('s2_accountId');
|
||
|
||
// §5.3.2 / COORD-006:与侧栏 CustomerServiceController 完全同源
|
||
$companyWaIds = CompanyWechatScopeService::resolveKefuSidebarAccountIds($companyId, $s2AccountId);
|
||
|
||
if (empty($companyWaIds)) {
|
||
return ResponseHelper::success(['list' => []]);
|
||
}
|
||
|
||
$wechatRows = Db::table('s2_wechat_account')->alias('wa')
|
||
->whereIn('wa.id', $companyWaIds)
|
||
->where('wa.isDeleted', 0)
|
||
->field('wa.id,wa.wechatId,wa.nickname,wa.alias,wa.currentDeviceId as deviceId,wa.deviceMemo as memo,wa.imei')
|
||
->order('wa.id desc')
|
||
->limit(50)
|
||
->select();
|
||
|
||
$wechatIds = array_filter(array_column($wechatRows ?: [], 'wechatId'));
|
||
$limits = [];
|
||
$todayAdded = [];
|
||
if (!empty($wechatIds)) {
|
||
$limits = Db::table('s2_wechat_account_score')
|
||
->whereIn('wechatId', $wechatIds)
|
||
->column('maxAddFriendPerDay', 'wechatId');
|
||
$start = strtotime(date('Y-m-d 00:00:00'));
|
||
$end = strtotime(date('Y-m-d 23:59:59'));
|
||
$stats = Db::table('s2_wechat_friend')
|
||
->whereIn('ownerWechatId', $wechatIds)
|
||
->where('isDeleted', 0)
|
||
->whereBetween('createTime', [$start, $end])
|
||
->field('ownerWechatId, count(*) as cnt')
|
||
->group('ownerWechatId')
|
||
->select();
|
||
foreach ($stats ?: [] as $sr) {
|
||
$todayAdded[(string)$sr['ownerWechatId']] = (int)$sr['cnt'];
|
||
}
|
||
}
|
||
|
||
$list = [];
|
||
foreach ($wechatRows ?: [] as $row) {
|
||
$wid = (string)($row['wechatId'] ?? '');
|
||
$max = (int)($limits[$wid] ?? 5);
|
||
if ($max <= 0) {
|
||
$max = 5;
|
||
}
|
||
$used = (int)($todayAdded[$wid] ?? 0);
|
||
$list[] = [
|
||
'wechatAccountId' => (int)$row['id'],
|
||
'wechatId' => $wid,
|
||
'nickname' => $row['nickname'] ?: ($row['alias'] ?: $wid),
|
||
'deviceId' => (int)($row['deviceId'] ?? 0),
|
||
'deviceMemo' => $row['memo'] ?: ($row['imei'] ?? ''),
|
||
'maxToday' => $max,
|
||
'usedToday' => $used,
|
||
'remainToday' => max(0, $max - $used),
|
||
];
|
||
}
|
||
|
||
return ResponseHelper::success(['list' => $list]);
|
||
}
|
||
|
||
/**
|
||
* AI 获客 · 发起加微信
|
||
* POST /v1/kefu/ai-lead/add-wechat
|
||
*/
|
||
public function addWechat()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$kefuId = (int)$this->getUserInfo('id');
|
||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||
$leadId = (int)$this->request->param('leadId', 0);
|
||
$wechatAccountId = (int)$this->request->param('wechatAccountId', 0);
|
||
$remark = trim((string)$this->request->param('remark', ''));
|
||
|
||
if ($leadId <= 0 || $wechatAccountId <= 0) {
|
||
return ResponseHelper::error('参数缺失', 400);
|
||
}
|
||
|
||
$lead = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where('id', $leadId)
|
||
->find();
|
||
if (!$lead) {
|
||
return ResponseHelper::error('客资不存在', 404);
|
||
}
|
||
|
||
$account = trim((string)($lead['phone'] ?? ''));
|
||
if ($account === '') {
|
||
$account = trim((string)($lead['unionId'] ?? ($lead['openId'] ?? '')));
|
||
}
|
||
if ($account === '') {
|
||
return ResponseHelper::error('该客资无手机号/微信号,无法添加', 400);
|
||
}
|
||
|
||
$poolId = (int)($lead['poolId'] ?? 0);
|
||
if ($remark !== '' && $poolId > 0) {
|
||
Db::name('traffic_pool_company')
|
||
->where('id', $poolId)
|
||
->where('companyId', $companyId)
|
||
->update(['remark' => $remark, 'updateTime' => time()]);
|
||
}
|
||
|
||
return ResponseHelper::success([
|
||
'leadId' => $leadId,
|
||
'wechatAccountId' => $wechatAccountId,
|
||
'account' => $account,
|
||
'poolId' => $poolId,
|
||
'remark' => $remark,
|
||
], '已发起加好友,请在手机端确认通过');
|
||
}
|
||
|
||
/**
|
||
* 标记已读/忽略
|
||
* POST /v1/kefu/ai-lead/mark-read
|
||
*/
|
||
public function markRead()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$kefuId = (int)$this->getUserInfo('id');
|
||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||
$leadId = $this->request->param('leadId', 0);
|
||
if (!$leadId) {
|
||
return ResponseHelper::error('leadId 缺失', 400);
|
||
}
|
||
$updated = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
})
|
||
->where('id', $leadId)
|
||
->update(['isRead' => 1, 'updateTime' => time()]);
|
||
if (!$updated) {
|
||
return ResponseHelper::error('客资不存在或无权操作', 404);
|
||
}
|
||
return ResponseHelper::success(true, '已忽略');
|
||
}
|
||
|
||
/**
|
||
* 添加标签(同步流量池——best effort)
|
||
* POST /v1/kefu/ai-lead/add-tag
|
||
*/
|
||
public function addTag()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = $this->getUserInfo('companyId');
|
||
$leadId = $this->request->param('leadId', 0);
|
||
$tags = $this->request->param('tags', []);
|
||
if (is_string($tags)) {
|
||
$tags = array_filter(array_map('trim', explode(',', $tags)));
|
||
}
|
||
if (!$leadId || empty($tags)) {
|
||
return ResponseHelper::error('参数缺失', 400);
|
||
}
|
||
$row = Db::name('ai_lead')->where('companyId', $companyId)->where('id', $leadId)->find();
|
||
if (!$row) {
|
||
return ResponseHelper::error('客资不存在', 404);
|
||
}
|
||
$old = !empty($row['tags']) ? (json_decode($row['tags'], true) ?: []) : [];
|
||
$merged = array_values(array_unique(array_merge($old, $tags)));
|
||
Db::name('ai_lead')->where('id', $leadId)->update([
|
||
'tags' => json_encode($merged, 256),
|
||
'updateTime' => time(),
|
||
]);
|
||
$poolTagSynced = $this->syncPoolTags($companyId, (int)($row['poolId'] ?? 0), $tags);
|
||
return ResponseHelper::success([
|
||
'tags' => $merged,
|
||
'poolTagSynced' => $poolTagSynced,
|
||
], '标签已添加');
|
||
}
|
||
|
||
/**
|
||
* 申请转给其他客服(不即时生效,须存客宝审批)
|
||
* POST /v1/kefu/ai-lead/transfer
|
||
*/
|
||
public function transfer()
|
||
{
|
||
$this->ensureTable();
|
||
$companyId = (int)$this->getUserInfo('companyId');
|
||
$kefuId = (int)$this->getUserInfo('id');
|
||
$scope = $this->resolveKefuScope($companyId, $kefuId);
|
||
$leadId = (int)$this->request->param('leadId', 0);
|
||
// 前端传 s2_company_account.id,需解析为 ck_users.id
|
||
$toAccountId = (int)$this->request->param('toKefuId', 0);
|
||
if ($leadId <= 0 || $toAccountId <= 0) {
|
||
return ResponseHelper::error('参数缺失', 400);
|
||
}
|
||
|
||
$row = Db::name('ai_lead')
|
||
->whereIn('companyId', $scope['companyIds'])
|
||
->where(function ($q) use ($scope) {
|
||
$q->whereIn('kefuId', $scope['kefuIds'])->whereOr('kefuId', 0);
|
||
})
|
||
->where('id', $leadId)
|
||
->find();
|
||
if (!$row) {
|
||
return ResponseHelper::error('客资不存在或无权操作', 404);
|
||
}
|
||
|
||
$toAccount = Db::table('s2_company_account')
|
||
->where('id', $toAccountId)
|
||
->where('departmentId', $companyId)
|
||
->field('id,userName,realName,nickname')
|
||
->find();
|
||
if (!$toAccount) {
|
||
return ResponseHelper::error('目标客服不存在或不属于本项目', 403);
|
||
}
|
||
|
||
$toCkbUserId = (int)Db::name('users')
|
||
->where('s2_accountId', $toAccountId)
|
||
->where('companyId', $companyId)
|
||
->value('id');
|
||
if ($toCkbUserId <= 0) {
|
||
$toCkbUserId = (int)Db::name('users')
|
||
->where('s2_accountId', $toAccountId)
|
||
->order('id', 'desc')
|
||
->value('id');
|
||
}
|
||
if ($toCkbUserId <= 0) {
|
||
return ResponseHelper::error('目标客服未绑定触客宝账号', 400);
|
||
}
|
||
|
||
Db::name('ai_lead')->where('id', $leadId)->update([
|
||
'kefuId' => $toCkbUserId,
|
||
'updateTime' => time(),
|
||
]);
|
||
|
||
$toName = ($toAccount['userName'] ?: $toAccount['realName'] ?: $toAccount['nickname']) ?: '';
|
||
return ResponseHelper::success([
|
||
'leadId' => $leadId,
|
||
'toKefuId' => $toCkbUserId,
|
||
'toAccountId' => $toAccountId,
|
||
'toAccountName' => $toName,
|
||
'status' => 'transferred',
|
||
], '已转给客服 ' . $toName);
|
||
}
|
||
|
||
/**
|
||
* Webhook 配置(展示/复制 URL)
|
||
* GET /v1/kefu/ai-lead/webhook-config
|
||
*/
|
||
public function webhookConfig()
|
||
{
|
||
$companyId = $this->getUserInfo('companyId');
|
||
$kefuId = $this->getUserInfo('id');
|
||
$base = $this->request->domain();
|
||
return ResponseHelper::success([
|
||
'url' => $base . '/v1/kefu/ai-lead/webhook',
|
||
'companyId' => $companyId,
|
||
'kefuId' => $kefuId,
|
||
'tip' => '由存客宝场景获客 Server 调用,payload 与 lead-webhook/export-batch 对齐',
|
||
]);
|
||
}
|
||
}
|