流量池服务端

This commit is contained in:
wong
2026-02-02 11:00:26 +08:00
parent f0c278a35f
commit 519a9b1d1b
22 changed files with 7239 additions and 2252 deletions

View File

@@ -379,10 +379,50 @@ class MomentsController extends BaseController
$total = KfMoments::where(['companyId' => $companyId, 'userId' => $userId, 'isDel' => 0])->count();
// 收集所有需要查询的微信账号ID
$allWechatAccountIds = [];
foreach ($list as $item) {
$sendData = json_decode($item->sendData, true);
$items = $sendData['jobPublishWechatMomentsItems'] ?? [];
foreach ($items as $accountItem) {
if (!empty($accountItem['wechatAccountId'])) {
$allWechatAccountIds[] = $accountItem['wechatAccountId'];
}
}
}
// 批量查询微信账号信息
$wechatAccountsMap = [];
if (!empty($allWechatAccountIds)) {
$wechatAccounts = Db::table('s2_wechat_account')
->whereIn('id', array_unique($allWechatAccountIds))
->field('id, wechatId, nickName, avatar')
->select();
foreach ($wechatAccounts as $account) {
$wechatAccountsMap[$account['id']] = $account;
}
}
// 处理数据
$data = [];
foreach ($list as $item) {
$sendData = json_decode($item->sendData,true);
$sendData = json_decode($item->sendData, true);
$momentsItems = $sendData['jobPublishWechatMomentsItems'] ?? [];
// 构建账号详情列表
$accounts = [];
foreach ($momentsItems as $accountItem) {
$wechatAccountId = $accountItem['wechatAccountId'] ?? 0;
$accountInfo = $wechatAccountsMap[$wechatAccountId] ?? null;
$accounts[] = [
'wechatAccountId' => $wechatAccountId,
'wechatId' => $accountInfo['wechatId'] ?? '',
'nickName' => $accountInfo['nickName'] ?? '',
'avatar' => $accountInfo['avatar'] ?? '',
'labels' => $accountItem['labels'] ?? []
];
}
$data[] = [
'id' => $item->id,
'content' => $sendData['text'] ?? '',
@@ -392,8 +432,9 @@ class MomentsController extends BaseController
'link' => $sendData['link'] ?? [],
'publicMode' => $sendData['publicMode'] ?? 2,
'isSend' => $item->isSend,
'sendTime' => date('Y-m-d H:i:s',$item->sendTime),
'accountCount' => count($sendData['jobPublishWechatMomentsItems'] ?? [])
'sendTime' => date('Y-m-d H:i:s', $item->sendTime),
'accountCount' => count($accounts),
'accounts' => $accounts
];
}

View File

@@ -50,4 +50,7 @@ return [
// 检查未读/未回复消息并自动迁移好友
'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友
// V2 流量池数据迁移
'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统
];

View File

@@ -0,0 +1,235 @@
<?php
namespace app\command;
use think\facade\Log;
use think\console\Input;
use think\console\Output;
use think\console\Command;
use think\console\input\Option;
use think\facade\App;
use WeChatDeviceApi\Adapters\ChuKeBao\Adapter as ChuKeBaoAdapter;
/**
* V2 流量池数据迁移命令
*
* 使用方法:
* php think migrate:trafficPoolV2 # 执行完整迁移
* php think migrate:trafficPoolV2 --step=1 # 只执行第1步好友同步到流量池总表
* php think migrate:trafficPoolV2 --step=2 # 只执行第2步好友同步到公司流量详情表
* php think migrate:trafficPoolV2 --step=3 # 只执行第3步好友同步到流量来源表
* php think migrate:trafficPoolV2 --step=4 # 只执行第4步群成员同步到流量池总表
* php think migrate:trafficPoolV2 --step=5 # 只执行第5步群成员同步到公司流量详情表
* php think migrate:trafficPoolV2 --step=6 # 只执行第6步群成员同步到流量来源表
* php think migrate:trafficPoolV2 --step=7 # 只执行第7步同步微信标签
*
* 执行前请确保已运行 SQL 迁移脚本创建了 V2 版本的表
*/
class MigrateTrafficPoolV2Command extends Command
{
protected $lockFile;
public function __construct()
{
parent::__construct();
$this->lockFile = App::getRuntimePath() . 'migrate_traffic_pool_v2.lock';
}
protected function configure()
{
$this->setName('migrate:trafficPoolV2')
->setDescription('迁移数据到 V2 流量池系统')
->addOption('step', 's', Option::VALUE_OPTIONAL, '执行指定步骤1-7不指定则执行全部', null);
}
protected function execute(Input $input, Output $output)
{
// 检查锁文件
if (file_exists($this->lockFile)) {
$lockTime = filectime($this->lockFile);
if (time() - $lockTime < 7200) { // 2小时内
$output->writeln('<error>迁移任务已在运行中,跳过本次执行</error>');
return false;
}
unlink($this->lockFile);
}
file_put_contents($this->lockFile, time());
try {
$step = $input->getOption('step');
$adapter = new ChuKeBaoAdapter();
$output->writeln('<info>====================================</info>');
$output->writeln('<info> V2 流量池数据迁移开始</info>');
$output->writeln('<info>====================================</info>');
$output->writeln('');
$startTime = microtime(true);
if ($step === null) {
// 执行完整迁移
$results = $this->runFullMigration($adapter, $output);
} else {
// 执行指定步骤
$results = $this->runStep((int)$step, $adapter, $output);
}
$endTime = microtime(true);
$duration = round($endTime - $startTime, 2);
$output->writeln('');
$output->writeln('<info>====================================</info>');
$output->writeln('<info> 迁移完成</info>');
$output->writeln('<info>====================================</info>');
$output->writeln("耗时: {$duration}");
$output->writeln('');
$output->writeln('<comment>结果统计:</comment>');
foreach ($results as $key => $value) {
$output->writeln(" - {$key}: {$value}");
}
return true;
} catch (\Exception $e) {
$output->writeln('<error>迁移异常: ' . $e->getMessage() . '</error>');
Log::error('V2流量池迁移异常' . $e->getMessage() . "\n" . $e->getTraceAsString());
return false;
} finally {
if (file_exists($this->lockFile)) {
unlink($this->lockFile);
}
}
}
/**
* 执行完整迁移
*/
protected function runFullMigration(ChuKeBaoAdapter $adapter, Output $output)
{
$results = [
'friend_pool' => 0,
'friend_pool_company' => 0,
'friend_pool_source' => 0,
'chatroom_pool' => 0,
'chatroom_pool_company' => 0,
'chatroom_pool_source' => 0,
'pool_tags' => 0,
];
// === 好友数据迁移 ===
$output->writeln('<comment>【好友数据迁移】</comment>');
// Step 1: 好友同步到流量池总表
$output->writeln('<comment>[1/7] 同步好友到流量池总表 ck_traffic_pool ...</comment>');
$results['friend_pool'] = $adapter->syncToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool']}</info>");
// Step 2: 好友同步到公司流量详情表
$output->writeln('<comment>[2/7] 同步好友到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_company']}</info>");
// Step 3: 好友同步到流量来源表
$output->writeln('<comment>[3/7] 同步好友到流量来源表 ck_traffic_pool_source ...</comment>');
$results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_source']}</info>");
// === 群成员数据迁移 ===
$output->writeln('');
$output->writeln('<comment>【群成员数据迁移】</comment>');
// Step 4: 群成员同步到流量池总表
$output->writeln('<comment>[4/7] 同步群成员到流量池总表 ck_traffic_pool ...</comment>');
$results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool']}</info>");
// Step 5: 群成员同步到公司流量详情表
$output->writeln('<comment>[5/7] 同步群成员到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_company']}</info>");
// Step 6: 群成员同步到流量来源表
$output->writeln('<comment>[6/7] 同步群成员到流量来源表 ck_traffic_pool_source ...</comment>');
$results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_source']}</info>");
// === 标签数据迁移 ===
$output->writeln('');
$output->writeln('<comment>【标签数据迁移】</comment>');
// Step 7: 同步微信标签
$output->writeln('<comment>[7/7] 同步微信标签 ck_traffic_pool_tag ...</comment>');
$results['pool_tags'] = $adapter->syncWechatTagsToV2();
$output->writeln("<info> 完成,影响行数: {$results['pool_tags']}</info>");
return $results;
}
/**
* 执行指定步骤
*/
protected function runStep(int $step, ChuKeBaoAdapter $adapter, Output $output)
{
$results = [];
switch ($step) {
case 1:
$output->writeln('<comment>[Step 1] 同步好友到流量池总表 ck_traffic_pool ...</comment>');
$results['friend_pool'] = $adapter->syncToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool']}</info>");
break;
case 2:
$output->writeln('<comment>[Step 2] 同步好友到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['friend_pool_company'] = $adapter->syncToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_company']}</info>");
break;
case 3:
$output->writeln('<comment>[Step 3] 同步好友到流量来源表 ck_traffic_pool_source ...</comment>');
$results['friend_pool_source'] = $adapter->syncToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['friend_pool_source']}</info>");
break;
case 4:
$output->writeln('<comment>[Step 4] 同步群成员到流量池总表 ck_traffic_pool ...</comment>');
$results['chatroom_pool'] = $adapter->syncChatroomMembersToTrafficPoolV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool']}</info>");
break;
case 5:
$output->writeln('<comment>[Step 5] 同步群成员到公司流量详情表 ck_traffic_pool_company ...</comment>');
$results['chatroom_pool_company'] = $adapter->syncChatroomMembersToTrafficPoolCompanyV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_company']}</info>");
break;
case 6:
$output->writeln('<comment>[Step 6] 同步群成员到流量来源表 ck_traffic_pool_source ...</comment>');
$results['chatroom_pool_source'] = $adapter->syncChatroomMembersToTrafficPoolSourceV2();
$output->writeln("<info> 完成,影响行数: {$results['chatroom_pool_source']}</info>");
break;
case 7:
$output->writeln('<comment>[Step 7] 同步微信标签 ck_traffic_pool_tag ...</comment>');
$results['pool_tags'] = $adapter->syncWechatTagsToV2();
$output->writeln("<info> 完成,影响行数: {$results['pool_tags']}</info>");
break;
default:
$output->writeln('<error>无效的步骤编号,请输入 1-7</error>');
$output->writeln('');
$output->writeln('步骤说明:');
$output->writeln(' 1 - 同步好友到流量池总表');
$output->writeln(' 2 - 同步好友到公司流量详情表');
$output->writeln(' 3 - 同步好友到流量来源表');
$output->writeln(' 4 - 同步群成员到流量池总表');
$output->writeln(' 5 - 同步群成员到公司流量详情表');
$output->writeln(' 6 - 同步群成员到流量来源表');
$output->writeln(' 7 - 同步微信标签');
break;
}
return $results;
}
}

View File

