Files
cunkebao_v3/Server/application/chukebao/controller/AiLeadController.php
Manus AI 5182529a58 sync(本地→GitHub): 2026-05-30 21:41 全量单向同步
## 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>
2026-05-30 21:41:40 +08:00

1130 lines
43 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\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();
// 绑定流量池/客户资料池(同源),回填 poolIdpoolCompanyId
$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 对齐',
]);
}
}