Files
CKB-Interface/application/cunkebao/service/TrafficPoolGroupService.php
2026-02-04 11:02:33 +08:00

831 lines
27 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\cunkebao\service;
use app\common\model\TrafficPoolGroup;
use app\common\model\TrafficPoolGroupMember;
use app\common\model\TrafficPoolCompany;
use app\common\model\TrafficPoolTag;
use think\Db;
/**
* 流量池分组服务类
* 处理流量池分组的查询、创建、成员管理等业务逻辑
*/
class TrafficPoolGroupService
{
/**
* 获取分组列表
*
* @param int $companyId 公司ID
* @param bool $withCount 是否包含成员数量
* @return array
*/
public function getGroupList(int $companyId, bool $withCount = true)
{
$groups = TrafficPoolGroup::getGroupsByCompany($companyId)->toArray();
if ($withCount) {
foreach ($groups as &$group) {
if ($group['ruleType'] == TrafficPoolGroup::RULE_TYPE_DYNAMIC) {
// 动态规则分组,实时计算成员数量
$group['memberCount'] = $this->countGroupMembers($group['id'], $companyId);
}
// 手动分组使用缓存的 memberCount
// 计算分组的 RFM 平均值
$rfmStats = $this->getGroupRfmStats($group['id'], $companyId);
$group['avgRfmR'] = $rfmStats['avgR'];
$group['avgRfmF'] = $rfmStats['avgF'];
$group['avgRfmM'] = $rfmStats['avgM'];
$group['avgRfmScore'] = $rfmStats['avgScore'];
}
}
return $groups;
}
/**
* 获取分组详情
*
* @param int $groupId 分组ID
* @param int $companyId 公司ID
* @return array|null
*/
public function getGroupDetail(int $groupId, int $companyId)
{
$group = TrafficPoolGroup::where('id', $groupId)
->whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->find();
if (!$group) {
return null;
}
$data = $group->toArray();
// 计算成员数量
$data['memberCount'] = $this->countGroupMembers($groupId, $companyId);
// 计算 RFM 统计
$rfmStats = $this->getGroupRfmStats($groupId, $companyId);
$data['rfmStats'] = $rfmStats;
return $data;
}
/**
* 创建分组
*
* @param int $companyId 公司ID
* @param array $data 分组数据
* @param int $userId 创建用户ID
* @return TrafficPoolGroup
*/
public function createGroup(int $companyId, array $data, int $userId = null)
{
// 生成分组编码
$groupCode = $data['groupCode'] ?? 'custom_' . uniqid();
// 检查编码是否已存在
$existGroup = TrafficPoolGroup::where('companyId', $companyId)
->where('groupCode', $groupCode)
->where('isDel', 0)
->find();
if ($existGroup) {
throw new \Exception('分组编码已存在');
}
$group = TrafficPoolGroup::create([
'companyId' => $companyId,
'groupCode' => $groupCode,
'groupName' => $data['groupName'],
'groupIcon' => $data['groupIcon'] ?? null,
'groupColor' => $data['groupColor'] ?? null,
'description' => $data['description'] ?? null,
'isSystem' => 0,
'isDefault' => $data['isDefault'] ?? 0,
'ruleType' => $data['ruleType'] ?? TrafficPoolGroup::RULE_TYPE_DYNAMIC,
'ruleConfig' => $data['ruleConfig'] ?? null,
'sort' => $data['sort'] ?? 100,
'status' => TrafficPoolGroup::STATUS_ENABLED,
'userId' => $userId,
'createTime' => time()
]);
return $group;
}
/**
* 更新分组
*
* @param int $groupId 分组ID
* @param int $companyId 公司ID
* @param array $data 更新数据
* @return bool
*/
public function updateGroup(int $groupId, int $companyId, array $data)
{
$group = TrafficPoolGroup::where('id', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$group) {
throw new \Exception('分组不存在');
}
if ($group->isSystem) {
throw new \Exception('系统分组不允许修改');
}
$allowFields = [
'groupName', 'groupIcon', 'groupColor', 'description',
'isDefault', 'ruleType', 'ruleConfig', 'sort', 'status'
];
$updateData = array_intersect_key($data, array_flip($allowFields));
$updateData['updateTime'] = time();
return $group->save($updateData);
}
/**
* 删除分组
*
* @param int $groupId 分组ID
* @param int $companyId 公司ID
* @return bool
*/
public function deleteGroup(int $groupId, int $companyId)
{
$group = TrafficPoolGroup::where('id', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$group) {
throw new \Exception('分组不存在');
}
if ($group->isSystem) {
throw new \Exception('系统分组不允许删除');
}
Db::startTrans();
try {
// 删除分组
$group->save([
'isDel' => 1,
'deleteTime' => time()
]);
// 删除分组成员
TrafficPoolGroupMember::where('groupId', $groupId)
->where('isDel', 0)
->update([
'isDel' => 1,
'deleteTime' => time()
]);
Db::commit();
return true;
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
/**
* 获取分组成员列表
*
* @param int $groupId 分组ID
* @param int $companyId 公司ID
* @param int $page 页码
* @param int $pageSize 每页数量
* @param array $filters 筛选条件
* @return array
*/
public function getGroupMembers(int $groupId, int $companyId, int $page = 1, int $pageSize = 10, array $filters = [])
{
$group = TrafficPoolGroup::where('id', $groupId)
->whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->find();
if (!$group) {
throw new \Exception('分组不存在');
}
if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) {
// 手动分组,从成员表查询
return $this->getManualGroupMembers($groupId, $companyId, $page, $pageSize, $filters);
} else {
// 动态规则分组,根据规则查询
return $this->getDynamicGroupMembers($group, $companyId, $page, $pageSize, $filters);
}
}
/**
* 获取手动分组成员
*/
protected function getManualGroupMembers(int $groupId, int $companyId, int $page, int $pageSize, array $filters)
{
$query = TrafficPoolGroupMember::alias('tpgm')
->join('ck_traffic_pool_company tpc', 'tpc.id = tpgm.poolCompanyId', 'LEFT')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpgm.groupId', $groupId)
->where('tpgm.companyId', $companyId)
->where('tpgm.isDel', 0)
->where('tpc.isDel', 0);
// 应用关键字筛选
if (!empty($filters['keyword'])) {
$keyword = $filters['keyword'];
$query->where(function($q) use ($keyword) {
$q->where('tp.nickname', 'like', "%{$keyword}%")
->whereOr('tp.wechatId', 'like', "%{$keyword}%")
->whereOr('tpc.realName', 'like', "%{$keyword}%")
->whereOr('tpc.phone', 'like', "%{$keyword}%");
});
}
$total = $query->count();
$list = $query->field([
'tpc.id',
'tpc.poolId',
'tpc.identifier',
'tpc.companyId',
'tpc.friendStatus',
'tpc.level',
'tpc.intentionLevel',
'tpc.lastInteractTime',
'tpc.rfmF',
'tpc.rfmM',
'tpc.totalMsgCount',
'tpc.totalOrderAmount',
'tpc.lastMsgTime',
'tpc.firstSourceType',
'tpc.firstSourceTime',
'tpc.lifecycle',
'tpc.createTime',
'tpc.realName',
'tpc.phone',
'tp.nickname',
'tp.avatar',
'tp.wechatId',
'tp.wechatAlias',
'tp.gender',
'tp.region',
'tp.country',
'tp.province',
'tp.city',
'tpgm.createTime as addTime'
])
->order('tpgm.createTime DESC')
->page($page, $pageSize)
->select();
return $this->formatMemberList($list, $total, $page, $pageSize);
}
/**
* 获取动态规则分组成员
*/
protected function getDynamicGroupMembers(TrafficPoolGroup $group, int $companyId, int $page, int $pageSize, array $filters)
{
$query = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpc.companyId', $companyId)
->where('tpc.isDel', 0);
// 解析并应用规则
$ruleConfig = $group->ruleConfig;
if (!empty($ruleConfig)) {
$this->applyRuleConditions($query, $ruleConfig, $companyId);
}
// 应用额外筛选
if (!empty($filters['keyword'])) {
$keyword = $filters['keyword'];
$query->where(function($q) use ($keyword) {
$q->where('tp.nickname', 'like', "%{$keyword}%")
->whereOr('tp.wechatId', 'like', "%{$keyword}%")
->whereOr('tpc.realName', 'like', "%{$keyword}%")
->whereOr('tpc.phone', 'like', "%{$keyword}%");
});
}
$total = $query->count();
$list = $query->field([
'tpc.id',
'tpc.poolId',
'tpc.identifier',
'tpc.companyId',
'tpc.friendStatus',
'tpc.level',
'tpc.intentionLevel',
'tpc.lastInteractTime',
'tpc.rfmF',
'tpc.rfmM',
'tpc.totalMsgCount',
'tpc.totalOrderAmount',
'tpc.lastMsgTime',
'tpc.firstSourceType',
'tpc.firstSourceTime',
'tpc.lifecycle',
'tpc.createTime',
'tpc.realName',
'tpc.phone',
'tpc.createTime as addTime',
'tp.nickname',
'tp.avatar',
'tp.wechatId',
'tp.wechatAlias',
'tp.gender',
'tp.region',
'tp.country',
'tp.province',
'tp.city'
])
->order('tpc.id DESC')
->page($page, $pageSize)
->select();
return $this->formatMemberList($list, $total, $page, $pageSize);
}
/**
* 应用规则条件到查询
*/
protected function applyRuleConditions($query, array $ruleConfig, int $companyId)
{
if (empty($ruleConfig['conditions'])) {
return;
}
$logic = strtoupper($ruleConfig['logic'] ?? 'AND');
$conditions = $ruleConfig['conditions'];
if ($logic === 'AND') {
foreach ($conditions as $condition) {
$this->applyCondition($query, $condition, $companyId, 'AND');
}
} else {
$query->where(function($q) use ($conditions, $companyId) {
foreach ($conditions as $condition) {
$this->applyCondition($q, $condition, $companyId, 'OR');
}
});
}
}
/**
* 应用单个条件
*/
protected function applyCondition($query, array $condition, int $companyId, string $logic = 'AND')
{
$method = $logic === 'OR' ? 'whereOr' : 'where';
if ($condition['type'] === 'group') {
// 嵌套分组
$subLogic = strtoupper($condition['logic'] ?? 'AND');
$subConditions = $condition['conditions'] ?? [];
$query->$method(function($q) use ($subConditions, $companyId, $subLogic) {
foreach ($subConditions as $subCond) {
$subMethod = $subLogic === 'OR' ? 'whereOr' : 'where';
$this->applyCondition($q, $subCond, $companyId, $subLogic);
}
});
} elseif ($condition['type'] === 'field') {
// 字段条件 - 根据字段所属表使用正确的别名
$fieldName = $condition['field'];
$operator = $condition['operator'];
$value = $condition['value'];
// 特殊处理keyword 字段用于多字段搜索
if ($fieldName === 'keyword') {
$keyword = $value;
$query->$method(function($q) use ($keyword) {
$q->where('tp.nickname', 'like', "%{$keyword}%")
->whereOr('tp.wechatId', 'like', "%{$keyword}%")
->whereOr('tp.wechatAlias', 'like', "%{$keyword}%")
->whereOr('tpc.realName', 'like', "%{$keyword}%")
->whereOr('tpc.phone', 'like', "%{$keyword}%");
});
return;
}
// 特殊处理friendIds 字段用于指定好友ID列表
if ($fieldName === 'friendIds') {
if (is_array($value) && !empty($value)) {
$query->$method('tpc.id', 'in', $value);
}
return;
}
// ck_traffic_pool 表的字段(基础用户信息)
$tpFields = ['nickname', 'avatar', 'wechatId', 'wechatAlias', 'gender', 'region', 'country', 'province', 'city', 'signature'];
// 判断字段属于哪个表
if (in_array($fieldName, $tpFields)) {
$field = 'tp.' . $fieldName;
} else {
// ck_traffic_pool_company 表的字段(公司维度信息)
$field = 'tpc.' . $fieldName;
}
// 特殊处理地区字段province
// 前端可能传递 "广东" 或 "广东 广州市"
if ($fieldName === 'province' && strpos($value, ' ') !== false) {
// 包含空格,说明是 "省份 城市" 格式
$parts = explode(' ', $value, 2);
$provinceName = trim($parts[0]);
$cityName = trim($parts[1]);
$query->$method(function($q) use ($provinceName, $cityName) {
$q->where('tp.province', '=', $provinceName)
->where('tp.city', 'like', "%{$cityName}%");
});
return;
}
switch ($operator) {
case '=':
case '!=':
case '>':
case '<':
case '>=':
case '<=':
$query->$method($field, $operator, $value);
break;
case 'in':
$query->$method($field, 'in', $value);
break;
case 'not_in':
$query->$method($field, 'not in', $value);
break;
case 'between':
$query->$method($field, 'between', $value);
break;
case 'like':
$query->$method($field, 'like', "%{$value}%");
break;
}
} elseif ($condition['type'] === 'tag') {
// 标签条件
$tagNames = $condition['value'];
$operator = $condition['operator'];
if ($operator === 'contains') {
$query->$method(function($q) use ($tagNames, $companyId) {
$q->whereExists(function($subQuery) use ($tagNames, $companyId) {
$subQuery->table('ck_traffic_pool_tag')
->where('ck_traffic_pool_tag.poolCompanyId = tpc.id')
->where('ck_traffic_pool_tag.companyId', $companyId)
->where('ck_traffic_pool_tag.tagName', 'in', $tagNames)
->where('ck_traffic_pool_tag.isDel', 0);
});
});
} elseif ($operator === 'not_contains') {
$query->$method(function($q) use ($tagNames, $companyId) {
$q->whereNotExists(function($subQuery) use ($tagNames, $companyId) {
$subQuery->table('ck_traffic_pool_tag')
->where('ck_traffic_pool_tag.poolCompanyId = tpc.id')
->where('ck_traffic_pool_tag.companyId', $companyId)
->where('ck_traffic_pool_tag.tagName', 'in', $tagNames)
->where('ck_traffic_pool_tag.isDel', 0);
});
});
}
}
}
/**
* 格式化成员列表
*/
protected function formatMemberList($list, int $total, int $page, int $pageSize)
{
$result = [];
$poolCompanyIds = [];
// 收集所有的poolCompanyId
foreach ($list as $item) {
$poolCompanyIds[] = $item['id'];
}
// 批量查询标签
$tagsMap = [];
if (!empty($poolCompanyIds)) {
$tags = \think\Db::table('ck_traffic_pool_tag')
->alias('tpt')
->join('ck_traffic_pool_tag_define tptd', 'tpt.tagDefineId = tptd.id', 'LEFT')
->where('tpt.poolCompanyId', 'in', $poolCompanyIds)
->where('tpt.isDel', 0)
->where('tptd.isDel', 0)
->field('tpt.poolCompanyId, tptd.tagName, tptd.tagType')
->select();
foreach ($tags as $tag) {
$poolCompanyId = $tag['poolCompanyId'];
if (!isset($tagsMap[$poolCompanyId])) {
$tagsMap[$poolCompanyId] = [];
}
$tagsMap[$poolCompanyId][] = [
'tagName' => $tag['tagName'],
'tagType' => $tag['tagType']
];
}
}
foreach ($list as $item) {
$data = $item->toArray();
// 计算 RFM R 值
$data['rfmR'] = $item->lastInteractTime ? (int)floor((time() - $item->lastInteractTime) / 86400) : 9999;
// 计算 RFM 总分
$data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'] ?? 0, $data['rfmM'] ?? 0);
// 添加标签
$data['tags'] = $tagsMap[$item['id']] ?? [];
$result[] = $data;
}
return [
'list' => $result,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize
];
}
/**
* 计算分组成员数量
*/
public function countGroupMembers(int $groupId, int $companyId)
{
$group = TrafficPoolGroup::where('id', $groupId)
->whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->find();
if (!$group) {
return 0;
}
if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) {
return TrafficPoolGroupMember::where('groupId', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->count();
}
// 动态规则分组
$query = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpc.companyId', $companyId)
->where('tpc.isDel', 0);
$ruleConfig = $group->ruleConfig;
if (!empty($ruleConfig)) {
$this->applyRuleConditions($query, $ruleConfig, $companyId);
}
return $query->count();
}
/**
* 获取分组 RFM 统计
*/
protected function getGroupRfmStats(int $groupId, int $companyId)
{
$group = TrafficPoolGroup::where('id', $groupId)
->whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->find();
if (!$group) {
return ['avgR' => 0, 'avgF' => 0, 'avgM' => 0, 'avgScore' => 0];
}
if ($group->ruleType == TrafficPoolGroup::RULE_TYPE_MANUAL) {
$query = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool_group_member tpgm', 'tpgm.poolCompanyId = tpc.id', 'INNER')
->where('tpgm.groupId', $groupId)
->where('tpgm.isDel', 0)
->where('tpc.isDel', 0);
} else {
$query = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpc.companyId', $companyId)
->where('tpc.isDel', 0);
$ruleConfig = $group->ruleConfig;
if (!empty($ruleConfig)) {
$this->applyRuleConditions($query, $ruleConfig, $companyId);
}
}
$stats = $query->field([
'AVG(DATEDIFF(NOW(), FROM_UNIXTIME(IFNULL(tpc.lastInteractTime, tpc.createTime)))) as avgR',
'AVG(tpc.rfmF) as avgF',
'AVG(tpc.rfmM) as avgM'
])->find();
$avgR = round($stats['avgR'] ?? 0, 1);
$avgF = round($stats['avgF'] ?? 0, 1);
$avgM = round($stats['avgM'] ?? 0, 2);
$rfmScore = $this->calculateRfmScore($avgR, $avgF, $avgM);
return [
'avgR' => $avgR,
'avgF' => $avgF,
'avgM' => $avgM,
'avgScore' => $rfmScore['total']
];
}
/**
* 添加成员到分组
*
* @param int $groupId 分组ID
* @param array $poolCompanyIds 成员ID数组
* @param int $companyId 公司ID
* @param int $operatorId 操作人ID
* @return int 成功添加数量
*/
public function addMembers(int $groupId, array $poolCompanyIds, int $companyId, int $operatorId = null)
{
$group = TrafficPoolGroup::where('id', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$group) {
throw new \Exception('分组不存在');
}
if ($group->ruleType != TrafficPoolGroup::RULE_TYPE_MANUAL) {
throw new \Exception('动态规则分组不支持手动添加成员');
}
return TrafficPoolGroupMember::batchAddMembers($groupId, $poolCompanyIds, $companyId, $operatorId);
}
/**
* 从分组移除成员
*
* @param int $groupId 分组ID
* @param array $poolCompanyIds 成员ID数组
* @param int $companyId 公司ID
* @return int 成功移除数量
*/
public function removeMembers(int $groupId, array $poolCompanyIds, int $companyId)
{
$group = TrafficPoolGroup::where('id', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$group) {
throw new \Exception('分组不存在');
}
if ($group->ruleType != TrafficPoolGroup::RULE_TYPE_MANUAL) {
throw new \Exception('动态规则分组不支持手动移除成员');
}
return TrafficPoolGroupMember::batchRemoveMembers($groupId, $poolCompanyIds);
}
/**
* 计算 RFM 评分
*/
protected function calculateRfmScore($r, $f, $m)
{
// R 评分
if ($r <= 7) {
$rScore = 5;
} elseif ($r <= 30) {
$rScore = 4;
} elseif ($r <= 90) {
$rScore = 3;
} elseif ($r <= 180) {
$rScore = 2;
} else {
$rScore = 1;
}
// F 评分
if ($f >= 100) {
$fScore = 5;
} elseif ($f >= 50) {
$fScore = 4;
} elseif ($f >= 20) {
$fScore = 3;
} elseif ($f >= 5) {
$fScore = 2;
} else {
$fScore = 1;
}
// M 评分
if ($m >= 10000) {
$mScore = 5;
} elseif ($m >= 5000) {
$mScore = 4;
} elseif ($m >= 1000) {
$mScore = 3;
} elseif ($m >= 100) {
$mScore = 2;
} else {
$mScore = 1;
}
return [
'R' => $rScore,
'F' => $fScore,
'M' => $mScore,
'total' => $rScore + $fScore + $mScore
];
}
/**
* 预览动态分组成员(不创建分组,只预览符合条件的用户)
*
* @param int $companyId 公司ID
* @param array $ruleConfig 规则配置
* @param int $page 页码
* @param int $pageSize 每页数量
* @param string $keyword 搜索关键词
* @return array
*/
public function previewGroupMembers(int $companyId, array $ruleConfig, int $page = 1, int $pageSize = 20, string $keyword = '')
{
$query = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpc.companyId', $companyId)
->where('tpc.isDel', 0);
// 应用规则条件
if (!empty($ruleConfig)) {
$this->applyRuleConditions($query, $ruleConfig, $companyId);
}
// 应用关键字搜索
if (!empty($keyword)) {
$query->where(function($q) use ($keyword) {
$q->where('tp.nickname', 'like', "%{$keyword}%")
->whereOr('tp.wechatId', 'like', "%{$keyword}%")
->whereOr('tp.wechatAlias', 'like', "%{$keyword}%")
->whereOr('tpc.realName', 'like', "%{$keyword}%")
->whereOr('tpc.phone', 'like', "%{$keyword}%");
});
}
$total = $query->count();
$list = $query->field([
'tpc.id',
'tpc.poolId',
'tpc.identifier',
'tpc.companyId',
'tpc.friendStatus',
'tpc.level',
'tpc.intentionLevel',
'tpc.lastInteractTime',
'tpc.rfmF',
'tpc.rfmM',
'tpc.totalMsgCount',
'tpc.totalOrderAmount',
'tpc.lastMsgTime',
'tpc.firstSourceType',
'tpc.firstSourceTime',
'tpc.lifecycle',
'tpc.createTime',
'tpc.realName',
'tpc.phone',
'tp.nickname',
'tp.avatar',
'tp.wechatId',
'tp.wechatAlias',
'tp.gender',
'tp.region',
'tp.country',
'tp.province',
'tp.city'
])
->order('tpc.id DESC')
->page($page, $pageSize)
->select();
return $this->formatMemberList($list, $total, $page, $pageSize);
}
}