@@ -124,5 +124,43 @@ class SyncWechatDataToCkbTask extends Command
return $ChuKeBaoAdapter->syncCallRecording();
}
/**
* 同步数据到 V2 流量池总表
*/
protected function syncToTrafficPoolV2(ChuKeBaoAdapter $ChuKeBaoAdapter)
{
return $ChuKeBaoAdapter->syncToTrafficPoolV2();
}
/**
* 同步数据到 V2 公司流量详情表
*/
protected function syncToTrafficPoolCompanyV2(ChuKeBaoAdapter $ChuKeBaoAdapter)
{
return $ChuKeBaoAdapter->syncToTrafficPoolCompanyV2();
}
/**
* 同步数据到 V2 流量来源表
*/
protected function syncToTrafficPoolSourceV2(ChuKeBaoAdapter $ChuKeBaoAdapter)
{
return $ChuKeBaoAdapter->syncToTrafficPoolSourceV2();
}
/**
* 同步微信标签到 V2 标签系统
*/
protected function syncWechatTagsToV2(ChuKeBaoAdapter $ChuKeBaoAdapter)
{
return $ChuKeBaoAdapter->syncWechatTagsToV2();
}
/**
* 执行完整的 V2 流量池数据迁移
*/
protected function migrateToTrafficPoolV2(ChuKeBaoAdapter $ChuKeBaoAdapter)
{
return $ChuKeBaoAdapter->migrateToTrafficPoolV2();
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量分配记录表模型类
* 表名ck_traffic_pool_allot_record
* 用途:记录流量的分配历史
*/
class TrafficPoolAllotRecord extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_allot_record';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 分配类型常量
const ALLOT_TYPE_FIRST = 1; // 首次分配
const ALLOT_TYPE_REASSIGN = 2; // 重新分配
const ALLOT_TYPE_RECYCLE = 3; // 回收后分配
// 状态常量
const STATUS_ACTIVE = 1; // 生效中
const STATUS_EXPIRED = 2; // 已过期
const STATUS_RECYCLED = 3; // 已回收
/**
* 关联公司流量详情
*/
public function poolCompany()
{
return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id');
}
/**
* 创建分配记录
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param string $toWechatId
* @param int $toAccountId
* @param int $toUserId
* @param int $expireDays
* @param int $operatorId
* @param array $fromInfo [fromWechatId, fromAccountId, fromUserId]
* @return static
*/
public static function createAllotRecord(
int $poolCompanyId,
string $identifier,
int $companyId,
string $toWechatId,
int $toAccountId = null,
int $toUserId = null,
int $expireDays = 30,
int $operatorId = null,
array $fromInfo = []
) {
// 判断分配类型
$existRecord = self::where('poolCompanyId', $poolCompanyId)
->where('status', self::STATUS_ACTIVE)
->find();
$allotType = self::ALLOT_TYPE_FIRST;
if ($existRecord) {
// 将原记录设为已回收
$existRecord->save([
'status' => self::STATUS_RECYCLED,
'updateTime' => time()
]);
$allotType = self::ALLOT_TYPE_REASSIGN;
}
// 检查之前是否有过分配记录(判断是否回收后分配)
$hasHistoryRecord = self::where('poolCompanyId', $poolCompanyId)
->where('status', 'in', [self::STATUS_EXPIRED, self::STATUS_RECYCLED])
->count();
if ($hasHistoryRecord && $allotType === self::ALLOT_TYPE_FIRST) {
$allotType = self::ALLOT_TYPE_RECYCLE;
}
$expireTime = $expireDays > 0 ? time() + ($expireDays * 86400) : null;
$record = self::create([
'poolCompanyId' => $poolCompanyId,
'identifier' => $identifier,
'companyId' => $companyId,
'allotType' => $allotType,
'fromWechatId' => $fromInfo['fromWechatId'] ?? null,
'fromAccountId' => $fromInfo['fromAccountId'] ?? null,
'fromUserId' => $fromInfo['fromUserId'] ?? null,
'toWechatId' => $toWechatId,
'toAccountId' => $toAccountId,
'toUserId' => $toUserId,
'expireDays' => $expireDays,
'expireTime' => $expireTime,
'status' => self::STATUS_ACTIVE,
'operatorId' => $operatorId,
'createTime' => time(),
]);
// 更新公司流量详情表的归属信息
TrafficPoolCompany::where('id', $poolCompanyId)->update([
'ownerWechatId' => $toWechatId,
'ownerAccountId' => $toAccountId,
'ownerUserId' => $toUserId,
'allocateStatus' => TrafficPoolCompany::ALLOCATE_STATUS_ALLOCATED,
'allocateTime' => time(),
'expireTime' => $expireTime,
'updateTime' => time()
]);
return $record;
}
/**
* 回收分配
* @param int $poolCompanyId
* @param int $operatorId
* @return bool
*/
public static function recycleAllot(int $poolCompanyId, int $operatorId = null)
{
// 更新当前生效的分配记录
$activeRecord = self::where('poolCompanyId', $poolCompanyId)
->where('status', self::STATUS_ACTIVE)
->find();
if ($activeRecord) {
$activeRecord->save([
'status' => self::STATUS_RECYCLED,
'updateTime' => time()
]);
}
// 更新公司流量详情表
return TrafficPoolCompany::where('id', $poolCompanyId)->update([
'ownerWechatId' => null,
'ownerAccountId' => null,
'ownerUserId' => null,
'allocateStatus' => TrafficPoolCompany::ALLOCATE_STATUS_RECYCLED,
'expireTime' => null,
'updateTime' => time()
]);
}
/**
* 获取流量的分配历史
* @param int $poolCompanyId
* @return \think\Collection
*/
public static function getAllotHistory(int $poolCompanyId)
{
return self::where('poolCompanyId', $poolCompanyId)
->order('createTime DESC')
->select();
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量行为表模型类
* 表名ck_traffic_pool_behavior
* 用途:记录流量的各种行为(包括所有消息互动)
*/
class TrafficPoolBehavior extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_behavior';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'createTime';
protected $createTime = 'createTime';
protected $updateTime = false;
// 行为类型常量
const BEHAVIOR_TYPE_SEND_MSG = 1; // 发送消息
const BEHAVIOR_TYPE_RECEIVE_MSG = 2; // 接收消息
const BEHAVIOR_TYPE_VIEW = 3; // 浏览
const BEHAVIOR_TYPE_CLICK = 4; // 点击
const BEHAVIOR_TYPE_CONSULT = 5; // 咨询
const BEHAVIOR_TYPE_ORDER = 6; // 下单
const BEHAVIOR_TYPE_PAY = 7; // 支付
const BEHAVIOR_TYPE_REFUND = 8; // 退款
const BEHAVIOR_TYPE_LIKE_MOMENTS = 9; // 点赞朋友圈
const BEHAVIOR_TYPE_COMMENT_MOMENTS = 10; // 评论朋友圈
// 行为类型名称映射
const BEHAVIOR_TYPE_NAMES = [
self::BEHAVIOR_TYPE_SEND_MSG => '发送消息',
self::BEHAVIOR_TYPE_RECEIVE_MSG => '接收消息',
self::BEHAVIOR_TYPE_VIEW => '浏览',
self::BEHAVIOR_TYPE_CLICK => '点击',
self::BEHAVIOR_TYPE_CONSULT => '咨询',
self::BEHAVIOR_TYPE_ORDER => '下单',
self::BEHAVIOR_TYPE_PAY => '支付',
self::BEHAVIOR_TYPE_REFUND => '退款',
self::BEHAVIOR_TYPE_LIKE_MOMENTS => '点赞朋友圈',
self::BEHAVIOR_TYPE_COMMENT_MOMENTS => '评论朋友圈',
];
/**
* 关联公司流量详情
*/
public function poolCompany()
{
return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id');
}
/**
* 获取额外信息
* @param string $value
* @return array
*/
public function getExtraAttr($value)
{
return $value ? json_decode($value, true) : [];
}
/**
* 设置额外信息
* @param array $value
* @return string
*/
public function setExtraAttr($value)
{
return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null;
}
/**
* 获取行为类型名称
* @return string
*/
public function getBehaviorTypeNameAttr()
{
return self::BEHAVIOR_TYPE_NAMES[$this->behaviorType] ?? '未知行为';
}
/**
* 记录消息行为
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param int $behaviorType 发送/接收
* @param int $messageId
* @param int $wechatAccountId
* @param array $extra
* @return static
*/
public static function recordMessageBehavior(int $poolCompanyId, string $identifier, int $companyId, int $behaviorType, int $messageId = null, int $wechatAccountId = null, array $extra = [])
{
$behavior = self::create([
'poolCompanyId' => $poolCompanyId,
'identifier' => $identifier,
'companyId' => $companyId,
'behaviorType' => $behaviorType,
'behaviorName' => self::BEHAVIOR_TYPE_NAMES[$behaviorType] ?? '消息',
'messageId' => $messageId,
'wechatAccountId' => $wechatAccountId,
'extra' => $extra,
'behaviorTime' => time(),
'createTime' => time(),
]);
// 更新流量统计
$poolCompany = TrafficPoolCompany::find($poolCompanyId);
if ($poolCompany) {
$poolCompany->incrementMsgCount(1);
}
return $behavior;
}
/**
* 记录订单行为
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param int $behaviorType
* @param string $orderId
* @param float $amount
* @param array $extra
* @return static
*/
public static function recordOrderBehavior(int $poolCompanyId, string $identifier, int $companyId, int $behaviorType, string $orderId, float $amount = 0, array $extra = [])
{
$behavior = self::create([
'poolCompanyId' => $poolCompanyId,
'identifier' => $identifier,
'companyId' => $companyId,
'behaviorType' => $behaviorType,
'behaviorName' => self::BEHAVIOR_TYPE_NAMES[$behaviorType] ?? '订单',
'orderId' => $orderId,
'amount' => $amount,
'extra' => $extra,
'behaviorTime' => time(),
'createTime' => time(),
]);
// 如果是支付行为,更新订单统计
if ($behaviorType === self::BEHAVIOR_TYPE_PAY) {
$poolCompany = TrafficPoolCompany::find($poolCompanyId);
if ($poolCompany) {
$poolCompany->incrementOrderStats($amount);
}
}
return $behavior;
}
/**
* 记录朋友圈互动行为
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param int $behaviorType 点赞/评论
* @param int $momentsId
* @param array $extra
* @return static
*/
public static function recordMomentsBehavior(int $poolCompanyId, string $identifier, int $companyId, int $behaviorType, int $momentsId, array $extra = [])
{
return self::create([
'poolCompanyId' => $poolCompanyId,
'identifier' => $identifier,
'companyId' => $companyId,
'behaviorType' => $behaviorType,
'behaviorName' => self::BEHAVIOR_TYPE_NAMES[$behaviorType] ?? '朋友圈互动',
'momentsId' => $momentsId,
'extra' => $extra,
'behaviorTime' => time(),
'createTime' => time(),
]);
}
/**
* 获取用户行为轨迹
* @param int $poolCompanyId
* @param int $limit
* @return \think\Collection
*/
public static function getUserJourney(int $poolCompanyId, int $limit = 50)
{
return self::where('poolCompanyId', $poolCompanyId)
->order('behaviorTime DESC')
->limit($limit)
->select();
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace app\common\model;
use think\Model;
use think\Db;
/**
* 公司流量详情表模型类
* 表名ck_traffic_pool_company
* 用途:存储流量在各公司的详细信息,支持多租户
*/
class TrafficPoolCompany extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_company';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 好友状态常量
const FRIEND_STATUS_NOT_ADDED = 0; // 未加
const FRIEND_STATUS_PENDING = 1; // 待通过
const FRIEND_STATUS_PASSED = 2; // 已通过
const FRIEND_STATUS_DELETED = 3; // 已删除(我删除对方)
const FRIEND_STATUS_BE_DELETED = 4; // 被删除(对方删除我)
// 客户等级常量
const LEVEL_NORMAL = 0; // 普通
const LEVEL_IMPORTANT = 1; // 重要
const LEVEL_VIP = 2; // VIP
// 意向度常量
const INTENTION_UNKNOWN = 0; // 未知
const INTENTION_LOW = 1; // 低
const INTENTION_MEDIUM = 2; // 中
const INTENTION_HIGH = 3; // 高
// 生命周期常量
const LIFECYCLE_NEW = 1; // 新流量
const LIFECYCLE_FOLLOWING = 2; // 跟进中
const LIFECYCLE_CONVERTED = 3; // 已成交
const LIFECYCLE_SILENT = 4; // 沉默
const LIFECYCLE_LOST = 5; // 流失
// 状态常量
const STATUS_DISABLED = 0; // 禁用
const STATUS_NORMAL = 1; // 正常
const STATUS_BLACKLIST = 2; // 黑名单
// 分配状态常量
const ALLOCATE_STATUS_NOT = 0; // 未分配
const ALLOCATE_STATUS_ALLOCATED = 1; // 已分配
const ALLOCATE_STATUS_RECYCLED = 2; // 已回收
/**
* 关联流量池总表
*/
public function pool()
{
return $this->belongsTo(TrafficPoolV2::class, 'poolId', 'id');
}
/**
* 关联来源记录
*/
public function sources()
{
return $this->hasMany(TrafficPoolSource::class, 'poolCompanyId', 'id');
}
/**
* 关联标签记录
*/
public function tags()
{
return $this->hasMany(TrafficPoolTag::class, 'poolCompanyId', 'id');
}
/**
* 关联行为记录
*/
public function behaviors()
{
return $this->hasMany(TrafficPoolBehavior::class, 'poolCompanyId', 'id');
}
/**
* 关联分配记录
*/
public function allotRecords()
{
return $this->hasMany(TrafficPoolAllotRecord::class, 'poolCompanyId', 'id');
}
/**
* 根据identifier和companyId查找或创建
* @param string $identifier
* @param int $companyId
* @param int $poolId
* @param array $data
* @return static
*/
public static function findOrCreateByIdentifierAndCompany(string $identifier, int $companyId, int $poolId, array $data = [])
{
$record = self::where('identifier', $identifier)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$record) {
$insertData = array_merge([
'poolId' => $poolId,
'identifier' => $identifier,
'companyId' => $companyId,
'createTime' => time(),
], $data);
$record = self::create($insertData);
}
return $record;
}
/**
* 原子更新消息统计
* @param int $count 增加的消息数量
* @return bool
*/
public function incrementMsgCount(int $count = 1)
{
return Db::table($this->getTable())
->where('id', $this->id)
->inc('totalMsgCount', $count)
->inc('rfmF', $count)
->update([
'lastMsgTime' => time(),
'lastInteractTime' => time(),
'updateTime' => time()
]);
}
/**
* 原子更新订单统计
* @param float $amount 订单金额
* @return bool
*/
public function incrementOrderStats(float $amount)
{
return Db::table($this->getTable())
->where('id', $this->id)
->inc('totalOrderCount', 1)
->inc('totalOrderAmount', $amount)
->inc('rfmM', $amount)
->update([
'lastOrderTime' => time(),
'lastInteractTime' => time(),
'updateTime' => time()
]);
}
/**
* 计算RFM R值最后互动距今天数
* @return int
*/
public function getRfmRAttr()
{
if (empty($this->lastInteractTime)) {
return 9999; // 未互动过
}
return (int) floor((time() - $this->lastInteractTime) / 86400);
}
/**
* 获取自定义字段
* @param string $value
* @return array
*/
public function getCustomFieldsAttr($value)
{
return $value ? json_decode($value, true) : [];
}
/**
* 设置自定义字段
* @param array $value
* @return string
*/
public function setCustomFieldsAttr($value)
{
return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null;
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量池分组表模型类
* 表名ck_traffic_pool_group
* 用途:管理流量池分组(如:高价值客户池、潜在客户池等)
*/
class TrafficPoolGroup extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_group';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 规则类型常量
const RULE_TYPE_DYNAMIC = 1; // 动态规则
const RULE_TYPE_MANUAL = 2; // 手动添加
// 状态常量
const STATUS_DISABLED = 0;
const STATUS_ENABLED = 1;
// 系统默认分组编码
const GROUP_CODE_ALL_FRIENDS = 'all_friends'; // 全部好友流量池
const GROUP_CODE_HIGH_VALUE = 'high_value'; // 高价值客户池
const GROUP_CODE_POTENTIAL = 'potential'; // 潜在客户池
const GROUP_CODE_HIGH_INTERACT = 'high_interact'; // 高互动客户池
/**
* 关联分组成员
*/
public function members()
{
return $this->hasMany(TrafficPoolGroupMember::class, 'groupId', 'id');
}
/**
* 获取规则配置
* @param string $value
* @return array|null
*/
public function getRuleConfigAttr($value)
{
return $value ? json_decode($value, true) : null;
}
/**
* 设置规则配置
* @param array $value
* @return string
*/
public function setRuleConfigAttr($value)
{
return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null;
}
/**
* 获取公司可用的分组列表(包含系统分组和公司自定义分组)
* @param int $companyId
* @param bool $onlyEnabled
* @return \think\Collection
*/
public static function getGroupsByCompany(int $companyId, bool $onlyEnabled = true)
{
$query = self::whereIn('companyId', [0, $companyId])
->where('isDel', 0);
if ($onlyEnabled) {
$query->where('status', self::STATUS_ENABLED);
}
return $query->order('sort ASC, id ASC')->select();
}
/**
* 根据分组编码获取分组
* @param string $groupCode
* @param int $companyId
* @return static|null
*/
public static function getByCode(string $groupCode, int $companyId = 0)
{
return self::where('groupCode', $groupCode)
->whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->find();
}
/**
* 解析规则配置生成SQL条件
* @param array $ruleConfig
* @return array [whereConditions, bindings]
*/
public static function parseRuleToConditions(array $ruleConfig)
{
$conditions = [];
$bindings = [];
if (empty($ruleConfig['conditions'])) {
return [$conditions, $bindings];
}
$logic = strtoupper($ruleConfig['logic'] ?? 'AND');
foreach ($ruleConfig['conditions'] as $condition) {
if ($condition['type'] === 'group') {
// 嵌套分组,递归处理
[$subConditions, $subBindings] = self::parseRuleToConditions($condition);
if (!empty($subConditions)) {
$conditions[] = '(' . implode(' ' . ($condition['logic'] ?? 'AND') . ' ', $subConditions) . ')';
$bindings = array_merge($bindings, $subBindings);
}
} elseif ($condition['type'] === 'field') {
// 字段条件
$field = $condition['field'];
$operator = $condition['operator'];
$value = $condition['value'];
switch ($operator) {
case '=':
case '!=':
case '>':
case '<':
case '>=':
case '<=':
$conditions[] = "`{$field}` {$operator} ?";
$bindings[] = $value;
break;
case 'in':
$placeholders = implode(',', array_fill(0, count($value), '?'));
$conditions[] = "`{$field}` IN ({$placeholders})";
$bindings = array_merge($bindings, $value);
break;
case 'not_in':
$placeholders = implode(',', array_fill(0, count($value), '?'));
$conditions[] = "`{$field}` NOT IN ({$placeholders})";
$bindings = array_merge($bindings, $value);
break;
case 'between':
$conditions[] = "`{$field}` BETWEEN ? AND ?";
$bindings[] = $value[0];
$bindings[] = $value[1];
break;
case 'like':
$conditions[] = "`{$field}` LIKE ?";
$bindings[] = '%' . $value . '%';
break;
}
} elseif ($condition['type'] === 'tag') {
// 标签条件需要特殊处理,通过子查询
// 这里返回需要在Service层特殊处理
$conditions[] = "EXISTS (SELECT 1 FROM ck_traffic_pool_tag tpt WHERE tpt.poolCompanyId = ck_traffic_pool_company.id AND tpt.tagName IN (?))";
$bindings[] = implode("','", $condition['value']);
}
}
return [$conditions, $bindings, $logic];
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量池分组成员表模型类
* 表名ck_traffic_pool_group_member
* 用途手动添加到分组的成员ruleType=2时使用
*/
class TrafficPoolGroupMember extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_group_member';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = 'createTime';
protected $createTime = 'createTime';
protected $updateTime = false;
// 添加方式常量
const ADD_TYPE_MANUAL = 1; // 手动
const ADD_TYPE_IMPORT = 2; // 批量导入
/**
* 关联分组
*/
public function group()
{
return $this->belongsTo(TrafficPoolGroup::class, 'groupId', 'id');
}
/**
* 关联公司流量详情
*/
public function poolCompany()
{
return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id');
}
/**
* 批量添加成员到分组
* @param int $groupId
* @param array $poolCompanyIds
* @param int $companyId
* @param int $operatorId
* @param int $addType
* @return int 成功添加的数量
*/
public static function batchAddMembers(int $groupId, array $poolCompanyIds, int $companyId, int $operatorId = null, int $addType = self::ADD_TYPE_MANUAL)
{
$count = 0;
$time = time();
// 获取已存在的成员
$existIds = self::where('groupId', $groupId)
->whereIn('poolCompanyId', $poolCompanyIds)
->where('isDel', 0)
->column('poolCompanyId');
// 获取要添加的流量详情
$poolCompanies = TrafficPoolCompany::whereIn('id', $poolCompanyIds)
->where('companyId', $companyId)
->where('isDel', 0)
->column('identifier', 'id');
$insertData = [];
foreach ($poolCompanyIds as $poolCompanyId) {
if (in_array($poolCompanyId, $existIds)) {
continue; // 跳过已存在的
}
if (!isset($poolCompanies[$poolCompanyId])) {
continue; // 跳过不存在的
}
$insertData[] = [
'groupId' => $groupId,
'poolCompanyId' => $poolCompanyId,
'identifier' => $poolCompanies[$poolCompanyId],
'companyId' => $companyId,
'addType' => $addType,
'operatorId' => $operatorId,
'createTime' => $time,
'isDel' => 0,
];
}
if (!empty($insertData)) {
(new self())->saveAll($insertData);
$count = count($insertData);
// 更新分组成员数量缓存
TrafficPoolGroup::where('id', $groupId)->setInc('memberCount', $count);
}
return $count;
}
/**
* 批量移除成员
* @param int $groupId
* @param array $poolCompanyIds
* @return int
*/
public static function batchRemoveMembers(int $groupId, array $poolCompanyIds)
{
$count = self::where('groupId', $groupId)
->whereIn('poolCompanyId', $poolCompanyIds)
->where('isDel', 0)
->update([
'isDel' => 1,
'deleteTime' => time()
]);
if ($count > 0) {
// 更新分组成员数量缓存
TrafficPoolGroup::where('id', $groupId)->setDec('memberCount', $count);
}
return $count;
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量来源表模型类
* 表名ck_traffic_pool_source
* 用途:记录流量的获取渠道和来源路径
*/
class TrafficPoolSource extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_source';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 来源类型常量
const SOURCE_TYPE_FRIEND_ADD = 1; // 好友添加
const SOURCE_TYPE_GROUP_MEMBER = 2; // 群成员
const SOURCE_TYPE_POSTER = 3; // 海报获客
const SOURCE_TYPE_PHONE = 4; // 电话获客
const SOURCE_TYPE_ORDER = 5; // 订单获客
const SOURCE_TYPE_API = 6; // API导入
const SOURCE_TYPE_MANUAL = 7; // 手动导入
const SOURCE_TYPE_FISSION = 8; // 裂变活动
// 来源类型名称映射
const SOURCE_TYPE_NAMES = [
self::SOURCE_TYPE_FRIEND_ADD => '好友添加',
self::SOURCE_TYPE_GROUP_MEMBER => '群成员',
self::SOURCE_TYPE_POSTER => '海报获客',
self::SOURCE_TYPE_PHONE => '电话获客',
self::SOURCE_TYPE_ORDER => '订单获客',
self::SOURCE_TYPE_API => 'API导入',
self::SOURCE_TYPE_MANUAL => '手动导入',
self::SOURCE_TYPE_FISSION => '裂变活动',
];
/**
* 关联公司流量详情
*/
public function poolCompany()
{
return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id');
}
/**
* 获取额外信息
* @param string $value
* @return array
*/
public function getExtraAttr($value)
{
return $value ? json_decode($value, true) : [];
}
/**
* 设置额外信息
* @param array $value
* @return string
*/
public function setExtraAttr($value)
{
return $value ? json_encode($value, JSON_UNESCAPED_UNICODE) : null;
}
/**
* 获取来源类型名称
* @return string
*/
public function getSourceTypeNameAttr()
{
return self::SOURCE_TYPE_NAMES[$this->sourceType] ?? '未知来源';
}
/**
* 创建来源记录
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param int $sourceType
* @param array $data
* @return static
*/
public static function createSource(int $poolCompanyId, string $identifier, int $companyId, int $sourceType, array $data = [])
{
// 检查是否为首次来源
$existSource = self::where('poolCompanyId', $poolCompanyId)->find();
$isFirstSource = $existSource ? 0 : 1;
$insertData = array_merge([
'poolCompanyId' => $poolCompanyId,
'identifier' => $identifier,
'companyId' => $companyId,
'sourceType' => $sourceType,
'isFirstSource' => $isFirstSource,
'createTime' => time(),
], $data);
$source = self::create($insertData);
// 如果是首次来源,更新公司流量详情表
if ($isFirstSource) {
TrafficPoolCompany::where('id', $poolCompanyId)->update([
'firstSourceType' => $sourceType,
'firstSourceTime' => time(),
'updateTime' => time()
]);
}
return $source;
}
/**
* 获取流量的所有来源
* @param int $poolCompanyId
* @return \think\Collection
*/
public static function getSourcesByPoolCompany(int $poolCompanyId)
{
return self::where('poolCompanyId', $poolCompanyId)
->order('createTime DESC')
->select();
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量标签关联表模型类
* 表名ck_traffic_pool_tag
* 用途:记录流量与标签的关联关系
*/
class TrafficPoolTag extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_tag';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标签类型常量
const TAG_TYPE_WECHAT = 1; // 微信标签
const TAG_TYPE_SITE = 2; // 站内标签
const TAG_TYPE_AI = 3; // AI标签
// 打标来源常量
const SOURCE_MANUAL = 1; // 手动
const SOURCE_RULE = 2; // 规则自动
const SOURCE_AI = 3; // AI自动
const SOURCE_WECHAT_SYNC = 4; // 微信同步
// 打标来源名称
const SOURCE_NAMES = [
self::SOURCE_MANUAL => '手动打标',
self::SOURCE_RULE => '规则自动',
self::SOURCE_AI => 'AI自动',
self::SOURCE_WECHAT_SYNC => '微信同步',
];
/**
* 关联公司流量详情
*/
public function poolCompany()
{
return $this->belongsTo(TrafficPoolCompany::class, 'poolCompanyId', 'id');
}
/**
* 关联标签定义
*/
public function tagDefine()
{
return $this->belongsTo(TrafficPoolTagDefine::class, 'tagDefineId', 'id');
}
/**
* 为流量添加标签
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param int $tagDefineId
* @param int $source
* @param int $operatorId
* @param string $tagValue
* @param float $score AI置信度
* @return static|null
*/
public static function addTag(
int $poolCompanyId,
string $identifier,
int $companyId,
int $tagDefineId,
int $source = self::SOURCE_MANUAL,
int $operatorId = null,
string $tagValue = null,
float $score = null
) {
// 检查标签定义是否存在
$tagDefine = TrafficPoolTagDefine::find($tagDefineId);
if (!$tagDefine) {
return null;
}
// 检查是否已存在
$existTag = self::where('poolCompanyId', $poolCompanyId)
->where('tagDefineId', $tagDefineId)
->where('isDel', 0)
->find();
if ($existTag) {
// 更新现有标签
$existTag->save([
'tagValue' => $tagValue,
'source' => $source,
'operatorId' => $operatorId,
'score' => $score,
'updateTime' => time()
]);
return $existTag;
}
// 如果是互斥标签,先删除同类目下的其他标签
if ($tagDefine->isExclusive) {
self::where('poolCompanyId', $poolCompanyId)
->where('categoryId', $tagDefine->categoryId)
->where('isDel', 0)
->update([
'isDel' => 1,
'deleteTime' => time()
]);
}
// 创建新标签关联
$tag = self::create([
'poolCompanyId' => $poolCompanyId,
'identifier' => $identifier,
'companyId' => $companyId,
'tagDefineId' => $tagDefineId,
'tagType' => $tagDefine->tagType,
'categoryId' => $tagDefine->categoryId,
'tagName' => $tagDefine->tagName,
'tagValue' => $tagValue,
'source' => $source,
'operatorId' => $operatorId,
'score' => $score,
'createTime' => time()
]);
// 增加标签使用次数
$tagDefine->incrementUseCount();
return $tag;
}
/**
* 移除流量标签
* @param int $poolCompanyId
* @param int $tagDefineId
* @return bool
*/
public static function removeTag(int $poolCompanyId, int $tagDefineId)
{
$tag = self::where('poolCompanyId', $poolCompanyId)
->where('tagDefineId', $tagDefineId)
->where('isDel', 0)
->find();
if ($tag) {
$tag->save([
'isDel' => 1,
'deleteTime' => time()
]);
// 减少标签使用次数
$tagDefine = TrafficPoolTagDefine::find($tagDefineId);
if ($tagDefine) {
$tagDefine->decrementUseCount();
}
return true;
}
return false;
}
/**
* 获取流量的所有标签
* @param int $poolCompanyId
* @param int|null $tagType
* @return \think\Collection
*/
public static function getTagsByPoolCompany(int $poolCompanyId, int $tagType = null)
{
$query = self::where('poolCompanyId', $poolCompanyId)
->where('isDel', 0);
if ($tagType !== null) {
$query->where('tagType', $tagType);
}
return $query->order('createTime DESC')->select();
}
/**
* 同步微信标签
* @param int $poolCompanyId
* @param string $identifier
* @param int $companyId
* @param array $wechatLabels 微信标签名称数组
* @return int 同步的标签数量
*/
public static function syncWechatTags(int $poolCompanyId, string $identifier, int $companyId, array $wechatLabels)
{
$count = 0;
// 获取微信默认标签类目假设ID为1
$wechatCategoryId = 1;
foreach ($wechatLabels as $labelName) {
if (empty($labelName)) {
continue;
}
// 获取或创建标签定义
$tagDefine = TrafficPoolTagDefine::getOrCreateByName($labelName, $companyId, $wechatCategoryId);
// 添加标签关联
$tag = self::addTag(
$poolCompanyId,
$identifier,
$companyId,
$tagDefine->id,
self::SOURCE_WECHAT_SYNC
);
if ($tag) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 标签类目表模型类
* 表名ck_traffic_pool_tag_category
* 用途:管理标签的类目/分组,支持多级分类
*/
class TrafficPoolTagCategory extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_tag_category';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标签类型常量
const TAG_TYPE_WECHAT = 1; // 微信标签
const TAG_TYPE_SITE = 2; // 站内标签
const TAG_TYPE_AI = 3; // AI标签
// 标签类型名称映射
const TAG_TYPE_NAMES = [
self::TAG_TYPE_WECHAT => '微信标签',
self::TAG_TYPE_SITE => '站内标签',
self::TAG_TYPE_AI => 'AI标签',
];
// 状态常量
const STATUS_DISABLED = 0;
const STATUS_ENABLED = 1;
/**
* 关联标签定义
*/
public function tagDefines()
{
return $this->hasMany(TrafficPoolTagDefine::class, 'categoryId', 'id');
}
/**
* 关联子类目
*/
public function children()
{
return $this->hasMany(self::class, 'parentId', 'id');
}
/**
* 关联父类目
*/
public function parent()
{
return $this->belongsTo(self::class, 'parentId', 'id');
}
/**
* 获取公司可用的标签类目
* @param int $companyId
* @param int|null $tagType
* @return \think\Collection
*/
public static function getCategoriesByCompany(int $companyId, int $tagType = null)
{
$query = self::whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->where('status', self::STATUS_ENABLED);
if ($tagType !== null) {
$query->where('tagType', $tagType);
}
return $query->order('sort ASC, id ASC')->select();
}
/**
* 获取类目树结构
* @param int $companyId
* @param int|null $tagType
* @return array
*/
public static function getCategoryTree(int $companyId, int $tagType = null)
{
$categories = self::getCategoriesByCompany($companyId, $tagType)->toArray();
return self::buildTree($categories);
}
/**
* 构建树结构
* @param array $items
* @param int $parentId
* @return array
*/
private static function buildTree(array $items, int $parentId = 0)
{
$result = [];
foreach ($items as $item) {
if ($item['parentId'] == $parentId) {
$children = self::buildTree($items, $item['id']);
if (!empty($children)) {
$item['children'] = $children;
}
$result[] = $item;
}
}
return $result;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 标签定义表模型类
* 表名ck_traffic_pool_tag_define
* 用途:定义具体的标签
*/
class TrafficPoolTagDefine extends Model
{
// 设置数据表名
protected $name = 'traffic_pool_tag_define';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标签类型常量(与类目表一致)
const TAG_TYPE_WECHAT = 1; // 微信标签
const TAG_TYPE_SITE = 2; // 站内标签
const TAG_TYPE_AI = 3; // AI标签
// 状态常量
const STATUS_DISABLED = 0;
const STATUS_ENABLED = 1;
/**
* 关联类目
*/
public function category()
{
return $this->belongsTo(TrafficPoolTagCategory::class, 'categoryId', 'id');
}
/**
* 关联标签使用记录
*/
public function tags()
{
return $this->hasMany(TrafficPoolTag::class, 'tagDefineId', 'id');
}
/**
* 获取公司可用的标签定义
* @param int $companyId
* @param int|null $tagType
* @param int|null $categoryId
* @return \think\Collection
*/
public static function getTagDefinesByCompany(int $companyId, int $tagType = null, int $categoryId = null)
{
$query = self::whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->where('status', self::STATUS_ENABLED);
if ($tagType !== null) {
$query->where('tagType', $tagType);
}
if ($categoryId !== null) {
$query->where('categoryId', $categoryId);
}
return $query->order('sort ASC, id ASC')->select();
}
/**
* 根据标签编码获取标签定义
* @param string $tagCode
* @param int $companyId
* @return static|null
*/
public static function getByCode(string $tagCode, int $companyId = 0)
{
return self::where('tagCode', $tagCode)
->whereIn('companyId', [0, $companyId])
->where('isDel', 0)
->find();
}
/**
* 根据标签名称获取或创建标签(用于微信标签同步)
* @param string $tagName
* @param int $companyId
* @param int $categoryId
* @return static
*/
public static function getOrCreateByName(string $tagName, int $companyId, int $categoryId = 1)
{
$tagCode = 'wechat_' . md5($tagName . '_' . $companyId);
$tag = self::where('tagCode', $tagCode)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$tag) {
$tag = self::create([
'companyId' => $companyId,
'categoryId' => $categoryId,
'tagType' => self::TAG_TYPE_WECHAT,
'tagCode' => $tagCode,
'tagName' => $tagName,
'isSystem' => 0,
'syncFromWechat' => 1,
'status' => self::STATUS_ENABLED,
'createTime' => time()
]);
}
return $tag;
}
/**
* 增加使用次数
* @return bool
*/
public function incrementUseCount()
{
return $this->setInc('useCount');
}
/**
* 减少使用次数
* @return bool
*/
public function decrementUseCount()
{
if ($this->useCount > 0) {
return $this->setDec('useCount');
}
return true;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 流量池总表模型类V2版本
* 表名ck_traffic_pool
* 用途:存储全局唯一的流量标识,不区分公司
*/
class TrafficPoolV2 extends Model
{
// 设置数据表名(不带前缀)
protected $name = 'traffic_pool';
// 主键
protected $pk = 'id';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 标识类型常量
const IDENTIFIER_TYPE_WECHAT_ID = 1; // 微信ID
const IDENTIFIER_TYPE_WECHAT_ALIAS = 2; // 微信号
const IDENTIFIER_TYPE_MOBILE = 3; // 手机号
// 性别常量
const GENDER_UNKNOWN = 0;
const GENDER_MALE = 1;
const GENDER_FEMALE = 2;
/**
* 关联公司流量详情
*/
public function companies()
{
return $this->hasMany(TrafficPoolCompany::class, 'poolId', 'id');
}
/**
* 根据identifier查找或创建流量记录
* @param string $identifier 唯一标识
* @param array $data 额外数据
* @return static
*/
public static function findOrCreateByIdentifier(string $identifier, array $data = [])
{
$record = self::where('identifier', $identifier)->find();
if (!$record) {
$insertData = array_merge([
'identifier' => $identifier,
'identifierType' => self::IDENTIFIER_TYPE_WECHAT_ID,
'createTime' => time(),
], $data);
// 如果identifier是微信ID同时设置wechatId
if (empty($insertData['wechatId']) && $insertData['identifierType'] == self::IDENTIFIER_TYPE_WECHAT_ID) {
$insertData['wechatId'] = $identifier;
}
$record = self::create($insertData);
}
return $record;
}
/**
* 更新基础信息
* @param array $data
* @return bool
*/
public function updateBasicInfo(array $data)
{
$allowFields = ['nickname', 'avatar', 'gender', 'region', 'country', 'province', 'city', 'signature', 'wechatAlias', 'mobile'];
$updateData = array_intersect_key($data, array_flip($allowFields));
$updateData['updateTime'] = time();
$updateData['lastSeenTime'] = time();
return $this->save($updateData);
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace app\common\service;
use think\facade\Log;
/**
* 标签引擎服务类
* 对接外部标签系统API
*/
class TagEngineService
{
/**
* API基础URL
* @var string
*/
private $baseUrl = 'http://192.168.1.134:3000';
/**
* API Key
* @var string
*/
private $apiKey = '69aebe46b03d334f1796ef88808d3042d5851d0fd91d728bcba6ad6be436acf6';
/**
* 设置API基础URL
* @param string $url
* @return $this
*/
public function setBaseUrl($url)
{
$this->baseUrl = rtrim($url, '/');
return $this;
}
/**
* 设置API Key
* @param string $key
* @return $this
*/
public function setApiKey($key)
{
$this->apiKey = $key;
return $this;
}
/**
* 构建请求头
* @return array
*/
private function buildHeaders()
{
return [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json'
];
}
/**
* 通过标识查询标签
*
* @param array $identifiers 用户标识列表最多100个
* 格式:[['type' => 'phone', 'value' => '13800138000'], ...]
* @param array $options 查询选项
* - include_tags: array 包含指定标签(标签代码列表)
* - exclude_tags: array 排除指定标签(标签代码列表)
* - tag_category: string 按分类筛选标签
* - mask_identifier: bool 是否脱敏标识信息,默认 true
* @return array|false
*/
public function queryByIdentifiers($identifiers, $options = [])
{
try {
// 参数验证
if (empty($identifiers) || !is_array($identifiers)) {
Log::error('标签引擎:标识列表不能为空');
return false;
}
if (count($identifiers) > 100) {
Log::error('标签引擎单次最多查询100个标识');
return false;
}
// 构建请求数据
$data = [
'identifiers' => $identifiers,
];
if (!empty($options)) {
$data['options'] = $options;
}
// 发起请求
$url = $this->baseUrl . '/api/v1/tag/query-by-identifiers';
$response = requestCurl($url, $data, 'POST', $this->buildHeaders(), 'json');
// 处理响应
$result = handleApiResponse($response);
// 记录日志
Log::info('标签引擎-通过标识查询标签', [
'identifiers_count' => count($identifiers),
'response' => $result
]);
return $result;
} catch (\Exception $e) {
Log::error('标签引擎-通过标识查询标签异常:' . $e->getMessage());
return false;
}
}
/**
* 通过标签查询用户
*
* @param array $tagConditions 标签条件列表最多10个条件
* 格式:[
* ['tag_code' => 'user.trade.total_amount', 'operator' => '>=', 'value' => '5000'],
* ...
* ]
* 支持的操作符:=, !=, >, >=, <, <=, in, not_in
* @param string $logic 逻辑关系AND默认或 OR
* @param bool $includeSensitive 是否返回敏感信息QQ号、身份证默认 false
* @param int $page 页码,默认 1
* @param int $pageSize 每页数量,默认 20最大 100
* @return array|false
*/
public function queryUsersByTags($tagConditions, $logic = 'AND', $includeSensitive = false, $page = 1, $pageSize = 20)
{
try {
// 参数验证
if (empty($tagConditions) || !is_array($tagConditions)) {
Log::error('标签引擎:标签条件不能为空');
return false;
}
if (count($tagConditions) > 10) {
Log::error('标签引擎单次最多10个标签条件');
return false;
}
if ($pageSize > 100) {
Log::error('标签引擎单页最多返回100条记录');
return false;
}
// 构建请求数据
$data = [
'tag_conditions' => $tagConditions,
'logic' => strtoupper($logic),
'include_sensitive' => $includeSensitive,
'page' => max(1, intval($page)),
'page_size' => min(100, max(1, intval($pageSize)))
];
// 发起请求
$url = $this->baseUrl . '/api/v1/tag/query-users-by-tags';
$response = requestCurl($url, $data, 'POST', $this->buildHeaders(), 'json');
// 处理响应
$result = handleApiResponse($response);
// 记录日志
Log::info('标签引擎-通过标签查询用户', [
'conditions_count' => count($tagConditions),
'page' => $page,
'page_size' => $pageSize,
'response' => $result
]);
return $result;
} catch (\Exception $e) {
Log::error('标签引擎-通过标签查询用户异常:' . $e->getMessage());
return false;
}
}
/**
* 内部调用 - 通过手机号查询标签
*
* @param string|array $phones 手机号或手机号数组
* @param array $options 查询选项
* @return array|false
*/
public function queryByPhone($phones, $options = [])
{
if (!is_array($phones)) {
$phones = [$phones];
}
$identifiers = [];
foreach ($phones as $phone) {
$identifiers[] = [
'type' => 'phone',
'value' => $phone
];
}
return $this->queryByIdentifiers($identifiers, $options);
}
/**
* 内部调用 - 通过微信号查询标签
*
* @param string|array $wechats 微信号或微信号数组
* @param array $options 查询选项
* @return array|false
*/
public function queryByWechat($wechats, $options = [])
{
if (!is_array($wechats)) {
$wechats = [$wechats];
}
$identifiers = [];
foreach ($wechats as $wechat) {
$identifiers[] = [
'type' => 'wechat',
'value' => $wechat
];
}
return $this->queryByIdentifiers($identifiers, $options);
}
/**
* 内部调用 - 通过身份证号查询标签
*
* @param string|array $idCards 身份证号或身份证号数组
* @param array $options 查询选项
* @return array|false
*/
public function queryByIdCard($idCards, $options = [])
{
if (!is_array($idCards)) {
$idCards = [$idCards];
}
$identifiers = [];
foreach ($idCards as $idCard) {
$identifiers[] = [
'type' => 'id_card',
'value' => $idCard
];
}
return $this->queryByIdentifiers($identifiers, $options);
}
/**
* 内部调用 - 通过QQ号查询标签
*
* @param string|array $qqs QQ号或QQ号数组
* @param array $options 查询选项
* @return array|false
*/
public function queryByQQ($qqs, $options = [])
{
if (!is_array($qqs)) {
$qqs = [$qqs];
}
$identifiers = [];
foreach ($qqs as $qq) {
$identifiers[] = [
'type' => 'qq',
'value' => $qq
];
}
return $this->queryByIdentifiers($identifiers, $options);
}
}

View File

@@ -61,7 +61,7 @@ Route::group('v1/', function () {
Route::get('getUserList', 'app\cunkebao\controller\plan\PlanSceneV1Controller@getUserList');
});
// 流量池相关
// 流量池相关V1 旧版接口,保持兼容)
Route::group('traffic/pool', function () {
Route::get('getPackage', 'app\cunkebao\controller\TrafficController@getPackage'); // 获取流量池包列表
Route::get('getPackageDetail', 'app\cunkebao\controller\TrafficController@getPackageDetail'); // 获取流量池详情(元数据)
@@ -71,25 +71,46 @@ Route::group('v1/', function () {
Route::get('user-list', 'app\cunkebao\controller\TrafficController@getTrafficPoolList'); // 获取流量池用户列表(数据列表)
//Route::get('', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@index');
Route::get('getUserJourney', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@getUserJourney');
Route::get('getUserTags', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@getUserTags');
Route::get('getUserInfo', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@getUser');
// Route::post('addPackage', 'app\cunkebao\controller\traffic\GetPotentialListWithInCompanyV1Controller@addPackage');
Route::get('converted', 'app\cunkebao\controller\traffic\GetConvertedListWithInCompanyV1Controller@index');
Route::get('types', 'app\cunkebao\controller\traffic\GetPotentialTypeSectionV1Controller@index');
Route::get('sources', 'app\cunkebao\controller\traffic\GetTrafficSourceSectionV1Controller@index');
Route::get('statistics', 'app\cunkebao\controller\traffic\GetPoolStatisticsV1Controller@index');
});
// 流量池 V2 新版接口
Route::group('traffic/pool/v2', function () {
// 分组相关
Route::get('groups', 'app\cunkebao\controller\TrafficPoolV2Controller@getGroups'); // 获取分组列表
Route::get('group/detail', 'app\cunkebao\controller\TrafficPoolV2Controller@getGroupDetail'); // 获取分组详情
Route::post('group/create', 'app\cunkebao\controller\TrafficPoolV2Controller@createGroup'); // 创建分组
Route::put('group/update', 'app\cunkebao\controller\TrafficPoolV2Controller@updateGroup'); // 更新分组
Route::delete('group/delete', 'app\cunkebao\controller\TrafficPoolV2Controller@deleteGroup'); // 删除分组
Route::get('group/members', 'app\cunkebao\controller\TrafficPoolV2Controller@getGroupMembers'); // 获取分组成员
Route::post('group/add-members', 'app\cunkebao\controller\TrafficPoolV2Controller@addMembersToGroup'); // 添加成员到分组
Route::post('group/remove-members', 'app\cunkebao\controller\TrafficPoolV2Controller@removeMembersFromGroup'); // 移除分组成员
// 流量池成员相关
Route::get('list', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolList'); // 获取流量池列表
Route::get('detail', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolDetail'); // 获取流量详情
Route::put('update', 'app\cunkebao\controller\TrafficPoolV2Controller@updatePool'); // 更新流量信息
// 标签相关
Route::get('tag/categories', 'app\cunkebao\controller\TrafficPoolV2Controller@getTagCategories'); // 获取标签类目
Route::get('tag/defines', 'app\cunkebao\controller\TrafficPoolV2Controller@getTagDefines'); // 获取标签定义
Route::get('tag/pool-tags', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolTags'); // 获取流量的标签
Route::post('tag/add', 'app\cunkebao\controller\TrafficPoolV2Controller@addTag'); // 添加标签
Route::delete('tag/remove', 'app\cunkebao\controller\TrafficPoolV2Controller@removeTag'); // 移除标签
// 分配相关
Route::post('allocate', 'app\cunkebao\controller\TrafficPoolV2Controller@allocatePool'); // 分配流量
Route::post('recycle', 'app\cunkebao\controller\TrafficPoolV2Controller@recyclePool'); // 回收流量
// 统计相关
Route::get('statistics', 'app\cunkebao\controller\TrafficPoolV2Controller@getStatistics'); // 获取统计数据
});
// 工作台相关

View File

@@ -0,0 +1,569 @@
<?php
namespace app\cunkebao\controller;
use app\cunkebao\service\TrafficPoolService;
use app\cunkebao\service\TrafficPoolGroupService;
use app\common\model\TrafficPoolGroup;
use app\common\model\TrafficPoolCompany;
use app\common\model\TrafficPoolTag;
use app\common\model\TrafficPoolTagCategory;
use app\common\model\TrafficPoolTagDefine;
use app\common\model\TrafficPoolAllotRecord;
use app\common\service\ClassTableService;
use library\ResponseHelper;
/**
* 流量池控制器 V2
* 基于新架构的流量池 API 接口
*/
class TrafficPoolV2Controller extends BaseController
{
/**
* @var TrafficPoolService
*/
protected $poolService;
/**
* @var TrafficPoolGroupService
*/
protected $groupService;
public function __construct(ClassTableService $classTable)
{
parent::__construct($classTable);
$this->poolService = new TrafficPoolService();
$this->groupService = new TrafficPoolGroupService();
}
// ==================== 分组相关接口 ====================
/**
* 获取流量池分组列表
* @return \think\response\Json
*/
public function getGroups()
{
$companyId = $this->getUserInfo('companyId');
try {
$groups = $this->groupService->getGroupList($companyId, true);
return ResponseHelper::success($groups);
} catch (\Exception $e) {
return ResponseHelper::error('获取分组列表失败:' . $e->getMessage());
}
}
/**
* 获取分组详情
* @return \think\response\Json
*/
public function getGroupDetail()
{
$groupId = $this->request->param('groupId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
try {
$detail = $this->groupService->getGroupDetail($groupId, $companyId);
if (!$detail) {
return ResponseHelper::error('分组不存在');
}
return ResponseHelper::success($detail);
} catch (\Exception $e) {
return ResponseHelper::error('获取分组详情失败:' . $e->getMessage());
}
}
/**
* 创建分组
* @return \think\response\Json
*/
public function createGroup()
{
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
$data = $this->request->param();
if (empty($data['groupName'])) {
return ResponseHelper::error('分组名称不能为空');
}
try {
$group = $this->groupService->createGroup($companyId, $data, $userId);
return ResponseHelper::success(['id' => $group->id], '创建成功');
} catch (\Exception $e) {
return ResponseHelper::error('创建分组失败:' . $e->getMessage());
}
}
/**
* 更新分组
* @return \think\response\Json
*/
public function updateGroup()
{
$groupId = $this->request->param('groupId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
$data = $this->request->param();
try {
$this->groupService->updateGroup($groupId, $companyId, $data);
return ResponseHelper::success(null, '更新成功');
} catch (\Exception $e) {
return ResponseHelper::error('更新分组失败:' . $e->getMessage());
}
}
/**
* 删除分组
* @return \think\response\Json
*/
public function deleteGroup()
{
$groupId = $this->request->param('groupId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
try {
$this->groupService->deleteGroup($groupId, $companyId);
return ResponseHelper::success(null, '删除成功');
} catch (\Exception $e) {
return ResponseHelper::error('删除分组失败:' . $e->getMessage());
}
}
// ==================== 流量池成员相关接口 ====================
/**
* 获取分组成员列表(用户列表)
* @return \think\response\Json
*/
public function getGroupMembers()
{
$groupId = $this->request->param('groupId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
$page = $this->request->param('page', 1, 'intval');
$pageSize = $this->request->param('pageSize', 10, 'intval');
$keyword = $this->request->param('keyword', '');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
$filters = [
'keyword' => $keyword
];
try {
$result = $this->groupService->getGroupMembers($groupId, $companyId, $page, $pageSize, $filters);
return ResponseHelper::success($result);
} catch (\Exception $e) {
return ResponseHelper::error('获取成员列表失败:' . $e->getMessage());
}
}
/**
* 获取流量池列表(全量,带筛选)
* @return \think\response\Json
*/
public function getPoolList()
{
$companyId = $this->getUserInfo('companyId');
$page = $this->request->param('page', 1, 'intval');
$pageSize = $this->request->param('pageSize', 10, 'intval');
$filters = [
'keyword' => $this->request->param('keyword', ''),
'friendStatus' => $this->request->param('friendStatus'),
'level' => $this->request->param('level'),
'lifecycle' => $this->request->param('lifecycle'),
'allocateStatus' => $this->request->param('allocateStatus'),
'ownerWechatId' => $this->request->param('ownerWechatId', ''),
'rfmMMin' => $this->request->param('rfmMMin'),
'rfmMMax' => $this->request->param('rfmMMax'),
];
// 移除空值
$filters = array_filter($filters, function($v) {
return $v !== null && $v !== '';
});
try {
$result = $this->poolService->getPoolList($companyId, $page, $pageSize, $filters);
return ResponseHelper::success($result);
} catch (\Exception $e) {
return ResponseHelper::error('获取流量池列表失败:' . $e->getMessage());
}
}
/**
* 获取流量详情
* @return \think\response\Json
*/
public function getPoolDetail()
{
$poolCompanyId = $this->request->param('id', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
if (empty($poolCompanyId)) {
return ResponseHelper::error('流量ID不能为空');
}
try {
$detail = $this->poolService->getPoolDetail($poolCompanyId, $companyId);
if (!$detail) {
return ResponseHelper::error('流量不存在');
}
return ResponseHelper::success($detail);
} catch (\Exception $e) {
return ResponseHelper::error('获取流量详情失败:' . $e->getMessage());
}
}
/**
* 更新流量信息
* @return \think\response\Json
*/
public function updatePool()
{
$poolCompanyId = $this->request->param('id', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
if (empty($poolCompanyId)) {
return ResponseHelper::error('流量ID不能为空');
}
$data = $this->request->param();
try {
$this->poolService->updatePool($poolCompanyId, $companyId, $data);
return ResponseHelper::success(null, '更新成功');
} catch (\Exception $e) {
return ResponseHelper::error('更新失败:' . $e->getMessage());
}
}
/**
* 添加成员到分组(手动分组)
* @return \think\response\Json
*/
public function addMembersToGroup()
{
$groupId = $this->request->param('groupId', 0, 'intval');
$poolCompanyIds = $this->request->param('poolCompanyIds/a', []);
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
if (empty($poolCompanyIds)) {
return ResponseHelper::error('请选择要添加的成员');
}
try {
$count = $this->groupService->addMembers($groupId, $poolCompanyIds, $companyId, $userId);
return ResponseHelper::success(['count' => $count], "成功添加 {$count} 个成员");
} catch (\Exception $e) {
return ResponseHelper::error('添加失败:' . $e->getMessage());
}
}
/**
* 从分组移除成员
* @return \think\response\Json
*/
public function removeMembersFromGroup()
{
$groupId = $this->request->param('groupId', 0, 'intval');
$poolCompanyIds = $this->request->param('poolCompanyIds/a', []);
$companyId = $this->getUserInfo('companyId');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
if (empty($poolCompanyIds)) {
return ResponseHelper::error('请选择要移除的成员');
}
try {
$count = $this->groupService->removeMembers($groupId, $poolCompanyIds, $companyId);
return ResponseHelper::success(['count' => $count], "成功移除 {$count} 个成员");
} catch (\Exception $e) {
return ResponseHelper::error('移除失败:' . $e->getMessage());
}
}
// ==================== 标签相关接口 ====================
/**
* 获取标签类目列表
* @return \think\response\Json
*/
public function getTagCategories()
{
$companyId = $this->getUserInfo('companyId');
$tagType = $this->request->param('tagType');
try {
$categories = TrafficPoolTagCategory::getCategoryTree($companyId, $tagType);
return ResponseHelper::success($categories);
} catch (\Exception $e) {
return ResponseHelper::error('获取标签类目失败:' . $e->getMessage());
}
}
/**
* 获取标签定义列表
* @return \think\response\Json
*/
public function getTagDefines()
{
$companyId = $this->getUserInfo('companyId');
$tagType = $this->request->param('tagType');
$categoryId = $this->request->param('categoryId');
try {
$defines = TrafficPoolTagDefine::getTagDefinesByCompany($companyId, $tagType, $categoryId);
return ResponseHelper::success($defines);
} catch (\Exception $e) {
return ResponseHelper::error('获取标签定义失败:' . $e->getMessage());
}
}
/**
* 为流量添加标签
* @return \think\response\Json
*/
public function addTag()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$tagDefineId = $this->request->param('tagDefineId', 0, 'intval');
$tagValue = $this->request->param('tagValue', '');
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
if (empty($poolCompanyId) || empty($tagDefineId)) {
return ResponseHelper::error('参数不完整');
}
// 验证流量归属
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
return ResponseHelper::error('流量不存在');
}
try {
$tag = TrafficPoolTag::addTag(
$poolCompanyId,
$poolCompany->identifier,
$companyId,
$tagDefineId,
TrafficPoolTag::SOURCE_MANUAL,
$userId,
$tagValue
);
if ($tag) {
return ResponseHelper::success(['id' => $tag->id], '添加成功');
} else {
return ResponseHelper::error('添加失败');
}
} catch (\Exception $e) {
return ResponseHelper::error('添加标签失败:' . $e->getMessage());
}
}
/**
* 移除流量标签
* @return \think\response\Json
*/
public function removeTag()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$tagDefineId = $this->request->param('tagDefineId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
if (empty($poolCompanyId) || empty($tagDefineId)) {
return ResponseHelper::error('参数不完整');
}
// 验证流量归属
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
return ResponseHelper::error('流量不存在');
}
try {
$result = TrafficPoolTag::removeTag($poolCompanyId, $tagDefineId);
if ($result) {
return ResponseHelper::success(null, '移除成功');
} else {
return ResponseHelper::error('标签不存在');
}
} catch (\Exception $e) {
return ResponseHelper::error('移除标签失败:' . $e->getMessage());
}
}
/**
* 获取流量的标签
* @return \think\response\Json
*/
public function getPoolTags()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$tagType = $this->request->param('tagType');
$companyId = $this->getUserInfo('companyId');
if (empty($poolCompanyId)) {
return ResponseHelper::error('流量ID不能为空');
}
// 验证流量归属
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
return ResponseHelper::error('流量不存在');
}
try {
$tags = TrafficPoolTag::getTagsByPoolCompany($poolCompanyId, $tagType);
return ResponseHelper::success($tags);
} catch (\Exception $e) {
return ResponseHelper::error('获取标签失败:' . $e->getMessage());
}
}
// ==================== 分配相关接口 ====================
/**
* 分配流量给客服
* @return \think\response\Json
*/
public function allocatePool()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$toWechatId = $this->request->param('toWechatId', '');
$toAccountId = $this->request->param('toAccountId', 0, 'intval');
$toUserId = $this->request->param('toUserId', 0, 'intval');
$expireDays = $this->request->param('expireDays', 30, 'intval');
$companyId = $this->getUserInfo('companyId');
$operatorId = $this->getUserInfo('id');
if (empty($poolCompanyId) || empty($toWechatId)) {
return ResponseHelper::error('参数不完整');
}
// 验证流量归属
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
return ResponseHelper::error('流量不存在');
}
try {
$fromInfo = [
'fromWechatId' => $poolCompany->ownerWechatId,
'fromAccountId' => $poolCompany->ownerAccountId,
'fromUserId' => $poolCompany->ownerUserId,
];
$record = TrafficPoolAllotRecord::createAllotRecord(
$poolCompanyId,
$poolCompany->identifier,
$companyId,
$toWechatId,
$toAccountId ?: null,
$toUserId ?: null,
$expireDays,
$operatorId,
$fromInfo
);
return ResponseHelper::success(['id' => $record->id], '分配成功');
} catch (\Exception $e) {
return ResponseHelper::error('分配失败:' . $e->getMessage());
}
}
/**
* 回收流量分配
* @return \think\response\Json
*/
public function recyclePool()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
$operatorId = $this->getUserInfo('id');
if (empty($poolCompanyId)) {
return ResponseHelper::error('流量ID不能为空');
}
// 验证流量归属
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
return ResponseHelper::error('流量不存在');
}
try {
TrafficPoolAllotRecord::recycleAllot($poolCompanyId, $operatorId);
return ResponseHelper::success(null, '回收成功');
} catch (\Exception $e) {
return ResponseHelper::error('回收失败:' . $e->getMessage());
}
}
// ==================== 统计相关接口 ====================
/**
* 获取流量池统计数据
* @return \think\response\Json
*/
public function getStatistics()
{
$companyId = $this->getUserInfo('companyId');
try {
$statistics = $this->poolService->getStatistics($companyId);
return ResponseHelper::success($statistics);
} catch (\Exception $e) {
return ResponseHelper::error('获取统计数据失败:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace app\cunkebao\controller\tag;
use app\common\service\TagEngineService;
use library\ResponseHelper;
use think\Controller;
use think\Validate;
/**
* 通过标识查询标签控制器
*/
class QueryTagsByIdentifiersController extends Controller
{
/**
* 通过标识查询标签
*
* @return \think\response\Json
*/
public function index()
{
try {
// 获取请求参数
$identifiers = $this->request->param('identifiers', []);
$options = $this->request->param('options', []);
// 参数验证
$validate = Validate::make([
'identifiers' => 'require|array',
'identifiers.*' => 'array',
]);
if (!$validate->check(['identifiers' => $identifiers])) {
throw new \Exception($validate->getError(), 400);
}
// 验证标识格式
foreach ($identifiers as $key => $identifier) {
if (!isset($identifier['type']) || !isset($identifier['value'])) {
throw new \Exception("标识[{$key}]格式错误必须包含type和value字段", 400);
}
// 验证标识类型
$allowedTypes = ['phone', 'id_card', 'wechat', 'qq'];
if (!in_array($identifier['type'], $allowedTypes)) {
throw new \Exception("标识[{$key}]类型不支持,仅支持:" . implode(', ', $allowedTypes), 400);
}
// 验证值不为空
if (empty($identifier['value'])) {
throw new \Exception("标识[{$key}]的值不能为空", 400);
}
}
// 限制数量
if (count($identifiers) > 100) {
throw new \Exception('单次最多查询100个标识', 400);
}
// 调用标签引擎服务
$service = new TagEngineService();
$result = $service->queryByIdentifiers($identifiers, $options);
if ($result === false) {
throw new \Exception('查询标签失败', 500);
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500);
}
return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
/**
* 快捷方法:通过手机号查询标签
*
* @return \think\response\Json
*/
public function byPhone()
{
try {
// 获取请求参数
$phones = $this->request->param('phones', []);
$options = $this->request->param('options', []);
// 参数验证
if (empty($phones)) {
throw new \Exception('手机号不能为空', 400);
}
// 如果是字符串,转为数组
if (is_string($phones)) {
$phones = explode(',', $phones);
}
if (!is_array($phones)) {
throw new \Exception('手机号格式错误', 400);
}
// 调用标签引擎服务
$service = new TagEngineService();
$result = $service->queryByPhone($phones, $options);
if ($result === false) {
throw new \Exception('查询标签失败', 500);
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500);
}
return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
/**
* 快捷方法:通过微信号查询标签
*
* @return \think\response\Json
*/
public function byWechat()
{
try {
// 获取请求参数
$wechats = $this->request->param('wechats', []);
$options = $this->request->param('options', []);
// 参数验证
if (empty($wechats)) {
throw new \Exception('微信号不能为空', 400);
}
// 如果是字符串,转为数组
if (is_string($wechats)) {
$wechats = explode(',', $wechats);
}
if (!is_array($wechats)) {
throw new \Exception('微信号格式错误', 400);
}
// 调用标签引擎服务
$service = new TagEngineService();
$result = $service->queryByWechat($wechats, $options);
if ($result === false) {
throw new \Exception('查询标签失败', 500);
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500);
}
return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace app\cunkebao\controller\tag;
use app\common\service\TagEngineService;
use library\ResponseHelper;
use think\Controller;
use think\Validate;
/**
* 通过标签查询用户控制器
*/
class QueryUsersByTagsController extends Controller
{
/**
* 通过标签查询用户
*
* @return \think\response\Json
*/
public function index()
{
try {
// 获取请求参数
$tagConditions = $this->request->param('tag_conditions', []);
$logic = $this->request->param('logic', 'AND');
$includeSensitive = $this->request->param('include_sensitive', false);
$page = $this->request->param('page', 1);
$pageSize = $this->request->param('page_size', 20);
// 参数验证
$validate = Validate::make([
'tag_conditions' => 'require|array',
'tag_conditions.*' => 'array',
'logic' => 'in:AND,OR',
'page' => 'number|>=:1',
'page_size' => 'number|between:1,100',
]);
$params = [
'tag_conditions' => $tagConditions,
'logic' => $logic,
'page' => $page,
'page_size' => $pageSize,
];
if (!$validate->check($params)) {
throw new \Exception($validate->getError(), 400);
}
// 验证标签条件格式
foreach ($tagConditions as $key => $condition) {
if (!isset($condition['tag_code']) || !isset($condition['operator']) || !isset($condition['value'])) {
throw new \Exception("标签条件[{$key}]格式错误必须包含tag_code、operator和value字段", 400);
}
// 验证操作符
$allowedOperators = ['=', '!=', '>', '>=', '<', '<=', 'in', 'not_in'];
if (!in_array($condition['operator'], $allowedOperators)) {
throw new \Exception("标签条件[{$key}]操作符不支持,仅支持:" . implode(', ', $allowedOperators), 400);
}
// 验证 in/not_in 的值必须是数组
if (in_array($condition['operator'], ['in', 'not_in']) && !is_array($condition['value'])) {
throw new \Exception("标签条件[{$key}]使用{$condition['operator']}操作符时value必须是数组", 400);
}
}
// 限制条件数量
if (count($tagConditions) > 10) {
throw new \Exception('单次最多10个标签条件', 400);
}
// 调用标签引擎服务
$service = new TagEngineService();
$result = $service->queryUsersByTags($tagConditions, $logic, $includeSensitive, $page, $pageSize);
if ($result === false) {
throw new \Exception('查询用户失败', 500);
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500);
}
return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
/**
* 快捷方法:查询高价值用户(示例)
* 查询累计消费金额 >= 5000 的用户
*
* @return \think\response\Json
*/
public function highValueUsers()
{
try {
$page = $this->request->param('page', 1);
$pageSize = $this->request->param('page_size', 20);
$minAmount = $this->request->param('min_amount', 5000);
$tagConditions = [
[
'tag_code' => 'user.trade.total_amount',
'operator' => '>=',
'value' => strval($minAmount)
]
];
// 调用标签引擎服务
$service = new TagEngineService();
$result = $service->queryUsersByTags($tagConditions, 'AND', false, $page, $pageSize);
if ($result === false) {
throw new \Exception('查询用户失败', 500);
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500);
}
return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
/**
* 快捷方法查询VIP用户示例
* 查询用户等级为 VIP、SVIP 或金卡会员的用户
*
* @return \think\response\Json
*/
public function vipUsers()
{
try {
$page = $this->request->param('page', 1);
$pageSize = $this->request->param('page_size', 20);
$levels = $this->request->param('levels', ['VIP', 'SVIP', '金卡会员']);
// 如果是字符串,转为数组
if (is_string($levels)) {
$levels = explode(',', $levels);
}
$tagConditions = [
[
'tag_code' => 'user.trade.level',
'operator' => 'in',
'value' => $levels
]
];
// 调用标签引擎服务
$service = new TagEngineService();
$result = $service->queryUsersByTags($tagConditions, 'AND', false, $page, $pageSize);
if ($result === false) {
throw new \Exception('查询用户失败', 500);
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '查询失败', $result['code'] ?? 500);
}
return ResponseHelper::success($result['data'] ?? $result, $result['message'] ?? '查询成功');
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode() ?: 500);
}
}
}

View File

@@ -0,0 +1,650 @@
<?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.friendStatus',
'tpc.level',
'tpc.lastInteractTime',
'tpc.rfmF',
'tpc.rfmM',
'tpc.totalMsgCount',
'tpc.totalOrderAmount',
'tpc.realName',
'tpc.phone',
'tp.nickname',
'tp.avatar',
'tp.wechatId',
'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.friendStatus',
'tpc.level',
'tpc.lastInteractTime',
'tpc.rfmF',
'tpc.rfmM',
'tpc.totalMsgCount',
'tpc.totalOrderAmount',
'tpc.realName',
'tpc.phone',
'tpc.createTime as addTime',
'tp.nickname',
'tp.avatar',
'tp.wechatId'
])
->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') {
// 字段条件
$field = 'tpc.' . $condition['field'];
$operator = $condition['operator'];
$value = $condition['value'];
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 = [];
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);
$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')
->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')
->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
];
}
}

View File

@@ -0,0 +1,507 @@
<?php
namespace app\cunkebao\service;
use app\common\model\TrafficPoolV2;
use app\common\model\TrafficPoolCompany;
use app\common\model\TrafficPoolSource;
use app\common\model\TrafficPoolBehavior;
use app\common\model\TrafficPoolTag;
use app\common\model\TrafficPoolAllotRecord;
use think\Db;
/**
* 流量池核心服务类
* 处理流量的入池、查询、更新等核心业务逻辑
*/
class TrafficPoolService
{
/**
* 流量入池(核心方法)
*
* @param string $identifier 唯一标识微信ID优先
* @param int $companyId 公司ID
* @param int $sourceType 来源类型
* @param array $poolData 流量总表数据
* @param array $companyData 公司流量数据
* @param array $sourceData 来源数据
* @return array [poolId, poolCompanyId]
*/
public function enterPool(
string $identifier,
int $companyId,
int $sourceType,
array $poolData = [],
array $companyData = [],
array $sourceData = []
) {
Db::startTrans();
try {
// 1. 查找或创建总表记录
$pool = TrafficPoolV2::findOrCreateByIdentifier($identifier, $poolData);
// 2. 查找或创建公司记录
$poolCompany = TrafficPoolCompany::findOrCreateByIdentifierAndCompany(
$identifier,
$companyId,
$pool->id,
$companyData
);
// 3. 创建来源记录
TrafficPoolSource::createSource(
$poolCompany->id,
$identifier,
$companyId,
$sourceType,
$sourceData
);
Db::commit();
return [
'poolId' => $pool->id,
'poolCompanyId' => $poolCompany->id
];
} catch (\Exception $e) {
Db::rollback();
throw $e;
}
}
/**
* 好友通过时同步流量信息
*
* @param string $identifier 微信ID
* @param int $companyId 公司ID
* @param int $wechatFriendId 微信好友表ID
* @param array $friendData 好友数据
* @return TrafficPoolCompany|null
*/
public function syncFriendPass(string $identifier, int $companyId, int $wechatFriendId, array $friendData = [])
{
// 查找流量池记录
$poolCompany = TrafficPoolCompany::where('identifier', $identifier)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
// 如果不存在,先入池
$result = $this->enterPool(
$identifier,
$companyId,
TrafficPoolSource::SOURCE_TYPE_FRIEND_ADD,
$friendData,
array_merge($friendData, [
'wechatFriendId' => $wechatFriendId,
'friendStatus' => TrafficPoolCompany::FRIEND_STATUS_PASSED,
'friendPassTime' => time()
])
);
$poolCompany = TrafficPoolCompany::find($result['poolCompanyId']);
} else {
// 更新现有记录
$poolCompany->save([
'wechatFriendId' => $wechatFriendId,
'friendStatus' => TrafficPoolCompany::FRIEND_STATUS_PASSED,
'friendPassTime' => time(),
'updateTime' => time()
]);
}
// 更新总表基础信息
if (!empty($friendData)) {
$pool = TrafficPoolV2::find($poolCompany->poolId);
if ($pool) {
$pool->updateBasicInfo($friendData);
}
}
return $poolCompany;
}
/**
* 同步微信标签
*
* @param int $poolCompanyId 公司流量ID
* @param array $labels 标签数组
* @return int 同步数量
*/
public function syncWechatTags(int $poolCompanyId, array $labels)
{
$poolCompany = TrafficPoolCompany::find($poolCompanyId);
if (!$poolCompany) {
return 0;
}
return TrafficPoolTag::syncWechatTags(
$poolCompanyId,
$poolCompany->identifier,
$poolCompany->companyId,
$labels
);
}
/**
* 获取流量池列表(分页)
*
* @param int $companyId 公司ID
* @param int $page 页码
* @param int $pageSize 每页数量
* @param array $filters 筛选条件
* @return array
*/
public function getPoolList(int $companyId, int $page = 1, int $pageSize = 10, array $filters = [])
{
$query = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpc.companyId', $companyId)
->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('tp.wechatAlias', 'like', "%{$keyword}%")
->whereOr('tp.mobile', 'like', "%{$keyword}%")
->whereOr('tpc.realName', 'like', "%{$keyword}%")
->whereOr('tpc.phone', 'like', "%{$keyword}%");
});
}
if (isset($filters['friendStatus'])) {
$query->where('tpc.friendStatus', $filters['friendStatus']);
}
if (isset($filters['level'])) {
$query->where('tpc.level', $filters['level']);
}
if (isset($filters['lifecycle'])) {
$query->where('tpc.lifecycle', $filters['lifecycle']);
}
if (isset($filters['allocateStatus'])) {
$query->where('tpc.allocateStatus', $filters['allocateStatus']);
}
if (!empty($filters['ownerWechatId'])) {
$query->where('tpc.ownerWechatId', $filters['ownerWechatId']);
}
// RFM 筛选
if (isset($filters['rfmMMin'])) {
$query->where('tpc.rfmM', '>=', $filters['rfmMMin']);
}
if (isset($filters['rfmMMax'])) {
$query->where('tpc.rfmM', '<=', $filters['rfmMMax']);
}
// 统计总数
$total = $query->count();
// 查询列表
$list = $query->field([
'tpc.id',
'tpc.poolId',
'tpc.identifier',
'tpc.companyId',
'tpc.friendStatus',
'tpc.level',
'tpc.intentionLevel',
'tpc.lifecycle',
'tpc.lastInteractTime',
'tpc.rfmF',
'tpc.rfmM',
'tpc.totalMsgCount',
'tpc.totalOrderCount',
'tpc.totalOrderAmount',
'tpc.ownerWechatId',
'tpc.allocateStatus',
'tpc.realName',
'tpc.phone',
'tpc.createTime',
'tp.nickname',
'tp.avatar',
'tp.gender',
'tp.wechatId',
'tp.wechatAlias',
'tp.mobile',
'tp.region'
])
->order('tpc.id DESC')
->page($page, $pageSize)
->select();
// 获取标签
$poolCompanyIds = array_column($list->toArray(), 'id');
$tags = $this->getTagsForPoolCompanies($poolCompanyIds);
// 组装数据
$result = [];
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'], $data['rfmM']);
// 添加标签
$data['tags'] = $tags[$item->id] ?? [];
$result[] = $data;
}
return [
'list' => $result,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize
];
}
/**
* 获取流量详情
*
* @param int $poolCompanyId 公司流量ID
* @param int $companyId 公司ID
* @return array|null
*/
public function getPoolDetail(int $poolCompanyId, int $companyId)
{
$poolCompany = TrafficPoolCompany::alias('tpc')
->join('ck_traffic_pool tp', 'tp.id = tpc.poolId', 'LEFT')
->where('tpc.id', $poolCompanyId)
->where('tpc.companyId', $companyId)
->where('tpc.isDel', 0)
->field('tpc.*, tp.nickname, tp.avatar, tp.gender, tp.wechatId, tp.wechatAlias, tp.mobile, tp.region, tp.country, tp.province, tp.city, tp.signature')
->find();
if (!$poolCompany) {
return null;
}
$data = $poolCompany->toArray();
// 获取标签
$data['tags'] = TrafficPoolTag::getTagsByPoolCompany($poolCompanyId)->toArray();
// 获取来源历史
$data['sources'] = TrafficPoolSource::getSourcesByPoolCompany($poolCompanyId)->toArray();
// 获取行为轨迹最近50条
$data['behaviors'] = TrafficPoolBehavior::getUserJourney($poolCompanyId, 50)->toArray();
// 获取分配历史
$data['allotRecords'] = TrafficPoolAllotRecord::getAllotHistory($poolCompanyId)->toArray();
// 计算 RFM
$data['rfmR'] = $poolCompany->lastInteractTime ? (int)floor((time() - $poolCompany->lastInteractTime) / 86400) : 9999;
$data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'], $data['rfmM']);
return $data;
}
/**
* 更新流量信息
*
* @param int $poolCompanyId 公司流量ID
* @param int $companyId 公司ID
* @param array $data 更新数据
* @return bool
*/
public function updatePool(int $poolCompanyId, int $companyId, array $data)
{
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
return false;
}
// 允许更新的字段
$allowFields = [
'realName', 'phone', 'email', 'birthday', 'address',
'company', 'position', 'remark', 'customFields',
'level', 'intentionLevel', 'lifecycle', 'status'
];
$updateData = array_intersect_key($data, array_flip($allowFields));
$updateData['updateTime'] = time();
return $poolCompany->save($updateData);
}
/**
* 批量获取流量的标签
*
* @param array $poolCompanyIds
* @return array
*/
protected function getTagsForPoolCompanies(array $poolCompanyIds)
{
if (empty($poolCompanyIds)) {
return [];
}
$tags = TrafficPoolTag::whereIn('poolCompanyId', $poolCompanyIds)
->where('isDel', 0)
->select();
$result = [];
foreach ($tags as $tag) {
if (!isset($result[$tag->poolCompanyId])) {
$result[$tag->poolCompanyId] = [];
}
$result[$tag->poolCompanyId][] = [
'id' => $tag->id,
'tagDefineId' => $tag->tagDefineId,
'tagName' => $tag->tagName,
'tagType' => $tag->tagType,
'tagValue' => $tag->tagValue
];
}
return $result;
}
/**
* 计算 RFM 评分
*
* @param int $r 最后互动距今天数
* @param int $f 互动频次
* @param float $m 消费金额
* @return array
*/
public 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
* @return array
*/
public function getStatistics(int $companyId)
{
$today = strtotime('today');
$yesterday = strtotime('yesterday');
$thisWeek = strtotime('monday this week');
$thisMonth = strtotime('first day of this month');
// 总流量数
$totalCount = TrafficPoolCompany::where('companyId', $companyId)
->where('isDel', 0)
->count();
// 好友数
$friendCount = TrafficPoolCompany::where('companyId', $companyId)
->where('friendStatus', TrafficPoolCompany::FRIEND_STATUS_PASSED)
->where('isDel', 0)
->count();
// 今日新增
$todayNewCount = TrafficPoolCompany::where('companyId', $companyId)
->where('createTime', '>=', $today)
->where('isDel', 0)
->count();
// 本周新增
$weekNewCount = TrafficPoolCompany::where('companyId', $companyId)
->where('createTime', '>=', $thisWeek)
->where('isDel', 0)
->count();
// 本月新增
$monthNewCount = TrafficPoolCompany::where('companyId', $companyId)
->where('createTime', '>=', $thisMonth)
->where('isDel', 0)
->count();
// 客户等级分布
$levelDistribution = TrafficPoolCompany::where('companyId', $companyId)
->where('isDel', 0)
->group('level')
->field('level, COUNT(*) as count')
->select()
->toArray();
// 生命周期分布
$lifecycleDistribution = TrafficPoolCompany::where('companyId', $companyId)
->where('isDel', 0)
->group('lifecycle')
->field('lifecycle, COUNT(*) as count')
->select()
->toArray();
// 来源分布
$sourceDistribution = TrafficPoolSource::where('companyId', $companyId)
->where('isFirstSource', 1)
->group('sourceType')
->field('sourceType, COUNT(*) as count')
->select()
->toArray();
return [
'totalCount' => $totalCount,
'friendCount' => $friendCount,
'todayNewCount' => $todayNewCount,
'weekNewCount' => $weekNewCount,
'monthNewCount' => $monthNewCount,
'levelDistribution' => $levelDistribution,
'lifecycleDistribution' => $lifecycleDistribution,
'sourceDistribution' => $sourceDistribution
];
}
}

File diff suppressed because it is too large Load Diff