13 Commits

Author SHA1 Message Date
wong
1a28c9ae75 代码优化 2026-04-29 16:33:23 +08:00
wong
edfc21c373 111111 2026-04-15 17:21:12 +08:00
wong
b1bfcec35b feat(media): 微信消息媒体 OSS 归档与下载地址回写
- 新增 MediaArchiveJob、MediaOssArchiveService、WechatMediaArchiveService 与回填命令
- Message/DataProcessing 等支持归档调度与持久化下载 URL
- WebSocket 控制器整理;朋友圈与文档/定时任务说明更新

Made-with: Cursor
2026-04-13 17:23:02 +08:00
wong
3bf1b2aee9 11111 2026-04-09 12:30:38 +08:00
wong
29413f57c7 1111 2026-03-24 10:38:29 +08:00
wong
1853934d85 代码提交 2026-03-24 10:34:26 +08:00
wong
2855ab80fb 新版流量池提交 2026-02-04 11:02:33 +08:00
wong
a20794366a 1、豆包新增生成图片功能
2、消息优化
3、场景获客新增全局配置
4、工作台新增全局配置
2026-01-15 14:31:12 +08:00
Ghost
1566f8fb7c 入群欢迎语功能提交 2026-01-12 09:43:25 +08:00
Ghost
9462e6630c 场景获客支持拉群 2026-01-08 10:47:13 +08:00
Ghost
2fe455e7b3 1、新增一个所有好友的流量池
2、旧版场景获客数据迁移
3、场景获客功能兼容旧版数据
2026-01-07 10:43:09 +08:00
Ghost
a184a76fea 群发功能优化 2026-01-06 11:08:32 +08:00
Ghost
b101c45ab3 Merge tag 'v1.1.3' into develop 2026-01-05 10:27:09 +08:00
98 changed files with 13567 additions and 11662 deletions

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ nginx.htaccess
.cursor/
thinkphp/
public/static/
*.log
Server.code-workspace

View File

@@ -44,7 +44,7 @@
```bash
# 每分钟执行一次调度器(调度器内部会根据 cron 表达式判断哪些任务需要执行)
* * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think scheduler:run >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/scheduler.log 2>&1
* * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think scheduler:run >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/scheduler.log 2>&1
```
### 4. 系统要求

View File

@@ -13,6 +13,7 @@ Route::group('v1/ai', function () {
//豆包ai
Route::group('doubao', function () {
Route::post('text', 'app\ai\controller\DouBaoAI@text');
Route::post('text', 'app\ai\controller\DouBaoAI@text'); // 文本生成
Route::post('image', 'app\ai\controller\DouBaoAI@image'); // 图片生成
});
})->middleware(['jwt']);

View File

@@ -49,7 +49,7 @@ class DouBaoAI extends Controller
],
];
}
$result = requestCurl($this->apiUrl, $params, 'POST', $this->headers, 'json');
$result = requestCurl($this->apiUrl.'/api/v3/chat/completions', $params, 'POST', $this->headers, 'json');
$result = json_decode($result, true);
if(isset($result['error'])){
$error = $result['error'];
@@ -64,5 +64,211 @@ class DouBaoAI extends Controller
}
/**
* 图片生成功能(基于火山方舟 Seedream 4.0-4.5 API
* 参考文档https://www.volcengine.com/docs/82379/1541523?lang=zh
*
* @param array $params 请求参数,如果为空则从请求中获取
* @return string JSON格式的响应
*/
public function image($params = [])
{
try {
// 如果参数为空,从请求中获取
if (empty($params)){
$content = $this->request->param('content', '');
$model = $this->request->param('model', 'doubao-seedream-4-5-251128');
$size = $this->request->param('size', '16:9'); // 支持档位(1K/2K/4K)、比例(16:9/9:16等)、像素(1280x720等)
$responseFormat = $this->request->param('response_format', 'url'); // url 或 b64_json
$sequentialImageGeneration = $this->request->param('sequential_image_generation', 'disabled'); // enabled 或 disabled
$watermark = $this->request->param('watermark', true); // true 或 false
// 参数验证
if(empty($content)){
return json_encode(['code' => 500, 'msg' => '提示词prompt不能为空']);
}
// 验证和规范化尺寸参数
$size = $this->validateAndNormalizeSize($size);
if(!in_array($responseFormat, ['url', 'b64_json'])){
$responseFormat = 'url';
}
if(!in_array($sequentialImageGeneration, ['enabled', 'disabled'])){
$sequentialImageGeneration = 'disabled';
}
// 构建请求参数(根据火山方舟文档)
$params = [
'model' => $model,
'prompt' => $content,
'sequential_image_generation' => $sequentialImageGeneration,
'response_format' => $responseFormat,
'size' => $size,
'stream' => false,
'watermark' => true
];
}
// 确保API URL正确图片生成API的endpoint
$imageApiUrl = $this->apiUrl. '/api/v3/images/generations';
// 发送请求
$result = requestCurl($imageApiUrl, $params, 'POST', $this->headers, 'json');
$result = json_decode($result, true);
// 错误处理
if(isset($result['error'])){
$error = $result['error'];
$errorMsg = isset($error['message']) ? $error['message'] : '图片生成失败';
$errorCode = isset($error['code']) ? $error['code'] : 'unknown';
\think\facade\Log::error('火山方舟图片生成失败', [
'error' => $error,
'params' => $params
]);
return json_encode([
'code' => 500,
'msg' => $errorMsg,
'error_code' => $errorCode
]);
}
// 成功响应处理(根据火山方舟文档的响应格式)
if(isset($result['data']) && is_array($result['data']) && !empty($result['data'])){
$imageData = $result['data'][0];
// 根据 response_format 获取图片数据
$imageUrl = null;
$imageB64 = null;
if(isset($imageData['url'])){
$imageUrl = $imageData['url'];
}
if(isset($imageData['b64_json'])){
$imageB64 = $imageData['b64_json'];
}
// 计算token如果有usage信息
$token = 0;
if(isset($result['usage']['total_tokens'])){
$token = intval($result['usage']['total_tokens']) * 20;
}
// 构建返回数据
$responseData = [
'token' => $token,
'image_url' => $imageUrl,
'image_b64' => $imageB64,
'model' => $params['model'] ?? '',
'size' => $params['size'] ?? '2K',
'created' => isset($result['created']) ? $result['created'] : time()
];
// 根据请求的response_format返回对应的数据
if($params['response_format'] == 'url' && $imageUrl){
$responseData['content'] = $imageUrl;
} elseif($params['response_format'] == 'b64_json' && $imageB64){
$responseData['content'] = $imageB64;
}
return json_encode([
'code' => 200,
'msg' => '图片生成成功',
'data' => $responseData
]);
} else {
// 响应格式不符合预期
\think\facade\Log::warning('火山方舟图片生成响应格式异常', [
'result' => $result,
'params' => $params
]);
return json_encode([
'code' => 500,
'msg' => '图片生成响应格式异常',
'raw_response' => $result
]);
}
} catch (\Exception $e) {
\think\facade\Log::error('火山方舟图片生成异常', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return json_encode([
'code' => 500,
'msg' => '图片生成异常:' . $e->getMessage()
]);
}
}
/**
* 验证和规范化尺寸参数
* 支持三种格式:
* 1. 档位形式1K, 2K, 4K不区分大小写
* 2. 比例形式16:9, 9:16, 1:1, 4:3, 3:4 等
* 3. 像素形式1280x720, 2048x2048 等宽度1280-4096高度720-4096宽高比0.0625-16
*
* @param string $size 尺寸参数
* @return string 规范化后的尺寸值
*/
private function validateAndNormalizeSize($size)
{
if (empty($size)) {
return '2K';
}
$size = trim($size);
// 1. 检查是否为档位形式1K, 2K, 4K
$sizeUpper = strtoupper($size);
if (in_array($sizeUpper, ['1K', '2K', '4K'])) {
return $sizeUpper;
}
// 2. 检查是否为比例形式(如 16:9, 9:16, 1:1
if (preg_match('/^(\d+):(\d+)$/', $size, $matches)) {
$width = intval($matches[1]);
$height = intval($matches[2]);
if ($width > 0 && $height > 0) {
$ratio = $width / $height;
// 验证宽高比范围0.0625 ~ 16
if ($ratio >= 0.0625 && $ratio <= 16) {
return $size; // 返回比例形式,如 "16:9"
}
}
}
// 3. 检查是否为像素形式(如 1280x720, 2048x2048
if (preg_match('/^(\d+)x(\d+)$/i', $size, $matches)) {
$width = intval($matches[1]);
$height = intval($matches[2]);
// 验证宽度范围1280 ~ 4096
if ($width < 1280 || $width > 4096) {
return '2K'; // 默认返回 2K
}
// 验证高度范围720 ~ 4096
if ($height < 720 || $height > 4096) {
return '2K'; // 默认返回 2K
}
// 验证宽高比范围0.0625 ~ 16
$ratio = $width / $height;
if ($ratio < 0.0625 || $ratio > 16) {
return '2K'; // 默认返回 2K
}
return $size; // 返回像素形式,如 "1280x720"
}
// 如果都不匹配,返回默认值
return '2K';
}
}

View File

@@ -60,12 +60,24 @@ class AccountController extends BaseController
$result = requestCurl($this->baseUrl . 'api/Account/myTenantPageAccounts', $params, 'GET', $header);
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 保存数据到数据库
if (!empty($response['results'])) {
foreach ($response['results'] as $item) {
if (is_array($item)) {
$this->saveAccount($item);
}
}
}
if ($isInner) {
return json_encode(['code' => 200, 'msg' => '获取公司账号列表成功', 'data' => $response]);

View File

@@ -41,13 +41,20 @@ class AllotRuleController extends BaseController
$result = requestCurl($this->baseUrl . 'api/AllotRule/all', [], 'GET', $header);
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 保存数据到数据库
if (!empty($response)) {
AllotRuleModel::where('1=1')->update(['isDel' => 1]);
foreach ($response as $item) {
if (is_array($item)) {
$this->saveAllotRule($item);
}
}
}
if ($isInner) {
return json_encode(['code' => 200, 'msg' => 'success', 'data' => $response]);

View File

@@ -68,12 +68,24 @@ class CallRecordingController extends BaseController
$result = requestCurl($this->baseUrl . 'api/CallRecording/list', $params, 'GET', $header);
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 保存数据到数据库
if (!empty($response['results'])) {
foreach ($response['results'] as $item) {
if (is_array($item)) {
$this->saveCallRecording($item);
}
}
}
if ($isInner) {
return json_encode(['code' => 200, 'msg' => '获取通话记录列表成功', 'data' => $response]);

View File

@@ -74,12 +74,24 @@ class DeviceController extends BaseController
$result = requestCurl($this->baseUrl . 'api/device/pageResult', $params, 'GET', $header);
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 保存数据到数据库
if (!empty($response['results'])) {
foreach ($response['results'] as $item) {
if (is_array($item)) {
$this->saveDevice($item);
}
}
}
if($isInner){
return json_encode(['code'=>200,'msg'=>'success','data'=>$response]);
@@ -467,12 +479,19 @@ class DeviceController extends BaseController
// 发送请求
$result = requestCurl($this->baseUrl . 'api/DeviceGroup/list', [], 'GET', $header,'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 保存数据到数据库
if (!empty($response)) {
foreach ($response as $item) {
if (is_array($item)) {
$this->saveDeviceGroup($item);
}
}
}
if($isInner){
return json_encode(['code'=>200,'msg'=>'success','data'=>$response]);
}else{

View File

@@ -46,13 +46,24 @@ class FriendTaskController extends BaseController
$result = requestCurl($this->baseUrl . 'api/AddFriendByPhoneTask/list', $params, 'GET', $header,'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 保存数据到数据库
if (!empty($response['results'])) {
foreach ($response['results'] as $item) {
if (is_array($item)) {
$this->saveFriendTask($item);
}
}
}
if($isInner){
return json_encode(['code'=>200,'msg'=>'获取添加好友记录列表成功','data'=>$response]);
}else{

View File

@@ -3,7 +3,11 @@
namespace app\api\controller;
use app\api\model\WechatMessageModel;
use app\common\service\FriendTransferService;
use app\common\service\WechatMediaArchiveService;
use app\job\MediaArchiveJob;
use think\Db;
use think\facade\Log;
use think\facade\Request;
class MessageController extends BaseController
@@ -17,7 +21,7 @@ class MessageController extends BaseController
public function getFriendsList($pageIndex = '',$pageSize = '',$isInner = false)
{
// 获取授权token
$authorization = trim($this->request->header('authorization', $this->authorization));
$authorization = $this->authorization;
if (empty($authorization)) {
if($isInner){
return json_encode(['code'=>500,'msg'=>'缺少授权信息']);
@@ -26,7 +30,7 @@ class MessageController extends BaseController
}
}
$fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00', strtotime('-1 days')));
$fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00'));
$toTime = $this->request->param('toTime', date('Y-m-d 23:59:59'));
@@ -61,6 +65,17 @@ class MessageController extends BaseController
// 发送请求获取好友列表
$result = requestCurl($this->baseUrl . 'api/WechatFriend/listWechatFriendForMsgPagination', $params, 'POST', $header, 'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 获取同步消息标志
$syncMessages = $this->request->param('syncMessages', true);
// 如果需要同步消息,则获取每个好友的消息
@@ -88,12 +103,20 @@ class MessageController extends BaseController
// 调用获取消息的接口
$messageResult = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $messageParams, 'GET', $header, 'json');
$messageResponse = handleApiResponse($messageResult);
// 确保 messageResponse 是数组格式
if (!is_array($messageResponse)) {
$messageResponse = [];
}
// 保存消息到数据库
if (!empty($messageResponse)) {
foreach ($messageResponse as $item) {
if (is_array($item)) {
$this->saveMessage($item);
}
}
}
// 将消息列表添加到好友数据中
$friend['messages'] = $messageResponse ?? [];
@@ -158,12 +181,19 @@ class MessageController extends BaseController
$result = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $params, 'GET', $header, 'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 保存数据到数据库
if (!empty($response)) {
foreach ($response as $item) {
if (is_array($item)) {
$this->saveMessage($item);
}
}
}
return successJson($response);
} catch (\Exception $e) {
@@ -180,7 +210,8 @@ class MessageController extends BaseController
public function getChatroomList($pageIndex = '',$pageSize = '',$isInner = false)
{
// 获取授权token
$authorization = trim($this->request->header('authorization', $this->authorization));
$authorization = $this->authorization;
//$authorization = 'vIxE_SlpPqQLpG3maOL8VaPBDz_uoGqhK4HGR4VtxvtsjNkW9kP6RQicwsfX6lLXruq9UqyDV7wBU5iGT2OPv3t_GZKfVUv-PG_CL4zc6806GKhmT7QxFOXHLF0KH2VWlzVfo9i_MxsuPm9MqiuYwKDXKOpBwSemNL6vwYOrIkZBAcanG06rPEdSlrNcNyJiYrUpqZKDeQEgxE4o9WeYVczYLN8OS-p8Z57DXlVwW8CJCdLsFi7csBVT7uTreDJnAv7wraMRHB5FYs1U7vEmO9IbmsQhhdC1swMuz0kQIESr2zf11nBKEDEadMoH4HptIENXQQ';
if (empty($authorization)) {
if($isInner){
return json_encode(['code'=>500,'msg'=>'缺少授权信息']);
@@ -189,7 +220,7 @@ class MessageController extends BaseController
}
}
$fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00', strtotime('-1 days')));
$fromTime = $this->request->param('fromTime', date('Y-m-d 00:00:00'));
$toTime = $this->request->param('toTime', date('Y-m-d 23:59:59'));
@@ -224,11 +255,21 @@ class MessageController extends BaseController
$result = requestCurl($this->baseUrl . 'api/WechatChatroom/listWechatChatroomForMsgPagination', $params, 'POST', $header, 'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 获取同步消息标志
$syncMessages = $this->request->param('syncMessages', true);
// 如果需要同步消息,则获取每个群的消息
if ($syncMessages && !empty($response)) {
if ($syncMessages && !empty($response['results'])) {
$from = strtotime($fromTime) * 1000;
$to = strtotime($toTime) * 1000;
foreach ($response['results'] as &$chatroom) {
@@ -253,12 +294,19 @@ class MessageController extends BaseController
$messageResult = requestCurl($this->baseUrl . 'api/ChatroomMessage/searchMessage', $messageParams, 'GET', $header, 'json');
$messageResponse = handleApiResponse($messageResult);
// 确保 messageResponse 是数组格式
if (!is_array($messageResponse)) {
$messageResponse = [];
}
// 保存消息到数据库
if (!empty($messageResponse)) {
foreach ($messageResponse as $item) {
if (is_array($item)) {
$this->saveChatroomMessage($item);
}
}
}
// 将消息列表添加到群聊数据中
$chatroom['messages'] = $messageResponse ?? [];
@@ -324,15 +372,22 @@ class MessageController extends BaseController
$result = requestCurl($this->baseUrl . 'api/ChatroomMessage/searchMessage', $params, 'GET', $header, 'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 保存数据到数据库
if (!empty($response)) {
foreach ($response as $item) {
if (is_array($item)) {
$res = $this->saveChatroomMessage($item);
if(!$res){
return errorJson('保存群聊消息失败');
}
}
}
}
return successJson($response);
} catch (\Exception $e) {
@@ -349,7 +404,7 @@ class MessageController extends BaseController
public function saveMessage($item)
{
// 检查消息是否已存在
$exists = WechatMessageModel::where('id', $item['id']) ->find();
$exists = WechatMessageModel::where(['id'=> $item['id'],'type' => 1])->find();
if (!empty($exists) && $exists['sendStatus'] == 0){
return true;
@@ -366,6 +421,7 @@ class MessageController extends BaseController
'type' => 1,
'accountId' => $item['accountId'],
'content' => $item['content'],
'originalContent' => $item['content'],
'createTime' => $createTime,
'deleteTime' => $deleteTime,
'isDeleted' => $item['isDeleted'] ?? false,
@@ -389,38 +445,17 @@ class MessageController extends BaseController
if ($item['msgType'] == 10000 && strpos($item['content'],'开启了朋友验证') !== false) {
Db::table('s2_wechat_friend')->where('id',$item['wechatFriendId'])->update(['isDeleted'=> 1,'deleteTime' => $wechatTime]);
}else{
//优先分配在线客服
//优先分配在线客服 - 使用新的好友迁移服务
$friend = Db::table('s2_wechat_friend')->where('id',$item['wechatFriendId'])->find();
if (!empty($friend)){
$accountId = $item['accountId'];
$accountData = Db::table('s2_company_account')->where('id',$accountId)->find();
if (!empty($accountData)){
$account = new AccountController();
$account->getlist(['pageIndex' => 0,'pageSize' => 100,'departmentId' => $accountData['departmentId']]);
$accountIds = Db::table('s2_company_account')->where(['departmentId' => $accountData['departmentId'],'alive' => 1])->column('id');
if (!empty($accountIds)){
if (!in_array($friend['accountId'],$accountIds)){
// 执行切换好友命令
$randomKey = array_rand($accountIds, 1);
$toAccountId = $accountIds[$randomKey];
$toAccountData = Db::table('s2_company_account')->where('id',$toAccountId)->find();
$automaticAssign = new AutomaticAssign();
$automaticAssign->allotWechatFriend([
'wechatFriendId' => $friend['id'],
'toAccountId' => $toAccountId
], true);
Db::table('s2_wechat_friend')
->where('id',$friend['id'])
->update([
'accountId' => $toAccountId,
'accountUserName' => $toAccountData['userName'],
'accountRealName' => $toAccountData['realName'],
'accountNickname' => $toAccountData['nickname'],
]);
}
}
}
$friendTransferService = new FriendTransferService();
$result = $friendTransferService->transferFriend(
$item['wechatFriendId'],
$accountId,
'账号不在线,自动迁移到在线账号'
);
// 迁移结果已记录在服务中,这里不需要额外处理
}
}
@@ -432,6 +467,9 @@ class MessageController extends BaseController
}else{
$id = $data['id'];
unset($data['id']);
if (!empty($exists['originalContent'])) {
unset($data['originalContent']);
}
$res = $exists->save($data);
}
@@ -441,7 +479,13 @@ class MessageController extends BaseController
if (!empty($res) && empty($item['isSend']) && in_array($item['msgType'],[1,3,20,34,40,42,43,47,49])){
$friend = Db::name('wechat_friendship')->where('id',$item['wechatFriendId'])->find();
if (!empty($friend)){
$trafficPoolId = Db::name('traffic_pool')->where('identifier',$friend['wechatId'])->value('id');
// ========== 旧版流量池代码(已废弃) ==========
// $trafficPoolId = Db::name('traffic_pool_v1')->where('identifier',$friend['wechatId'])->value('id');
// ========== 新版流量池代码 ==========
$trafficPool = Db::name('traffic_pool')->where('identifier', $friend['wechatId'])->find();
$trafficPoolId = $trafficPool ? $trafficPool['id'] : null;
// ========== 旧版流量池代码结束 ==========
if (!empty($trafficPoolId)){
$data = [
'type' => 4,
@@ -459,6 +503,9 @@ class MessageController extends BaseController
}
}
}
if (in_array((int)($item['msgType'] ?? 0), [3, 34, 43, 47, 49], true) && !empty($id)) {
MediaArchiveJob::dispatch('message', $id, ['source' => 'saveMessage']);
}
return true;
}
@@ -469,9 +516,11 @@ class MessageController extends BaseController
*/
public function saveChatroomMessage($item)
{
// 检查消息是否已存在
$exists = WechatMessageModel::where('id', $item['id'])->find();
// 检查消息是否已存在(必须指定 type=2 表示群聊消息)
$exists = WechatMessageModel::where(['id' => $item['id'], 'type' => 2])->find();
// 如果消息已存在且 sendStatus == 0已发送则跳过更新
// 注意这里只跳过已发送的消息未发送的消息sendStatus != 0仍然需要更新
if (!empty($exists) && $exists['sendStatus'] == 0){
return true;
}
@@ -522,20 +571,59 @@ class MessageController extends BaseController
'recallId' => $item['recallId'] ?? false
];
// 创建新记录
// 创建或更新记录
try {
if(empty($exists)){
WechatMessageModel::create($data);
// 新记录,直接创建
$result = WechatMessageModel::create($data);
if (!$result) {
throw new \Exception('创建群聊消息记录失败');
}
}else{
// 已存在记录,更新(排除 id 字段)
unset($data['id']);
$exists->save($data);
$result = $exists->save($data);
if ($result === false) {
throw new \Exception('更新群聊消息记录失败');
}
}
if (in_array((int)($item['msgType'] ?? 0), [3, 34, 43, 47, 49], true)) {
MediaArchiveJob::dispatch('message', $item['id'], ['source' => 'saveChatroomMessage']);
}
return true;
} catch (\Exception $e) {
// 记录错误日志,便于调试
\think\facade\Log::error('保存群聊消息失败:' . $e->getMessage(), [
'message_id' => $item['id'] ?? '',
'data' => $data ?? []
]);
return false;
}
}
/**
* 持久化视频/文件下载后的真实地址,并重新触发 OSS 归档
* @param array $data
* @return bool
*/
public function updateDownloadedMessageMedia($data)
{
$messageId = (int)($data['friendMessageId'] ?? $data['chatroomMessageId'] ?? 0);
$downloadUrl = trim((string)($data['url'] ?? ''));
if ($messageId <= 0 || empty($downloadUrl)) {
return false;
}
$updated = WechatMediaArchiveService::updateDownloadedMessageMedia($messageId, $downloadUrl);
if (!$updated) {
return false;
}
MediaArchiveJob::dispatch('message', $messageId, ['source' => $data['type'] ?? 'download_result']);
return true;
}
/**
* 处理消息内容提取发送者ID和消息内容
* @param string $content 原始消息内容

View File

@@ -12,7 +12,7 @@ use think\facade\Env;
use app\api\model\WechatFriendModel as WechatFriend;
use app\api\model\WechatMomentsModel as WechatMoments;
use think\facade\Cache;
use app\common\util\AliyunOSS;
use app\common\service\MediaOssArchiveService;
class WebSocketController extends BaseController
@@ -239,7 +239,8 @@ class WebSocketController extends BaseController
$wechatAccountId = !empty($data['wechatAccountId']) ? $data['wechatAccountId'] : '';
$wechatFriendId = !empty($data['wechatFriendId']) ? $data['wechatFriendId'] : 0;
$prevSnsId = !empty($data['prevSnsId']) ? $data['prevSnsId'] : 0;
$maxPages = 1; // 最大页数限制为20
$isTimeline = !empty($data['isTimeline']) ? $data['isTimeline'] : false;
$maxPages = !empty($data['maxPages']) ? $data['maxPages'] : 1; // 最大页数限制为20
$currentPage = 1; // 当前页码
$allMoments = []; // 存储所有朋友圈数据
@@ -254,7 +255,7 @@ class WebSocketController extends BaseController
"cmdType" => "CmdFetchMoment",
"count" => $count,
"createTimeSec" => time(),
"isTimeline" => false,
"isTimeline" => $isTimeline,
"prevSnsId" => $prevSnsId,
"wechatAccountId" => $wechatAccountId,
"wechatFriendId" => $wechatFriendId,
@@ -467,7 +468,7 @@ class WebSocketController extends BaseController
// 更新数据库保存原始URL和OSS URL并标记已上传
$updateData = [
'resUrls' => $urls,
'isOssUploaded' => 1, // 标识已上传到OSS
'isOssUploaded' => !empty($ossUrls) ? 1 : 0,
'update_time' => time()
];
@@ -512,7 +513,7 @@ class WebSocketController extends BaseController
// 更新数据库保存原始URL和OSS URL并标记已上传
$updateData = [
'resUrls' => $urls,
'isOssUploaded' => 1, // 标识已上传到OSS
'isOssUploaded' => !empty($ossUrls) ? 1 : 0,
'update_time' => time()
];
@@ -557,75 +558,22 @@ class WebSocketController extends BaseController
return $ossUrls;
}
try {
// 创建临时目录(兼容无 runtime_path() 辅助函数的环境)
if (function_exists('runtime_path')) {
$baseRuntimePath = rtrim(runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
} elseif (defined('RUNTIME_PATH')) {
$baseRuntimePath = rtrim(RUNTIME_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
} else {
// 兜底:使用项目根目录下的 runtime 目录
$baseRuntimePath = rtrim(ROOT_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR;
}
$tempDir = $baseRuntimePath . 'temp' . DIRECTORY_SEPARATOR . 'moments' . DIRECTORY_SEPARATOR . date('Y' . DIRECTORY_SEPARATOR . 'm' . DIRECTORY_SEPARATOR . 'd') . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
foreach ($urls as $index => $url) {
if (empty($url)) {
foreach ($urls as $url) {
if (!MediaOssArchiveService::isRemoteHttpUrl($url)) {
continue;
}
try {
// 下载图片到临时文件
$tempFile = $tempDir . md5($url . $snsId . $index) . '.jpg';
// 使用curl下载图片
$ch = curl_init($url);
$fp = fopen($tempFile, 'wb');
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if ($httpCode != 200 || !file_exists($tempFile) || filesize($tempFile) == 0) {
Log::warning('下载朋友圈图片失败:' . $url . ', HTTP Code: ' . $httpCode);
@unlink($tempFile);
continue;
}
// 生成OSS对象名称
$objectName = 'moments/' . date('Y/m/d/') . md5($snsId . $index . time()) . '.jpg';
// 上传到OSS
$result = AliyunOSS::uploadFile($tempFile, $objectName);
if ($result['success']) {
$resourceType = preg_match('/\.(mp4|mov|avi|webm|mkv)(\?.*)?$/i', $url) ? 'video' : 'image';
$result = MediaOssArchiveService::archiveRemoteUrl($url, 'moments', $resourceType, (string)$snsId);
if (!empty($result['success']) && !empty($result['url'])) {
$ossUrls[] = $result['url'];
} else {
Log::error('朋友圈图片上传OSS失败' . $url . ', 错误:' . ($result['error'] ?? '未知错误'));
continue;
}
// 删除临时文件
@unlink($tempFile);
} catch (\Exception $e) {
Log::error('上传朋友圈图片到OSS异常' . $e->getMessage() . ', URL: ' . $url);
if (isset($tempFile) && file_exists($tempFile)) {
@unlink($tempFile);
}
}
}
} catch (\Exception $e) {
Log::error('上传朋友圈图片到OSS异常' . $e->getMessage());
Log::error('朋友圈媒体上传OSS失败' . ($result['error'] ?? '未知错误'), [
'snsId' => $snsId,
'url' => $url,
]);
}
return $ossUrls;
@@ -695,8 +643,8 @@ class WebSocketController extends BaseController
}
// 获取资源链接检查是否已上传到OSS如果已上传则跳过
if(empty($momentEntity['urls']) || $moment['type'] != 1) {
// 如果没有urls或类型不是1,跳过
if(empty($momentEntity['urls'])) {
// 如果没有urls跳过
} elseif ($isOssUploaded == 1) {
// 如果已上传到OSS跳过采集
} else {
@@ -805,11 +753,8 @@ class WebSocketController extends BaseController
"wechatChatroomId" => 0,
"wechatFriendId" => $dataArray['wechatFriendId'],
];
// 发送请求
$this->client->send(json_encode($params));
// 接收响应
$response = $this->client->receive();
$message = json_decode($response, true);
// 发送请求并获取响应
$message = $this->sendMessage($params);
if (!empty($message)) {
return json_encode(['code' => 200, 'msg' => '信息发送成功', 'data' => $message]);
}
@@ -853,12 +798,8 @@ class WebSocketController extends BaseController
"wechatChatroomId" => $dataArray['wechatChatroomId'],
"wechatFriendId" => 0,
];
// 发送请求
$this->client->send(json_encode($params));
// 接收响应
$response = $this->client->receive();
$message = json_decode($response, true);
// 发送请求并获取响应
$message = $this->sendMessage($params);
if (!empty($message)) {
return json_encode(['code' => 200, 'msg' => '信息发送成功', 'data' => $message]);
}
@@ -904,7 +845,7 @@ class WebSocketController extends BaseController
$message = [];
try {
//消息拼接 msgType(1:文本 3:图片 43:视频 47:动图表情包 49:小程序)
$result = [
$params = [
"cmdType" => "CmdSendMessage",
"content" => $dataArray['content'],
"msgSubType" => 0,
@@ -914,15 +855,10 @@ class WebSocketController extends BaseController
"wechatChatroomId" => $dataArray['wechatChatroomId'],
"wechatFriendId" => 0,
];
$result = json_encode($result);
$this->client->send($result);
$message = $this->client->receive();
//关闭WS链接
$this->client->close();
// 发送请求并获取响应
$message = $this->sendMessage($params);
//Log::write('WS群消息发送');
//Log::write($message);
$message = json_decode($message, 1);
} catch (\Exception $e) {
$msg = $e->getMessage();
}
@@ -1017,8 +953,8 @@ class WebSocketController extends BaseController
"seq" => time(),
"wechatAccountId" => $data['wechatAccountId'],
"chatroomName" => $data['chatroomName'],
// "wechatFriendIds" => $data['wechatFriendIds']
"wechatFriendIds" => [17453051,17453058]
"wechatFriendIds" => $data['wechatFriendIds']
//"wechatFriendIds" => [17453051,17453058]
];
$message = $this->sendMessage($params,false);
return json_encode(['code' => 200, 'msg' => '群聊创建成功', 'data' => $message]);

View File

@@ -5,7 +5,9 @@ namespace app\api\controller;
use app\api\model\WechatChatroomModel;
use app\api\model\WechatChatroomMemberModel;
use app\job\WechatChatroomJob;
use app\job\WorkbenchGroupWelcomeJob;
use think\facade\Request;
use think\Queue;
class WechatChatroomController extends BaseController
{
@@ -51,16 +53,28 @@ class WechatChatroomController extends BaseController
$result = requestCurl($this->baseUrl . 'api/WechatChatroom/pagelist', $params, 'GET', $header,'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 保存数据到数据库
if (!empty($response['results'])) {
$isUpdate = false;
foreach ($response['results'] as $item) {
if (is_array($item)) {
$updated = $this->saveChatroom($item);
if($updated && $isDel == 0){
$isUpdate = true;
}
}
}
}
if($isInner){
return json_encode(['code'=>200,'msg'=>'success','data'=>$response,'isUpdate'=>$isUpdate]);
@@ -172,12 +186,19 @@ class WechatChatroomController extends BaseController
$result = requestCurl($this->baseUrl . 'api/WechatChatroom/listChatroomMember', $params, 'GET', $header);
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 保存数据到数据库
if (!empty($response)) {
foreach ($response as $item) {
if (is_array($item)) {
$this->saveChatroomMember($item, $chatroomId);
}
}
}
if($isInner){
return json_encode(['code'=>200,'msg'=>'success','data'=>$response]);
@@ -218,8 +239,9 @@ class WechatChatroomController extends BaseController
])->find();
if ($member) {
$member->savea($data);
$member->save($data);
} else {
// 新成员,记录首次出现时间
$data['createTime'] = time();
WechatChatroomMemberModel::create($data);
}

View File

@@ -50,11 +50,24 @@ class WechatController extends BaseController
// 发送请求获取基本信息
$result = requestCurl($this->baseUrl . 'api/WechatAccount/list', $params, 'GET', $header);
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 确保 results 字段存在且是数组
if (!isset($response['results']) || !is_array($response['results'])) {
$response['results'] = [];
}
// 保存基本数据到数据库
if (!empty($response['results'])) {
foreach ($response['results'] as $item) {
if (is_array($item)) {
$this->saveWechatAccount($item);
}
}
// 获取并更新微信账号状态信息
$this->getListTenantWechatPartial($authorization);

View File

@@ -76,16 +76,23 @@ class WechatFriendController extends BaseController
$result = requestCurl($this->baseUrl . 'api/WechatFriend/friendlistData', $params, 'POST', $header, 'json');
$response = handleApiResponse($result);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 保存数据到数据库
if (is_array($response)) {
if (!empty($response)) {
$isUpdate = false;
foreach ($response as $item) {
if (is_array($item)) {
$updated = $this->saveFriend($item);
if($updated && $isDel == 0){
$isUpdate = true;
}
}
}
}
if ($isInner) {
return json_encode(['code' => 200, 'msg' => 'success', 'data' => $response, 'isUpdate' => $isUpdate]);

View File

@@ -196,7 +196,7 @@ class AiSettingsController extends BaseController
return ResponseHelper::error('参数缺失');
}
//列出所有好友
$row = Db::name('traffic_source_package_item')->alias('a')
$row = Db::name('traffic_source_package_item_v1')->alias('a')
->join('wechat_friendship f','a.identifier = f.wechatId and f.companyId = '.$companyId)
->join(['s2_wechat_account' => 'wa'],'f.ownerWechatId = wa.wechatId')
->whereIn('a.packageId' , $packageId)

View File

@@ -24,11 +24,17 @@ class CustomerServiceController extends BaseController
$wechatAliveTime = time() - 86400 * 30;
$list = Db::table('s2_wechat_account')
->whereIn('id',$accountIds)
->where('wechatAliveTime','>',$wechatAliveTime)
->order('id desc')
->group('id')
$list = Db::table('s2_wechat_account')->alias('wa')
->join(['s2_device' => 'd'],'wa.currentDeviceId = d.id','LEFT')
->whereIn('wa.id',$accountIds)
->where('wa.wechatAliveTime','>',$wechatAliveTime)
->order('wa.id desc')
->group('wa.id')
->field([
'wa.*',
'd.imei',
'd.extra',
])
->select();
foreach ($list as $k=>&$v){
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : '';
@@ -37,11 +43,16 @@ class CustomerServiceController extends BaseController
$momentsSetting = Db::name('kf_moments_settings')->where(['userId' => $userId,'companyId' => $companyId,'wechatId' =>$v['id']])->find();
$v['momentsMax'] = !empty($momentsSetting['max']) ? $momentsSetting['max'] : 5;
$v['momentsNum'] = !empty($momentsSetting['sendNum']) ? $momentsSetting['sendNum'] : 0;
$v['deviceExtra'] = json_decode($v['extra'],true);
$v['deviceExtra']['imei'] = $v['imei'];
$v['deviceExtra']['memo'] = $v['deviceMemo'];
unset(
$v['accountUserName'],
$v['accountRealName'],
$v['accountNickname'],
$v['extra'],
$v['imei'],
$v['deviceMemo'],
);
}
unset($v);

View File

@@ -7,6 +7,7 @@ use library\ResponseHelper;
use app\api\model\WechatFriendModel;
use app\api\model\WechatMessageModel;
use app\api\controller\MessageController;
use think\Db;
class DataProcessing extends BaseController
@@ -36,6 +37,8 @@ class DataProcessing extends BaseController
'CmdChatroomOperate', //修改群信息 {chatroomName群名、announce公告、extra公告、wechatAccountId、wechatChatroomId}
'CmdNewMessage', //接收消息
'CmdSendMessageResult', //更新消息状态
'CmdDownloadVideoResult', //视频下载结果回写
'CmdDownloadFileResult', //文件下载结果回写
'CmdPinToTop', //置顶
];
@@ -60,6 +63,7 @@ class DataProcessing extends BaseController
$friend->conRemark = $newRemark;
$friend->updateTime = time();
$friend->save();
$msg = '修改备成功';
break;
case 'CmdModifyFriendLabel': //修改好友标签
@@ -73,6 +77,7 @@ class DataProcessing extends BaseController
$friend->labels = json_encode($labels,256);
$friend->updateTime = time();
$friend->save();
$msg = '修标签成功';
break;
case 'CmdAllotFriend': //迁移好友
@@ -165,6 +170,34 @@ class DataProcessing extends BaseController
$msg = '更新消息状态成功';
break;
case 'CmdDownloadVideoResult':
case 'CmdDownloadFileResult':
$friendMessageId = $this->request->param('friendMessageId', 0);
$chatroomMessageId = $this->request->param('chatroomMessageId', 0);
$url = trim((string)$this->request->param('url', ''));
if (empty($friendMessageId) && empty($chatroomMessageId)) {
return ResponseHelper::error('friendMessageId或chatroomMessageId至少提供一个');
}
if (empty($url)) {
return ResponseHelper::error('url不能为空');
}
$messageController = new MessageController();
$updated = $messageController->updateDownloadedMessageMedia([
'friendMessageId' => $friendMessageId,
'chatroomMessageId' => $chatroomMessageId,
'url' => $url,
'type' => $type,
]);
if (!$updated) {
return ResponseHelper::error('媒体地址回写失败');
}
$msg = '媒体地址回写成功';
break;
case 'CmdPinToTop': //置顶
$wechatFriendId = $this->request->param('wechatFriendId', 0);
$wechatChatroomId = $this->request->param('wechatChatroomId', 0);
@@ -199,6 +232,7 @@ class DataProcessing extends BaseController
$data->updateTime = time();
$data->isTop = $isTop;
$data->save();
break;
}
return ResponseHelper::success('',$msg,$codee);

View File

@@ -30,143 +30,191 @@ class MessageController extends BaseController
return ResponseHelper::error('请先登录');
}
// 直接查询好友ID列表
$ids = Db::table('s2_wechat_friend')
->where(['accountId' => $accountId, 'isDeleted' => 0])
->column('id');
$friendIds = empty($ids) ? [0] : $ids; // 避免 IN 查询为空
// 直接查询好友信息
$friends = Db::table('s2_wechat_friend')
->where(['accountId' => $accountId, 'isDeleted' => 0])
->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region,isTop');
// 构建好友子查询
$friendSubQuery = Db::table('s2_wechat_friend')
// 直接查询群聊信息
$chatrooms = Db::table('s2_wechat_chatroom')
->where(['accountId' => $accountId, 'isDeleted' => 0])
->field('id')
->buildSql();
->column('id,nickname,chatroomAvatar,chatroomId,isTop');
// 优化后的查询使用MySQL兼容的查询方式
$unionQuery = "
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop
// 获取群聊ID列表
$chatroomIds = array_keys($chatrooms);
if (empty($chatroomIds)) {
$chatroomIds = [0];
}
// 1. 查询群聊最新消息
$chatroomMessages = [];
if (!empty($chatroomIds) && $chatroomIds[0] != 0) {
$chatroomIdsStr = implode(',', array_map('intval', $chatroomIds));
$chatroomLatestQuery = "
SELECT wc.id as chatroomId, m.id, m.content, m.wechatChatroomId, m.createTime, m.wechatTime, m.wechatAccountId,
wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop, 2 as msgType
FROM s2_wechat_chatroom wc
INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2
INNER JOIN (
SELECT wechatChatroomId, MAX(wechatTime) as maxTime, MAX(id) as maxId
FROM s2_wechat_message
WHERE type = 2
WHERE type = 2 AND wechatChatroomId IN ({$chatroomIdsStr})
GROUP BY wechatChatroomId
) latest ON m.wechatChatroomId = latest.wechatChatroomId AND m.wechatTime = latest.maxTime AND m.id = latest.maxId
) latest ON wc.id = latest.wechatChatroomId
INNER JOIN s2_wechat_message m ON m.wechatChatroomId = latest.wechatChatroomId
AND m.wechatTime = latest.maxTime AND m.id = latest.maxId
WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0
)
UNION ALL
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId, 0 as isTop
";
$chatroomMessages = Db::query($chatroomLatestQuery);
}
// 2. 查询好友最新消息
$friendMessages = [];
if (!empty($friendIds) && $friendIds[0] != 0) {
$friendIdsStr = implode(',', array_map('intval', $friendIds));
$friendLatestQuery = "
SELECT m.wechatFriendId, m.id, m.content, m.createTime, m.wechatTime,
f.wechatAccountId, 1 as msgType, 0 as isTop
FROM s2_wechat_message m
INNER JOIN (
SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId
FROM s2_wechat_message
WHERE type = 1 AND wechatFriendId IN {$friendSubQuery}
WHERE type = 1 AND wechatFriendId IN ({$friendIdsStr})
GROUP BY wechatFriendId
) latest ON m.wechatFriendId = latest.wechatFriendId AND m.wechatTime = latest.maxTime AND m.id = latest.maxId
WHERE m.type = 1 AND m.wechatFriendId IN {$friendSubQuery}
)
ORDER BY wechatTime DESC
LIMIT " . (($page - 1) * $limit) . ", {$limit}
) latest ON m.wechatFriendId = latest.wechatFriendId
AND m.wechatTime = latest.maxTime AND m.id = latest.maxId
INNER JOIN s2_wechat_friend f ON f.id = m.wechatFriendId
WHERE m.type = 1 AND m.wechatFriendId IN ({$friendIdsStr})
";
$friendMessages = Db::query($friendLatestQuery);
}
$list = Db::query($unionQuery);
// 对分页后的结果进行排序按wechatTime降序
usort($list, function ($a, $b) {
// 合并结果并排序
$allMessages = array_merge($chatroomMessages, $friendMessages);
usort($allMessages, function ($a, $b) {
return $b['wechatTime'] <=> $a['wechatTime'];
});
// 批量统计未读数量isRead=0按好友/群聊分别聚合
$friendIds = [];
$chatroomIds = [];
// 计算总数
$totalCount = count($allMessages);
// 分页处理
$list = array_slice($allMessages, ($page - 1) * $limit, $limit);
// 收集需要查询的ID
$queryFriendIds = [];
$queryChatroomIds = [];
foreach ($list as $row) {
if (!empty($row['wechatFriendId'])) {
$friendIds[] = $row['wechatFriendId'];
$queryFriendIds[] = $row['wechatFriendId'];
}
if (!empty($row['wechatChatroomId'])) {
$chatroomIds[] = $row['wechatChatroomId'];
$queryChatroomIds[] = $row['wechatChatroomId'];
}
}
$friendIds = array_values(array_unique(array_filter($friendIds)));
$chatroomIds = array_values(array_unique(array_filter($chatroomIds)));
$queryFriendIds = array_unique($queryFriendIds);
$queryChatroomIds = array_unique($queryChatroomIds);
$friendUnreadMap = [];
if (!empty($friendIds)) {
// 获取未读消息数量
$friendUnreadMap = Db::table('s2_wechat_message')
->where(['isRead' => 0])
->whereIn('wechatFriendId', $friendIds)
// 批量查询未读数量(优化:合并查询)
$unreadMap = [];
if (!empty($queryFriendIds)) {
$friendUnreads = Db::table('s2_wechat_message')
->where(['isRead' => 0, 'type' => 1])
->whereIn('wechatFriendId', $queryFriendIds)
->field('wechatFriendId, COUNT(*) as cnt')
->group('wechatFriendId')
->column('COUNT(*) AS cnt', 'wechatFriendId');
->select();
foreach ($friendUnreads as $item) {
$unreadMap['friend_' . $item['wechatFriendId']] = (int)$item['cnt'];
}
}
$chatroomUnreadMap = [];
if (!empty($chatroomIds)) {
// 获取未读消息数量
$chatroomUnreadMap = Db::table('s2_wechat_message')
->where(['isRead' => 0])
->whereIn('wechatChatroomId', $chatroomIds)
if (!empty($queryChatroomIds)) {
$chatroomUnreads = Db::table('s2_wechat_message')
->where(['isRead' => 0, 'type' => 2])
->whereIn('wechatChatroomId', $queryChatroomIds)
->field('wechatChatroomId, COUNT(*) as cnt')
->group('wechatChatroomId')
->column('COUNT(*) AS cnt', 'wechatChatroomId');
->select();
foreach ($chatroomUnreads as $item) {
$unreadMap['chatroom_' . $item['wechatChatroomId']] = (int)$item['cnt'];
}
}
// 批量查询AI类型
$aiTypeData = [];
if (!empty($friendIds)) {
$aiTypeData = FriendSettings::where('friendId', 'in', $friendIds)->column('friendId,type');
if (!empty($queryFriendIds)) {
$aiTypeData = FriendSettings::where('friendId', 'in', $queryFriendIds)->column('friendId,type');
}
// 格式化数据
foreach ($list as $k => &$v) {
$createTime = !empty($v['createTime']) ? date('Y-m-d H:i:s', $v['createTime']) : '';
$wechatTime = !empty($v['wechatTime']) ? date('Y-m-d H:i:s', $v['wechatTime']) : '';
$unreadCount = 0;
$v['aiType'] = 0;
if (!empty($v['wechatFriendId'])) {
$v['nickname'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['nickname'] : '';
$v['avatar'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['avatar'] : '';
$v['conRemark'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['conRemark'] : '';
$v['groupId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['groupId'] : '';
$v['wechatAccountId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['wechatAccountId'] : '';
$v['wechatId'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['wechatId'] : '';
$v['extendFields'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['extendFields'] : [];
$v['region'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['region'] : '';
$v['phone'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['phone'] : '';
$v['isTop'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['isTop'] : 0;
$v['labels'] = !empty($friends[$v['wechatFriendId']]) ? json_decode($friends[$v['wechatFriendId']]['labels'], true) : [];
// 好友消息
$friendId = $v['wechatFriendId'];
$friend = $friends[$friendId] ?? null;
$unreadCount = isset($friendUnreadMap[$v['wechatFriendId']]) ? (int)$friendUnreadMap[$v['wechatFriendId']] : 0;
$v['aiType'] = isset($aiTypeData[$v['wechatFriendId']]) ? $aiTypeData[$v['wechatFriendId']] : 0;
$v['nickname'] = $friend['nickname'] ?? '';
$v['avatar'] = $friend['avatar'] ?? '';
$v['conRemark'] = $friend['conRemark'] ?? '';
$v['groupId'] = $friend['groupId'] ?? '';
$v['wechatAccountId'] = $friend['wechatAccountId'] ?? '';
$v['wechatId'] = $friend['wechatId'] ?? '';
$v['extendFields'] = $friend['extendFields'] ?? [];
$v['region'] = $friend['region'] ?? '';
$v['phone'] = $friend['phone'] ?? '';
$v['isTop'] = $friend['isTop'] ?? 0;
$v['labels'] = !empty($friend['labels']) ? json_decode($friend['labels'], true) : [];
$unreadCount = $unreadMap['friend_' . $friendId] ?? 0;
$v['aiType'] = $aiTypeData[$friendId] ?? 0;
$v['id'] = $friendId;
unset($v['chatroomId']);
}
} elseif (!empty($v['wechatChatroomId'])) {
// 群聊消息
$chatroomId = $v['wechatChatroomId'];
$chatroom = $chatrooms[$chatroomId] ?? null;
if (!empty($v['wechatChatroomId'])) {
$v['nickname'] = $chatroom['nickname'] ?? '';
$v['avatar'] = $chatroom['chatroomAvatar'] ?? '';
$v['conRemark'] = '';
$unreadCount = isset($chatroomUnreadMap[$v['wechatChatroomId']]) ? (int)$chatroomUnreadMap[$v['wechatChatroomId']] : 0;
$v['isTop'] = $chatroom['isTop'] ?? 0;
$v['chatroomId'] = $chatroom['chatroomId'] ?? '';
$unreadCount = $unreadMap['chatroom_' . $chatroomId] ?? 0;
$v['id'] = $chatroomId;
unset($v['wechatFriendId']);
}
$v['id'] = !empty($v['wechatFriendId']) ? $v['wechatFriendId'] : $v['wechatChatroomId'];
$v['config'] = [
'top' => !empty($v['isTop']) ? true : false,
'unreadCount' => $unreadCount,
'chat' => true,
'msgTime' => $v['wechatTime'],
'msgTime' => $wechatTime,
];
$v['createTime'] = $createTime;
$v['lastUpdateTime'] = $wechatTime;
// 最新消息内容已经在UNION查询中获取直接使用
$v['latestMessage'] = [
'content' => $v['content'],
'content' => $v['content'] ?? '',
'wechatTime' => $wechatTime
];
unset($v['wechatFriendId'], $v['wechatChatroomId'],$v['isTop']);
unset($v['wechatChatroomId'], $v['isTop'], $v['msgType']);
}
unset($v);
return ResponseHelper::success($list);
return ResponseHelper::success(['list' => $list, 'total' => $totalCount]);
}

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

@@ -127,6 +127,27 @@ class ReplyController extends BaseController
if ($title === '') {
return ResponseHelper::error('标题不能为空');
}
if ($content === '') {
return ResponseHelper::error('内容不能为空');
}
// 根据 msgType 处理 content3=图片43=视频49=链接 需要 JSON 编码
if (in_array($msgType, [3, 43, 49])) {
// 如果 content 已经是数组,直接编码;如果是字符串,先尝试解码再编码(确保格式正确)
if (is_array($content)) {
$content = json_encode($content, JSON_UNESCAPED_UNICODE);
} elseif (is_string($content)) {
// 尝试解析,如果已经是 JSON 字符串,确保格式正确
$decoded = json_decode($content, true);
if ($decoded !== null) {
// 是有效的 JSON重新编码确保格式统一
$content = json_encode($decoded, JSON_UNESCAPED_UNICODE);
} else {
// 不是 JSON直接编码
$content = json_encode($content, JSON_UNESCAPED_UNICODE);
}
}
}
try {
$now = time();
@@ -142,12 +163,20 @@ class ReplyController extends BaseController
'lastUpdateTime' => $now,
'userId' => $userId,
];
/** @var Reply $reply */
$reply = new Reply();
$reply->save($data);
return ResponseHelper::success($reply->toArray(), '创建成功');
// 返回时解析 content与 buildGroupData 保持一致)
$replyData = $reply->toArray();
if (in_array($msgType, [3, 43, 49]) && !empty($replyData['content'])) {
$decoded = json_decode($replyData['content'], true);
if ($decoded !== null) {
$replyData['content'] = $decoded;
}
}
return ResponseHelper::success($replyData, '创建成功');
} catch (\Exception $e) {
return ResponseHelper::error('创建失败:' . $e->getMessage());
}
@@ -232,9 +261,46 @@ class ReplyController extends BaseController
$sortIndex = $this->request->param('sortIndex', null);
if ($groupId !== null) $data['groupId'] = (int)$groupId;
if ($title !== null) $data['title'] = $title;
if ($title !== null) {
if ($title === '') {
return ResponseHelper::error('标题不能为空');
}
$data['title'] = $title;
}
if ($msgType !== null) $data['msgType'] = (int)$msgType;
if ($content !== null) $data['content'] = $content;
if ($content !== null) {
// 确定 msgType如果传了新的 msgType用新的否则用原有的
$currentMsgType = $msgType !== null ? (int)$msgType : null;
if ($currentMsgType === null) {
// 需要查询原有的 msgType
$reply = Reply::where(['id' => $id, 'isDel' => 0])->find();
if (empty($reply)) {
return ResponseHelper::error('快捷语不存在');
}
$currentMsgType = $reply->msgType;
}
// 根据 msgType 处理 content3=图片43=视频49=链接 需要 JSON 编码
if (in_array($currentMsgType, [3, 43, 49])) {
// 如果 content 已经是数组,直接编码;如果是字符串,先尝试解码再编码(确保格式正确)
if (is_array($content)) {
$data['content'] = json_encode($content, JSON_UNESCAPED_UNICODE);
} elseif (is_string($content)) {
// 尝试解析,如果已经是 JSON 字符串,确保格式正确
$decoded = json_decode($content, true);
if ($decoded !== null) {
// 是有效的 JSON重新编码确保格式统一
$data['content'] = json_encode($decoded, JSON_UNESCAPED_UNICODE);
} else {
// 不是 JSON直接编码
$data['content'] = json_encode($content, JSON_UNESCAPED_UNICODE);
}
}
} else {
// 文本类型,直接使用
$data['content'] = $content;
}
}
if ($sortIndex !== null) $data['sortIndex'] = (string)$sortIndex;
if (!empty($data)) {
$data['lastUpdateTime'] = time();
@@ -245,12 +311,23 @@ class ReplyController extends BaseController
}
try {
$reply = Reply::where(['id' => $id,'isDel' => 0])->find();
$reply = Reply::where(['id' => $id, 'isDel' => 0])->find();
if (empty($reply)) {
return ResponseHelper::error('快捷语不存在');
}
$reply->save($data);
return ResponseHelper::success($reply->toArray(), '更新成功');
// 返回时解析 content与 buildGroupData 保持一致)
$replyData = $reply->toArray();
$finalMsgType = isset($data['msgType']) ? $data['msgType'] : $reply->msgType;
if (in_array($finalMsgType, [3, 43, 49]) && !empty($replyData['content'])) {
$decoded = json_decode($replyData['content'], true);
if ($decoded !== null) {
$replyData['content'] = $decoded;
}
}
return ResponseHelper::success($replyData, '更新成功');
} catch (\Exception $e) {
return ResponseHelper::error('更新失败:' . $e->getMessage());
}
@@ -329,10 +406,24 @@ class ReplyController extends BaseController
// 获取该分组下的快捷回复
$replies = Reply::where($replyWhere)
->order('sortIndex asc, id desc
')
->order('sortIndex asc, id desc')
->select();
// 解析 replies 的 content 字段(根据 msgType 判断是否需要 JSON 解析)
$repliesArray = [];
foreach ($replies as $reply) {
$replyData = $reply->toArray();
// 根据 msgType 解析 content3=图片43=视频49=链接
if (in_array($replyData['msgType'], [3, 43, 49]) && !empty($replyData['content'])) {
$decoded = json_decode($replyData['content'], true);
// 如果解析成功,使用解析后的内容;否则保持原样
if ($decoded !== null) {
$replyData['content'] = $decoded;
}
}
$repliesArray[] = $replyData;
}
return [
'id' => $group->id,
'groupName' => $group->groupName,
@@ -342,7 +433,7 @@ class ReplyController extends BaseController
'replys' => $group->replys,
'companyId' => $group->companyId,
'userId' => $group->userId,
'replies' => $replies->toArray(),
'replies' => $repliesArray,
'children' => [] // 子分组
];
}

View File

@@ -118,12 +118,13 @@ class WechatChatroomController extends BaseController
}
$detail = Db::table('s2_wechat_chatroom')
->where(['accountId' => $accountId, 'id' => $id, 'isDeleted' => 0])
//->where(['accountId' => $accountId, 'id' => $id, 'isDeleted' => 0])
->where([ 'id' => $id, 'isDeleted' => 0])
->find();
if (!$detail) {
return ResponseHelper::error('聊天室不存在或无权限访问');
}
// if (!$detail) {
// return ResponseHelper::error('聊天室不存在或无权限访问');
// }
// 处理时间格式
$detail['createTime'] = !empty($detail['createTime']) ? date('Y-m-d H:i:s', $detail['createTime']) : '';

View File

@@ -38,6 +38,7 @@ return [
'workbench:trafficDistribute' => 'app\command\WorkbenchTrafficDistributeCommand', // 工作台流量分发任务
'workbench:groupPush' => 'app\command\WorkbenchGroupPushCommand', // 工作台群推送任务
'workbench:groupCreate' => 'app\command\WorkbenchGroupCreateCommand', // 工作台群创建任务
'workbench:groupWelcome' => 'app\command\WorkbenchGroupWelcomeCommand', // 工作台入群欢迎语任务
'workbench:import-contact' => 'app\command\WorkbenchImportContactCommand', // 工作台通讯录导入任务
'kf:notice' => 'app\command\KfNoticeCommand', // 客服端消息通知
@@ -46,4 +47,11 @@ return [
// 统一任务调度器
'scheduler:run' => 'app\command\TaskSchedulerCommand', // 统一任务调度器,支持多进程并发执行
// 检查未读/未回复消息并自动迁移好友
'check:unread-message' => 'app\command\CheckUnreadMessageCommand', // 检查未读/未回复消息并自动迁移好友
// V2 流量池数据迁移
'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统
'media:archive' => 'app\command\BackfillMediaOssCommand', // 历史媒体资源归档到OSS
];

View File

@@ -0,0 +1,121 @@
<?php
namespace app\command;
use app\common\service\WechatMediaArchiveService;
use app\job\MediaArchiveJob;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\Db;
class BackfillMediaOssCommand extends Command
{
protected function configure()
{
$this->setName('media:archive')
->setDescription('扫描聊天记录和朋友圈媒体资源并加入 OSS 归档队列')
->addOption('scope', null, Option::VALUE_OPTIONAL, '归档范围chat|moments|all', 'all')
->addOption('limit', null, Option::VALUE_OPTIONAL, '单次扫描数量', 100)
->addOption('startId', null, Option::VALUE_OPTIONAL, '仅扫描大于该ID的记录', 0)
->addOption('messageId', null, Option::VALUE_OPTIONAL, '仅同步归档单条聊天记录(本地调试用,不走队列)', 0);
}
protected function execute(Input $input, Output $output)
{
$singleMessageId = (int)$input->getOption('messageId');
if ($singleMessageId > 0) {
return $this->archiveOneChatMessage($singleMessageId, $output);
}
$scope = strtolower((string)$input->getOption('scope'));
$limit = max(1, (int)$input->getOption('limit'));
$startId = max(0, (int)$input->getOption('startId'));
$messageCount = 0;
$momentCount = 0;
if (in_array($scope, ['all', 'chat'])) {
$messageIds = Db::table('s2_wechat_message')
->where('id', '>', $startId)
->whereIn('msgType', [3, 34, 43, 47, 49])
->where(function ($query) {
$query->whereLike('content', 'http%')
->whereOrLike('content', '%"url"%')
->whereOrLike('content', '%"previewImage"%')
->whereOrLike('content', '%"tencentUrl"%');
})
->order('id', 'asc')
->limit($limit)
->column('id');
foreach ($messageIds as $id) {
MediaArchiveJob::dispatch('message', $id, ['source' => 'backfill_command']);
$messageCount++;
}
}
if (in_array($scope, ['all', 'moments'])) {
$momentIds = Db::table('s2_wechat_moments')
->where('id', '>', $startId)
->where(function ($query) {
$query->where('isOssUploaded', 0)
->whereOr('ossUrls', 'null')
->whereOr('ossUrls', '')
->whereOr('ossUrls', '[]');
})
->where(function ($query) {
$query->where('resUrls', '<>', '')
->whereOr('urls', '<>', '');
})
->order('id', 'asc')
->limit($limit)
->column('id');
foreach ($momentIds as $id) {
MediaArchiveJob::dispatch('moment', $id, ['source' => 'backfill_command']);
$momentCount++;
}
}
$output->writeln(sprintf(
'已加入 OSS 归档队列:聊天记录 %d 条,朋友圈 %d 条。',
$messageCount,
$momentCount
));
return 0;
}
/**
* 单条聊天消息同步归档(用于本地验证 AliyunOSS 与下载链路)
*/
protected function archiveOneChatMessage($messageId, Output $output)
{
$row = Db::table('s2_wechat_message')->where('id', $messageId)->find();
if (empty($row)) {
$output->writeln('错误:未找到消息 id=' . $messageId);
return 1;
}
$preview = function ($s, $len) {
$s = (string)$s;
return function_exists('mb_substr') ? mb_substr($s, 0, $len) : substr($s, 0, $len);
};
$output->writeln('msgType=' . ($row['msgType'] ?? '') . ' content(前200字): ' . $preview($row['content'] ?? '', 200));
$ok = WechatMediaArchiveService::archiveMessageById($messageId);
if (!$ok) {
$output->writeln('失败:归档未执行或无需更新(请确认 msgType 为 3/34/43/47/49 且 content 含可下载 http 资源)');
return 1;
}
$after = Db::table('s2_wechat_message')->where('id', $messageId)->find();
$output->writeln('成功:归档完成。更新后 content(前300字):');
$output->writeln($preview($after['content'] ?? '', 300));
return 0;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace app\command;
use app\common\service\FriendTransferService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Log;
/**
* 检查未读/未回复消息并自动迁移好友命令
*
* 功能:
* 1. 检查消息未读超过30分钟的好友
* 2. 检查消息未回复超过30分钟的好友
* 3. 自动迁移这些好友到其他在线账号
*/
class CheckUnreadMessageCommand extends Command
{
protected function configure()
{
$this->setName('check:unread-message')
->setDescription('检查未读/未回复消息并自动迁移好友')
->addOption('minutes', 'm', \think\console\input\Option::VALUE_OPTIONAL, '未读/未回复分钟数默认10分钟', 10)
->addOption('page-size', 'p', \think\console\input\Option::VALUE_OPTIONAL, '每页处理数量默认100条', 100);
}
protected function execute(Input $input, Output $output)
{
$minutes = intval($input->getOption('minutes'));
if ($minutes <= 0) {
$minutes = 10;
}
$pageSize = intval($input->getOption('page-size'));
if ($pageSize <= 0) {
$pageSize = 100;
}
$output->writeln("开始检查未读/未回复消息(超过{$minutes}分钟,每页处理{$pageSize}条)...");
try {
$friendTransferService = new FriendTransferService();
$result = $friendTransferService->checkAndTransferUnreadOrUnrepliedFriends($minutes, $pageSize);
$output->writeln("检查完成:");
$output->writeln(" 总计需要迁移的好友数:{$result['total']}");
$output->writeln(" 成功迁移的好友数:{$result['transferred']}");
$output->writeln(" 迁移失败的好友数:{$result['failed']}");
if ($result['total'] > 0) {
Log::info("未读/未回复消息检查完成:总计{$result['total']},成功{$result['transferred']},失败{$result['failed']}");
}
} catch (\Exception $e) {
$errorMsg = "检查未读/未回复消息异常:" . $e->getMessage();
$output->writeln("<error>{$errorMsg}</error>");
Log::error($errorMsg);
}
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\facade\App;
use think\facade\Log;
/**
* 清除过期日志文件命令
*
* 使用方法:
* php think clean:logs # 使用默认保留10天
* php think clean:logs --days=7 # 保留7天
* php think clean:logs --days=30 # 保留30天
* php think clean:logs --dry-run # 预览模式,不实际删除
*/
class CleanLogsCommand extends Command
{
protected function configure()
{
$this->setName('clean:logs')
->setDescription('清除过期的日志文件')
->addOption('days', 'd', Option::VALUE_OPTIONAL, '保留天数默认10天', 10)
->addOption('dry-run', null, Option::VALUE_NONE, '预览模式,不实际删除文件');
}
protected function execute(Input $input, Output $output)
{
$days = (int)$input->getOption('days');
$dryRun = $input->getOption('dry-run');
if ($days <= 0) {
$output->writeln('<error>保留天数必须大于0</error>');
return false;
}
if ($dryRun) {
$output->writeln('<info>运行在预览模式,不会实际删除文件</info>');
}
$output->writeln("<info>====================================</info>");
$output->writeln("<info> 清除过期日志文件</info>");
$output->writeln("<info>====================================</info>");
$output->writeln("保留天数: {$days}");
$output->writeln("");
// 获取日志目录
$logPath = App::getRuntimePath() . 'log' . DIRECTORY_SEPARATOR;
if (!is_dir($logPath)) {
$output->writeln("<comment>日志目录不存在: {$logPath}</comment>");
return false;
}
// 计算截止时间(保留指定天数之前的日志)
$cutoffTime = time() - ($days * 24 * 60 * 60);
$cutoffDate = date('Y-m-d H:i:s', $cutoffTime);
$output->writeln("<comment>清除 {$cutoffDate} 之前的日志文件</comment>");
$output->writeln("");
// 统计信息
$totalFiles = 0;
$deletedFiles = 0;
$totalSize = 0;
$freedSize = 0;
try {
// 递归扫描日志目录
$result = $this->cleanLogDirectory($logPath, $cutoffTime, $dryRun, $output);
$totalFiles = $result['total'];
$deletedFiles = $result['deleted'];
$totalSize = $result['totalSize'];
$freedSize = $result['freedSize'];
} catch (\Exception $e) {
$output->writeln('<error>清除日志时发生错误: ' . $e->getMessage() . '</error>');
Log::error('清除日志失败: ' . $e->getMessage());
return false;
}
// 输出统计信息
$output->writeln("");
$output->writeln("<info>====================================</info>");
$output->writeln("<info> 清除完成</info>");
$output->writeln("<info>====================================</info>");
$output->writeln("扫描文件数: {$totalFiles}");
$output->writeln("删除文件数: {$deletedFiles}");
$output->writeln("释放空间: " . $this->formatBytes($freedSize));
if ($dryRun) {
$output->writeln("");
$output->writeln("<comment>预览模式:实际未删除任何文件</comment>");
}
return true;
}
/**
* 递归清理日志目录
*/
protected function cleanLogDirectory($dir, $cutoffTime, $dryRun, Output $output)
{
$total = 0;
$deleted = 0;
$totalSize = 0;
$freedSize = 0;
if (!is_dir($dir)) {
return ['total' => 0, 'deleted' => 0, 'totalSize' => 0, 'freedSize' => 0];
}
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . $item;
if (is_dir($path)) {
// 递归处理子目录
$result = $this->cleanLogDirectory($path . DIRECTORY_SEPARATOR, $cutoffTime, $dryRun, $output);
$total += $result['total'];
$deleted += $result['deleted'];
$totalSize += $result['totalSize'];
$freedSize += $result['freedSize'];
} elseif (is_file($path)) {
$total++;
$fileSize = filesize($path);
$totalSize += $fileSize;
// 获取文件修改时间
$fileMTime = filemtime($path);
// 如果文件修改时间早于截止时间,则删除
if ($fileMTime < $cutoffTime) {
$freedSize += $fileSize;
if ($dryRun) {
$output->writeln("<comment>[预览] 将删除: {$path} (" . date('Y-m-d H:i:s', $fileMTime) . ", " . $this->formatBytes($fileSize) . ")</comment>");
} else {
if (@unlink($path)) {
$deleted++;
$output->writeln("<info>已删除: {$path}</info>");
} else {
$output->writeln("<error>删除失败: {$path}</error>");
}
}
}
}
}
return [
'total' => $total,
'deleted' => $deleted,
'totalSize' => $totalSize,
'freedSize' => $freedSize,
];
}
/**
* 格式化字节数
*/
protected function formatBytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
if ($bytes == 0) {
return '0 B';
}
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

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

@@ -9,7 +9,7 @@ use think\console\Command;
use think\facade\App;
use WeChatDeviceApi\Adapters\ChuKeBao\Adapter as ChuKeBaoAdapter;
// */7 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:wechatData >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/sync_wechat_data.log 2>&1
// */7 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think sync:wechatData >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/sync_wechat_data.log 2>&1
class SyncWechatDataToCkbTask extends Command
{
protected $lockFile;
@@ -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

@@ -29,7 +29,7 @@ class TaskSchedulerCommand extends Command
/**
* 最大并发进程数
*/
protected $maxConcurrent = 10;
protected $maxConcurrent = 20;
/**
* 当前运行的进程数
@@ -41,10 +41,17 @@ class TaskSchedulerCommand extends Command
*/
protected $logDir = '';
/**
* 锁文件目录
*/
protected $lockDir = '';
protected function configure()
{
$this->setName('scheduler:run')
->setDescription('统一任务调度器,支持多进程并发执行所有定时任务');
->setDescription('统一任务调度器,支持多进程并发执行所有定时任务')
->addOption('task', 't', \think\console\input\Option::VALUE_OPTIONAL, '指定要执行的任务ID测试模式忽略Cron表达式', '')
->addOption('force', 'f', \think\console\input\Option::VALUE_NONE, '强制执行所有启用的任务忽略Cron表达式');
}
protected function execute(Input $input, Output $output)
@@ -61,35 +68,50 @@ class TaskSchedulerCommand extends Command
$this->maxConcurrent = 1;
}
// 加载任务配置(优先使用框架配置,其次直接引入配置文件,避免加载失败
// 获取项目根目录(使用 __DIR__ 更可靠
// TaskSchedulerCommand.php 位于 application/command/,向上两级到项目根目录
$rootPath = dirname(__DIR__, 2);
// 加载任务配置
// 方法1尝试通过框架配置加载
$this->tasks = Config::get('task_scheduler', []);
// 如果通过 Config 没有读到,再尝试直接 include 配置文件
// 方法2如果框架配置没有直接加载配置文件
if (empty($this->tasks)) {
// 以项目根目录为基准查找 config/task_scheduler.php
$configFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php';
$configFile = $rootPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php';
if (is_file($configFile)) {
$output->writeln("<info>找到配置文件:{$configFile}</info>");
$config = include $configFile;
if (is_array($config) && !empty($config)) {
$this->tasks = $config;
} else {
$output->writeln("<error>配置文件返回的不是数组或为空:{$configFile}</error>");
}
}
}
if (empty($this->tasks)) {
$output->writeln('<error>错误未找到任务配置task_scheduler,请检查 config/task_scheduler.php 是否存在且返回数组</error>');
$output->writeln('<error>错误未找到任务配置task_scheduler</error>');
$output->writeln('<error>请检查以下位置:</error>');
$output->writeln('<error>1. config/task_scheduler.php 文件是否存在</error>');
$output->writeln('<error>2. 文件是否返回有效的数组</error>');
$output->writeln('<error>3. 文件权限是否正确</error>');
$output->writeln('<error>项目根目录:' . $rootPath . '</error>');
$output->writeln('<error>期望配置文件:' . $rootPath . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'task_scheduler.php</error>');
return false;
}
// 设置日志目录ThinkPHP5 中无 runtime_path 辅助函数,直接使用 ROOT_PATH/runtime/log
if (!defined('ROOT_PATH')) {
// CLI 下正常情况下 ROOT_PATH 已在入口脚本 define这里兜底一次
define('ROOT_PATH', dirname(__DIR__, 2));
}
$this->logDir = ROOT_PATH . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR;
// 设置日志目录和锁文件目录(使用 __DIR__ 获取的根目录
$this->logDir = $rootPath . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR;
$this->lockDir = $rootPath . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'lock' . DIRECTORY_SEPARATOR;
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0755, true);
}
if (!is_dir($this->lockDir)) {
mkdir($this->lockDir, 0755, true);
}
// 获取当前时间
$currentTime = time();
@@ -99,27 +121,74 @@ class TaskSchedulerCommand extends Command
$currentMonth = date('m', $currentTime);
$currentWeekday = date('w', $currentTime); // 0=Sunday, 6=Saturday
// 获取命令行参数
$testTaskId = $input->getOption('task');
$force = $input->getOption('force');
$output->writeln("当前时间: {$currentHour}:{$currentMinute}");
$output->writeln("已加载 " . count($this->tasks) . " 个任务配置");
// 筛选需要执行的任务
$tasksToRun = [];
foreach ($this->tasks as $taskId => $task) {
if (!isset($task['enabled']) || !$task['enabled']) {
continue;
// 测试模式:只执行指定的任务
if (!empty($testTaskId)) {
if (!isset($this->tasks[$testTaskId])) {
$output->writeln("<error>错误:任务 {$testTaskId} 不存在</error>");
$output->writeln("<info>可用任务列表:</info>");
foreach ($this->tasks as $id => $task) {
$taskName = $task['name'] ?? $id;
$enabled = isset($task['enabled']) && $task['enabled'] ? '✓' : '✗';
$output->writeln(" {$enabled} {$taskName} ({$id})");
}
return false;
}
if ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) {
$task = $this->tasks[$testTaskId];
if (!isset($task['enabled']) || !$task['enabled']) {
$output->writeln("<error>错误:任务 {$testTaskId} 已禁用</error>");
return false;
}
$taskName = $task['name'] ?? $testTaskId;
$output->writeln("<info>测试模式:执行任务 {$taskName} ({$testTaskId})</info>");
$output->writeln("<comment>注意:测试模式会忽略 Cron 表达式,直接执行任务</comment>");
$tasksToRun = [$testTaskId => $task];
} else {
// 正常模式:筛选需要执行的任务
$tasksToRun = [];
$enabledCount = 0;
$disabledCount = 0;
foreach ($this->tasks as $taskId => $task) {
if (!isset($task['enabled']) || !$task['enabled']) {
$disabledCount++;
continue;
}
$enabledCount++;
// 强制模式:忽略 Cron 表达式,执行所有启用的任务
if ($force) {
$tasksToRun[$taskId] = $task;
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>强制模式:任务 {$taskName} ({$taskId}) 将被执行</info>");
} elseif ($this->shouldRun($task['schedule'], $currentMinute, $currentHour, $currentDay, $currentMonth, $currentWeekday)) {
$tasksToRun[$taskId] = $task;
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>任务 {$taskName} ({$taskId}) 符合执行条件schedule: {$task['schedule']}</info>");
}
}
$output->writeln("已启用任务数: {$enabledCount},已禁用任务数: {$disabledCount}");
if (empty($tasksToRun)) {
$output->writeln('<info>当前时间没有需要执行的任务</info>');
if (!$force) {
$output->writeln('<info>提示:使用 --force 参数可以强制执行所有启用的任务</info>');
}
return true;
}
$output->writeln("找到 " . count($tasksToRun) . " 个需要执行的任务");
}
// 执行任务
if ($this->maxConcurrent > 1 && function_exists('pcntl_fork')) {
@@ -139,7 +208,7 @@ class TaskSchedulerCommand extends Command
}
/**
* 判断任务是否应该执行
* 判断任务是否应该执行(参考 schedule.php 的实现)
*
* @param string $schedule cron表达式格式分钟 小时 日 月 星期
* @param int $minute 当前分钟
@@ -152,36 +221,36 @@ class TaskSchedulerCommand extends Command
protected function shouldRun($schedule, $minute, $hour, $day, $month, $weekday)
{
$parts = preg_split('/\s+/', trim($schedule));
if (count($parts) < 5) {
if (count($parts) !== 5) {
return false;
}
list($scheduleMinute, $scheduleHour, $scheduleDay, $scheduleMonth, $scheduleWeekday) = $parts;
// 解析分钟
if (!$this->matchCronField($scheduleMinute, $minute)) {
if (!$this->matchCronPart($scheduleMinute, $minute)) {
return false;
}
// 解析小时
if (!$this->matchCronField($scheduleHour, $hour)) {
if (!$this->matchCronPart($scheduleHour, $hour)) {
return false;
}
// 解析日期
if (!$this->matchCronField($scheduleDay, $day)) {
if (!$this->matchCronPart($scheduleDay, $day)) {
return false;
}
// 解析月份
if (!$this->matchCronField($scheduleMonth, $month)) {
if (!$this->matchCronPart($scheduleMonth, $month)) {
return false;
}
// 解析星期注意cron中0和7都表示星期日
// 解析星期注意cron中0和7都表示星期日PHP的wday中0=Sunday
if ($scheduleWeekday !== '*') {
$scheduleWeekday = str_replace('7', '0', $scheduleWeekday);
if (!$this->matchCronField($scheduleWeekday, $weekday)) {
if (!$this->matchCronPart($scheduleWeekday, $weekday)) {
return false;
}
}
@@ -190,60 +259,49 @@ class TaskSchedulerCommand extends Command
}
/**
* 匹配cron字段
* 匹配Cron表达式的单个部分(参考 schedule.php 的实现)
*
* @param string $field cron字段表达式
* @param string $pattern cron字段表达式
* @param int $value 当前值
* @return bool
*/
protected function matchCronField($field, $value)
protected function matchCronPart($pattern, $value)
{
// 通配符
if ($field === '*') {
// * 表示匹配所有
if ($pattern === '*') {
return true;
}
// 列表(逗号分隔)
if (strpos($field, ',') !== false) {
$values = explode(',', $field);
// 数字,精确匹配
if (is_numeric($pattern)) {
return (int)$pattern === $value;
}
// */n 表示每n个单位
if (preg_match('/^\*\/(\d+)$/', $pattern, $matches)) {
$interval = (int)$matches[1];
return $value % $interval === 0;
}
// n-m 表示范围
if (preg_match('/^(\d+)-(\d+)$/', $pattern, $matches)) {
$min = (int)$matches[1];
$max = (int)$matches[2];
return $value >= $min && $value <= $max;
}
// n,m 表示多个值
if (strpos($pattern, ',') !== false) {
$values = explode(',', $pattern);
foreach ($values as $v) {
if ($this->matchCronField(trim($v), $value)) {
if ((int)trim($v) === $value) {
return true;
}
}
return false;
}
// 范围(如 1-5
if (strpos($field, '-') !== false) {
list($start, $end) = explode('-', $field);
return $value >= (int)$start && $value <= (int)$end;
}
// 步长(如 */5 或 0-59/5
if (strpos($field, '/') !== false) {
$parts = explode('/', $field);
$base = $parts[0];
$step = (int)$parts[1];
if ($base === '*') {
return $value % $step === 0;
} else {
// 处理范围步长,如 0-59/5
if (strpos($base, '-') !== false) {
list($start, $end) = explode('-', $base);
if ($value >= (int)$start && $value <= (int)$end) {
return ($value - (int)$start) % $step === 0;
}
return false;
} else {
return $value % $step === 0;
}
}
}
// 精确匹配
return (int)$field === $value;
}
/**
@@ -263,11 +321,10 @@ class TaskSchedulerCommand extends Command
usleep(100000); // 等待100ms
}
// 检查任务是否已经在运行(防止重复执行
$lockKey = "scheduler_task_lock:{$taskId}";
$lockTime = Cache::get($lockKey);
if ($lockTime && (time() - $lockTime) < 300) { // 5分钟内不重复执行
$output->writeln("<comment>任务 {$taskId} 正在运行中,跳过</comment>");
// 检查任务是否已经在运行(使用文件锁,更可靠
if ($this->isTaskRunning($taskId)) {
$taskName = $task['name'] ?? $taskId;
$output->writeln("<comment>任务 {$taskName} ({$taskId}) 正在运行中,跳过</comment>");
continue;
}
@@ -276,8 +333,9 @@ class TaskSchedulerCommand extends Command
if ($pid == -1) {
// 创建进程失败
$output->writeln("<error>创建子进程失败:{$taskId}</error>");
Log::error("任务调度器:创建子进程失败", ['task' => $taskId]);
$taskName = $task['name'] ?? $taskId;
$output->writeln("<error>创建子进程失败:{$taskName} ({$taskId})</error>");
Log::error("任务调度器:创建子进程失败", ['task' => $taskId, 'name' => $taskName]);
continue;
} elseif ($pid == 0) {
// 子进程:执行任务
@@ -289,10 +347,11 @@ class TaskSchedulerCommand extends Command
'task_id' => $taskId,
'start_time' => time(),
];
$output->writeln("<info>启动任务:{$taskId} (PID: {$pid})</info>");
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>启动任务:{$taskName} ({$taskId}) (PID: {$pid})</info>");
// 设置任务锁
Cache::set($lockKey, time(), 600); // 10分钟过期
// 创建任务锁文件
$this->createLock($taskId, $pid);
}
}
@@ -314,13 +373,14 @@ class TaskSchedulerCommand extends Command
$output->writeln('<info>使用单进程顺序执行任务</info>');
foreach ($tasks as $taskId => $task) {
$output->writeln("<info>执行任务:{$taskId}</info>");
$taskName = $task['name'] ?? $taskId;
$output->writeln("<info>执行任务:{$taskName} ({$taskId})</info>");
$this->runTask($taskId, $task);
}
}
/**
* 执行单个任务
* 执行单个任务(参考 schedule.php 的实现,改进超时和错误处理)
*
* @param string $taskId 任务ID
* @param array $task 任务配置
@@ -336,105 +396,183 @@ class TaskSchedulerCommand extends Command
mkdir($logDir, 0755, true);
}
// 构建命令
// 使用项目根目录下的 think 脚本(同命令行 php think
if (!defined('ROOT_PATH')) {
define('ROOT_PATH', dirname(__DIR__, 2));
// 获取项目根目录(使用 __DIR__ 动态获取)
// TaskSchedulerCommand.php 位于 application/command/,向上两级到项目根目录
$executionPath = dirname(__DIR__, 2);
// 获取 PHP 可执行文件路径
$phpPath = PHP_BINARY ?: 'php';
// 获取 think 脚本路径(使用项目根目录)
$thinkPath = $executionPath . DIRECTORY_SEPARATOR . 'think';
// 检查 think 文件是否存在
if (!is_file($thinkPath)) {
$errorMsg = "错误think 文件不存在:{$thinkPath}";
Log::error($errorMsg);
file_put_contents($logFile, $errorMsg . "\n", FILE_APPEND);
$this->removeLock($taskId); // 删除锁文件
return;
}
$thinkPath = ROOT_PATH . DIRECTORY_SEPARATOR . 'think';
$command = "php {$thinkPath} {$task['command']}";
// 构建命令(使用绝对路径,确保在 Linux 上能正确执行)
$command = escapeshellarg($phpPath) . ' ' . escapeshellarg($thinkPath) . ' ' . escapeshellarg($task['command']);
if (!empty($task['options'])) {
foreach ($task['options'] as $option) {
$command .= ' ' . escapeshellarg($option);
}
}
// 添加日志重定向
$command .= " >> " . escapeshellarg($logFile) . " 2>&1";
// 获取任务名称
$taskName = $task['name'] ?? $taskId;
// 记录任务开始
$logMessage = "\n" . str_repeat('=', 60) . "\n";
$logMessage .= "任务开始执行: {$taskId}\n";
$logMessage .= "任务开始执行: {$taskName} ({$taskId})\n";
$logMessage .= "执行时间: " . date('Y-m-d H:i:s') . "\n";
$logMessage .= "命令: {$command}\n";
$logMessage .= str_repeat('=', 60) . "\n";
file_put_contents($logFile, $logMessage, FILE_APPEND);
// 执行命令
// 设置超时时间
$timeout = $task['timeout'] ?? 3600;
// 执行命令(参考 schedule.php 的实现)
$descriptorspec = [
0 => ['file', (PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'), 'r'], // stdin
1 => ['file', $logFile, 'a'], // stdout
2 => ['file', $logFile, 'a'], // stderr
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$process = @proc_open($command, $descriptorspec, $pipes, ROOT_PATH);
$process = @proc_open($command, $descriptorspec, $pipes, $executionPath);
if (is_resource($process)) {
// 关闭管道
if (isset($pipes[0])) @fclose($pipes[0]);
if (isset($pipes[1])) @fclose($pipes[1]);
if (isset($pipes[2])) @fclose($pipes[2]);
if (!is_resource($process)) {
$errorMsg = "任务执行失败: 无法启动进程";
$lastError = error_get_last();
if ($lastError) {
$errorMsg .= "\n错误信息: " . $lastError['message'];
}
Log::error($errorMsg, ['task' => $taskId]);
file_put_contents($logFile, $errorMsg . "\n", FILE_APPEND);
$this->removeLock($taskId); // 删除锁文件
return;
}
// 设置非阻塞模式
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
// 设置超时
$timeout = $task['timeout'] ?? 3600;
$startWaitTime = time();
$output = '';
$error = '';
$finalExitCode = null; // 保存进程结束时的退出码
// 等待进程完成或超时
while (true) {
$status = proc_get_status($process);
// 读取输出
$output .= stream_get_contents($pipes[1]);
$error .= stream_get_contents($pipes[2]);
// 检查是否完成
if (!$status['running']) {
// 保存退出码(在进程刚结束时获取,此时最准确)
if (isset($status['exitcode'])) {
$finalExitCode = $status['exitcode'];
}
break;
}
// 检查超时
if ((time() - $startWaitTime) > $timeout) {
Log::warning("任务执行超时({$timeout}秒),终止进程", ['task' => $taskId]);
file_put_contents($logFile, "任务执行超时({$timeout}秒),终止进程\n", FILE_APPEND);
if (function_exists('proc_terminate')) {
proc_terminate($process, SIGTERM);
// 等待进程终止
sleep(2);
$status = proc_get_status($process);
if ($status['running']) {
// 强制终止
proc_terminate($process, SIGKILL);
}
}
Log::warning("任务执行超时", [
'task' => $taskId,
'timeout' => $timeout,
]);
break;
proc_terminate($process);
}
usleep(500000); // 等待500ms
}
// 关闭进程
// 关闭管道
@fclose($pipes[0]);
@fclose($pipes[1]);
@fclose($pipes[2]);
proc_close($process);
} else {
// 如果 proc_open 失败,尝试直接执行(后台执行)
if (PHP_OS_FAMILY === 'Windows') {
pclose(popen("start /B " . $command, "r"));
} else {
exec($command . ' > /dev/null 2>&1 &');
$this->removeLock($taskId); // 删除锁文件
return;
}
// 等待100ms
usleep(100000);
}
// 读取剩余输出
$output .= stream_get_contents($pipes[1]);
$error .= stream_get_contents($pipes[2]);
// 关闭管道
@fclose($pipes[0]);
@fclose($pipes[1]);
@fclose($pipes[2]);
// 获取退出码
$exitCodeFromClose = proc_close($process);
// 优先使用进程刚结束时保存的退出码proc_get_status 在进程刚结束时的返回值)
// 因为关闭管道后proc_get_status 可能会返回 -1这是 PHP 的已知行为
if ($finalExitCode !== null) {
$exitCode = $finalExitCode;
} else {
$exitCode = $exitCodeFromClose;
}
// 记录输出
if (!empty($output)) {
file_put_contents($logFile, "任务输出:\n{$output}\n", FILE_APPEND);
}
if (!empty($error)) {
file_put_contents($logFile, "任务错误:\n{$error}\n", FILE_APPEND);
Log::error("任务执行错误", ['task' => $taskId, 'error' => $error]);
}
$endTime = microtime(true);
$duration = round($endTime - $startTime, 2);
// 获取任务名称
$taskName = $task['name'] ?? $taskId;
// 解释退出码含义
$exitCodeMeaning = $this->getExitCodeMeaning($exitCode);
// 记录任务完成
$logMessage = "\n" . str_repeat('=', 60) . "\n";
$logMessage .= "任务执行完成: {$taskId}\n";
$logMessage .= "任务执行完成: {$taskName} ({$taskId})\n";
$logMessage .= "完成时间: " . date('Y-m-d H:i:s') . "\n";
$logMessage .= "执行时长: {$duration}\n";
$logMessage .= "退出码: {$exitCode} ({$exitCodeMeaning})\n";
$logMessage .= str_repeat('=', 60) . "\n";
file_put_contents($logFile, $logMessage, FILE_APPEND);
Log::info("任务执行完成", [
if ($exitCode === 0) {
Log::info("任务执行成功", [
'task' => $taskId,
'name' => $taskName,
'duration' => $duration,
]);
} else {
Log::error("任务执行失败", [
'task' => $taskId,
'name' => $taskName,
'duration' => $duration,
'exit_code' => $exitCode,
'exit_code_meaning' => $exitCodeMeaning,
]);
}
// 删除锁文件(任务完成)
$this->removeLock($taskId);
}
/**
@@ -448,18 +586,66 @@ class TaskSchedulerCommand extends Command
if ($result == $pid || $result == -1) {
// 进程已结束
$taskId = $info['task_id'];
unset($this->runningProcesses[$pid]);
// 删除任务锁文件
$this->removeLock($taskId);
$duration = time() - $info['start_time'];
Log::info("子进程执行完成", [
'pid' => $pid,
'task' => $info['task_id'],
'task' => $taskId,
'duration' => $duration,
]);
}
}
}
/**
* 获取退出码的含义说明
* @param int $exitCode 退出码
* @return string 退出码含义
*/
protected function getExitCodeMeaning($exitCode)
{
switch ($exitCode) {
case 0:
return '成功';
case -1:
return '进程被信号终止或异常终止(可能是被强制终止、超时终止或发生致命错误)';
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7:
case 8:
case 9:
case 10:
return '一般性错误';
case 126:
return '命令不可执行';
case 127:
return '命令未找到';
case 128:
return '无效的退出参数';
case 130:
return '进程被 Ctrl+C 终止 (SIGINT)';
case 137:
return '进程被 SIGKILL 信号强制终止';
case 143:
return '进程被 SIGTERM 信号终止';
default:
if ($exitCode > 128 && $exitCode < 256) {
$signal = $exitCode - 128;
return "进程被信号 {$signal} 终止";
}
return '未知错误';
}
}
/**
* 清理僵尸进程
*/
@@ -474,5 +660,80 @@ class TaskSchedulerCommand extends Command
// 清理僵尸进程
}
}
/**
* 检查任务是否正在运行(通过锁文件,参考 schedule.php
*
* @param string $taskId 任务ID
* @return bool
*/
protected function isTaskRunning($taskId)
{
$lockFile = $this->lockDir . 'schedule_' . md5($taskId) . '.lock';
if (!file_exists($lockFile)) {
return false;
}
// 检查锁文件是否过期超过1小时认为过期
$lockTime = filemtime($lockFile);
if (time() - $lockTime > 3600) {
@unlink($lockFile);
return false;
}
// 读取锁文件中的PID
$lockContent = @file_get_contents($lockFile);
if ($lockContent !== false) {
$lockData = json_decode($lockContent, true);
if (isset($lockData['pid']) && function_exists('posix_kill')) {
// 检查进程是否真的在运行
if (@posix_kill($lockData['pid'], 0)) {
return true;
} else {
// 进程不存在,删除锁文件
@unlink($lockFile);
return false;
}
}
}
// 如果没有PID或无法检查使用时间判断2分钟内认为在运行
if (time() - $lockTime < 120) {
return true;
}
return false;
}
/**
* 创建任务锁文件(参考 schedule.php
*
* @param string $taskId 任务ID
* @param int $pid 进程ID
*/
protected function createLock($taskId, $pid = null)
{
$lockFile = $this->lockDir . 'schedule_' . md5($taskId) . '.lock';
$lockData = [
'task_id' => $taskId,
'pid' => $pid ?: getmypid(),
'time' => time(),
];
file_put_contents($lockFile, json_encode($lockData));
}
/**
* 删除任务锁文件(参考 schedule.php
*
* @param string $taskId 任务ID
*/
protected function removeLock($taskId)
{
$lockFile = $this->lockDir . 'schedule_' . md5($taskId) . '.lock';
if (file_exists($lockFile)) {
@unlink($lockFile);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\command;
use app\job\WorkbenchGroupWelcomeJob;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Log;
class WorkbenchGroupWelcomeCommand extends Command
{
protected function configure()
{
$this->setName('workbench:groupWelcome')
->setDescription('工作台入群欢迎语任务队列');
}
protected function execute(Input $input, Output $output)
{
$output->writeln('开始处理工作台入群欢迎语任务...');
try {
$job = new WorkbenchGroupWelcomeJob();
$result = $job->processWelcomeMessage([], 0);
if ($result) {
$output->writeln('入群欢迎语任务处理完成');
} else {
$output->writeln('入群欢迎语任务处理失败');
}
return $result;
} catch (\Exception $e) {
$errorMsg = '工作台入群欢迎语任务执行失败:' . $e->getMessage();
Log::error($errorMsg);
$output->writeln($errorMsg);
return false;
}
}
}

View File

@@ -5,11 +5,20 @@ namespace app\common\model;
use think\Model;
/**
* 流量池模型类
* 流量池模型类(旧版,已废弃)
*
* @deprecated 此模型已废弃,请使用 TrafficPoolV2 模型
* 旧表ck_traffic_pool_v1
* 新表ck_traffic_pool使用 TrafficPoolV2 模型)
*/
class TrafficPool extends Model
{
// ========== 旧版流量池表(已废弃) ==========
// 设置数据表名
// protected $name = 'traffic_pool_v1';
// ========== 新版流量池表 ==========
// 注意:为了兼容性,暂时保留此模型,但表名已改为新版
// 新代码请使用 TrafficPoolV2 模型
protected $name = 'traffic_pool';
// 自动写入时间戳

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,240 @@
<?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();
}
/**
* 分页获取用户行为轨迹
* @param int $poolCompanyId
* @param int $page 页码
* @param int $pageSize 每页数量
* @param string $keyword 搜索关键词(搜索行为名称)
* @param int $behaviorType 行为类型筛选
* @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10]
*/
public static function getUserJourneyPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = '', int $behaviorType = 0): array
{
$query = self::where('poolCompanyId', $poolCompanyId);
// 关键词搜索
if (!empty($keyword)) {
$query->where('behaviorName', 'like', '%' . $keyword . '%');
}
// 行为类型筛选
if ($behaviorType > 0) {
$query->where('behaviorType', $behaviorType);
}
// 统计总数
$total = $query->count();
// 分页查询
$behaviors = $query->order('behaviorTime DESC')
->page($page, $pageSize)
->select()
->toArray();
return [
'list' => $behaviors,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize
];
}
}

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,509 @@
<?php
namespace app\common\model;
use think\Model;
use think\Db;
/**
* 流量来源表模型类
* 表名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;
}
/**
* 兼容历史库s2_wechat_friend 早期可能不存在 headImgUrl 列
* @param string $wechatId
* @return string
*/
protected static function getFriendHeadImgUrl(string $wechatId): string
{
$wechatId = trim($wechatId);
if ($wechatId === '') {
return '';
}
static $hasHeadImgUrl = null;
if ($hasHeadImgUrl === null) {
try {
$cols = Db::query("SHOW COLUMNS FROM s2_wechat_friend LIKE 'headImgUrl'");
$hasHeadImgUrl = !empty($cols);
} catch (\Throwable $e) {
$hasHeadImgUrl = false;
}
}
if (!$hasHeadImgUrl) {
return '';
}
try {
$val = Db::table('s2_wechat_friend')
->where('wechatId', $wechatId)
->value('headImgUrl');
return $val ? (string)$val : '';
} catch (\Throwable $e) {
// 兜底:避免因字段缺失导致接口直接 1054
return '';
}
}
/**
* 获取来源类型名称
* @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();
}
/**
* 获取流量的所有来源(带群归属信息)
* @param int $poolCompanyId
* @param int $limit 限制数量0表示不限制
* @return array
*/
public static function getSourcesWithOwners(int $poolCompanyId, int $limit = 0): array
{
$query = self::where('poolCompanyId', $poolCompanyId)
->order('createTime DESC');
if ($limit > 0) {
$query->limit($limit);
}
$sources = $query->select()->toArray();
if (empty($sources)) {
return [];
}
// 收集所有群ID
$chatroomIds = [];
foreach ($sources as $source) {
if (!empty($source['sourceChatroomId'])) {
$chatroomIds[] = $source['sourceChatroomId'];
}
}
// 查询群信息和归属客服
$chatroomOwners = [];
if (!empty($chatroomIds)) {
$chatroomOwners = self::getChatroomOwners($chatroomIds);
}
// 组装数据(按来源类型和关键标识去重)
$result = [];
$seenChatroomIds = []; // 用于群成员来源去重
$seenFriendIds = []; // 用于好友添加来源去重
foreach ($sources as $source) {
// 群成员来源去重:同一个群只保留一条记录
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
$chatroomId = $source['sourceChatroomId'];
if (isset($seenChatroomIds[$chatroomId])) {
continue; // 跳过重复的群
}
$seenChatroomIds[$chatroomId] = true;
}
// 好友添加来源去重同一个好友按sourceWechatId或sourceName只保留一条记录
if ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
$friendKey = $source['sourceWechatId'] ?: ($source['sourceName'] ?: '');
if (!empty($friendKey) && isset($seenFriendIds[$friendKey])) {
continue; // 跳过重复的好友来源
}
if (!empty($friendKey)) {
$seenFriendIds[$friendKey] = true;
}
}
$sourceData = $source;
// 格式化时间(兼容时间戳和日期字符串)
if (!empty($source['createTime'])) {
if (is_numeric($source['createTime'])) {
$sourceData['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$source['createTime']);
} else {
$sourceData['createTimeFormatted'] = $source['createTime'];
}
} else {
$sourceData['createTimeFormatted'] = null;
}
// 添加来源类型名称
$sourceData['sourceTypeName'] = self::SOURCE_TYPE_NAMES[$source['sourceType']] ?? '未知来源';
// 如果是群成员来源添加群归属信息和群ID展示
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
$chatroomId = $source['sourceChatroomId'];
$sourceData['chatroomOwners'] = $chatroomOwners[$chatroomId] ?? [];
$sourceData['chatroomInfo'] = self::getChatroomInfo($chatroomId);
// 添加群ID用于展示
$sourceData['displayId'] = $chatroomId;
} elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
// 好友添加来源添加好友微信ID用于展示
$sourceData['chatroomOwners'] = [];
$sourceData['chatroomInfo'] = null;
$sourceData['displayId'] = $source['sourceWechatId'] ?: '';
// 尝试获取好友头像
if (!empty($source['sourceWechatId'])) {
$avatar = Db::table('ck_traffic_pool')
->where('wechatId', $source['sourceWechatId'])
->value('avatar');
$sourceData['sourceAvatar'] = $avatar ?: self::getFriendHeadImgUrl((string)$source['sourceWechatId']);
}
} else {
$sourceData['chatroomOwners'] = [];
$sourceData['chatroomInfo'] = null;
$sourceData['displayId'] = $source['sourceId'] ?: '';
}
$result[] = $sourceData;
}
return $result;
}
/**
* 获取群的归属客服信息(支持多个客服)
* @param array $chatroomIds 群聊ID数组
* @return array [chatroomId => [owner1, owner2, ...]]
*/
protected static function getChatroomOwners(array $chatroomIds): array
{
if (empty($chatroomIds)) {
return [];
}
// 查询群和归属账号信息
$chatrooms = Db::table(['s2_wechat_chatroom' => 'wc'])
->leftJoin(['s2_wechat_account' => 'wa'], 'wa.wechatId = wc.wechatAccountWechatId')
->whereIn('wc.chatroomId', $chatroomIds)
->where('wc.isDeleted', 0)
->field([
'wc.chatroomId',
'wc.nickname as chatroomName',
'wc.chatroomAvatar',
'wc.wechatAccountWechatId as ownerWechatId',
'wc.wechatAccountNickname as ownerNickname',
'wc.wechatAccountAvatar as ownerAvatar',
'wc.wechatAccountAlias as ownerAlias',
'wa.id as accountId',
'wa.nickName as accountNickname',
])
->select();
// 按 chatroomId 分组,一个群可能有多条记录(多个客服管理)
$result = [];
foreach ($chatrooms as $chatroom) {
$chatroomId = $chatroom['chatroomId'];
if (!isset($result[$chatroomId])) {
$result[$chatroomId] = [];
}
// 避免重复添加相同的客服
$ownerWechatId = $chatroom['ownerWechatId'];
$exists = false;
foreach ($result[$chatroomId] as $existing) {
if ($existing['ownerWechatId'] === $ownerWechatId) {
$exists = true;
break;
}
}
if (!$exists && !empty($ownerWechatId)) {
$result[$chatroomId][] = [
'ownerWechatId' => $ownerWechatId,
'ownerNickname' => $chatroom['ownerNickname'] ?: $chatroom['accountNickname'] ?: '',
'ownerAvatar' => $chatroom['ownerAvatar'] ?: '',
'ownerAlias' => $chatroom['ownerAlias'] ?: '',
'accountId' => $chatroom['accountId'],
];
}
}
return $result;
}
/**
* 获取群信息
* @param string $chatroomId 群聊ID
* @return array|null
*/
protected static function getChatroomInfo(string $chatroomId): ?array
{
$chatroom = Db::table(['s2_wechat_chatroom' => 'wc'])
->where('wc.chatroomId', $chatroomId)
->where('wc.isDeleted', 0)
->field([
'wc.id',
'wc.chatroomId',
'wc.nickname as chatroomName',
'wc.chatroomAvatar',
'wc.createTime',
])
->find();
if (!$chatroom) {
return null;
}
// 格式化创建时间(兼容时间戳和日期字符串)
if (!empty($chatroom['createTime'])) {
if (is_numeric($chatroom['createTime'])) {
$chatroom['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$chatroom['createTime']);
} else {
$chatroom['createTimeFormatted'] = $chatroom['createTime'];
}
} else {
$chatroom['createTimeFormatted'] = null;
}
return $chatroom;
}
/**
* 分页获取流量的来源(带群归属信息)
* @param int $poolCompanyId
* @param int $page 页码
* @param int $pageSize 每页数量
* @param string $keyword 搜索关键词(搜索来源名称)
* @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10]
*/
public static function getSourcesWithOwnersPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = ''): array
{
$query = self::where('poolCompanyId', $poolCompanyId);
// 关键词搜索
if (!empty($keyword)) {
$query->where('sourceName', 'like', '%' . $keyword . '%');
}
// 统计总数
$total = $query->count();
// 分页查询
$sources = $query->order('createTime DESC')
->page($page, $pageSize)
->select()
->toArray();
if (empty($sources)) {
return [
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize
];
}
// 收集所有群ID
$chatroomIds = [];
foreach ($sources as $source) {
if (!empty($source['sourceChatroomId'])) {
$chatroomIds[] = $source['sourceChatroomId'];
}
}
// 查询群信息和归属客服
$chatroomOwners = [];
if (!empty($chatroomIds)) {
$chatroomOwners = self::getChatroomOwners($chatroomIds);
}
// 组装数据(按来源类型和关键标识去重)
$result = [];
$seenChatroomIds = []; // 用于群成员来源去重
$seenFriendIds = []; // 用于好友添加来源去重
foreach ($sources as $source) {
// 群成员来源去重:同一个群只保留一条记录
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
$chatroomId = $source['sourceChatroomId'];
if (isset($seenChatroomIds[$chatroomId])) {
continue; // 跳过重复的群
}
$seenChatroomIds[$chatroomId] = true;
}
// 好友添加来源去重按来源微信ID或来源名称去重
if ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
$friendAddKey = $source['sourceWechatId'] ?? ($source['sourceName'] ?? '');
if (!empty($friendAddKey) && isset($seenFriendIds[$friendAddKey])) {
continue; // 跳过重复的好友添加
}
$seenFriendIds[$friendAddKey] = true;
}
$sourceData = $source;
// 设置显示ID
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
$sourceData['displayId'] = "群ID" . $source['sourceChatroomId'];
} elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
$sourceData['displayId'] = "好友ID" . ($source['sourceWechatId'] ?? $source['sourceName'] ?? '-');
} else {
$sourceData['displayId'] = null;
}
// 格式化时间(兼容时间戳和日期字符串)
if (!empty($source['createTime'])) {
if (is_numeric($source['createTime'])) {
$sourceData['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$source['createTime']);
} else {
$sourceData['createTimeFormatted'] = $source['createTime'];
}
} else {
$sourceData['createTimeFormatted'] = null;
}
// 添加来源类型名称
$sourceData['sourceTypeName'] = self::SOURCE_TYPE_NAMES[$source['sourceType']] ?? '未知来源';
// 如果是群成员来源,添加群归属信息
if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) {
$chatroomId = $source['sourceChatroomId'];
$sourceData['chatroomOwners'] = $chatroomOwners[$chatroomId] ?? [];
$sourceData['chatroomInfo'] = self::getChatroomInfo($chatroomId);
} elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) {
$sourceData['chatroomOwners'] = [];
$sourceData['chatroomInfo'] = null;
// 尝试获取好友头像
if (!empty($source['sourceWechatId'])) {
$avatar = Db::table('ck_traffic_pool')
->where('wechatId', $source['sourceWechatId'])
->value('avatar');
$sourceData['sourceAvatar'] = $avatar ?: self::getFriendHeadImgUrl((string)$source['sourceWechatId']);
}
} else {
$sourceData['chatroomOwners'] = [];
$sourceData['chatroomInfo'] = null;
}
$result[] = $sourceData;
}
return [
'list' => $result,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize
];
}
}

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

@@ -18,7 +18,7 @@ class TrafficSource extends Model
// 设置数据表名
protected $name = 'traffic_source';
protected $name = 'traffic_source_v1';
// 自动写入时间戳
protected $autoWriteTimestamp = true;

View File

@@ -11,7 +11,7 @@ class TrafficSourcePackage extends Model
{
// 设置数据表名
protected $name = 'traffic_source_package';
protected $name = 'traffic_source_package_v1';
}

View File

@@ -11,7 +11,7 @@ class TrafficSourcePackageItem extends Model
{
// 设置数据表名
protected $name = 'traffic_source_package_item';
protected $name = 'traffic_source_package_item_v1';
}

View File

@@ -0,0 +1,480 @@
<?php
namespace app\common\service;
use app\api\controller\AutomaticAssign;
use think\Db;
use think\facade\Log;
/**
* 好友迁移服务类
* 负责处理好友在不同账号之间的迁移逻辑
*/
class FriendTransferService
{
/**
* 迁移好友到其他账号
* @param int $wechatFriendId 微信好友ID
* @param int $currentAccountId 当前账号ID
* @param string $reason 迁移原因
* @return array ['success' => bool, 'message' => string, 'toAccountId' => int|null]
*/
public function transferFriend($wechatFriendId, $currentAccountId, $reason = '')
{
try {
// 获取好友信息
$friend = Db::table('s2_wechat_friend')->where('id', $wechatFriendId)->find();
if (empty($friend)) {
return [
'success' => false,
'message' => '好友不存在',
'toAccountId' => null
];
}
// 获取当前账号的部门信息
$accountData = Db::table('s2_company_account')->where('id', $currentAccountId)->find();
if (empty($accountData)) {
return [
'success' => false,
'message' => '当前账号不存在',
'toAccountId' => null
];
}
// 获取同部门的在线账号列表
$accountIds = Db::table('s2_company_account')
->where([
'departmentId' => $accountData['departmentId'],
'alive' => 1
])
->column('id');
if (empty($accountIds)) {
return [
'success' => false,
'message' => '没有可用的在线账号',
'toAccountId' => null
];
}
// 如果好友当前账号不在可用账号列表中,或者需要迁移到其他账号
$needTransfer = !in_array($friend['accountId'], $accountIds);
// 如果需要迁移,选择目标账号
if ($needTransfer || $currentAccountId != $friend['accountId']) {
// 排除当前账号,选择其他账号
$availableAccountIds = array_filter($accountIds, function($id) use ($currentAccountId) {
return $id != $currentAccountId;
});
if (empty($availableAccountIds)) {
return [
'success' => false,
'message' => '没有其他可用的在线账号',
'toAccountId' => null
];
}
// 随机选择一个账号
$availableAccountIds = array_values($availableAccountIds);
$randomKey = array_rand($availableAccountIds, 1);
$toAccountId = $availableAccountIds[$randomKey];
// 获取目标账号信息
$toAccountData = Db::table('s2_company_account')->where('id', $toAccountId)->find();
if (empty($toAccountData)) {
return [
'success' => false,
'message' => '目标账号不存在',
'toAccountId' => null
];
}
// 执行迁移
$automaticAssign = new AutomaticAssign();
$result = $automaticAssign->allotWechatFriend([
'wechatFriendId' => $wechatFriendId,
'toAccountId' => $toAccountId
], true);
$resultData = json_decode($result, true);
if (isset($resultData['code']) && $resultData['code'] == 200) {
// 更新好友的账号信息
Db::table('s2_wechat_friend')
->where('id', $wechatFriendId)
->update([
'accountId' => $toAccountId,
'accountUserName' => $toAccountData['userName'],
'accountRealName' => $toAccountData['realName'],
'accountNickname' => $toAccountData['nickname'],
]);
$logMessage = "好友迁移成功好友ID={$wechatFriendId},从账号{$currentAccountId}迁移到账号{$toAccountId}";
if (!empty($reason)) {
$logMessage .= ",原因:{$reason}";
}
Log::info($logMessage);
return [
'success' => true,
'message' => '好友迁移成功',
'toAccountId' => $toAccountId
];
} else {
$errorMsg = isset($resultData['msg']) ? $resultData['msg'] : '迁移失败';
Log::error("好友迁移失败好友ID={$wechatFriendId},错误:{$errorMsg}");
return [
'success' => false,
'message' => $errorMsg,
'toAccountId' => null
];
}
}
return [
'success' => true,
'message' => '好友已在正确的账号上,无需迁移',
'toAccountId' => $friend['accountId']
];
} catch (\Exception $e) {
Log::error("好友迁移异常好友ID={$wechatFriendId},错误:" . $e->getMessage());
return [
'success' => false,
'message' => '迁移异常:' . $e->getMessage(),
'toAccountId' => null
];
}
}
/**
* 批量迁移好友到其他账号(按账号分组处理)
* @param array $friends 好友列表,格式:[['friendId' => int, 'accountId' => int], ...]
* @param int $currentAccountId 当前账号ID
* @param string $reason 迁移原因
* @return array ['transferred' => int, 'failed' => int]
*/
public function transferFriendsBatch($friends, $currentAccountId, $reason = '')
{
$transferred = 0;
$failed = 0;
if (empty($friends)) {
return ['transferred' => 0, 'failed' => 0];
}
try {
// 获取当前账号的部门信息
$accountData = Db::table('s2_company_account')->where('id', $currentAccountId)->find();
if (empty($accountData)) {
Log::error("批量迁移失败当前账号不存在账号ID={$currentAccountId}");
return ['transferred' => 0, 'failed' => count($friends)];
}
// 获取同部门的在线账号列表
$accountIds = Db::table('s2_company_account')
->where([
'departmentId' => $accountData['departmentId'],
'alive' => 1
])
->column('id');
if (empty($accountIds)) {
Log::warning("批量迁移失败没有可用的在线账号账号ID={$currentAccountId}");
return ['transferred' => 0, 'failed' => count($friends)];
}
// 排除当前账号,选择其他账号
$availableAccountIds = array_filter($accountIds, function($id) use ($currentAccountId) {
return $id != $currentAccountId;
});
if (empty($availableAccountIds)) {
Log::warning("批量迁移失败没有其他可用的在线账号账号ID={$currentAccountId}");
return ['transferred' => 0, 'failed' => count($friends)];
}
// 随机选择一个目标账号(同一批次使用同一个目标账号)
$availableAccountIds = array_values($availableAccountIds);
$randomKey = array_rand($availableAccountIds, 1);
$toAccountId = $availableAccountIds[$randomKey];
// 获取目标账号信息
$toAccountData = Db::table('s2_company_account')->where('id', $toAccountId)->find();
if (empty($toAccountData)) {
Log::error("批量迁移失败目标账号不存在账号ID={$toAccountId}");
return ['transferred' => 0, 'failed' => count($friends)];
}
// 批量获取好友信息
$friendIds = array_column($friends, 'friendId');
$friendList = Db::table('s2_wechat_friend')
->where('id', 'in', $friendIds)
->select();
$friendMap = [];
foreach ($friendList as $friend) {
$friendMap[$friend['id']] = $friend;
}
// 批量执行迁移
$automaticAssign = new AutomaticAssign();
$updateData = [];
foreach ($friends as $friendItem) {
$wechatFriendId = $friendItem['friendId'];
if (!isset($friendMap[$wechatFriendId])) {
$failed++;
Log::warning("批量迁移失败好友不存在好友ID={$wechatFriendId}");
continue;
}
$friend = $friendMap[$wechatFriendId];
// 如果好友当前账号不在可用账号列表中,或者需要迁移到其他账号
$needTransfer = !in_array($friend['accountId'], $accountIds) || $currentAccountId != $friend['accountId'];
if ($needTransfer) {
// 执行迁移
$result = $automaticAssign->allotWechatFriend([
'wechatFriendId' => $wechatFriendId,
'toAccountId' => $toAccountId
], true);
$resultData = json_decode($result, true);
if (isset($resultData['code']) && $resultData['code'] == 200) {
// 收集需要更新的数据
$updateData[] = [
'id' => $wechatFriendId,
'accountId' => $toAccountId,
'accountUserName' => $toAccountData['userName'],
'accountRealName' => $toAccountData['realName'],
'accountNickname' => $toAccountData['nickname'],
];
$transferred++;
} else {
$errorMsg = isset($resultData['msg']) ? $resultData['msg'] : '迁移失败';
$failed++;
Log::warning("批量迁移失败好友ID={$wechatFriendId},错误:{$errorMsg}");
}
} else {
// 无需迁移
$transferred++;
}
}
// 批量更新好友的账号信息
if (!empty($updateData)) {
foreach ($updateData as $data) {
Db::table('s2_wechat_friend')
->where('id', $data['id'])
->update([
'accountId' => $data['accountId'],
'accountUserName' => $data['accountUserName'],
'accountRealName' => $data['accountRealName'],
'accountNickname' => $data['accountNickname'],
]);
}
$logMessage = "批量迁移成功账号ID={$currentAccountId},共" . count($updateData) . "个好友迁移到账号{$toAccountId}";
if (!empty($reason)) {
$logMessage .= ",原因:{$reason}";
}
Log::info($logMessage);
}
return [
'transferred' => $transferred,
'failed' => $failed
];
} catch (\Exception $e) {
Log::error("批量迁移异常账号ID={$currentAccountId},错误:" . $e->getMessage());
return [
'transferred' => $transferred,
'failed' => count($friends) - $transferred
];
}
}
/**
* 检查并迁移未读或未回复的好友
* @param int $unreadMinutes 未读分钟数默认30分钟
* @param int $pageSize 每页处理数量默认100
* @return array ['total' => int, 'transferred' => int, 'failed' => int]
*/
public function checkAndTransferUnreadOrUnrepliedFriends($unreadMinutes = 30, $pageSize = 100)
{
$total = 0;
$transferred = 0;
$failed = 0;
try {
$currentTime = time();
$timeThreshold = $currentTime - ($unreadMinutes * 60); // 超过指定分钟数的时间点
$last24Hours = $currentTime - (24 * 60 * 60); // 近24小时的时间点
// 确保每页数量合理
$pageSize = max(1, min(1000, intval($pageSize)));
// 查询需要迁移的好友
// 条件以消息表为主表查询近24小时内的消息
// 1. 最后一条消息是用户发送的消息isSend=0
// 2. 消息时间在近24小时内
// 3. 消息时间超过指定分钟数默认30分钟
// 4. 在这条用户消息之后,客服没有发送任何回复
// 即用户发送了消息但客服超过30分钟没有回复需要迁移给其他客服处理
// SQL逻辑说明以消息表为主表
// 1. 从消息表开始筛选近24小时内的用户消息isSend=0
// 2. 找到每个好友的最后一条用户消息通过MAX(id)
// 3. 这条消息的时间超过指定分钟数wm.wechatTime <= timeThreshold
// 4. 在这条用户消息之后客服没有发送任何回复NOT EXISTS isSend=1的消息
// 5. 关联好友表,确保好友未删除且已分配账号
// 先统计总数
$countSql = "
SELECT COUNT(DISTINCT wm.wechatFriendId) as total
FROM s2_wechat_message wm
INNER JOIN (
SELECT wechatFriendId, MAX(id) as maxId
FROM s2_wechat_message
WHERE type = 1
AND isSend = 0 -- 用户发送的消息
AND wechatTime >= ? -- 近24小时内的消息
GROUP BY wechatFriendId
) last_msg ON wm.id = last_msg.maxId
INNER JOIN s2_wechat_friend wf ON wf.id = wm.wechatFriendId
WHERE wm.type = 1
AND wm.isSend = 0 -- 最后一条消息是用户发送的(客服接收的)
AND wm.wechatTime >= ? -- 近24小时内的消息
AND wm.wechatTime <= ? -- 超过指定时间默认30分钟
AND wf.isDeleted = 0
AND wf.accountId IS NOT NULL
AND NOT EXISTS (
-- 检查在这条用户消息之后,是否有客服的回复
SELECT 1
FROM s2_wechat_message
WHERE wechatFriendId = wm.wechatFriendId
AND type = 1
AND isSend = 1 -- 客服发送的消息
AND wechatTime > wm.wechatTime -- 在用户消息之后
)
";
$countResult = Db::query($countSql, [$last24Hours, $last24Hours, $timeThreshold]);
$total = isset($countResult[0]['total']) ? intval($countResult[0]['total']) : 0;
if ($total == 0) {
Log::info("未找到需要迁移的未读/未回复好友近24小时内");
return [
'total' => 0,
'transferred' => 0,
'failed' => 0
];
}
Log::info("开始检查未读/未回复好友近24小时内共找到 {$total} 个需要迁移的好友,将分页处理(每页{$pageSize}条)");
// 分页处理
$page = 1;
$processed = 0;
do {
$offset = ($page - 1) * $pageSize;
$sql = "
SELECT DISTINCT
wf.id as friendId,
wf.accountId,
wm.wechatAccountId,
wm.wechatTime,
wm.id as lastMessageId
FROM s2_wechat_message wm
INNER JOIN (
SELECT wechatFriendId, MAX(id) as maxId
FROM s2_wechat_message
WHERE type = 1
AND isSend = 0 -- 用户发送的消息
AND wechatTime >= ? -- 近24小时内的消息
GROUP BY wechatFriendId
) last_msg ON wm.id = last_msg.maxId
INNER JOIN s2_wechat_friend wf ON wf.id = wm.wechatFriendId
WHERE wm.type = 1
AND wm.isSend = 0 -- 最后一条消息是用户发送的(客服接收的)
AND wm.wechatTime >= ? -- 近24小时内的消息
AND wm.wechatTime <= ? -- 超过指定时间默认30分钟
AND wf.isDeleted = 0
AND wf.accountId IS NOT NULL
AND NOT EXISTS (
-- 检查在这条用户消息之后,是否有客服的回复
SELECT 1
FROM s2_wechat_message
WHERE wechatFriendId = wm.wechatFriendId
AND type = 1
AND isSend = 1 -- 客服发送的消息
AND wechatTime > wm.wechatTime -- 在用户消息之后
)
ORDER BY wf.accountId ASC, wm.id ASC
LIMIT ? OFFSET ?
";
$friends = Db::query($sql, [$last24Hours, $last24Hours, $timeThreshold, $pageSize, $offset]);
$currentPageCount = count($friends);
if ($currentPageCount == 0) {
break;
}
Log::info("处理第 {$page} 页,本页 {$currentPageCount} 条记录");
// 按 accountId 分组
$friendsByAccount = [];
foreach ($friends as $friend) {
$accountId = $friend['accountId'];
if (!isset($friendsByAccount[$accountId])) {
$friendsByAccount[$accountId] = [];
}
$friendsByAccount[$accountId][] = $friend;
}
// 按账号分组批量处理
foreach ($friendsByAccount as $accountId => $accountFriends) {
$batchResult = $this->transferFriendsBatch(
$accountFriends,
$accountId,
"消息未读或未回复超过{$unreadMinutes}分钟"
);
$transferred += $batchResult['transferred'];
$failed += $batchResult['failed'];
$processed += count($accountFriends);
Log::info("账号 {$accountId} 批量迁移完成:成功{$batchResult['transferred']},失败{$batchResult['failed']},共" . count($accountFriends) . "个好友");
}
$page++;
// 每处理一页后记录进度
Log::info("已处理 {$processed}/{$total} 条记录,成功:{$transferred},失败:{$failed}");
} while ($currentPageCount == $pageSize && $processed < $total);
Log::info("未读/未回复好友迁移完成:总计{$total},成功{$transferred},失败{$failed}");
return [
'total' => $total,
'transferred' => $transferred,
'failed' => $failed
];
} catch (\Exception $e) {
Log::error("检查未读/未回复好友异常:" . $e->getMessage());
return [
'total' => $total,
'transferred' => $transferred,
'failed' => $failed
];
}
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace app\common\service;
use app\common\util\AliyunOSS;
use think\facade\Log;
class MediaOssArchiveService
{
protected static $mimeExtensionMap = [
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp' => 'bmp',
'image/svg+xml' => 'svg',
'video/mp4' => 'mp4',
'video/quicktime' => 'mov',
'video/x-msvideo' => 'avi',
'video/webm' => 'webm',
'audio/mpeg' => 'mp3',
'audio/mp3' => 'mp3',
'audio/wav' => 'wav',
'audio/x-wav' => 'wav',
'audio/amr' => 'amr',
'audio/aac' => 'aac',
'audio/mp4' => 'm4a',
'audio/ogg' => 'ogg',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'application/zip' => 'zip',
'application/x-rar-compressed' => 'rar',
'application/x-7z-compressed' => '7z',
'text/plain' => 'txt',
];
public static function isRemoteHttpUrl($url)
{
if (!is_string($url)) {
return false;
}
return (bool)preg_match('/^https?:\/\//i', trim($url));
}
public static function isOwnOssUrl($url)
{
if (!self::isRemoteHttpUrl($url)) {
return false;
}
$host = strtolower((string)parse_url($url, PHP_URL_HOST));
if (empty($host)) {
return false;
}
$customHost = strtolower((string)parse_url(AliyunOSS::ossUrl, PHP_URL_HOST));
$bucketHost = strtolower(AliyunOSS::BUCKET . '.' . AliyunOSS::ENDPOINT);
return $host === $customHost || $host === $bucketHost || strpos($host, strtolower(AliyunOSS::BUCKET . '.')) === 0;
}
public static function archiveRemoteUrl($url, $bizType, $resourceType, $bizKey, array $options = [])
{
$url = trim((string)$url);
if (!self::isRemoteHttpUrl($url)) {
return [
'success' => false,
'error' => 'URL不是有效的http(s)地址',
'url' => '',
];
}
if (self::isOwnOssUrl($url)) {
return [
'success' => true,
'alreadyArchived' => true,
'url' => self::normalizeOssUrl($url),
'originalUrl' => $url,
'object_name' => '',
];
}
$tempFile = '';
try {
$tempDir = self::ensureTempDirectory();
$tempFile = $tempDir . md5($url . microtime(true) . mt_rand()) . '.tmp';
$downloadResult = self::downloadToLocal($url, $tempFile, (int)($options['timeout'] ?? 60));
if (!$downloadResult['success']) {
return $downloadResult;
}
$extension = self::detectExtension(
$url,
$tempFile,
$downloadResult['contentType'] ?? '',
$options['extension'] ?? ''
);
$objectName = self::buildObjectName($bizType, $resourceType, $bizKey, $extension);
$result = AliyunOSS::uploadFile($tempFile, $objectName);
if (!$result['success']) {
return [
'success' => false,
'error' => $result['error'] ?? '上传OSS失败',
'url' => '',
];
}
return [
'success' => true,
'alreadyArchived' => false,
'url' => self::normalizeOssUrl($result['url'] ?? ''),
'originalUrl' => $url,
'object_name' => $objectName,
'extension' => $extension,
'mime_type' => $result['mime_type'] ?? ($downloadResult['contentType'] ?? ''),
'size' => $result['size'] ?? 0,
];
} catch (\Exception $e) {
Log::error('媒体资源上传OSS失败' . $e->getMessage(), [
'url' => $url,
'bizType' => $bizType,
'resourceType' => $resourceType,
'bizKey' => $bizKey,
]);
return [
'success' => false,
'error' => $e->getMessage(),
'url' => '',
];
} finally {
if (!empty($tempFile) && file_exists($tempFile)) {
@unlink($tempFile);
}
}
}
protected static function ensureTempDirectory()
{
if (function_exists('runtime_path')) {
$baseRuntimePath = rtrim(runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
} elseif (defined('RUNTIME_PATH')) {
$baseRuntimePath = rtrim(RUNTIME_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
} else {
$baseRuntimePath = rtrim(ROOT_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR;
}
$tempDir = $baseRuntimePath . 'temp' . DIRECTORY_SEPARATOR . 'media_archive' . DIRECTORY_SEPARATOR . date('Y' . DIRECTORY_SEPARATOR . 'm' . DIRECTORY_SEPARATOR . 'd') . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
return $tempDir;
}
protected static function downloadToLocal($url, $tempFile, $timeout)
{
$ch = curl_init($url);
$fp = fopen($tempFile, 'wb');
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_USERAGENT, 'CKB-MediaArchive/1.0');
curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
fclose($fp);
if (!empty($curlError)) {
@unlink($tempFile);
return [
'success' => false,
'error' => '下载资源失败:' . $curlError,
'url' => '',
];
}
if ($httpCode < 200 || $httpCode >= 300 || !file_exists($tempFile) || filesize($tempFile) <= 0) {
@unlink($tempFile);
return [
'success' => false,
'error' => '下载资源失败HTTP状态码' . $httpCode,
'url' => '',
];
}
return [
'success' => true,
'contentType' => $contentType,
'httpCode' => $httpCode,
];
}
protected static function detectExtension($url, $tempFile, $contentType = '', $forcedExtension = '')
{
$forcedExtension = strtolower(trim((string)$forcedExtension, '. '));
if (!empty($forcedExtension)) {
return $forcedExtension;
}
$pathExtension = strtolower((string)pathinfo((string)parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (preg_match('/^[a-z0-9]{1,8}$/i', $pathExtension)) {
return $pathExtension;
}
$contentType = strtolower(trim((string)$contentType));
if (!empty($contentType)) {
$contentType = trim(explode(';', $contentType)[0]);
if (!empty(self::$mimeExtensionMap[$contentType])) {
return self::$mimeExtensionMap[$contentType];
}
}
$mimeType = '';
if (function_exists('mime_content_type')) {
$mimeType = strtolower((string)mime_content_type($tempFile));
}
if (!empty($mimeType) && !empty(self::$mimeExtensionMap[$mimeType])) {
return self::$mimeExtensionMap[$mimeType];
}
return 'bin';
}
protected static function buildObjectName($bizType, $resourceType, $bizKey, $extension)
{
$bizType = trim((string)$bizType, '/');
$resourceType = trim((string)$resourceType, '/');
$bizKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', (string)$bizKey);
$extension = trim((string)$extension, '.');
return $bizType . '/' . $resourceType . '/' . date('Y/m/d/') . $bizKey . '_' . substr(md5(uniqid('', true)), 0, 16) . '.' . $extension;
}
protected static function normalizeOssUrl($url)
{
$url = trim((string)$url);
if (strpos($url, 'http://') === 0) {
return 'https://' . substr($url, 7);
}
return $url;
}
}

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.40:8080';
/**
* 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

@@ -0,0 +1,372 @@
<?php
namespace app\common\service;
use think\Db;
use think\facade\Log;
class WechatMediaArchiveService
{
public static function archiveMessageById($messageId)
{
$messageId = (int)$messageId;
if ($messageId <= 0) {
return false;
}
$row = Db::table('s2_wechat_message')->where('id', $messageId)->find();
if (empty($row)) {
return false;
}
$update = self::buildMessageArchiveUpdate($row);
if (empty($update)) {
return false;
}
Db::table('s2_wechat_message')->where('id', $messageId)->update($update);
return true;
}
public static function archiveMomentById($momentId)
{
$momentId = (int)$momentId;
if ($momentId <= 0) {
return false;
}
$row = Db::table('s2_wechat_moments')->where('id', $momentId)->find();
if (empty($row)) {
return false;
}
$displayUrls = self::decodeJsonArray($row['resUrls'] ?? '');
$rawUrls = self::decodeJsonArray($row['urls'] ?? '');
$candidateUrls = !empty($displayUrls) ? $displayUrls : $rawUrls;
if (empty($candidateUrls)) {
return false;
}
$ossUrls = [];
foreach ($candidateUrls as $index => $url) {
if (!MediaOssArchiveService::isRemoteHttpUrl($url)) {
continue;
}
$resourceType = self::isVideoUrl($url) ? 'video' : 'image';
$result = MediaOssArchiveService::archiveRemoteUrl($url, 'moments', $resourceType, (string)($row['snsId'] ?? $momentId), [
'index' => $index,
]);
if (!empty($result['success']) && !empty($result['url'])) {
$ossUrls[] = $result['url'];
}
}
if (empty($ossUrls)) {
return false;
}
Db::table('s2_wechat_moments')->where('id', $momentId)->update([
'ossUrls' => json_encode($ossUrls, JSON_UNESCAPED_UNICODE),
'isOssUploaded' => 1,
'update_time' => time(),
]);
return true;
}
public static function updateDownloadedMessageMedia($messageId, $downloadUrl)
{
$messageId = (int)$messageId;
$downloadUrl = trim((string)$downloadUrl);
if ($messageId <= 0 || empty($downloadUrl)) {
return false;
}
$row = Db::table('s2_wechat_message')->where('id', $messageId)->find();
if (empty($row)) {
return false;
}
$content = (string)($row['content'] ?? '');
$originalContent = (string)($row['originalContent'] ?? '');
$msgType = (int)($row['msgType'] ?? 0);
$update = [];
if (empty($originalContent)) {
$update['originalContent'] = $content;
}
if ($msgType === 43) {
$payload = self::decodeJsonObject($content);
if (empty($payload)) {
$payload = [];
}
$payload['videoUrl'] = $downloadUrl;
$payload['isLoading'] = false;
$update['content'] = self::encodeJson($payload);
} elseif ($msgType === 49) {
$payload = self::decodeJsonObject($content);
if (empty($payload) || strtolower((string)($payload['type'] ?? '')) !== 'file') {
$payload = [
'type' => 'file',
'title' => self::extractFileTitle($content),
];
}
$payload['url'] = $downloadUrl;
$payload['isDownloading'] = false;
$update['content'] = self::encodeJson($payload);
} else {
return false;
}
Db::table('s2_wechat_message')->where('id', $messageId)->update($update);
return true;
}
protected static function buildMessageArchiveUpdate(array $row)
{
$msgType = (int)($row['msgType'] ?? 0);
$content = (string)($row['content'] ?? '');
$originalContent = (string)($row['originalContent'] ?? '');
$sourceOriginalContent = $originalContent !== '' ? $originalContent : $content;
$update = [];
switch ($msgType) {
case 3:
case 47:
$imageUpdate = self::archiveImageLikeContent($content, $msgType, $row['id']);
if (!empty($imageUpdate)) {
$update = array_merge($update, $imageUpdate);
}
break;
case 34:
$audioUpdate = self::archiveAudioContent($content, $row['id']);
if (!empty($audioUpdate)) {
$update = array_merge($update, $audioUpdate);
}
break;
case 43:
$videoUpdate = self::archiveVideoContent($content, $row['id']);
if (!empty($videoUpdate)) {
$update = array_merge($update, $videoUpdate);
}
break;
case 49:
$fileUpdate = self::archiveFileContent($content, $row['id']);
if (!empty($fileUpdate)) {
$update = array_merge($update, $fileUpdate);
}
break;
default:
break;
}
if (!empty($update) && empty($originalContent) && !empty($sourceOriginalContent)) {
$update['originalContent'] = $sourceOriginalContent;
}
return $update;
}
protected static function archiveImageLikeContent($content, $msgType, $messageId)
{
$payload = self::decodeJsonObject($content);
if (!empty($payload) && !empty($payload['url'])) {
$sourceUrl = (string)($payload['originUrl'] ?? $payload['url']);
$result = MediaOssArchiveService::archiveRemoteUrl(
$sourceUrl,
'messages',
$msgType == 47 ? 'emoji' : 'image',
(string)$messageId
);
if (empty($result['success']) || empty($result['url'])) {
return [];
}
$payload['originUrl'] = $payload['originUrl'] ?? $sourceUrl;
$payload['ossUrl'] = $result['url'];
$payload['url'] = $result['url'];
return ['content' => self::encodeJson($payload)];
}
if (!MediaOssArchiveService::isRemoteHttpUrl($content)) {
return [];
}
$result = MediaOssArchiveService::archiveRemoteUrl(
$content,
'messages',
$msgType == 47 ? 'emoji' : 'image',
(string)$messageId
);
if (empty($result['success']) || empty($result['url'])) {
return [];
}
return ['content' => $result['url']];
}
protected static function archiveAudioContent($content, $messageId)
{
$payload = self::decodeJsonObject($content);
if (!empty($payload) && !empty($payload['url'])) {
$sourceUrl = (string)($payload['originUrl'] ?? $payload['url']);
$result = MediaOssArchiveService::archiveRemoteUrl($sourceUrl, 'messages', 'audio', (string)$messageId);
if (empty($result['success']) || empty($result['url'])) {
return [];
}
$payload['originUrl'] = $payload['originUrl'] ?? $sourceUrl;
$payload['ossUrl'] = $result['url'];
$payload['url'] = $result['url'];
return ['content' => self::encodeJson($payload)];
}
if (!MediaOssArchiveService::isRemoteHttpUrl($content)) {
return [];
}
$result = MediaOssArchiveService::archiveRemoteUrl($content, 'messages', 'audio', (string)$messageId);
if (empty($result['success']) || empty($result['url'])) {
return [];
}
return ['content' => $result['url']];
}
protected static function archiveVideoContent($content, $messageId)
{
$payload = self::decodeJsonObject($content);
if (empty($payload)) {
if (!MediaOssArchiveService::isRemoteHttpUrl($content)) {
return [];
}
$result = MediaOssArchiveService::archiveRemoteUrl($content, 'messages', 'video', (string)$messageId);
if (empty($result['success']) || empty($result['url'])) {
return [];
}
return ['content' => $result['url']];
}
$changed = false;
$previewSource = (string)($payload['previewImageOriginUrl'] ?? $payload['previewImage'] ?? '');
if (MediaOssArchiveService::isRemoteHttpUrl($previewSource)) {
$previewResult = MediaOssArchiveService::archiveRemoteUrl($previewSource, 'messages', 'video_cover', (string)$messageId);
if (!empty($previewResult['success']) && !empty($previewResult['url'])) {
$payload['previewImageOriginUrl'] = $payload['previewImageOriginUrl'] ?? $previewSource;
$payload['previewImageOssUrl'] = $previewResult['url'];
$payload['previewImage'] = $previewResult['url'];
$changed = true;
}
}
$videoSource = (string)($payload['originUrl'] ?? $payload['videoUrl'] ?? $payload['ossUrl'] ?? $payload['tencentUrl'] ?? $payload['url'] ?? '');
if (MediaOssArchiveService::isRemoteHttpUrl($videoSource)) {
$videoResult = MediaOssArchiveService::archiveRemoteUrl($videoSource, 'messages', 'video', (string)$messageId);
if (!empty($videoResult['success']) && !empty($videoResult['url'])) {
$payload['originUrl'] = $payload['originUrl'] ?? $videoSource;
$payload['ossUrl'] = $videoResult['url'];
$payload['videoUrl'] = $videoResult['url'];
$payload['url'] = $videoResult['url'];
$payload['isLoading'] = false;
$changed = true;
}
}
if (!$changed) {
return [];
}
return ['content' => self::encodeJson($payload)];
}
protected static function archiveFileContent($content, $messageId)
{
$payload = self::decodeJsonObject($content);
if (empty($payload) || strtolower((string)($payload['type'] ?? '')) !== 'file') {
return [];
}
$sourceUrl = (string)($payload['originUrl'] ?? $payload['url'] ?? '');
if (!MediaOssArchiveService::isRemoteHttpUrl($sourceUrl)) {
return [];
}
$result = MediaOssArchiveService::archiveRemoteUrl(
$sourceUrl,
'messages',
'file',
(string)$messageId,
[
'extension' => $payload['fileext'] ?? '',
]
);
if (empty($result['success']) || empty($result['url'])) {
return [];
}
$payload['originUrl'] = $payload['originUrl'] ?? $sourceUrl;
$payload['ossUrl'] = $result['url'];
$payload['url'] = $result['url'];
$payload['isDownloading'] = false;
return ['content' => self::encodeJson($payload)];
}
protected static function decodeJsonObject($value)
{
if (!is_string($value)) {
return [];
}
$decoded = json_decode(trim($value), true);
return is_array($decoded) ? $decoded : [];
}
protected static function decodeJsonArray($value)
{
if (is_array($value)) {
return $value;
}
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
protected static function encodeJson(array $payload)
{
return json_encode($payload, JSON_UNESCAPED_UNICODE);
}
protected static function extractFileTitle($rawContent)
{
if (!is_string($rawContent) || trim($rawContent) === '') {
return '文件';
}
if (preg_match('/<title><!\[CDATA\[(.*?)\]\]><\/title>/i', $rawContent, $matches) && !empty($matches[1])) {
return trim($matches[1]);
}
if (preg_match('/<title>([^<]+)<\/title>/i', $rawContent, $matches) && !empty($matches[1])) {
return trim($matches[1]);
}
return '文件';
}
protected static function isVideoUrl($url)
{
if (!is_string($url)) {
return false;
}
return (bool)preg_match('/\.(mp4|mov|avi|webm|mkv)(\?.*)?$/i', $url);
}
}

View File

@@ -61,34 +61,67 @@ 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('getPackage', 'app\cunkebao\controller\TrafficController@getPackage'); // 获取流量池包列表
Route::get('getPackageDetail', 'app\cunkebao\controller\TrafficController@getPackageDetail'); // 获取流量池详情(元数据)
Route::post('addPackage', 'app\cunkebao\controller\TrafficController@addPackage');
Route::post('editPackage', 'app\cunkebao\controller\TrafficController@editPackage');
Route::delete('deletePackage', 'app\cunkebao\controller\TrafficController@deletePackage');
Route::get('', 'app\cunkebao\controller\TrafficController@getTrafficPoolList');
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('preview-users', 'app\cunkebao\controller\TrafficPoolV2Controller@previewUsers'); // 预览用户列表(根据筛选条件)
Route::get('filter-fields', 'app\cunkebao\controller\TrafficPoolV2Controller@getFilterFields'); // 获取筛选字段元数据
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('tag/sync-from-engine', 'app\cunkebao\controller\TrafficPoolV2Controller@syncTagsFromEngine'); // 从标签引擎同步标签
// RFM评分相关
Route::post('calculate-rfm', 'app\cunkebao\controller\TrafficPoolV2Controller@calculateRfm'); // 计算RFM评分
Route::post('group/:groupId/calculate-rfm', 'app\cunkebao\controller\TrafficPoolV2Controller@calculateGroupRfm'); // 批量计算分组RFM评分
// 分配相关
Route::post('allocate', 'app\cunkebao\controller\TrafficPoolV2Controller@allocatePool'); // 分配流量
Route::post('recycle', 'app\cunkebao\controller\TrafficPoolV2Controller@recyclePool'); // 回收流量
// 统计相关
Route::get('statistics', 'app\cunkebao\controller\TrafficPoolV2Controller@getStatistics'); // 获取统计数据
// 来源和行为相关
Route::get('sources', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolSources'); // 分页获取来源
Route::get('behaviors', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolBehaviors'); // 分页获取行为轨迹
});
// 工作台相关
@@ -232,17 +265,38 @@ Route::group('v1/', function () {
});
});
// 客户标签功能
Route::group('tag', function () {
// 通过标识查询标签
Route::post('query-by-identifiers', 'app\cunkebao\controller\tag\QueryTagsByIdentifiersController@index');
Route::post('query-by-phone', 'app\cunkebao\controller\tag\QueryTagsByIdentifiersController@byPhone'); // 快捷方法:通过手机号查询
Route::post('query-by-wechat', 'app\cunkebao\controller\tag\QueryTagsByIdentifiersController@byWechat'); // 快捷方法:通过微信号查询
// 通过标签查询用户
Route::post('query-users-by-tags', 'app\cunkebao\controller\tag\QueryUsersByTagsController@index');
Route::get('high-value-users', 'app\cunkebao\controller\tag\QueryUsersByTagsController@highValueUsers'); // 快捷方法:查询高价值用户
Route::get('vip-users', 'app\cunkebao\controller\tag\QueryUsersByTagsController@vipUsers'); // 快捷方法查询VIP用户
});
})->middleware(['jwt']);
// 旧版场景获客对外接口(计划级 apiKey保持兼容
Route::group('v1/api/scenarios', function () {
Route::any('', 'app\cunkebao\controller\plan\PostExternalApiV1Controller@index');
});
// 新版开放接口(账号级 apiKey + JWT
// ① 公开:用 apiKey + sign 换取 JWT Token
Route::post('v1/open/auth/token', 'app\common\controller\OpenAuthController@getToken');
// ② 需要 JWT所有业务接口
Route::group('v1/open', function () {
Route::post('scenarios', 'app\common\controller\OpenScenariosController@submit'); // 场景获客线索上报
})->middleware(['jwt']);
//小程序
Route::group('v1/frontend', function () {

View File

@@ -46,14 +46,19 @@ class RFMController extends BaseController
$weightM = isset($config['weight_M']) ? (float)$config['weight_M'] : self::DEFAULT_WEIGHT_M;
$abnormalMoneyRatio = isset($config['abnormal_money_ratio']) ? (float)$config['abnormal_money_ratio'] : self::DEFAULT_ABNORMAL_MONEY_RATIO;
$scoreScale = isset($config['score_scale']) ? (int)$config['score_scale'] : self::DEFAULT_SCORE_SCALE;
$missingStrategy = isset($config['missing_strategy']) ? $config['missing_strategy'] : 'score_1';
$missingStrategy = isset($config['missing_strategy']) ? $confi961102'] : 'score_1';
// 权重归一化处理
$weightSum = $weightR + $weightF + $weightM;
if ($weightSum != 1.0) {
if ($weightSum != 1.0 && $weightSum > 0) {
$weightR = $weightR / $weightSum;
$weightF = $weightF / $weightSum;
$weightM = $weightM / $weightSum;
} elseif ($weightSum == 0) {
// 如果权重全为0使用默认权重
$weightR = self::DEFAULT_WEIGHT_R;
$weightF = self::DEFAULT_WEIGHT_F;
$weightM = self::DEFAULT_WEIGHT_M;
}
// 计算时间范围
@@ -111,6 +116,8 @@ class RFMController extends BaseController
// 3. 异常值处理 - 剔除大额异常订单
$mValues = array_column($customerData, 'M');
$abnormalThreshold = null; // 初始化异常阈值
if (!empty($mValues)) {
sort($mValues);
$m99Percentile = $this->percentile($mValues, 0.99);
@@ -120,6 +127,11 @@ class RFMController extends BaseController
foreach ($customerData as &$customer) {
$customer['isAbnormal'] = $customer['M'] > $abnormalThreshold;
}
} else {
// 如果没有M值数据标记所有客户为非异常
foreach ($customerData as &$customer) {
$customer['isAbnormal'] = false;
}
}
// 4. 使用五分位法计算各维度的区间阈值
@@ -127,7 +139,7 @@ class RFMController extends BaseController
$fThresholds = $this->calculatePercentiles(array_column($customerData, 'F'), false);
// M维度排除异常值计算区间
$mValuesForPercentile = array_filter(array_column($customerData, 'M'), function($m) use ($abnormalThreshold) {
return isset($abnormalThreshold) ? $m <= $abnormalThreshold : true;
return $abnormalThreshold !== null ? $m <= $abnormalThreshold : true;
});
$mThresholds = $this->calculatePercentiles(array_values($mValuesForPercentile), false);
@@ -136,7 +148,7 @@ class RFMController extends BaseController
foreach ($customerData as $customer) {
$rScore = $this->scoreByPercentile($customer['R'], $rThresholds, true); // R是反向的
$fScore = $this->scoreByPercentile($customer['F'], $fThresholds, false);
$mScore = $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分
$mScore = isset($customer['isAbnormal']) && $customer['isAbnormal'] ? 5 : $this->scoreByPercentile($customer['M'], $mThresholds, false); // 异常值给最高分
// 计算RFM总分加权求和
$rfmScore = $rScore * $weightR + $fScore * $weightF + $mScore * $weightM;
@@ -146,7 +158,8 @@ class RFMController extends BaseController
if ($scoreScale == 100) {
$rfmMin = $weightR * 1 + $weightF * 1 + $weightM * 1;
$rfmMax = $weightR * 5 + $weightF * 5 + $weightM * 5;
$standardScore = (int)round(($rfmScore - $rfmMin) / ($rfmMax - $rfmMin) * 99 + 1);
$range = $rfmMax - $rfmMin;
$standardScore = $range > 0 ? (int)round(($rfmScore - $rfmMin) / $range * 99 + 1) : 1;
}
$results[] = [
@@ -170,7 +183,7 @@ class RFMController extends BaseController
return $b['RFM_score'] <=> $a['RFM_score'];
});
// 6. 更新 ck_traffic_source 和 s2_wechat_friend 表的RFM值
// 6. 更新 ck_traffic_source_v1 和 s2_wechat_friend 表的RFM值
$this->updateRfmToTables($results, $ownerWechatId);
return [
@@ -187,7 +200,7 @@ class RFMController extends BaseController
],
'statistics' => [
'total_customers' => count($results),
'avg_rfm_score' => round(array_sum(array_column($results, 'RFM_score')) / count($results), 2),
'avg_rfm_score' => count($results) > 0 ? round(array_sum(array_column($results, 'RFM_score')) / count($results), 2) : 0,
]
]
];
@@ -352,7 +365,7 @@ class RFMController extends BaseController
}
/**
* 更新RFM值到 ck_traffic_sources2_wechat_friend 表
* 更新RFM值到 ck_traffic_source_v1、s2_wechat_friend 和 ck_traffic_pool_company
*
* @param array $results RFM计算结果数组
* @param string|null $ownerWechatId 微信ID用于过滤更新范围
@@ -365,8 +378,11 @@ class RFMController extends BaseController
$rScore = (string)$result['R_score'];
$fScore = (string)$result['F_score'];
$mScore = (string)$result['M_score'];
$rfmRaw = $result['R_raw'];
$rfmF = $result['F_raw'];
$rfmM = $result['M_raw'];
// 更新 ck_traffic_source
// 更新 ck_traffic_source_v1 表V1旧表
// 根据 identifier 更新所有匹配的记录
$trafficSourceUpdate = [
'R' => $rScore,
@@ -389,6 +405,18 @@ class RFMController extends BaseController
$wechatFriendWhere['ownerWechatId'] = $ownerWechatId;
}
WechatFriendModel::where($wechatFriendWhere)->update($wechatFriendUpdate);
// 更新 ck_traffic_pool_company 表V2新表
// 根据 identifier 更新identifier可能是wechatId、phone等
$poolCompanyUpdate = [
'rfmF' => $rfmF,
'rfmM' => $rfmM,
'updateTime' => date('Y-m-d H:i:s')
];
Db::table('ck_traffic_pool_company')
->where('identifier', $identifier)
->where('isDel', 0)
->update($poolCompanyUpdate);
}
} catch (\Exception $e) {

View File

@@ -25,8 +25,8 @@ class TrafficController extends BaseController
$keyword = $this->request->param('keyword', '');
$companyId = $this->getUserInfo('companyId');
$package = Db::name('traffic_source_package')->alias('tsp')
->join('traffic_source_package_item tspi', 'tspi.packageId=tsp.id', 'left')
$package = Db::name('traffic_source_package_v1')->alias('tsp')
->join('traffic_source_package_item_v1 tspi', 'tspi.packageId=tsp.id', 'left')
->whereIn('tsp.companyId', [$companyId, 0])
->field('tsp.id,tsp.name,tsp.description,tsp.pic,tsp.isSys as type,tsp.createTime,count(tspi.id) as num')
->group('tsp.id');
@@ -38,6 +38,41 @@ class TrafficController extends BaseController
$list = $package->page($page, $limit)->order('isSys ASC,id DESC')->select();
$total = $package->count();
// 添加"所有好友"特殊流量池ID为0
$allFriendsPackage = [
'id' => 0,
'name' => '所有好友',
'description' => '展示公司下所有设备的好友',
'pic' => '',
'type' => 1, // 系统类型
'createTime' => '',
'num' => 0, // 数量将在下面计算
];
// 计算所有好友数量
try {
$companyId = $this->getUserInfo('companyId');
$wechatIds = Db::name('device')->alias('d')
->join('(SELECT MAX(id) AS id, deviceId FROM ck_device_wechat_login WHERE companyId='.$companyId.' GROUP BY deviceId) dwl_max', 'dwl_max.deviceId = d.id')
->join('device_wechat_login dwl', 'dwl.id = dwl_max.id')
->where(['d.companyId' => $companyId, 'd.deleteTime' => 0])
->column('dwl.wechatId');
if (!empty($wechatIds)) {
$allFriendsCount = Db::table('s2_wechat_friend')
->where('ownerWechatId', 'in', $wechatIds)
->where('isDeleted', 0)
->count();
$allFriendsPackage['num'] = $allFriendsCount;
}
} catch (\Exception $e) {
// 如果查询失败保持num为0
}
// 将"所有好友"添加到列表最前面
array_unshift($list, $allFriendsPackage);
$total = $total + 1; // 总数加1
$rfmRule = 'default';
foreach ($list as $k => &$v) {
if ($v['type'] != 1) {
@@ -132,6 +167,11 @@ class TrafficController extends BaseController
return ResponseHelper::error('流量池ID不能为空');
}
// 禁止编辑"所有好友"特殊流量池
if ($packageId === '0' || $packageId === 0) {
return ResponseHelper::error('"所有好友"流量池不允许编辑');
}
if (empty($packageName)) {
return ResponseHelper::error('流量池名称不能为空');
}
@@ -194,6 +234,11 @@ class TrafficController extends BaseController
return ResponseHelper::error('流量池ID不能为空');
}
// 禁止删除"所有好友"特殊流量池
if ($packageId === '0' || $packageId === 0) {
return ResponseHelper::error('"所有好友"流量池不允许删除');
}
// 检查流量池是否存在且属于当前公司
$package = TrafficSourcePackage::where(['id' => $packageId, 'isDel' => 0])
->whereIn('companyId', [$companyId, 0])
@@ -243,6 +288,87 @@ class TrafficController extends BaseController
}
/**
* 获取流量池详情
* @return \think\response\Json
* @throws \Exception
*/
public function getPackageDetail()
{
$packageId = $this->request->param('packageId', '');
$companyId = $this->getUserInfo('companyId');
if (empty($packageId) && $packageId !== '0' && $packageId !== 0) {
return ResponseHelper::error('流量池ID不能为空');
}
// 特殊处理packageId为0时返回"所有好友"的详情
if ($packageId === '0' || $packageId === 0) {
$data = [
'id' => 0,
'name' => '所有好友',
'description' => '展示公司下所有设备的好友',
'pic' => '',
'type' => 1, // 系统类型
'isSys' => 1,
'createTime' => '',
'updateTime' => '',
'num' => 0,
];
// 计算所有好友数量
try {
$wechatIds = Db::name('device')->alias('d')
->join('(SELECT MAX(id) AS id, deviceId FROM ck_device_wechat_login WHERE companyId='.$companyId.' GROUP BY deviceId) dwl_max', 'dwl_max.deviceId = d.id')
->join('device_wechat_login dwl', 'dwl.id = dwl_max.id')
->where(['d.companyId' => $companyId, 'd.deleteTime' => 0])
->column('dwl.wechatId');
if (!empty($wechatIds)) {
$allFriendsCount = Db::table('s2_wechat_friend')
->where('ownerWechatId', 'in', $wechatIds)
->where('isDeleted', 0)
->count();
$data['num'] = $allFriendsCount;
}
} catch (\Exception $e) {
// 如果查询失败保持num为0
}
return ResponseHelper::success($data);
}
// 查询普通流量池详情
$package = TrafficSourcePackage::where(['id' => $packageId, 'isDel' => 0])
->whereIn('companyId', [$companyId, 0])
->find();
if (empty($package)) {
return ResponseHelper::error('流量池不存在或已删除');
}
// 统计流量池中的数量
$itemCount = TrafficSourcePackageItem::where([
'packageId' => $packageId,
'companyId' => $companyId,
'isDel' => 0
])->count();
$data = [
'id' => $package['id'],
'name' => $package['name'],
'description' => $package['description'] ?? '',
'pic' => $package['pic'] ?? '',
'type' => $package['isSys'] ?? 0,
'isSys' => $package['isSys'] ?? 0,
'createTime' => !empty($package['createTime']) ? formatRelativeTime($package['createTime']) : '',
'updateTime' => !empty($package['updateTime']) ? formatRelativeTime($package['updateTime']) : '',
'num' => $itemCount,
];
return ResponseHelper::success($data);
}
/**
* 流量池列表
* @return \think\response\Json
@@ -257,10 +383,15 @@ class TrafficController extends BaseController
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
if (empty($packageId)) {
if (empty($packageId) && $packageId !== '0' && $packageId !== 0) {
return ResponseHelper::error('流量包id不能为空');
}
// 特殊处理packageId为0时查询所有好友
if ($packageId === '0' || $packageId === 0) {
return $this->getAllFriendsList($page, $limit, $keyword, $companyId);
}
$trafficSourcePackage = TrafficSourcePackage::where(['id' => $packageId, 'isDel' => 0])->whereIn('companyId', [$companyId, 0])->find();
if (empty($trafficSourcePackage)) {
return ResponseHelper::error('流量包不存在或已删除');
@@ -270,7 +401,7 @@ class TrafficController extends BaseController
['tspi.packageId', '=', $packageId],
];
if (empty($keyword)) {
if (!empty($keyword)) {
$where[] = ['wa.nickname|wa.phone|wa.alias|wa.wechatId|p.mobile|p.identifier', 'like', '%' . $keyword . '%'];
}
@@ -281,19 +412,23 @@ class TrafficController extends BaseController
'wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.alias'
]
)
// ========== 旧版流量池代码(已废弃) ==========
// ->join('traffic_pool_v1 p', 'p.identifier=tspi.identifier', 'left')
// ========== 新版流量池代码 ==========
->join('traffic_pool p', 'p.identifier=tspi.identifier', 'left')
// ========== 旧版流量池代码结束 ==========
->join('wechat_account wa', 'tspi.identifier=wa.wechatId', 'left')
->where($where);
$query->order('tspi.id DESC,p.id DESC')->group('p.identifier');
$list = $query->page($page, $limit)->select()->toArray();
$list = $query->page($page, $limit)->select();
$total = $query->count();
foreach ($list as $k => &$v) {
//流量池筛选
$package = TrafficSourcePackageItem::alias('tspi')
->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId')
->join('traffic_source_package_v1 p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId')
->where(['tspi.identifier' => $v['identifier']])
->whereIn('tspi.companyId', [0, $v['companyId']])
->column('p.name');
@@ -307,8 +442,8 @@ class TrafficController extends BaseController
$v['F'] = $scores['F'];
$v['M'] = $scores['M'];
$v['RFM'] = $scores['R'] + $scores['F'] + $scores['M'];
$v['money'] = 2222;
$v['msgCount'] = 2222;
$v['money'] = 3;
$v['msgCount'] = 3 ;
$v['tag'] = ['test', 'test2'];
}
unset($v);
@@ -318,4 +453,78 @@ class TrafficController extends BaseController
return ResponseHelper::success($data);
}
/**
* 获取所有好友列表(特殊流量池)
* @param int $page
* @param int $limit
* @param string $keyword
* @param int $companyId
* @return \think\response\Json
*/
private function getAllFriendsList($page, $limit, $keyword, $companyId)
{
try {
// 获取公司下所有设备的微信ID
$wechatIds = Db::name('device')->alias('d')
->join('(SELECT MAX(id) AS id, deviceId FROM ck_device_wechat_login WHERE companyId='.$companyId.' GROUP BY deviceId) dwl_max', 'dwl_max.deviceId = d.id')
->join('device_wechat_login dwl', 'dwl.id = dwl_max.id')
->where(['d.companyId' => $companyId, 'd.deleteTime' => 0])
->column('dwl.wechatId');
if (empty($wechatIds)) {
return ResponseHelper::success(['list' => [], 'total' => 0]);
}
// 构建查询条件
$where = [
['wf.ownerWechatId', 'in', $wechatIds],
['wf.isDeleted', '=', 0],
];
// 关键字搜索
if (!empty($keyword)) {
$where[] = ['wf.nickname|wf.alias|wf.wechatId|wf.conRemark', 'like', '%' . $keyword . '%'];
}
// 查询好友列表
$query = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_wechat_account' => 'wa'], 'wa.wechatId = wf.ownerWechatId', 'left')
->field([
'wf.id', 'wf.wechatId as identifier', 'wf.wechatId',
Db::raw($companyId . ' as companyId'), 'wf.nickname', 'wf.avatar', 'wf.gender', 'wf.phone', 'wf.alias'
])
->where($where);
$total = $query->count();
$list = $query->order('wf.id DESC')->page($page, $limit)->select();
foreach ($list as $k => &$v) {
// 获取好友所属的流量池包
$package = TrafficSourcePackageItem::alias('tspi')
->join('traffic_source_package_v1 p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId')
->where(['tspi.identifier' => $v['identifier']])
->whereIn('tspi.companyId', [0, $companyId])
->column('p.name');
$v['packages'] = $package;
$v['phone'] = !empty($v['phone']) ? $v['phone'] : '';
// RFM评分示例数据实际应该从业务数据计算
$scores = RFMController::calcRfmScores(30, 30, 30);
$v['R'] = $scores['R'];
$v['F'] = $scores['F'];
$v['M'] = $scores['M'];
$v['RFM'] = $scores['R'] + $scores['F'] + $scores['M'];
$v['money'] = 2222;
$v['msgCount'] = 2222;
$v['tag'] = ['test', 'test2'];
}
unset($v);
$data = ['list' => $list, 'total' => $total];
return ResponseHelper::success($data);
} catch (\Exception $e) {
return ResponseHelper::error('获取好友列表失败:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,897 @@
<?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\model\TrafficPoolSource;
use app\common\model\TrafficPoolBehavior;
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,
'groupName' => $group->groupName
], '创建成功');
} catch (\Exception $e) {
return ResponseHelper::error('创建分组失败:' . $e->getMessage());
}
}
/**
* 根据筛选条件预览用户列表
* GET /v1/traffic/pool/v2/preview-users
*
* @return \think\response\Json
*/
public function previewUsers()
{
$companyId = $this->getUserInfo('companyId');
$ruleConfig = $this->request->param('ruleConfig');
$page = $this->request->param('page', 1, 'intval');
$pageSize = $this->request->param('pageSize', 20, 'intval');
$keyword = $this->request->param('keyword', '');
if (empty($ruleConfig)) {
return ResponseHelper::error('筛选条件不能为空');
}
// 如果ruleConfig是JSON字符串解析它
if (is_string($ruleConfig)) {
$ruleConfig = json_decode($ruleConfig, true);
}
// 从ruleConfig中提取keyword如果前端放在里面的话
if (empty($keyword) && isset($ruleConfig['keyword'])) {
$keyword = $ruleConfig['keyword'];
unset($ruleConfig['keyword']);
}
try {
$result = $this->groupService->previewGroupMembers($companyId, $ruleConfig, $page, $pageSize, $keyword);
return ResponseHelper::success($result);
} catch (\Exception $e) {
return ResponseHelper::error('获取用户列表失败:' . $e->getMessage());
}
}
/**
* 获取筛选条件可选项(字段元数据)
* GET /v1/traffic/pool/v2/filter-fields
*
* @return \think\response\Json
*/
public function getFilterFields()
{
try {
$fields = [
[
'field' => 'lifecycle',
'label' => '客户周期',
'type' => 'select',
'options' => [
['label' => '新流量', 'value' => 1],
['label' => '成长期', 'value' => 2],
['label' => '成熟期', 'value' => 3],
['label' => '衰退期', 'value' => 4],
['label' => '流失期', 'value' => 5],
]
],
[
'field' => 'intentionLevel',
'label' => '意向等级',
'type' => 'select',
'options' => [
['label' => '未知', 'value' => 0],
['label' => '低意向', 'value' => 1],
['label' => '中意向', 'value' => 2],
['label' => '高意向', 'value' => 3],
]
],
[
'field' => 'level',
'label' => '客户等级',
'type' => 'select',
'options' => [
['label' => '普通', 'value' => 0],
['label' => '白银', 'value' => 1],
['label' => '黄金', 'value' => 2],
['label' => '钻石', 'value' => 3],
]
],
[
'field' => 'gender',
'label' => '性别',
'type' => 'select',
'options' => [
['label' => '未知', 'value' => 0],
['label' => '男', 'value' => 1],
['label' => '女', 'value' => 2],
]
],
[
'field' => 'friendStatus',
'label' => '好友状态',
'type' => 'select',
'options' => [
['label' => '未添加', 'value' => 0],
['label' => '已申请', 'value' => 1],
['label' => '已通过', 'value' => 2],
['label' => '已拒绝', 'value' => 3],
['label' => '已删除', 'value' => 4],
]
],
[
'field' => 'province',
'label' => '地区',
'type' => 'province',
],
[
'field' => 'totalOrderAmount',
'label' => '总消费金额',
'type' => 'number',
],
[
'field' => 'totalOrderCount',
'label' => '订单数量',
'type' => 'number',
],
[
'field' => 'totalMsgCount',
'label' => '消息数量',
'type' => 'number',
],
[
'field' => 'rfmF',
'label' => 'RFM-F值',
'type' => 'number',
],
[
'field' => 'rfmM',
'label' => 'RFM-M值',
'type' => 'number',
],
];
return ResponseHelper::success($fields);
} 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 syncTagsFromEngine()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
$operatorId = $this->getUserInfo('id');
if (empty($poolCompanyId)) {
return ResponseHelper::error('流量ID不能为空');
}
try {
$result = $this->poolService->syncTagsFromEngine($poolCompanyId, $companyId, $operatorId);
return ResponseHelper::success($result, "同步成功:已同步 {$result['syncedCount']} 个标签");
} 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());
}
}
/**
* 分页获取流量来源
* @return \think\response\Json
*/
public function getPoolSources()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
$page = $this->request->param('page', 1, 'intval');
$pageSize = $this->request->param('pageSize', 20, 'intval');
$keyword = $this->request->param('keyword', '');
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 {
$result = TrafficPoolSource::getSourcesWithOwnersPaginated($poolCompanyId, $page, $pageSize, $keyword);
return ResponseHelper::success($result);
} catch (\Exception $e) {
return ResponseHelper::error('获取来源列表失败:' . $e->getMessage());
}
}
/**
* 分页获取流量行为轨迹
* @return \think\response\Json
*/
public function getPoolBehaviors()
{
$poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval');
$companyId = $this->getUserInfo('companyId');
$page = $this->request->param('page', 1, 'intval');
$pageSize = $this->request->param('pageSize', 20, 'intval');
$keyword = $this->request->param('keyword', '');
$behaviorType = $this->request->param('behaviorType', 0, 'intval');
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 {
$result = TrafficPoolBehavior::getUserJourneyPaginated($poolCompanyId, $page, $pageSize, $keyword, $behaviorType);
return ResponseHelper::success($result);
} catch (\Exception $e) {
return ResponseHelper::error('获取行为轨迹失败:' . $e->getMessage());
}
}
/**
* 计算并更新RFM评分
* POST /v1/traffic/pool/v2/calculate-rfm
*
* @return \think\response\Json
*/
public function calculateRfm()
{
$companyId = $this->getUserInfo('companyId');
$identifier = $this->request->post('identifier', null); // 可选,指定用户标识
try {
// 实例化RFM控制器传递ClassTableService
$rfmController = new RFMController($this->classTable);
// 获取配置参数(可从请求参数中获取,或使用默认值)
$config = [
'cycle_days' => $this->request->post('cycle_days', 180),
'weight_R' => $this->request->post('weight_R', 0.4),
'weight_F' => $this->request->post('weight_F', 0.3),
'weight_M' => $this->request->post('weight_M', 0.3),
'score_scale' => $this->request->post('score_scale', 5),
];
// 调用RFM计算方法
// 注意这里不传ownerWechatId因为V2系统是按companyId区分的
$result = $rfmController->calculateRfmFromTrafficOrder($identifier, null, $config);
if ($result['code'] == 200) {
return ResponseHelper::success($result['data'], 'RFM计算完成');
} else {
return ResponseHelper::error($result['msg']);
}
} catch (\Exception $e) {
return ResponseHelper::error('RFM计算失败' . $e->getMessage());
}
}
/**
* 批量更新指定分组的RFM评分
* POST /v1/traffic/pool/v2/group/{groupId}/calculate-rfm
*
* @return \think\response\Json
*/
public function calculateGroupRfm()
{
$companyId = $this->getUserInfo('companyId');
$groupId = $this->request->param('groupId');
if (empty($groupId)) {
return ResponseHelper::error('分组ID不能为空');
}
try {
// 获取分组成员
$members = $this->groupService->getGroupMembers($groupId, $companyId, 1, 9999, []);
if (empty($members['list'])) {
return ResponseHelper::error('分组无成员');
}
// 实例化RFM控制器传递ClassTableService
$rfmController = new RFMController($this->classTable);
$successCount = 0;
$failCount = 0;
// 为每个成员计算RFM
foreach ($members['list'] as $member) {
$identifier = $member['identifier'];
$result = $rfmController->calculateRfmFromTrafficOrder($identifier, null, []);
if ($result['code'] == 200) {
$successCount++;
} else {
$failCount++;
}
}
return ResponseHelper::success([
'total' => count($members['list']),
'success' => $successCount,
'fail' => $failCount
], 'RFM批量计算完成');
} catch (\Exception $e) {
return ResponseHelper::error('RFM批量计算失败' . $e->getMessage());
}
}
}

View File

@@ -49,7 +49,12 @@ class GetAddResultedV1Controller extends BaseController
$deviceIds = $this->getAllDevicesIdWithInCompany($companyId) ?: [0];
// 从 s2_device 导入数据。
$this->getNewDeviceFromS2_device($deviceIds, $companyId);
$newDeviceIds = $this->getNewDeviceFromS2_device($deviceIds, $companyId);
// 如果有新设备,自动加入到全局配置中
if (!empty($newDeviceIds)) {
$this->addDevicesToGlobalConfigs($newDeviceIds, $companyId);
}
}
/**
@@ -57,12 +62,23 @@ class GetAddResultedV1Controller extends BaseController
*
* @param array $ids
* @param int $companyId
* @return void
* @return array 返回新添加的设备ID数组
*/
protected function getNewDeviceFromS2_device(array $ids, int $companyId): void
protected function getNewDeviceFromS2_device(array $ids, int $companyId): array
{
$ids = implode(',', $ids);
// 先查询要插入的新设备ID
$newDeviceIds = Db::query("SELECT d.id
FROM s2_device d
JOIN s2_company_account a ON d.currentAccountId = a.id
WHERE isDeleted = 0 AND deletedAndStop = 0 AND d.id NOT IN ({$ids}) AND a.departmentId = {$companyId}");
$newDeviceIds = array_column($newDeviceIds, 'id');
if (empty($newDeviceIds)) {
return [];
}
$sql = "INSERT INTO ck_device(`id`, `imei`, `model`, phone, operatingSystem, memo, alive, brand, rooted, xPosed, softwareVersion, extra, createTime, updateTime, deleteTime, companyId)
SELECT
d.id, d.imei, d.model, d.phone, d.operatingSystem, d.memo, d.alive, d.brand, d.rooted, d.xPosed, d.softwareVersion, d.extra, d.createTime, d.lastUpdateTime, d.deleteTime, a.departmentId AS companyId
@@ -86,6 +102,8 @@ class GetAddResultedV1Controller extends BaseController
companyId = VALUES(companyId)";
Db::query($sql);
return $newDeviceIds;
}
/**
@@ -162,4 +180,144 @@ class GetAddResultedV1Controller extends BaseController
]
);
}
/**
* 将新设备自动加入到全局配置中planType=0的计划和工作台
*
* @param array $newDeviceIds 新添加的设备ID数组
* @param int $companyId 公司ID
* @return void
*/
protected function addDevicesToGlobalConfigs(array $newDeviceIds, int $companyId): void
{
try {
// 1. 更新全局计划(场景获客)的设备组
$this->addDevicesToGlobalPlans($newDeviceIds, $companyId);
// 2. 更新全局工作台的设备组
$this->addDevicesToGlobalWorkbenches($newDeviceIds, $companyId);
} catch (\Exception $e) {
// 记录错误但不影响设备添加流程
\think\facade\Log::error('自动添加设备到全局配置失败:' . $e->getMessage(), [
'newDeviceIds' => $newDeviceIds,
'companyId' => $companyId
]);
}
}
/**
* 将新设备加入到全局计划planType=0的设备组
*
* @param array $newDeviceIds 新添加的设备ID数组
* @param int $companyId 公司ID
* @return void
*/
protected function addDevicesToGlobalPlans(array $newDeviceIds, int $companyId): void
{
// 查询所有全局计划planType=0
$plans = Db::name('customer_acquisition_task')
->where('companyId', $companyId)
->where('planType', 0) // 全局计划
->where('deleteTime', 0)
->field('id,reqConf')
->select();
foreach ($plans as $plan) {
$reqConf = json_decode($plan['reqConf'], true) ?: [];
$deviceGroups = isset($reqConf['device']) ? $reqConf['device'] : [];
if (!is_array($deviceGroups)) {
$deviceGroups = [];
}
// 合并新设备ID去重
$deviceGroups = array_unique(array_merge($deviceGroups, $newDeviceIds));
$reqConf['device'] = array_values($deviceGroups); // 重新索引数组
// 更新数据库
Db::name('customer_acquisition_task')
->where('id', $plan['id'])
->update([
'reqConf' => json_encode($reqConf, JSON_UNESCAPED_UNICODE),
'updateTime' => time()
]);
}
}
/**
* 将新设备加入到全局工作台planType=0的设备组
*
* @param array $newDeviceIds 新添加的设备ID数组
* @param int $companyId 公司ID
* @return void
*/
protected function addDevicesToGlobalWorkbenches(array $newDeviceIds, int $companyId): void
{
// 查询所有全局工作台planType=0
$workbenches = Db::name('workbench')
->where('companyId', $companyId)
->where('planType', 0) // 全局工作台
->where('isDel', 0)
->field('id,type')
->select();
foreach ($workbenches as $workbench) {
// 根据工作台类型更新对应的配置表
$this->updateWorkbenchDevices($workbench['id'], $workbench['type'], $newDeviceIds);
}
}
/**
* 更新工作台的设备组
*
* @param int $workbenchId 工作台ID
* @param int $type 工作台类型
* @param array $newDeviceIds 新设备ID数组
* @return void
*/
protected function updateWorkbenchDevices(int $workbenchId, int $type, array $newDeviceIds): void
{
$configTableMap = [
1 => 'workbench_auto_like', // 自动点赞
2 => 'workbench_moments_sync', // 朋友圈同步
3 => 'workbench_group_push', // 群消息推送
4 => 'workbench_group_create', // 自动建群
5 => 'workbench_traffic_config', // 流量分发
6 => 'workbench_import_contact', // 通讯录导入
7 => 'workbench_group_welcome', // 入群欢迎语
];
$tableName = $configTableMap[$type] ?? null;
if (empty($tableName)) {
return;
}
// 查询配置
$config = Db::name($tableName)
->where('workbenchId', $workbenchId)
->field('id,devices')
->find();
if (empty($config)) {
return;
}
// 解析设备组
$deviceGroups = json_decode($config['devices'], true) ?: [];
if (!is_array($deviceGroups)) {
$deviceGroups = [];
}
// 合并新设备ID去重
$deviceGroups = array_unique(array_merge($deviceGroups, $newDeviceIds));
$deviceGroups = array_values($deviceGroups); // 重新索引数组
// 更新数据库
Db::name($tableName)
->where('id', $config['id'])
->update([
'devices' => json_encode($deviceGroups, JSON_UNESCAPED_UNICODE),
'updateTime' => time()
]);
}
}

View File

@@ -124,6 +124,86 @@ class GetAddFriendPlanDetailV1Controller extends Controller
$msgConf = json_decode($plan['msgConf'], true) ?: [];
$tagConf = json_decode($plan['tagConf'], true) ?: [];
// 处理拉群固定成员为数组,并构造下拉 options
if (!empty($plan['groupFixedMembers'])) {
$fixedMembers = json_decode($plan['groupFixedMembers'], true);
$plan['groupFixedMembers'] = is_array($fixedMembers) ? $fixedMembers : [];
} else {
$plan['groupFixedMembers'] = [];
}
// groupFixedMembersOptions参考 workbench 中好友 options 的结构,返回完整好友信息
$groupFixedMembersOptions = [];
if (!empty($plan['groupFixedMembers'])) {
$friendIds = [];
$manualIds = [];
foreach ($plan['groupFixedMembers'] as $member) {
if (is_numeric($member)) {
$friendIds[] = intval($member);
} else {
$manualIds[] = $member;
}
}
// 数字 ID从 s2_wechat_friend 中查询好友信息
if (!empty($friendIds)) {
$friendList = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
->join(['s2_company_account' => 'ca'], 'ca.id = wf.accountId', 'left')
->where('wf.id', 'in', $friendIds)
->order('wf.id', 'desc')
->field('wf.id,wf.wechatId,wf.nickname,wf.avatar,wf.alias,wf.gender,wf.phone,wa.nickName as accountNickname,ca.userName as account,ca.realName as username,wf.createTime,wf.updateTime,wf.deleteTime,wf.ownerWechatId')
->select();
// 获取群主信息,格式化时间
foreach ($friendList as &$friend) {
if (!empty($friend['ownerWechatId'])) {
$owner = Db::name('wechat_account')
->where('wechatId', $friend['ownerWechatId'])
->field('nickName,alias')
->find();
$friend['ownerNickname'] = $owner['nickName'] ?? '';
$friend['ownerAlias'] = $owner['alias'] ?? '';
} else {
$friend['ownerNickname'] = '';
$friend['ownerAlias'] = '';
}
$friend['isManual'] = '';
$friend['createTime'] = !empty($friend['createTime']) ? date('Y-m-d H:i:s', $friend['createTime']) : '';
$friend['updateTime'] = !empty($friend['updateTime']) ? date('Y-m-d H:i:s', $friend['updateTime']) : '';
$friend['deleteTime'] = !empty($friend['deleteTime']) ? date('Y-m-d H:i:s', $friend['deleteTime']) : '';
}
unset($friend);
$groupFixedMembersOptions = array_merge($groupFixedMembersOptions, $friendList);
}
// 手动 ID仅返回基础结构标记 isManual=1
if (!empty($manualIds)) {
foreach ($manualIds as $mid) {
$groupFixedMembersOptions[] = [
'id' => $mid,
'wechatId' => $mid,
'nickname' => $mid,
'avatar' => '',
'alias' => '',
'gender' => 0,
'phone' => '',
'accountNickname' => '',
'account' => '',
'username' => '',
'ownerNickname' => '',
'ownerAlias' => '',
'ownerWechatId' => '',
'createTime' => '',
'updateTime' => '',
'deleteTime' => '',
'isManual' => 1,
];
}
}
}
$sceneConf['groupFixedMembersOptions'] = $groupFixedMembersOptions;
// 处理分销配置
$distributionConfig = $sceneConf['distribution'] ?? [
'enabled' => false,
@@ -207,6 +287,13 @@ class GetAddFriendPlanDetailV1Controller extends Controller
$newData['messagePlans'] = $msgConf;
$newData = array_merge($newData, $sceneConf, $reqConf, $tagConf, $plan);
// 确保 planType 有默认值0=全局1=独立默认1
if (!isset($newData['planType'])) {
$newData['planType'] = 1;
} else {
$newData['planType'] = intval($newData['planType']);
}
// 移除不需要的字段
unset(
$newData['sceneConf'],

View File

@@ -62,6 +62,13 @@ class PlanSceneV1Controller extends BaseController
$val['msgConf'] = json_decode($val['msgConf'],true) ?: [];
$val['tagConf'] = json_decode($val['tagConf'],true) ?: [];
// 确保 planType 有默认值0=全局1=独立默认1
if (!isset($val['planType'])) {
$val['planType'] = 1;
} else {
$val['planType'] = intval($val['planType']);
}
$stats = $statsMap[$val['id']] ?? [
'acquiredCount' => 0,
'addedCount' => 0,

View File

@@ -21,8 +21,8 @@ class PostCreateAddFriendPlanV1Controller extends BaseController
*/
public function generateApiKey()
{
// 生成5组随机字符串每组5个字符
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
// 生成5组随机字符串每组5个字符(包含大小写字母和数字)
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$apiKey = '';
for ($i = 0; $i < 5; $i++) {
@@ -69,6 +69,21 @@ class PostCreateAddFriendPlanV1Controller extends BaseController
return ResponseHelper::error('请选择设备', 400);
}
// 拉群配置校验
// groupInviteEnabled拉群开关0/1
// groupName群名称
// groupFixedMembers固定成员数组
$groupInviteEnabled = !empty($params['groupInviteEnabled']) ? 1 : 0;
if ($groupInviteEnabled) {
if (empty($params['groupName'])) {
return ResponseHelper::error('拉群群名不能为空', 400);
}
if (empty($params['groupFixedMembers']) || !is_array($params['groupFixedMembers'])) {
return ResponseHelper::error('固定成员不能为空', 400);
}
}
$companyId = $this->getUserInfo('companyId');
// 处理分销配置
@@ -114,7 +129,11 @@ class PostCreateAddFriendPlanV1Controller extends BaseController
$sceneConf['distributionEnabled'],
$sceneConf['distributionChannels'],
$sceneConf['customerRewardAmount'],
$sceneConf['addFriendRewardAmount']
$sceneConf['addFriendRewardAmount'],
// 拉群相关字段单独存表,不放到 sceneConf
$sceneConf['groupInviteEnabled'],
$sceneConf['groupName'],
$sceneConf['groupFixedMembers']
);
// 将分销配置添加到sceneConf中
@@ -131,6 +150,14 @@ class PostCreateAddFriendPlanV1Controller extends BaseController
'userId' => $this->getUserInfo('id'),
'companyId' => $this->getUserInfo('companyId'),
'status' => !empty($params['status']) ? 1 : 0,
// 计划类型0=全局1=独立(默认)
'planType' => isset($params['planType']) ? intval($params['planType']) : 1,
// 拉群配置
'groupInviteEnabled' => $groupInviteEnabled,
'groupName' => $params['groupName'] ?? '',
'groupFixedMembers' => !empty($params['groupFixedMembers'])
? json_encode($params['groupFixedMembers'], JSON_UNESCAPED_UNICODE)
: json_encode([], JSON_UNESCAPED_UNICODE),
'apiKey' => $this->generateApiKey(), // 生成API密钥
'createTime' => time(),
'updateTime' => time(),

View File

@@ -6,6 +6,8 @@ use library\ResponseHelper;
use think\Controller;
use think\Db;
use app\cunkebao\service\DistributionRewardService;
use app\cunkebao\service\TrafficPoolService;
use app\common\model\TrafficPoolSource;
/**
* 对外API接口控制器
@@ -95,27 +97,28 @@ class PostExternalApiV1Controller extends Controller
// 渠道IDcid对应 distribution_channel.id
$channelId = !empty($params['cid']) ? intval($params['cid']) : 0;
$trafficPool = Db::name('traffic_pool')->where('identifier', $identifier)->find();
if (!$trafficPool) {
$trafficPoolId =Db::name('traffic_pool')->insertGetId([
'identifier' => $identifier,
'mobile' => !empty($params['phone']) ? $params['phone'] : '',
'createTime' => time()
]);
}else{
$trafficPoolId = $trafficPool['id'];
}
// ========== 旧版流量池代码(已废弃,保留用于兼容) ==========
// $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find();
// if (!$trafficPool) {
// $trafficPoolId =Db::name('traffic_pool_v1')->insertGetId([
// 'identifier' => $identifier,
// 'mobile' => !empty($params['phone']) ? $params['phone'] : '',
// 'createTime' => time()
// ]);
// }else{
// $trafficPoolId = $trafficPool['id'];
// }
// ========== 旧版流量池代码结束 ==========
$taskCustomer = Db::name('task_customer')
->where('task_id', $plan['id'])
->where('phone', $identifier)
->find();
// 处理用户画像
if(!empty($params['portrait']) && is_array($params['portrait'])){
$this->updatePortrait($params['portrait'],$trafficPoolId,$plan['companyId']);
}
// 处理用户画像已迁移到V2流量池此处保留兼容
// if(!empty($params['portrait']) && is_array($params['portrait'])){
// $this->updatePortrait($params['portrait'],$trafficPoolId,$plan['companyId']);
// }
if (!$taskCustomer) {
$tags = !empty($params['tags']) ? explode(',', $params['tags']) : [];
$siteTags = !empty($params['siteTags']) ? explode(',', $params['siteTags']) : [];
@@ -154,6 +157,60 @@ class PostExternalApiV1Controller extends Controller
'createTime' => time(),
]);
// 实时同步到 V2 流量池系统(异步处理,不影响主流程)
if ($customerId) {
try {
$poolService = new TrafficPoolService();
// 判断 identifier 类型:手机号还是微信号
$identifierType = 2; // 默认手机号
$isPhone = preg_match('/^\+?\d{6,}$/', $identifier);
if (!$isPhone && !empty($params['wechatId'])) {
$identifierType = 1; // 微信号
}
// 准备流量池数据
$poolData = [
'identifierType' => $identifierType,
'mobile' => !empty($params['phone']) ? $params['phone'] : ($isPhone ? $identifier : ''),
'wechatId' => !empty($params['wechatId']) ? $params['wechatId'] : (!$isPhone ? $identifier : ''),
'nickname' => !empty($params['name']) ? $params['name'] : '',
];
// 准备公司流量数据
$companyData = [
'phone' => !empty($params['phone']) ? $params['phone'] : ($isPhone ? $identifier : ''),
'realName' => !empty($params['name']) ? $params['name'] : '',
'remark' => !empty($params['remark']) ? $params['remark'] : '',
];
// 准备来源数据
$sourceData = [
'sourceName' => !empty($params['source']) ? $params['source'] : ('场景获客_' . $plan['name']),
'remark' => !empty($params['remark']) ? $params['remark'] : '',
'extra' => json_encode([
'planId' => $plan['id'],
'planName' => $plan['name'],
'channelId' => $finalChannelId,
'customerId' => $customerId,
], JSON_UNESCAPED_UNICODE),
];
// 同步到 V2 流量池
$poolService->enterPool(
$identifier,
$plan['companyId'],
TrafficPoolSource::SOURCE_TYPE_API, // API导入
$poolData,
$companyData,
$sourceData
);
} catch (\Exception $e) {
// 记录错误但不影响主流程
\think\facade\Log::error('同步到V2流量池失败' . $e->getMessage());
}
}
// 记录获客奖励(异步处理,不影响主流程)
if ($customerId) {
try {

View File

@@ -39,6 +39,18 @@ class PostUpdateAddFriendPlanV1Controller extends BaseController
return ResponseHelper::error('请选择设备', 400);
}
// 拉群配置校验
$groupInviteEnabled = !empty($params['groupInviteEnabled']) ? 1 : 0;
if ($groupInviteEnabled) {
if (empty($params['groupName'])) {
return ResponseHelper::error('拉群群名不能为空', 400);
}
if (empty($params['groupFixedMembers']) || !is_array($params['groupFixedMembers'])) {
return ResponseHelper::error('固定成员不能为空', 400);
}
}
// 检查计划是否存在
$plan = Db::name('customer_acquisition_task')
->where('id', $params['planId'])
@@ -94,7 +106,11 @@ class PostUpdateAddFriendPlanV1Controller extends BaseController
$sceneConf['distributionEnabled'],
$sceneConf['distributionChannels'],
$sceneConf['customerRewardAmount'],
$sceneConf['addFriendRewardAmount']
$sceneConf['addFriendRewardAmount'],
// 拉群相关字段单独存表,不放到 sceneConf
$sceneConf['groupInviteEnabled'],
$sceneConf['groupName'],
$sceneConf['groupFixedMembers']
);
// 将分销配置添加到sceneConf中
@@ -109,6 +125,14 @@ class PostUpdateAddFriendPlanV1Controller extends BaseController
'msgConf' => json_encode($msgConf, JSON_UNESCAPED_UNICODE),
'tagConf' => json_encode($tagConf, JSON_UNESCAPED_UNICODE),
'status' => !empty($params['status']) ? 1 : 0,
// 计划类型0=全局1=独立(默认)
'planType' => isset($params['planType']) ? intval($params['planType']) : 1,
// 拉群配置
'groupInviteEnabled' => $groupInviteEnabled,
'groupName' => $params['groupName'] ?? '',
'groupFixedMembers' => !empty($params['groupFixedMembers'])
? json_encode($params['groupFixedMembers'], JSON_UNESCAPED_UNICODE)
: json_encode([], JSON_UNESCAPED_UNICODE),
'updateTime' => time(),
];

View File

@@ -10,6 +10,8 @@ use think\facade\Env;
// use EasyWeChat\Kernel\Exceptions\DecryptException;
use EasyWeChat\Kernel\Http\StreamResponse;
use think\Db;
use app\cunkebao\service\TrafficPoolService;
use app\common\model\TrafficPoolSource;
class PosterWeChatMiniProgram extends Controller
{
@@ -112,16 +114,18 @@ class PosterWeChatMiniProgram extends Controller
if ($result['errcode'] == 0 && isset($result['phone_info']['phoneNumber'])) {
// ========== 旧版流量池代码(已废弃,保留用于兼容) ==========
// TODO 拿到手机号之后的后续操作:
// 1. 先写入 ck_traffic_pool 表 identifier mobile 都是 用 phone字段的值
$trafficPool = Db::name('traffic_pool')->where('identifier', $result['phone_info']['phoneNumber'])->find();
if (!$trafficPool) {
Db::name('traffic_pool')->insert([
'identifier' => $result['phone_info']['phoneNumber'],
'mobile' => $result['phone_info']['phoneNumber'],
'createTime' => time()
]);
}
// 1. 先写入 ck_traffic_pool_v1 表 identifier mobile 都是 用 phone字段的值
// $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $result['phone_info']['phoneNumber'])->find();
// if (!$trafficPool) {
// Db::name('traffic_pool_v1')->insert([
// 'identifier' => $result['phone_info']['phoneNumber'],
// 'mobile' => $result['phone_info']['phoneNumber'],
// 'createTime' => time()
// ]);
// }
// ========== 旧版流量池代码结束已迁移到V2实时同步 ==========
// 2. 写入 ck_task_customer: 以 task_id ~~identifier~~ phone 为条件如果存在则忽略使用类似laravel的firstOrcreate但我不知道thinkphp5.1里的写法)
// $taskCustomer = Db::name('task_customer')->where('task_id', $taskId)->where('identifier', $result['phone_info']['phoneNumber'])->find();
$taskCustomer = Db::name('task_customer')
@@ -165,6 +169,50 @@ class PosterWeChatMiniProgram extends Controller
'siteTags' => json_encode([]),
]);
// 实时同步到 V2 流量池系统(异步处理,不影响主流程)
if ($customerId) {
try {
$poolService = new TrafficPoolService();
$identifier = $result['phone_info']['phoneNumber'];
// 准备流量池数据
$poolData = [
'identifierType' => 2, // 手机号
'mobile' => $identifier,
];
// 准备公司流量数据
$companyData = [
'phone' => $identifier,
];
// 准备来源数据
$sourceData = [
'sourceName' => $task['name'] ?? '海报获客',
'extra' => json_encode([
'planId' => $taskId,
'planName' => $task['name'] ?? '',
'channelId' => $finalChannelId,
'customerId' => $customerId,
'source' => 'poster_miniprogram',
], JSON_UNESCAPED_UNICODE),
];
// 同步到 V2 流量池
$poolService->enterPool(
$identifier,
$task['companyId'],
TrafficPoolSource::SOURCE_TYPE_POSTER, // 海报获客
$poolData,
$companyData,
$sourceData
);
} catch (\Exception $e) {
// 记录错误但不影响主流程
\think\facade\Log::error('同步到V2流量池失败' . $e->getMessage());
}
}
// 记录获客奖励(异步处理,不影响主流程)
if ($customerId) {
try {
@@ -259,31 +307,33 @@ class PosterWeChatMiniProgram extends Controller
continue;
}
$isPhone = preg_match('/^\+?\d{6,}$/', $identifier);
$trafficPool = Db::name('traffic_pool')->where('identifier', $identifier)->find();
if (!$trafficPool) {
$insertData = [
'identifier' => $identifier,
'createTime' => time()
];
if ($isPhone) {
$insertData['mobile'] = $identifier;
} else {
$insertData['wechatId'] = $identifier;
}
Db::name('traffic_pool')->insert($insertData);
} else {
$updates = [];
if ($isPhone && empty($trafficPool['mobile'])) {
$updates['mobile'] = $identifier;
}
if (!$isPhone && empty($trafficPool['wechatId'])) {
$updates['wechatId'] = $identifier;
}
if (!empty($updates)) {
$updates['updateTime'] = time();
Db::name('traffic_pool')->where('id', $trafficPool['id'])->update($updates);
}
}
// ========== 旧版流量池代码(已废弃,保留用于兼容) ==========
// $trafficPool = Db::name('traffic_pool_v1')->where('identifier', $identifier)->find();
// if (!$trafficPool) {
// $insertData = [
// 'identifier' => $identifier,
// 'createTime' => time()
// ];
// if ($isPhone) {
// $insertData['mobile'] = $identifier;
// } else {
// $insertData['wechatId'] = $identifier;
// }
// Db::name('traffic_pool_v1')->insert($insertData);
// } else {
// $updates = [];
// if ($isPhone && empty($trafficPool['mobile'])) {
// $updates['mobile'] = $identifier;
// }
// if (!$isPhone && empty($trafficPool['wechatId'])) {
// $updates['wechatId'] = $identifier;
// }
// if (!empty($updates)) {
// $updates['updateTime'] = time();
// Db::name('traffic_pool_v1')->where('id', $trafficPool['id'])->update($updates);
// }
// }
// ========== 旧版流量池代码结束已迁移到V2实时同步 ==========
$taskCustomer = Db::name('task_customer')
->where('task_id', $taskId)
@@ -305,6 +355,55 @@ class PosterWeChatMiniProgram extends Controller
// 使用 insertGetId 以便在需要时记录获客奖励
$customerId = Db::name('task_customer')->insertGetId($insertCustomer);
// 实时同步到 V2 流量池系统(异步处理,不影响主流程)
if (!empty($customerId)) {
try {
$poolService = new TrafficPoolService();
// 判断 identifier 类型
$identifierType = $isPhone ? 2 : 1; // 2=手机号, 1=微信号
// 准备流量池数据
$poolData = [
'identifierType' => $identifierType,
'mobile' => $isPhone ? $identifier : '',
'wechatId' => !$isPhone ? $identifier : '',
];
// 准备公司流量数据
$companyData = [
'phone' => $isPhone ? $identifier : '',
'remark' => $remark,
];
// 准备来源数据
$sourceData = [
'sourceName' => $task['name'] ?? '海报获客',
'remark' => $remark,
'extra' => json_encode([
'planId' => $taskId,
'planName' => $task['name'] ?? '',
'channelId' => $finalChannelId,
'customerId' => $customerId,
'source' => 'poster_batch_import',
], JSON_UNESCAPED_UNICODE),
];
// 同步到 V2 流量池
$poolService->enterPool(
$identifier,
$task['companyId'],
TrafficPoolSource::SOURCE_TYPE_POSTER, // 海报获客
$poolData,
$companyData,
$sourceData
);
} catch (\Exception $e) {
// 记录错误但不影响主流程
\think\facade\Log::error('同步到V2流量池失败' . $e->getMessage());
}
}
// 表单录入成功即视为一次获客:
// 仅在存在有效渠道ID时记录获客奖励谁的cid谁获客
if (!empty($customerId) && $finalChannelId > 0) {
@@ -347,10 +446,31 @@ class PosterWeChatMiniProgram extends Controller
function getPosterTaskData()
{
$id = request()->param('id');
$oldId = request()->param('oldId');
// 兼容旧数据:如果传了 oldId通过 legacyId 和 isLegacy 查找
if (!empty($oldId)) {
$task = Db::name('customer_acquisition_task')
->where([
'legacyId' => $oldId,
'isLegacy' => 1,
'deleteTime' => 0
])
->field('id,name,sceneConf,status')
->find();
} elseif (!empty($id)) {
// 新数据:直接用 id 查找
$task = Db::name('customer_acquisition_task')
->where(['id' => $id, 'deleteTime' => 0])
->field('id,name,sceneConf,status')
->find();
} else {
return json([
'code' => 400,
'message' => '任务ID不能为空'
]);
}
if (!$task) {
return json([
'code' => 400,
@@ -367,8 +487,8 @@ class PosterWeChatMiniProgram extends Controller
$sceneConf = json_decode($task['sceneConf'], true);
if (isset($sceneConf['posters']['url'])) {
$posterUrl = !empty($sceneConf['posters']['url']);
if (isset($sceneConf['posters']['url']) && !empty($sceneConf['posters']['url'])) {
$posterUrl = $sceneConf['posters']['url'];
} else {
$posterUrl = 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif';
}

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

@@ -72,7 +72,11 @@ class GetConvertedListWithInCompanyV1Controller extends BaseController
'f.tags', 'f.createTime', TrafficSourceModel::STATUS_PASSED . ' status'
]
)
// ========== 旧版流量池代码(已废弃) ==========
// ->join('traffic_pool_v1 p', 'p.identifier=s.identifier')
// ========== 新版流量池代码 ==========
->join('traffic_pool p', 'p.identifier=s.identifier')
// ========== 旧版流量池代码结束 ==========
->join('wechat_account w', 'p.wechatId=w.wechatId')
->join('wechat_friendship f', 'w.wechatId=f.wechatId and f.deleteTime=0')
->order('s.id desc');

View File

@@ -83,10 +83,10 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
'wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.alias'
]
)
->join('traffic_source s', 'p.identifier=s.identifier')
->join('traffic_source_v1 s', 'p.identifier=s.identifier')
->join('wechat_account wa', 'p.identifier=wa.wechatId', 'left')
->join('traffic_source_package_item tspi', 'p.identifier = tspi.identifier AND s.companyId = tspi.companyId', 'left')
->join('traffic_source_package tsp', 'tspi.packageId=tsp.id', 'left')
->join('traffic_source_package_item_v1 tspi', 'p.identifier = tspi.identifier AND s.companyId = tspi.companyId', 'left')
->join('traffic_source_package_v1 tsp', 'tspi.packageId=tsp.id', 'left')
->join('device_wechat_login d', 's.sourceId=d.wechatId', 'left')
->where($where);
@@ -106,8 +106,8 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
if ($isPage) {
foreach ($list as &$item) {
//流量池筛选
$package = Db::name('traffic_source_package_item')->alias('tspi')
->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId')
$package = Db::name('traffic_source_package_item_v1')->alias('tspi')
->join('traffic_source_package_v1 p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId')
->where(['tspi.identifier' => $item->identifier])
->whereIn('tspi.companyId', [0, $item->companyId])
->column('p.name');
@@ -181,7 +181,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
$data['lastMsgTime'] = '';
//来源
$source = Db::name('traffic_source')->alias('ts')
$source = Db::name('traffic_source_v1')->alias('ts')
->field(['wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.wechatId', 'wa.alias',
'ts.createTime',
'wf.id as friendId', 'wf.wechatAccountId'])
@@ -221,12 +221,12 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
//流量池
$package = Db::name('traffic_source_package_item')->alias('tspi')
$package = Db::name('traffic_source_package_item_v1')->alias('tspi')
->join('traffic_source_package p', 'tspi.packageId=p.id AND tspi.companyId=p.companyId')
->where(['tspi.companyId' => $companyId, 'tspi.identifier' => $data['identifier']])
->column('p.name');
$package2 = Db::name('traffic_source_package_item')->alias('tspi')
->join('traffic_source_package p', 'tspi.packageId=p.id')
$package2 = Db::name('traffic_source_package_item_v1')->alias('tspi')
->join('traffic_source_package_v1 p', 'tspi.packageId=p.id')
->where(['tspi.companyId' => $companyId, 'tspi.identifier' => $data['identifier']])
->column('p.name');
$packages = array_merge($package, $package2);
@@ -328,12 +328,24 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
if (empty($userId)) {
return json_encode(['code' => 500, 'msg' => '用户id不能为空']);
}
$data = Db::name('traffic_pool')->alias('tp')
->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId=' . $companyId, 'left')
->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left')
->where(['tp.id' => $userId])
->order('tp.createTime desc')
// ========== 旧版流量池代码(已废弃) ==========
// $data = Db::name('traffic_pool_v1')->alias('tp')
// ->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId=' . $companyId, 'left')
// ->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left')
// ->where(['tp.id' => $userId])
// ->order('tp.createTime desc')
// ->column('wf.id,wf.labels,wf.siteLabels');
// ========== 新版流量池代码 ==========
$pool = Db::name('traffic_pool')->where('id', $userId)->find();
if (!$pool) {
return ResponseHelper::success(['wechat' => [], 'siteLabels' => []]);
}
$data = Db::name('s2_wechat_friend')->alias('wf')
->join('wechat_friendship f', 'wf.wechatId=f.wechatId AND f.companyId=' . $companyId, 'left')
->where(['wf.wechatId' => $pool['identifier']])
->order('wf.id desc')
->column('wf.id,wf.labels,wf.siteLabels');
// ========== 旧版流量池代码结束 ==========
if (empty($data)) {
return ResponseHelper::success(['wechat' => [], 'siteLabels' => []]);
}
@@ -383,7 +395,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
}
if (!empty($addPackageId)) {
$package = Db::name('traffic_source_package')
$package = Db::name('traffic_source_package_v1')
->where(['id' => $addPackageId, 'isDel' => 0])
->whereIn('companyId', [$companyId, 0])
->field('id,name')
@@ -393,7 +405,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
}
$packageId = $package['id'];
} else {
$package = Db::name('traffic_source_package')
$package = Db::name('traffic_source_package_v1')
->where(['isDel' => 0, 'name' => $packageName])
->whereIn('companyId', [$companyId, 0])
->field('id,name')
@@ -401,7 +413,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
if (!empty($package)) {
return ResponseHelper::error('该流量池名称已存在');
}
$packageId = Db::name('traffic_source_package')->insertGetId([
$packageId = Db::name('traffic_source_package_v1')->insertGetId([
'userId' => $userId,
'companyId' => $companyId,
'name' => $packageName,
@@ -424,12 +436,21 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
if (!is_array($userIds)) {
return ResponseHelper::error('选择的用户类型错误');
}
// ========== 旧版流量池代码(已废弃) ==========
// $result = Db::name('traffic_pool_v1')->alias('tp')
// ->join('traffic_source_v1 tc', 'tp.identifier=tc.identifier')
// ->whereIn('tp.id', $userIds)
// ->where(['companyId' => $companyId])
// ->group('tp.identifier')
// ->column('tc.identifier');
// ========== 新版流量池代码 ==========
$result = Db::name('traffic_pool')->alias('tp')
->join('traffic_source tc', 'tp.identifier=tc.identifier')
->join('traffic_pool_company tpc', 'tpc.poolId=tp.id AND tpc.companyId=' . $companyId)
->join('traffic_pool_source tps', 'tps.poolCompanyId=tpc.id')
->whereIn('tp.id', $userIds)
->where(['companyId' => $companyId])
->group('tp.identifier')
->column('tc.identifier');
->column('tps.identifier');
// ========== 旧版流量池代码结束 ==========
} else {
/*if (empty($tableFile)){
return ResponseHelper::error('请上传用户文件');
@@ -513,7 +534,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
$batchRows = array_slice($result, $i, $batchSize);
if (!empty($batchRows)) {
// 2. 批量查询已存在的手机
$existing = Db::name('traffic_source_package_item')
$existing = Db::name('traffic_source_package_item_v1')
->where(['companyId' => $companyId, 'packageId' => $packageId])
->whereIn('identifier', $batchRows)
->field('identifier')
@@ -533,7 +554,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
}
// 4. 批量插入新数据
if (!empty($newData)) {
Db::name('traffic_source_package_item')->insertAll($newData);
Db::name('traffic_source_package_item_v1')->insertAll($newData);
}
}
}
@@ -548,28 +569,33 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
$batchRows = array_slice($rows, $i, $batchSize);
if (!empty($batchRows)) {
$identifiers = array_column($batchRows, 'phone');
//流量池处理
$existing = Db::name('traffic_pool')
->whereIn('identifier', $identifiers)
->column('identifier');
$newData = [];
foreach ($batchRows as $row) {
if (!in_array($row['phone'], $existing)) {
$newData[] = [
'identifier' => $row['phone'],
'mobile' => $row['phone'],
'createTime' => time(),
];
}
}
if (!empty($newData)) {
Db::name('traffic_pool')->insertAll($newData);
}
// ========== 旧版流量池代码已废弃已迁移到V2实时同步 ==========
// //流量池处理
// $existing = Db::name('traffic_pool_v1')
// ->whereIn('identifier', $identifiers)
// ->column('identifier');
//
// $newData = [];
// foreach ($batchRows as $row) {
// if (!in_array($row['phone'], $existing)) {
// $newData[] = [
// 'identifier' => $row['phone'],
// 'mobile' => $row['phone'],
// 'createTime' => time(),
// ];
// }
// }
// if (!empty($newData)) {
// Db::name('traffic_pool_v1')->insertAll($newData);
// }
// ========== 新版流量池代码(使用 TrafficPoolService 实时同步) ==========
// 流量池处理 - 现在通过 TrafficPoolService 实时同步到 V2
// 如果需要批量导入,建议使用 migrate:trafficPoolV2 命令
// ========== 旧版流量池代码结束 ==========
//流量池来源处理
$newData2 = [];
$existing2 = Db::name('traffic_source')
$existing2 = Db::name('traffic_source_v1')
->where(['companyId' => $companyId])
->whereIn('identifier', $identifiers)
->column('identifier');
@@ -587,12 +613,12 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
}
}
if (!empty($newData2)) {
Db::name('traffic_source')->insertAll($newData2);
Db::name('traffic_source_v1')->insertAll($newData2);
}
//流量池包数据处理
$newData3 = [];
$existing3 = Db::name('traffic_source_package_item')
$existing3 = Db::name('traffic_source_package_item_v1')
->where(['companyId' => $companyId, 'packageId' => $packageId])
->whereIn('identifier', $identifiers)
->field('identifier')
@@ -608,7 +634,7 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
}
}
if (!empty($newData3)) {
Db::name('traffic_source_package_item')->insertAll($newData3);
Db::name('traffic_source_package_item_v1')->insertAll($newData3);
}
Db::commit();
@@ -638,10 +664,20 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
$isWechat = $this->request->param('isWechat', false);
$companyId = $this->getUserInfo('companyId');
$friend = Db::name('traffic_pool')->alias('tp')
->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId='.$companyId, 'left')
->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left')
->where(['tp.id' => $userId])
// ========== 旧版流量池代码(已废弃) ==========
// $friend = Db::name('traffic_pool_v1')->alias('tp')
// ->join('wechat_friendship f', 'tp.wechatId=f.wechatId AND f.companyId='.$companyId, 'left')
// ->join(['s2_wechat_friend' => 'wf'], 'f.wechatId=wf.wechatId', 'left')
// ->where(['tp.id' => $userId])
// ========== 新版流量池代码 ==========
$pool = Db::name('traffic_pool')->where('id', $userId)->find();
if (!$pool) {
return ResponseHelper::error('流量池记录不存在');
}
$friend = Db::name('s2_wechat_friend')->alias('wf')
->join('wechat_friendship f', 'wf.wechatId=f.wechatId AND f.companyId='.$companyId, 'left')
->where(['wf.wechatId' => $pool['identifier']])
// ========== 旧版流量池代码结束 ==========
->order('tp.createTime desc')
->column('wf.id,wf.accountId,wf.labels,wf.siteLabels');
if (empty($data)) {

View File

@@ -3,10 +3,6 @@
namespace app\cunkebao\controller\wechat;
use app\common\controller\ExportController;
use app\common\model\Device as DeviceModel;
use app\common\model\DeviceUser as DeviceUserModel;
use app\common\model\DeviceWechatLogin as DeviceWechatLoginModel;
use app\common\model\User as UserModel;
use app\cunkebao\controller\BaseController;
use library\ResponseHelper;
use think\Db;
@@ -16,58 +12,53 @@ use think\Db;
*/
class GetWechatMomentsV1Controller extends BaseController
{
/**
* 主操盘手获取项目下所有设备ID
*
* @return array
*/
protected function getCompanyDevicesId(): array
{
return DeviceModel::where('companyId', $this->getUserInfo('companyId'))
->column('id');
}
/**
* 非主操盘手仅可查看分配到的设备
*
* @return array
*/
protected function getUserDevicesId(): array
{
return DeviceUserModel::where([
'userId' => $this->getUserInfo('id'),
'companyId' => $this->getUserInfo('companyId'),
])->column('deviceId');
}
/**
* 获取当前用户可访问的设备ID
*
* @return array
*/
protected function getDevicesId(): array
{
return ($this->getUserInfo('isAdmin') == UserModel::ADMIN_STP)
? $this->getCompanyDevicesId()
: $this->getUserDevicesId();
}
/**
* 获取用户可访问的微信ID集合
* 使用 s2_wechat_friend 验证好友归属:
* - 非管理员:当前账号在好友表中的 ownerWechatId 集合
* - 管理员:公司下所有账号在好友表中的 ownerWechatId 集合
*
* @return array
* @throws \Exception
*/
protected function getAccessibleWechatIds(): array
{
$deviceIds = $this->getDevicesId();
if (empty($deviceIds)) {
throw new \Exception('暂无可用设备', 200);
$companyId = $this->getUserInfo('companyId');
$isAdmin = $this->getUserInfo('isAdmin');
$accountId = $this->getUserInfo('s2_accountId');
if (empty($companyId)) {
throw new \Exception('请先登录', 401);
}
return DeviceWechatLoginModel::distinct(true)
->where('companyId', $this->getUserInfo('companyId'))
->whereIn('deviceId', $deviceIds)
// 管理员根据公司下所有账号的好友归属s2_wechat_friend.accountId -> ownerWechatId
if (!empty($isAdmin)) {
// 获取公司下所有账号ID
$accountIds = Db::table('s2_company_account')
->where('departmentId', $companyId)
->column('id');
if (empty($accountIds)) {
return [];
}
// 从好友表中取出这些账号的 ownerWechatId去重排除已删除好友
return Db::table('s2_wechat_friend')
->distinct(true)
->whereIn('accountId', $accountIds)
->where('isDeleted', 0)
->column('wechatId');
}
// 非管理员:仅根据当前账号在好友表中的 ownerWechatId 列表
if (empty($accountId)) {
return [];
}
return Db::table('s2_wechat_friend')
->distinct(true)
->where('accountId', $accountId)
->where('isDeleted', 0)
->column('wechatId');
}
@@ -79,30 +70,18 @@ class GetWechatMomentsV1Controller extends BaseController
public function index()
{
try {
// 可选参数wechatId 不传则查看当前账号可访问的所有微信的朋友圈
$wechatId = $this->request->param('wechatId/s', '');
if (empty($wechatId)) {
return ResponseHelper::error('wechatId不能为空');
// 查询朋友圈:如果传了 userName 参数,则只查看指定用户的;否则查看所有
$query = Db::table('s2_wechat_moments');
if (!empty($wechatId)) {
$query->where('userName', $wechatId);
}
// 权限校验:只能查看当前账号可访问的微信
$accessibleWechatIds = $this->getAccessibleWechatIds();
if (!in_array($wechatId, $accessibleWechatIds, true)) {
return ResponseHelper::error('无权查看该微信的朋友圈', 403);
}
// 获取对应的微信账号ID
$accountId = Db::table('s2_wechat_account')
->where('wechatId', $wechatId)
->value('id');
if (empty($accountId)) {
return ResponseHelper::error('微信账号不存在或尚未同步', 404);
}
$query = Db::table('s2_wechat_moments')
->where('wechatAccountId', $accountId)
->where('userName', $wechatId);
// 关键词搜索
if ($keyword = trim((string)$this->request->param('keyword', ''))) {
$query->whereLike('content', '%' . $keyword . '%');
@@ -130,6 +109,7 @@ class GetWechatMomentsV1Controller extends BaseController
$limit = (int)$this->request->param('limit', 10);
$paginator = $query->order('createTime', 'desc')
->group('snsId')
->paginate($limit, false, ['page' => $page]);
$list = array_map(function ($item) {
@@ -160,24 +140,15 @@ class GetWechatMomentsV1Controller extends BaseController
return ResponseHelper::error('wechatId不能为空');
}
// 权限校验:只能查看当前账号可访问的微信
$accessibleWechatIds = $this->getAccessibleWechatIds();
if (!in_array($wechatId, $accessibleWechatIds, true)) {
return ResponseHelper::error('无权查看该微信的朋友圈', 403);
// 查询朋友圈(不限制 userName导出所有朋友圈
$query = Db::table('s2_wechat_moments');
if (!empty($wechatId)) {
$query->where('userName', $wechatId);
}
// 获取对应的微信账号ID
$accountId = Db::table('s2_wechat_account')
->where('wechatId', $wechatId)
->value('id');
if (empty($accountId)) {
return ResponseHelper::error('微信账号不存在或尚未同步', 404);
}
$query = Db::table('s2_wechat_moments')
->where('wechatAccountId', $accountId);
// 关键词搜索
if ($keyword = trim((string)$this->request->param('keyword', ''))) {
$query->whereLike('content', '%' . $keyword . '%');
@@ -202,7 +173,7 @@ class GetWechatMomentsV1Controller extends BaseController
}
// 获取所有数据(不分页)
$moments = $query->order('createTime', 'desc')->select();
$moments = $query->order('createTime', 'desc')->group('snsId')->select();
if (empty($moments)) {
return ResponseHelper::error('暂无数据可导出');
@@ -230,7 +201,7 @@ class GetWechatMomentsV1Controller extends BaseController
// 格式化数据
$rows = [];
foreach ($moments as $moment) {
$resUrls = $this->decodeJson($moment['resUrls'] ?? null);
$resUrls = $this->preferredMediaUrls($moment);
$imageUrls = is_array($resUrls) ? $resUrls : [];
// 格式化日期和时间
@@ -329,7 +300,8 @@ class GetWechatMomentsV1Controller extends BaseController
'content' => $row['content'] ?? '',
'commentList' => $this->decodeJson($row['commentList'] ?? null),
'likeList' => $this->decodeJson($row['likeList'] ?? null),
'resUrls' => $this->decodeJson($row['resUrls'] ?? null),
'resUrls' => $this->preferredMediaUrls($row),
'ossUrls' => $this->decodeJson($row['ossUrls'] ?? null),
'createTime' => $formatTime($row['createTime'] ?? null),
'momentEntity' => [
'lat' => $row['lat'] ?? 0,
@@ -341,6 +313,16 @@ class GetWechatMomentsV1Controller extends BaseController
];
}
protected function preferredMediaUrls(array $row): array
{
$ossUrls = $this->decodeJson($row['ossUrls'] ?? null);
if (!empty($ossUrls)) {
return $ossUrls;
}
return $this->decodeJson($row['resUrls'] ?? null);
}
/**
* JSON字段解析
*

View File

@@ -55,7 +55,7 @@ class GetWechatProfileV1Controller extends BaseController
{
return (string)TrafficPoolModel::alias('p')
->field('t.id')
->join('traffic_source s', 's.identifier = p.identifier')
->join('traffic_source_v1 s', 's.identifier = p.identifier')
->where('p.wechatId', $wechatId)
->value('fromd');
}

View File

@@ -106,3 +106,5 @@ class WorkbenchAutoLikeController extends Controller
}
}

View File

@@ -142,7 +142,7 @@ class WorkbenchHelperController extends Controller
$keyword = $this->request->param('keyword', '');
$companyId = $this->request->userInfo['companyId'];
$baseQuery = Db::name('traffic_source_package')->alias('tsp')
$baseQuery = Db::name('traffic_source_package_v1')->alias('tsp')
->where('tsp.isDel', 0)
->whereIn('tsp.companyId', [$companyId, 0]);
@@ -153,7 +153,7 @@ class WorkbenchHelperController extends Controller
$total = (clone $baseQuery)->count();
$list = $baseQuery
->leftJoin('traffic_source_package_item tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0')
->leftJoin('traffic_source_package_item_v1 tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0')
->field('tsp.id,tsp.name,tsp.description,tsp.pic,tsp.companyId,COUNT(tspi.id) as itemCount,max(tspi.createTime) as latestImportTime')
->group('tsp.id')
->order('tsp.id', 'desc')
@@ -311,3 +311,5 @@ class WorkbenchHelperController extends Controller
}
}

View File

@@ -25,10 +25,18 @@ class WorkbenchImportContactController extends Controller
];
// 查询发布记录
// ========== 旧版流量池代码(已废弃) ==========
// $list = Db::name('workbench_import_contact_item')->alias('wici')
// ->join('traffic_pool_v1 tp', 'tp.id = wici.poolId', 'left')
// ->join('traffic_source_v1 tc', 'tc.identifier = tp.identifier', 'left')
// ->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left')
// ========== 新版流量池代码 ==========
$list = Db::name('workbench_import_contact_item')->alias('wici')
->join('traffic_pool tp', 'tp.id = wici.poolId', 'left')
->join('traffic_source tc', 'tc.identifier = tp.identifier', 'left')
->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . $this->getUserInfo('companyId'), 'left')
->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left')
->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left')
// ========== 旧版流量池代码结束 ==========
->field([
'wici.id',
'wici.workbenchId',
@@ -36,7 +44,7 @@ class WorkbenchImportContactController extends Controller
'tp.identifier',
'tp.mobile',
'tp.wechatId',
'tc.name',
'tps.sourceName as name', // 从新版来源表获取名称
'wa.nickName',
'wa.avatar',
'wa.alias',
@@ -67,3 +75,5 @@ class WorkbenchImportContactController extends Controller
}
}

View File

@@ -123,3 +123,5 @@ class WorkbenchMomentsController extends Controller
}
}

View File

@@ -307,3 +307,5 @@ class WorkbenchTrafficController extends Controller
}
}

View File

@@ -67,6 +67,11 @@ class Workbench extends Model
return $this->hasOne('WorkbenchImportContact', 'workbenchId', 'id');
}
// 入群欢迎语配置关联
public function groupWelcome()
{
return $this->hasOne('WorkbenchGroupWelcome', 'workbenchId', 'id');
}
/**
* 用户关联

View File

@@ -0,0 +1,27 @@
<?php
namespace app\cunkebao\model;
use think\Model;
/**
* 入群欢迎语工作台模型
*/
class WorkbenchGroupWelcome extends Model
{
protected $table = 'ck_workbench_group_welcome';
protected $pk = 'id';
protected $name = 'workbench_group_welcome';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 定义关联的工作台
public function workbench()
{
return $this->belongsTo('Workbench', 'workbenchId', 'id');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace app\cunkebao\model;
use think\Model;
/**
* 入群欢迎语发送记录模型
*/
class WorkbenchGroupWelcomeItem extends Model
{
protected $table = 'ck_workbench_group_welcome_item';
protected $pk = 'id';
protected $name = 'workbench_group_welcome_item';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'createTime';
protected $updateTime = 'updateTime';
// 状态常量
const STATUS_PENDING = 0; // 待发送
const STATUS_SENDING = 1; // 发送中
const STATUS_SUCCESS = 2; // 发送成功
const STATUS_FAILED = 3; // 发送失败
/**
* 定义关联的工作台
*/
public function workbench()
{
return $this->belongsTo('Workbench', 'workbenchId', 'id');
}
/**
* 获取状态文本
* @param int $status 状态值
* @return string
*/
public static function getStatusText($status)
{
$statusMap = [
self::STATUS_PENDING => '待发送',
self::STATUS_SENDING => '发送中',
self::STATUS_SUCCESS => '发送成功',
self::STATUS_FAILED => '发送失败',
];
return $statusMap[$status] ?? '未知';
}
}

View File

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

View File

@@ -0,0 +1,762 @@
<?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();
// 获取来源历史带群归属信息限制50条
$data['sources'] = TrafficPoolSource::getSourcesWithOwners($poolCompanyId, 50);
// 获取行为轨迹最近50条
$data['behaviors'] = TrafficPoolBehavior::getUserJourney($poolCompanyId, 50)->toArray();
// 获取分配历史
$data['allotRecords'] = TrafficPoolAllotRecord::getAllotHistory($poolCompanyId)->toArray();
// 如果消息数为0从微信消息表中统计实际消息数
if (empty($data['totalMsgCount']) || $data['totalMsgCount'] == 0) {
$msgCount = 0;
// 优先通过wechatFriendId统计最准确
if (!empty($poolCompany->wechatFriendId)) {
$msgCount = Db::table('s2_wechat_message')
->where('wechatFriendId', $poolCompany->wechatFriendId)
->where('type', 1) // 好友消息type=1
->where('isDeleted', 0)
->count();
}
// 如果wechatFriendId没有统计到尝试通过identifier微信ID统计
if ($msgCount == 0 && !empty($poolCompany->identifier)) {
// 统计发送者或接收者是该微信ID的消息
// 需要关联s2_wechat_friend表通过wechatId匹配
$msgCount = Db::table('s2_wechat_message')
->alias('wm')
->join(['s2_wechat_friend' => 'wf'], 'wm.wechatFriendId = wf.id', 'LEFT')
->where(function($query) use ($poolCompany) {
$query->where('wm.senderWechatId', $poolCompany->identifier)
->whereOr('wf.wechatId', $poolCompany->identifier);
})
->where('wm.type', 1) // 好友消息
->where('wm.isDeleted', 0)
->where('wf.isDeleted', 0)
->count();
}
// 如果从行为表也有记录,取较大值(兼容旧数据)
$behaviorMsgCount = TrafficPoolBehavior::where('poolCompanyId', $poolCompanyId)
->whereIn('behaviorType', [
TrafficPoolBehavior::BEHAVIOR_TYPE_SEND_MSG,
TrafficPoolBehavior::BEHAVIOR_TYPE_RECEIVE_MSG
])
->count();
if ($behaviorMsgCount > $msgCount) {
$msgCount = $behaviorMsgCount;
}
if ($msgCount > 0) {
// 更新数据库中的消息数
$poolCompany->save([
'totalMsgCount' => $msgCount,
'updateTime' => time()
]);
$data['totalMsgCount'] = $msgCount;
}
}
// 计算 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
];
}
/**
* 从标签引擎同步用户标签
*
* @param int $poolCompanyId 流量池公司ID
* @param int $companyId 公司ID
* @param int $operatorId 操作人ID
* @return array 同步结果
*/
public function syncTagsFromEngine(int $poolCompanyId, int $companyId, int $operatorId = null)
{
// 获取流量池记录
$poolCompany = TrafficPoolCompany::where('id', $poolCompanyId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$poolCompany) {
throw new \Exception('流量不存在');
}
// 获取标识信息用于查询标签引擎
$identifiers = [];
// 微信ID
if (!empty($poolCompany->identifier)) {
$identifiers[] = [
'type' => 'wechat',
'value' => $poolCompany->identifier
];
}
// 手机号
if (!empty($poolCompany->phone)) {
$identifiers[] = [
'type' => 'phone',
'value' => $poolCompany->phone
];
}
if (empty($identifiers)) {
throw new \Exception('无有效标识可用于查询标签');
}
// 调用标签引擎服务
$tagEngineService = new \app\common\service\TagEngineService();
$result = $tagEngineService->queryByIdentifiers($identifiers, [
'mask_identifier' => false
]);
//exit_data($result);
if ($result === false) {
throw new \Exception('标签引擎查询失败');
}
// 检查返回结果
if (isset($result['code']) && $result['code'] !== 0) {
throw new \Exception($result['message'] ?? '标签引擎返回错误');
}
$data = $result['data'] ?? $result;
if (!is_array($data)) {
$data = [];
}
$syncedCount = 0;
$skippedCount = 0;
// 处理返回的标签数据
foreach ($data as $item) {
if (empty($item['found']) || empty($item['tags'])) {
continue;
}
foreach ($item['tags'] as $tagData) {
try {
// 查找或创建标签定义
$tagDefine = $this->findOrCreateTagDefine(
$companyId,
$tagData['tag_code'] ?? '',
$tagData['tag_name'] ?? '',
$tagData['category'] ?? '标签引擎',
$tagData['tag_type'] ?? 'string'
);
if (!$tagDefine) {
$skippedCount++;
continue;
}
// 添加标签到流量池
$tag = TrafficPoolTag::addTag(
$poolCompanyId,
$poolCompany->identifier,
$companyId,
$tagDefine->id,
TrafficPoolTag::SOURCE_AI, // 来源为AI/外部同步
$operatorId,
$tagData['tag_value'] ?? null,
null // score
);
if ($tag) {
$syncedCount++;
} else {
$skippedCount++;
}
} catch (\Exception $e) {
$skippedCount++;
continue;
}
}
}
return [
'syncedCount' => $syncedCount,
'skippedCount' => $skippedCount,
'total' => $syncedCount + $skippedCount
];
}
/**
* 查找或创建标签定义
*
* @param int $companyId 公司ID
* @param string $tagCode 标签代码
* @param string $tagName 标签名称
* @param string $categoryName 分类名称
* @param string $valueType 值类型
* @return \app\common\model\TrafficPoolTagDefine|null
*/
protected function findOrCreateTagDefine(
int $companyId,
string $tagCode,
string $tagName,
string $categoryName,
string $valueType
) {
if (empty($tagName)) {
return null;
}
// 标签类型映射
$typeMap = [
'numeric' => 'number',
'enum' => 'enum',
'string' => 'string',
'boolean' => 'boolean',
'datetime' => 'datetime',
'json' => 'json',
];
$mappedType = $typeMap[$valueType] ?? 'string';
// 先查找是否已存在该标签定义
$tagDefine = \app\common\model\TrafficPoolTagDefine::where('companyId', $companyId)
->where('tagName', $tagName)
->where('tagType', TrafficPoolTag::TAG_TYPE_AI) // AI标签类型
->where('isDel', 0)
->find();
if ($tagDefine) {
return $tagDefine;
}
// 查找或创建分类
$category = \app\common\model\TrafficPoolTagCategory::where('companyId', $companyId)
->where('categoryName', $categoryName)
->where('tagType', TrafficPoolTag::TAG_TYPE_AI)
->where('isDel', 0)
->find();
if (!$category) {
$category = new \app\common\model\TrafficPoolTagCategory();
$category->save([
'companyId' => $companyId,
'tagType' => TrafficPoolTag::TAG_TYPE_AI,
'categoryName' => $categoryName,
'description' => '从标签引擎同步的标签分类',
'sortOrder' => 0,
'isDel' => 0,
'createTime' => time(),
'updateTime' => time()
]);
}
// 创建标签定义
$tagDefine = new \app\common\model\TrafficPoolTagDefine();
$tagDefine->save([
'companyId' => $companyId,
'categoryId' => $category->id,
'tagType' => TrafficPoolTag::TAG_TYPE_AI,
'tagCode' => $tagCode ?: 'engine_' . md5($tagName),
'tagName' => $tagName,
'valueType' => $mappedType,
'description' => '从标签引擎同步',
'isSystem' => 0,
'isDel' => 0,
'createTime' => time(),
'updateTime' => time()
]);
return $tagDefine;
}
}

View File

@@ -13,13 +13,14 @@ class Workbench extends Validate
const TYPE_GROUP_CREATE = 4; // 自动建群
const TYPE_TRAFFIC_DISTRIBUTION = 5; // 流量分发
const TYPE_IMPORT_CONTACT = 6; // 流量分发
const TYPE_GROUP_WELCOME = 7; // 入群欢迎语
/**
* 验证规则
*/
protected $rule = [
'name' => 'require|max:100',
'type' => 'require|in:1,2,3,4,5,6',
'type' => 'require|in:1,2,3,4,5,6,7',
//'autoStart' => 'require|boolean',
// 自动点赞特有参数
'interval' => 'requireIf:type,1|number|min:1',
@@ -47,7 +48,7 @@ class Workbench extends Validate
'wechatGroups' => 'checkGroupPushTarget|array|min:1', // 当targetType=1时必填
'wechatFriends' => 'checkFriendPushTarget|array', // 当targetType=2时可选可以为空
'ownerWechatId' => 'checkFriendPushService', // 当targetType=2且未选择好友/流量池时必填
'contentGroups' => 'requireIf:type,3|array|min:1',
'contentGroups' => 'checkContentGroups|array', // 群推送时必填,但群公告时可以为空
// 群公告特有参数
'announcementContent' => 'checkAnnouncementContent|max:5000', // 群公告内容当groupPushSubType=2时必填
'enableAiRewrite' => 'checkEnableAiRewrite|in:0,1', // 是否启用AI智能话术改写
@@ -62,8 +63,14 @@ class Workbench extends Validate
'maxPerDay' => 'requireIf:type,5|number|min:1',
'timeType' => 'requireIf:type,5|in:1,2',
'accountGroups' => 'requireIf:type,5|array|min:1',
// 入群欢迎语特有参数
'wechatGroups' => 'requireIf:type,7|array|min:1', // 入群欢迎语必须选择群组
'interval' => 'requireIf:type,7|number|min:1', // 间隔时间
'startTime' => 'requireIf:type,7|dateFormat:H:i', // 开始时间
'endTime' => 'requireIf:type,7|dateFormat:H:i', // 结束时间
'messages' => 'requireIf:type,7|array|min:1', // 欢迎消息列表
// 通用参数
'deviceGroups' => 'requireIf:type,1,2,5|array',
'deviceGroups' => 'requireIf:type,1,2,5,7|array',
'trafficPools' => 'checkFriendPushPools',
];
@@ -106,7 +113,7 @@ class Workbench extends Validate
'endTime.dateFormat' => '发布结束时间格式错误',
'accountGroups.requireIf' => '请选择账号类型',
'accountGroups.in' => '账号类型错误',
'contentGroups.requireIf' => '选择内容库',
'contentGroups.checkContentGroups' => '群群发时必须选择内容库',
'contentGroups.array' => '内容库格式错误',
// 群消息推送相关提示
'pushType.requireIf' => '请选择推送方式',
@@ -185,6 +192,7 @@ class Workbench extends Validate
'announcementContent', 'enableAiRewrite', 'aiRewritePrompt',
'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax',
'distributeType', 'timeType', 'accountGroups',
'messages',
],
'update_status' => ['id', 'status'],
'update' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups',
@@ -194,6 +202,7 @@ class Workbench extends Validate
'announcementContent', 'enableAiRewrite', 'aiRewritePrompt',
'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax',
'distributeType', 'timeType', 'accountGroups',
'messages',
]
];
@@ -383,4 +392,31 @@ class Workbench extends Validate
}
return true;
}
/**
* 验证内容库(群推送时必填,但群公告时可以为空)
*/
protected function checkContentGroups($value, $rule, $data)
{
// 如果是群消息推送类型
if (isset($data['type']) && $data['type'] == self::TYPE_GROUP_PUSH) {
$targetType = isset($data['targetType']) ? intval($data['targetType']) : 1; // 默认1
$groupPushSubType = isset($data['groupPushSubType']) ? intval($data['groupPushSubType']) : 1; // 默认1
// 群公告groupPushSubType=2内容库可以为空不需要验证
if ($targetType == 1 && $groupPushSubType == 2) {
// 群公告时允许为空,不进行验证
return true;
}
// 其他情况(群群发、好友推送),内容库必填
if (!isset($value) || $value === null || $value === '') {
return false;
}
if (!is_array($value) || count($value) < 1) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace app\job;
use app\common\service\WechatMediaArchiveService;
use think\facade\Config;
use think\facade\Log;
use think\queue\Job;
use think\Queue;
class MediaArchiveJob
{
const QUEUE_NAME = 'media_archive';
public function fire(Job $job, $data)
{
try {
$scope = $data['scope'] ?? '';
$id = (int)($data['id'] ?? 0);
if (empty($scope) || $id <= 0) {
$job->delete();
return;
}
$success = false;
if ($scope === 'message') {
$success = WechatMediaArchiveService::archiveMessageById($id);
} elseif ($scope === 'moment') {
$success = WechatMediaArchiveService::archiveMomentById($id);
}
if ($success || $job->attempts() >= 3) {
$job->delete();
return;
}
$job->release(Config::get('queue.failed_delay', 10));
} catch (\Exception $e) {
Log::error('媒体资源归档任务失败:' . $e->getMessage(), $data ?: []);
if ($job->attempts() >= 3) {
$job->delete();
} else {
$job->release(Config::get('queue.failed_delay', 10));
}
}
}
public static function dispatch($scope, $id, array $extra = [])
{
$payload = array_merge($extra, [
'scope' => $scope,
'id' => (int)$id,
]);
return Queue::push(self::class, $payload, self::QUEUE_NAME);
}
}

View File

@@ -10,6 +10,11 @@ use app\api\controller\MessageController;
class MessageChatroomListJob
{
/**
* 最大同步页数0表示不限制同步所有页面
*/
const MAX_SYNC_PAGES = 0;
/**
* 队列任务处理
* @param Job $job 队列任务
@@ -76,17 +81,61 @@ class MessageChatroomListJob
$result = $messageController->getChatroomList($pageIndex,$pageSize,true);
$response = json_decode($result,true);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 判断是否成功
if ($response['code'] == 200) {
$data = $response['data'];
if (isset($response['code']) && $response['code'] == 200) {
$data = isset($response['data']) ? $response['data'] : [];
// 确保 data 是数组格式
if (!is_array($data)) {
$data = [];
}
// 获取 results 数组(实际的数据列表)
$results = isset($data['results']) && is_array($data['results']) ? $data['results'] : [];
$resultsCount = count($results);
Log::info("获取到 {$resultsCount} 条群聊记录,页码:{$pageIndex},页大小:{$pageSize}");
// 判断是否有下一页
if (!empty($data) && count($data['results']) > 0) {
// 有下一页,将下一页任务添加到队列
// 1. 如果返回的数据量等于页大小,说明可能还有下一页
// 2. 或者检查是否有 total 字段,通过计算判断是否有下一页
$hasNextPage = false;
if ($resultsCount > 0) {
// 方法1: 如果返回的数据量等于页大小,可能还有下一页
if ($resultsCount >= $pageSize) {
$hasNextPage = true;
Log::info("返回数据量({$resultsCount})等于或大于页大小({$pageSize}),可能存在下一页");
}
// 方法2: 如果有 total 字段,通过计算判断
if (isset($data['total']) && is_numeric($data['total'])) {
$total = intval($data['total']);
$currentPageEnd = ($pageIndex + 1) * $pageSize;
$hasNextPage = ($currentPageEnd < $total);
Log::info("根据 total{$total})计算,当前页结束位置:{$currentPageEnd},是否有下一页:" . ($hasNextPage ? '是' : '否'));
}
}
// 如果有下一页,且未超过最大同步页数,添加下一页任务
if ($hasNextPage) {
$nextPageIndex = $pageIndex + 1;
// 检查是否超过最大同步页数0表示不限制
if (self::MAX_SYNC_PAGES == 0 || $nextPageIndex < self::MAX_SYNC_PAGES) {
$this->addNextPageToQueue($nextPageIndex, $pageSize);
Log::info('添加下一页任务到队列,页码:' . $nextPageIndex);
Log::info("添加下一页任务到队列,页码:{$nextPageIndex}");
} else {
Log::info("已达到最大同步页数(" . self::MAX_SYNC_PAGES . "),停止添加下一页任务");
}
} else {
Log::info("没有更多页面需要同步,页码:{$pageIndex},返回数据量:{$resultsCount}");
}
return true;

View File

@@ -10,6 +10,11 @@ use app\api\controller\MessageController;
class MessageFriendsListJob
{
/**
* 最大同步页数
*/
const MAX_SYNC_PAGES = 5;
/**
* 队列任务处理
* @param Job $job 队列任务
@@ -78,17 +83,32 @@ class MessageFriendsListJob
$result = $messageController->getFriendsList($pageIndex,$pageSize,true);
$response = json_decode($result,true);
// 确保 response 是数组格式
if (!is_array($response)) {
$response = [];
}
// 判断是否成功
if ($response['code'] == 200) {
$data = $response['data'];
if (isset($response['code']) && $response['code'] == 200) {
$data = isset($response['data']) ? $response['data'] : [];
// 判断是否有下一页
if (!empty($data) && count($data) > 0) {
// 有下一页,将下一页任务添加到队列
// 确保 data 是数组格式
if (!is_array($data)) {
$data = [];
}
// 判断是否有下一页,且未超过最大同步页数
if (!empty($data) && is_array($data) && count($data) > 0) {
$nextPageIndex = $pageIndex + 1;
// 检查是否超过最大同步页数
if ($nextPageIndex < self::MAX_SYNC_PAGES) {
// 有下一页且未超过最大页数,将下一页任务添加到队列
$this->addNextPageToQueue($nextPageIndex, $pageSize);
Log::info('添加下一页任务到队列,页码:' . $nextPageIndex);
} else {
Log::info('已达到最大同步页数(' . self::MAX_SYNC_PAGES . '),停止添加下一页任务');
}
}
return true;

View File

@@ -97,7 +97,9 @@ class OwnMomentsCollectJob
// 采集自己的朋友圈wechatFriendId传0或空表示采集自己的朋友圈
$result = $webSocket->getMoments([
'wechatAccountId' => $wechatAccountId,
'wechatFriendId' => 0, // 0表示采集自己的朋友圈
'wechatFriendId' => 0,
'isTimeline' => true,
'maxPages' => 1,
'count' => 10 // 每次采集10条
]);

View File

@@ -5,12 +5,75 @@ use app\chukebao\model\Reply;
use app\chukebao\model\ReplyGroup;
use think\Db;
use think\queue\Job;
use app\api\controller\WebSocketController;
use think\facade\Env;
class SyncContentJob
{
public function fire(Job $job, $data)
{
$toAccountId = '';
$username = Env::get('api.username', '');
$password = Env::get('api.password', '');
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
// 建立WebSocket
$wsController = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
// 为了便于改写 appattach这里用 heredoc 先生成 contentXml再 json_encode 成 msgType=49 的 content
$contentXml = <<<'XML'
wxid_480es52qsj2812:
<?xml version="1.0"?>
<msg>
<appmsg appid="" sdkver="0">
<title>性格测试 - 快来测测你的MBTI性格类型</title>
<des>性格测试 - 快来测测你的MBTI性格类型</des>
<type>33</type>
<showtype>0</showtype>
<soundtype>0</soundtype>
<contentattr>0</contentattr>
<sourceusername>gh_5c672bbbc96f@app</sourceusername>
<weappinfo>
<username><![CDATA[gh_5c672bbbc96f@app]]></username>
<appid><![CDATA[]]></appid>
<type>2</type>
<version>50</version>
<weappiconurl><![CDATA[]]></weappiconurl>
<pagepath><![CDATA[pages/index/index.html?uid=1]]></pagepath>
<pkginfo>
<type>0</type>
<md5><![CDATA[]]></md5>
</pkginfo>
<wadynamicpageinfo>
<shouldUseDynamicPage>0</shouldUseDynamicPage>
<cacheKey><![CDATA[]]></cacheKey>
</wadynamicpageinfo>
<appservicetype>0</appservicetype>
</weappinfo>
</appmsg>
</msg>
XML;
$contentPayload = [
'contentXml' => $contentXml,
'previewImage' => 'https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/57fbafda482b09e1ac93f39fe4f34d47/3/192c0acf93b4b106e7d2eafdd278b0cc14e6693b',
'type' => 'miniprogram',
];
$ddd = $wsController->sendPersonal([
'wechatFriendId' => 17453058,
'wechatAccountId' => 300745,
'msgType' => 49,
'content' => json_encode($contentPayload, JSON_UNESCAPED_UNICODE),
]);
exit_data($ddd);
$ddd= Db::table('s2_wechat_friend')->where('ownerWechatId','wxid_h7nsh7vxseyn29')->select();
foreach ($ddd as $v) {
$d = Db::table('ck_task_customer')->where('task_id','167')->where('phone',$v['wechatId'])->find();

View File

@@ -162,11 +162,31 @@ class WorkbenchGroupCreateJob
// 获取流量池用户(如果配置了流量池)
$poolItem = [];
if (!empty($config['poolGroups'])) {
$poolItem = Db::name('traffic_source_package_item')
->whereIn('packageId', $config['poolGroups'])
// 检查是否包含"所有好友"packageId=0
$hasAllFriends = in_array(0, $config['poolGroups']) || in_array('0', $config['poolGroups']);
$normalPools = array_filter($config['poolGroups'], function($id) {
return $id !== 0 && $id !== '0';
});
// 处理"所有好友"特殊流量池
if ($hasAllFriends) {
$companyId = $workbench->companyId ?? 0;
$allFriendsIdentifiers = $this->getAllFriendsIdentifiersByCompany($companyId);
$poolItem = array_merge($poolItem, $allFriendsIdentifiers);
}
// 处理普通流量池
if (!empty($normalPools)) {
$normalIdentifiers = Db::name('traffic_source_package_item_v1')
->whereIn('packageId', $normalPools)
->where('isDel', 0)
->group('identifier')
->column('identifier');
$poolItem = array_merge($poolItem, $normalIdentifiers);
}
// 去重
$poolItem = array_unique($poolItem);
}
// 如果既没有流量池也没有指定群组,跳过
@@ -802,6 +822,34 @@ class WorkbenchGroupCreateJob
}
/**
* 获取公司下所有好友的identifier列表特殊流量池 packageId=0
* @param int $companyId
* @return array
*/
protected function getAllFriendsIdentifiersByCompany($companyId)
{
// 获取公司下所有设备的微信ID
$wechatIds = Db::name('device')->alias('d')
->join('(SELECT MAX(id) AS id, deviceId FROM ck_device_wechat_login WHERE companyId='.$companyId.' GROUP BY deviceId) dwl_max', 'dwl_max.deviceId = d.id')
->join('device_wechat_login dwl', 'dwl.id = dwl_max.id')
->where(['d.companyId' => $companyId, 'd.deleteTime' => 0])
->column('dwl.wechatId');
if (empty($wechatIds)) {
return [];
}
// 获取所有好友的wechatId作为identifier
$identifiers = Db::table('s2_wechat_friend')
->where('ownerWechatId', 'in', $wechatIds)
->where('isDeleted', 0)
->group('wechatId')
->column('wechatId');
return $identifiers ?: [];
}
/**
* 记录任务开始
* @param string $jobId

View File

@@ -333,8 +333,9 @@ class WorkbenchGroupPushJob
{
$sendData = [];
// 内容处理
if (!empty($content['content'])) {
// 内容处理(小程序素材 content 为 JSON不能按文本推送
$contentTypeNum = (int)($content['contentType'] ?? 0);
if (!empty($content['content']) && $contentTypeNum !== 5) {
// 京东转链
if (!empty($config['promotionSiteId'])) {
$WorkbenchController = new WorkbenchController();
@@ -430,6 +431,49 @@ class WorkbenchGroupPushJob
];
}
break;
case 5:
// 小程序content 为 JSON与触客宝快捷语 / 存客宝表单一致(可含 body 内容消息)
$mini = json_decode($content['content'] ?? '', true);
if (is_array($mini) && ($mini['type'] ?? '') === 'miniprogram') {
$body = trim((string)($mini['body'] ?? $mini['contentMessage'] ?? $mini['message'] ?? ''));
if ($body !== '') {
if ($type == 'group') {
$sendData[] = [
'content' => $body,
'msgType' => 1,
'wechatAccountId' => $wechatAccountId,
'wechatChatroomId' => $targetId,
];
} else {
$sendData[] = [
'content' => $body,
'msgType' => 1,
];
}
}
$miniPayload = [
'type' => 'miniprogram',
'title' => $mini['title'] ?? '',
'des' => $mini['des'] ?? '',
'gh' => $mini['gh'] ?? '',
'pagepath' => $mini['pagepath'] ?? '',
'previewImage' => $mini['previewImage'] ?? '',
];
if ($type == 'group') {
$sendData[] = [
'content' => $miniPayload,
'msgType' => 49,
'wechatAccountId' => $wechatAccountId,
'wechatChatroomId' => $targetId,
];
} else {
$sendData[] = [
'content' => $miniPayload,
'msgType' => 49,
];
}
}
break;
}
return $sendData;
@@ -516,13 +560,108 @@ class WorkbenchGroupPushJob
$companyId = $workbench->companyId ?? 0;
$query = Db::name('traffic_source_package_item')
// 检查是否包含"所有好友"packageId=0
$hasAllFriends = in_array(0, $trafficPools) || in_array('0', $trafficPools);
$normalPools = array_filter($trafficPools, function($id) {
return $id !== 0 && $id !== '0';
});
$friends = [];
// 处理"所有好友"特殊流量池
if ($hasAllFriends) {
$allFriends = $this->getAllFriendsByCompany($companyId, $ownerWechatIds);
$friends = array_merge($friends, $allFriends);
}
// 处理普通流量池
if (!empty($normalPools)) {
$normalFriends = $this->getFriendsByNormalPools($normalPools, $companyId, $ownerWechatIds);
$friends = array_merge($friends, $normalFriends);
}
// 去重
$uniqueFriends = [];
$seenIds = [];
foreach ($friends as $friend) {
$friendId = $friend['id'] ?? null;
if ($friendId && !in_array($friendId, $seenIds)) {
$seenIds[] = $friendId;
$uniqueFriends[] = $friend;
}
}
if (empty($uniqueFriends)) {
Log::info('好友推送:流量池未匹配到好友');
return [];
}
return $uniqueFriends;
}
/**
* 获取公司下所有好友(特殊流量池 packageId=0
* @param int $companyId
* @param array $ownerWechatIds
* @return array
*/
protected function getAllFriendsByCompany($companyId, array $ownerWechatIds = [])
{
// 获取公司下所有设备的微信ID
$wechatIds = Db::name('device')->alias('d')
->join('(SELECT MAX(id) AS id, deviceId FROM ck_device_wechat_login WHERE companyId='.$companyId.' GROUP BY deviceId) dwl_max', 'dwl_max.deviceId = d.id')
->join('device_wechat_login dwl', 'dwl.id = dwl_max.id')
->where(['d.companyId' => $companyId, 'd.deleteTime' => 0])
->column('dwl.wechatId');
if (empty($wechatIds)) {
return [];
}
$query = Db::table('s2_wechat_friend')->alias('wf')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
->where('wf.ownerWechatId', 'in', $wechatIds)
->where('wf.isDeleted', 0)
->whereNotNull('wf.id')
->whereNotNull('wf.wechatAccountId');
if (!empty($ownerWechatIds)) {
$query->whereIn('wf.wechatAccountId', $ownerWechatIds);
}
$friends = $query
->field('wf.id,wf.wechatAccountId,wf.wechatId,wf.ownerWechatId')
->group('wf.id')
->select();
return $friends ?: [];
}
/**
* 根据普通流量池获取好友信息
* @param array $packageIds
* @param int $companyId
* @param array $ownerWechatIds
* @return array
*/
protected function getFriendsByNormalPools(array $packageIds, $companyId, array $ownerWechatIds = [])
{
// ========== 旧版流量池代码(已废弃) ==========
// $query = Db::name('traffic_source_package_item_v1')
// ->alias('tspi')
// ->leftJoin('traffic_source_package_v1 tsp', 'tsp.id = tspi.packageId')
// ->leftJoin('traffic_pool_v1 tp', 'tp.identifier = tspi.identifier')
// ->leftJoin(['s2_wechat_friend' => 'wf'], 'wf.wechatId = tp.wechatId')
// ->leftJoin(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId')
// ========== 新版流量池代码 ==========
$query = Db::name('traffic_source_package_item_v1')
->alias('tspi')
->leftJoin('traffic_source_package tsp', 'tsp.id = tspi.packageId')
->leftJoin('traffic_source_package_v1 tsp', 'tsp.id = tspi.packageId')
->leftJoin('traffic_pool tp', 'tp.identifier = tspi.identifier')
->leftJoin(['s2_wechat_friend' => 'wf'], 'wf.wechatId = tp.wechatId')
->leftJoin(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId')
->whereIn('tspi.packageId', $trafficPools)
// ========== 旧版流量池代码结束 ==========
->whereIn('tspi.packageId', $packageIds)
->where('tsp.isDel', 0)
->where('wf.isDeleted', 0)
->whereNotNull('wf.id')
@@ -543,16 +682,7 @@ class WorkbenchGroupPushJob
->group('wf.id')
->select();
if (empty($friends)) {
Log::info('好友推送:流量池未匹配到好友');
return [];
}
if ($friends === false) {
return [];
}
return $friends;
return $friends ?: [];
}
/**

View File

@@ -0,0 +1,441 @@
<?php
namespace app\job;
use app\api\controller\WebSocketController;
use app\cunkebao\model\WorkbenchGroupWelcomeItem;
use think\Db;
use think\facade\Log;
use think\facade\Env;
use think\queue\Job;
/**
* 入群欢迎语任务
*/
class WorkbenchGroupWelcomeJob
{
// 常量定义
const MAX_RETRY_ATTEMPTS = 3; // 最大重试次数
const RETRY_DELAY = 10; // 重试延迟(秒)
const MAX_JOIN_AGE_SECONDS = 86400; // 最大入群时间1天
const MSG_TYPE_TEXT = 1; // 普通文本消息
const MSG_TYPE_AT = 90001; // @人消息
const WORKBENCH_TYPE_WELCOME = 7; // 入群欢迎语类型
const STATUS_SUCCESS = 2; // 发送成功状态
/**
* 队列执行方法
* @param Job $job 队列任务
* @param array $data 任务数据
* @return void
*/
public function fire(Job $job, $data)
{
try {
if ($this->processWelcomeMessage($data, $job->attempts())) {
$job->delete();
} else {
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
Log::error('入群欢迎语任务执行失败,已超过重试次数,数据:' . json_encode($data));
$job->delete();
} else {
Log::warning('入群欢迎语任务执行失败,重试次数:' . $job->attempts() . ',数据:' . json_encode($data));
$job->release(self::RETRY_DELAY);
}
}
} catch (\Exception $e) {
Log::error('入群欢迎语任务异常:' . $e->getMessage());
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
$job->delete();
} else {
$job->release(self::RETRY_DELAY);
}
}
}
/**
* 处理欢迎消息发送
* @param array $data 任务数据
* @param int $attempts 重试次数
* @return bool
*/
public function processWelcomeMessage($data, $attempts)
{
try {
// 查找该群配置的入群欢迎语工作台
$welcomeConfigs = Db::table('ck_workbench_group_welcome')
->alias('wgw')
->join('ck_workbench w', 'w.id = wgw.workbenchId')
->where('w.status', 1) // 工作台启用
->where('w.type', self::WORKBENCH_TYPE_WELCOME) // 入群欢迎语类型
->field('wgw.*,w.id as workbenchId')
->select();
if (empty($welcomeConfigs)) {
return true; // 没有配置欢迎语,不算失败
}
foreach ($welcomeConfigs as $config) {
// 解析配置中的群组列表
$wechatGroups = json_decode($config['groups'] ?? '[]', true);
if (!is_array($wechatGroups) || empty($wechatGroups)) {
continue; // 该配置没有配置群组,跳过
}
// 遍历该配置中的每个群ID处理每个群的欢迎语
foreach ($wechatGroups as $groupItemId) {
// 检查群是否存在
$chatroomExists = Db::table('s2_wechat_chatroom')
->where('id', $groupItemId)
->where('isDeleted', 0)
->count();
if (!$chatroomExists) {
Log::warning("群ID {$groupItemId} 不存在或已删除,跳过欢迎语处理");
continue;
}
// 处理单个群的欢迎语
$this->processSingleGroupWelcome($groupItemId, $config);
}
}
return true;
} catch (\Exception $e) {
Log::error('处理入群欢迎语异常:' . $e->getMessage() . ', 数据:' . json_encode($data));
return false;
}
}
/**
* 处理单个群的欢迎语发送
* @param int $groupId 群IDs2_wechat_chatroom表的id
* @param array $config 工作台配置
* @return void
*/
protected function processSingleGroupWelcome($groupId, $config)
{
// 根据groupId获取群信息
$chatroom = Db::table('s2_wechat_chatroom')
->where('id', $groupId)
->where('isDeleted', 0)
->field('wechatAccountId,wechatAccountWechatId')
->find();
if (empty($chatroom)) {
Log::warning("群ID {$groupId} 不存在或已删除,跳过欢迎语处理");
return;
}
// 检查时间范围
if (!$this->isInTimeRange($config['startTime'] ?? '', $config['endTime'] ?? '')) {
return; // 不在工作时间范围内
}
// 解析消息列表
$messages = json_decode($config['messages'] ?? '[]', true);
if (empty($messages) || !is_array($messages)) {
return; // 没有配置消息
}
// interval代表整组消息的时间间隔在此间隔内进群的成员都需要@
$interval = intval($config['interval'] ?? 0); // 秒
// 查找该群最近一次发送欢迎语的时间
$lastWelcomeTime = Db::table('ck_workbench_group_welcome_item')
->where('workbenchId', $config['workbenchId'])
->where('groupid', $groupId)
->where('status', self::STATUS_SUCCESS) // 发送成功
->order('sendTime', 'desc')
->value('sendTime');
// 确定时间窗口起点
if (!empty($lastWelcomeTime)) {
// 如果上次发送时间在interval内说明还在同一个时间窗口需要累积新成员
$windowStartTime = max($lastWelcomeTime, time() - $interval);
} else {
// 第一次发送从interval前开始
$windowStartTime = time() - $interval;
}
// 查询该群在时间窗口内的新成员
// 通过关联s2_wechat_chatroom表查询使用groupId
$recentMembers = Db::table('s2_wechat_chatroom_member')
->alias('wcm')
->join(['s2_wechat_chatroom' => 'wc'], 'wc.chatroomId = wcm.chatroomId')
->where('wc.id', $groupId)
->where('wcm.createTime', '>=', $windowStartTime)
->field('wcm.wechatId,wcm.nickname,wcm.createTime')
->select();
// 入群太久远的成员不要 @,只保留「近期加入」的成员
$minJoinTime = time() - self::MAX_JOIN_AGE_SECONDS;
$recentMembers = array_values(array_filter($recentMembers, function ($member) use ($minJoinTime) {
$joinTime = intval($member['createTime'] ?? 0);
return $joinTime >= $minJoinTime;
}));
if (empty($recentMembers)) {
return;
}
// 如果上次发送时间在interval内检查是否有新成员
if (!empty($lastWelcomeTime) && $lastWelcomeTime >= (time() - $interval)) {
// 获取上次发送时的成员列表
$lastWelcomeItem = Db::table('ck_workbench_group_welcome_item')
->where('workbenchId', $config['workbenchId'])
->where('groupid', $groupId)
->where('sendTime', $lastWelcomeTime)
->field('friendId')
->find();
$lastMemberIds = json_decode($lastWelcomeItem['friendId'] ?? '[]', true);
$currentMemberWechatIds = array_column($recentMembers, 'wechatId');
// 找出新加入的成员
$newMemberWechatIds = array_diff($currentMemberWechatIds, $lastMemberIds);
if (empty($newMemberWechatIds)) {
return; // 没有新成员,跳过
}
// 只发送给新加入的成员
$membersToWelcome = [];
foreach ($recentMembers as $member) {
if (in_array($member['wechatId'], $newMemberWechatIds)) {
$membersToWelcome[] = $member;
}
}
} else {
// 不在同一个时间窗口,@所有在时间间隔内的成员
$membersToWelcome = $recentMembers;
}
if (empty($membersToWelcome)) {
return;
}
// 获取设备信息(用于发送消息)
$devices = json_decode($config['devices'] ?? '[]', true);
if (empty($devices) || !is_array($devices)) {
return;
}
// wechatAccountId 是 s2_wechat_account 表的 id
$wechatAccountId = $chatroom['wechatAccountId'] ?? 0;
$wechatAccountWechatId = $chatroom['wechatAccountWechatId'] ?? '';
if (empty($wechatAccountWechatId)) {
Log::warning("群ID {$groupId} 的微信账号ID为空跳过欢迎语发送");
return;
}
// 初始化WebSocket
$username = Env::get('api.username', '');
$password = Env::get('api.password', '');
$toAccountId = '';
if (!empty($username) || !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
}
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
// 按order排序消息
usort($messages, function($a, $b) {
return (intval($a['order'] ?? 0)) <=> (intval($b['order'] ?? 0));
});
// 发送每条消息
foreach ($messages as $messageIndex => $message) {
$messageContent = $message['content'] ?? '';
$sendInterval = intval($message['sendInterval'] ?? 5); // 秒
$intervalUnit = $message['intervalUnit'] ?? 'seconds';
// 转换间隔单位
$sendInterval = $this->convertIntervalToSeconds($sendInterval, $intervalUnit);
// 替换 @{好友} 占位符
$processedContent = $this->replaceFriendPlaceholder($messageContent, $membersToWelcome);
// 构建@消息格式
$atContent = $this->buildAtMessage($processedContent, $membersToWelcome);
// 判断是否有@人如果有atId则使用90001否则使用1
$hasAtMembers = !empty($atContent['atId']);
$msgType = $hasAtMembers ? self::MSG_TYPE_AT : self::MSG_TYPE_TEXT;
// 发送消息
// 注意wechatChatroomId 使用 groupId数字类型不是 chatroomId
$sendResult = $webSocket->sendCommunitys([
'content' => json_encode($atContent, JSON_UNESCAPED_UNICODE),
'msgType' => $msgType,
'wechatAccountId' => intval($wechatAccountId),
'wechatChatroomId' => $groupId, // 使用 groupId数字类型
]);
$sendResultData = json_decode($sendResult, true);
$sendSuccess = !empty($sendResultData) && isset($sendResultData['code']) && $sendResultData['code'] == 200;
// 记录发送记录
$friendIds = array_column($membersToWelcome, 'wechatId');
$this->saveWelcomeItem([
'workbenchId' => $config['workbenchId'],
'groupId' => $groupId,
'deviceId' => !empty($devices) ? intval($devices[0]) : 0,
'wechatAccountId' => $wechatAccountId,
'friendId' => $friendIds,
'status' => $sendSuccess ? WorkbenchGroupWelcomeItem::STATUS_SUCCESS : WorkbenchGroupWelcomeItem::STATUS_FAILED,
'messageIndex' => $messageIndex,
'messageId' => $message['id'] ?? '',
'content' => $processedContent,
'sendTime' => time(),
'errorMsg' => $sendSuccess ? '' : ($sendResultData['msg'] ?? '发送失败'),
]);
// 如果不是最后一条消息,等待间隔时间
if ($messageIndex < count($messages) - 1) {
sleep($sendInterval);
}
}
Log::info("入群欢迎语发送成功工作台ID: {$config['workbenchId']}, 群ID: {$groupId}, 成员数: " . count($membersToWelcome));
}
/**
* 替换 @{好友} 占位符为群成员昵称(带@符号)
* @param string $content 原始内容
* @param array $members 成员列表
* @return string 替换后的内容
*/
protected function replaceFriendPlaceholder($content, $members)
{
if (empty($members)) {
return str_replace('@{好友}', '', $content);
}
// 将所有成员的昵称拼接,每个昵称前添加@符号
$atNicknames = [];
foreach ($members as $member) {
$nickname = $member['nickname'] ?? '';
if (!empty($nickname)) {
$atNicknames[] = '@' . $nickname;
} else {
// 如果没有昵称使用wechatId
$wechatId = $member['wechatId'] ?? '';
if (!empty($wechatId)) {
$atNicknames[] = '@' . $wechatId;
}
}
}
$atNicknameStr = implode(' ', $atNicknames);
// 替换 @{好友} 为 @昵称1 @昵称2 ...
$content = str_replace('@{好友}', $atNicknameStr, $content);
return $content;
}
/**
* 构建@消息格式
* @param string $text 文本内容(已替换@{好友}占位符,已包含@符号)
* @param array $members 成员列表
* @return array 格式:{"text":"@wong @wong 11111111","atId":"WANGMINGZHENG000,WANGMINGZHENG000"}
*/
protected function buildAtMessage($text, $members)
{
$atIds = [];
// 收集所有成员的wechatId用于atId
foreach ($members as $member) {
$wechatId = $member['wechatId'] ?? '';
if (!empty($wechatId)) {
$atIds[] = $wechatId;
}
}
// 文本中已经包含了@昵称在replaceFriendPlaceholder中已添加
// 直接使用处理后的文本
return [
'text' => trim($text),
'atId' => implode(',', $atIds)
];
}
/**
* 检查是否在工作时间范围内
* @param string $startTime 开始时间格式HH:mm
* @param string $endTime 结束时间格式HH:mm
* @return bool
*/
protected function isInTimeRange($startTime, $endTime)
{
if (empty($startTime) || empty($endTime)) {
return true; // 如果没有配置时间,默认全天可用
}
$currentTime = date('H:i');
$currentMinutes = $this->timeToMinutes($currentTime);
$startMinutes = $this->timeToMinutes($startTime);
$endMinutes = $this->timeToMinutes($endTime);
if ($startMinutes <= $endMinutes) {
// 正常情况09:00 - 21:00
return $currentMinutes >= $startMinutes && $currentMinutes <= $endMinutes;
} else {
// 跨天情况21:00 - 09:00
return $currentMinutes >= $startMinutes || $currentMinutes <= $endMinutes;
}
}
/**
* 将时间转换为分钟数
* @param string $time 时间格式HH:mm
* @return int 分钟数
*/
protected function timeToMinutes($time)
{
$parts = explode(':', $time);
if (count($parts) != 2) {
return 0;
}
return intval($parts[0]) * 60 + intval($parts[1]);
}
/**
* 转换间隔单位到秒
* @param int $interval 间隔数值
* @param string $unit 单位seconds/minutes/hours
* @return int 秒数
*/
protected function convertIntervalToSeconds($interval, $unit)
{
switch ($unit) {
case 'minutes':
return $interval * 60;
case 'hours':
return $interval * 3600;
case 'seconds':
default:
return $interval;
}
}
/**
* 保存欢迎语发送记录
* @param array $data 记录数据
* @return void
*/
protected function saveWelcomeItem($data)
{
try {
$item = new WorkbenchGroupWelcomeItem();
$item->workbenchId = $data['workbenchId'];
$item->groupId = $data['groupId'];
$item->deviceId = $data['deviceId'] ?? 0;
$item->wechatAccountId = $data['wechatAccountId'] ?? 0;
$item->friendId = json_encode($data['friendId'] ?? [], JSON_UNESCAPED_UNICODE);
$item->status = $data['status'] ?? WorkbenchGroupWelcomeItem::STATUS_PENDING;
$item->messageIndex = $data['messageIndex'] ?? null;
$item->messageId = $data['messageId'] ?? '';
$item->content = $data['content'] ?? '';
$item->sendTime = $data['sendTime'] ?? time();
$item->errorMsg = $data['errorMsg'] ?? '';
$item->retryCount = 0;
$item->createTime = time();
$item->updateTime = time();
$item->save();
} catch (\Exception $e) {
Log::error('保存入群欢迎语记录失败:' . $e->getMessage());
}
}
}

View File

@@ -322,31 +322,118 @@ class WorkbenchImportContactJob
if (empty($contactNum)) {
return false;
}
//过滤已删除的数据
$packageIds = Db::name('traffic_source_package')
->where(['isDel' => 0])
->whereIn('id', $pools)
->column('id');
// 检查是否包含"所有好友"packageId=0
$hasAllFriends = in_array(0, $pools) || in_array('0', $pools);
$normalPools = array_filter($pools, function($id) {
return $id !== 0 && $id !== '0';
});
if (empty($packageIds)) {
return false;
$data = [];
// 处理"所有好友"特殊流量池
if ($hasAllFriends) {
$allFriendsData = $this->getAllFriendsForImportContact($workbench, $contactNum);
$data = array_merge($data, $allFriendsData);
}
$data = Db::name('traffic_source_package_item')->alias('tpi')
// 处理普通流量池
if (!empty($normalPools)) {
//过滤已删除的数据
$packageIds = Db::name('traffic_source_package_v1')
->where(['isDel' => 0])
->whereIn('id', $normalPools)
->column('id');
if (!empty($packageIds)) {
// ========== 旧版流量池代码(已废弃) ==========
// $normalData = Db::name('traffic_source_package_item_v1')->alias('tpi')
// ->join('traffic_pool_v1 tp', 'tp.identifier = tpi.identifier')
// ->join('traffic_source_v1 ts', 'ts.identifier = tpi.identifier','left')
// ->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id,'left')
// ->where('tp.mobile', '>',0)
// ->where('wici.id','null')
// ->whereIn('tpi.packageId',$packageIds)
// ->field('tp.id,tpi.packageId,tp.mobile as phone,ts.name')
// ->order('tp.id DESC')
// ->group('tpi.identifier')
// ->limit($contactNum)
// ->select();
// ========== 新版流量池代码 ==========
$normalData = Db::name('traffic_source_package_item_v1')->alias('tpi')
->join('traffic_pool tp', 'tp.identifier = tpi.identifier')
->join('traffic_source ts', 'ts.identifier = tpi.identifier','left')
->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . ($workbench->companyId ?? 0))
->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left')
->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id,'left')
->where('tp.mobile', '>',0)
->where('wici.id','null')
->whereIn('tpi.packageId',$packageIds)
->field('tp.id,tpi.packageId,tp.mobile as phone,ts.name')
->field('tp.id,tpi.packageId,tp.mobile as phone,tps.sourceName as name')
->order('tp.id DESC')
->group('tpi.identifier')
->limit($contactNum)
->select();
// ========== 旧版流量池代码结束 ==========
$data = array_merge($data, $normalData ?: []);
}
}
if (empty($data)) {
return false;
}
return $data;
}
/**
* 获取"所有好友"流量池的联系人数据(用于通讯录导入)
* @param Workbench $workbench
* @param int $limit
* @return array
*/
protected function getAllFriendsForImportContact($workbench, $limit)
{
$companyId = $workbench->companyId ?? 0;
// 获取公司下所有设备的微信ID
$wechatIds = Db::name('device')->alias('d')
->join('(SELECT MAX(id) AS id, deviceId FROM ck_device_wechat_login WHERE companyId='.$companyId.' GROUP BY deviceId) dwl_max', 'dwl_max.deviceId = d.id')
->join('device_wechat_login dwl', 'dwl.id = dwl_max.id')
->where(['d.companyId' => $companyId, 'd.deleteTime' => 0])
->column('dwl.wechatId');
if (empty($wechatIds)) {
return [];
}
// ========== 旧版流量池代码(已废弃) ==========
// // 从 s2_wechat_friend 表获取好友,然后关联 traffic_pool_v1 表获取手机号
// $data = Db::table('s2_wechat_friend')->alias('wf')
// ->join('traffic_pool_v1 tp', 'tp.wechatId = wf.wechatId', 'left')
// ->join('traffic_source_v1 ts', 'ts.identifier = tp.identifier', 'left')
// ->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id, 'left')
// ->where('wf.ownerWechatId', 'in', $wechatIds)
// ========== 新版流量池代码 ==========
// 从 s2_wechat_friend 表获取好友,然后关联 traffic_pool 表获取手机号
$data = Db::table('s2_wechat_friend')->alias('wf')
->join('traffic_pool tp', 'tp.wechatId = wf.wechatId', 'left')
->join('traffic_pool_company tpc', 'tpc.poolId = tp.id AND tpc.companyId = ' . ($workbench->companyId ?? 0), 'left')
->join('traffic_pool_source tps', 'tps.poolCompanyId = tpc.id', 'left')
->join('workbench_import_contact_item wici', 'wici.poolId = tp.id AND wici.workbenchId = '.$workbench->id, 'left')
->where('wf.ownerWechatId', 'in', $wechatIds)
// ========== 旧版流量池代码结束 ==========
->where('wf.isDeleted', 0)
->where('tp.mobile', '>', 0)
->where('wici.id', 'null')
->field('tp.id,tp.mobile as phone,ts.name')
->field(Db::raw('0 as packageId')) // 标记为"所有好友"流量池
->order('tp.id DESC')
->group('tp.identifier')
->limit($limit)
->select();
return $data ?: [];
}
/**
* 记录任务开始
* @param string $jobId

View File

@@ -6,6 +6,6 @@ use think\Model;
class TrafficOrderModel extends Model
{
protected $name = 'traffic_order';
protected $name = 'traffic_order_v1';
}

View File

@@ -3,10 +3,10 @@
use think\facade\Route;
// 超级管理员认证相关路由(不需要鉴权)
Route::post('auth/login', 'app\superadmin\controller\auth\AuthLoginController@index');
Route::post('v1/admin/auth/login', 'app\superadmin\controller\auth\AuthLoginController@index');
// 需要登录认证的路由组
Route::group('', function () {
// 需要登录认证的路由组(使用通用 JWT 中间件)
Route::group('v1/admin', function () {
// 仪表盘概述
Route::group('dashboard', function () {
Route::get('base', 'app\superadmin\controller\dashboard\GetBasestatisticsController@index');
@@ -33,7 +33,7 @@ Route::group('', function () {
Route::get('detail', 'app\superadmin\controller\traffic\GetPoolDetailController@index');
});
// 设备管理
// 设备管理
Route::group('devices', function () {
Route::get('add-results', 'app\superadmin\controller\devices\GetAddResultedDevicesController@index');
});
@@ -49,4 +49,4 @@ Route::group('', function () {
Route::get('devices', 'app\superadmin\controller\company\GetCompanyDevicesForProfileController@index');
Route::get('subusers', 'app\superadmin\controller\company\GetCompanySubusersForProfileController@index');
});
})->middleware(['app\superadmin\middleware\AdminAuth']);
})->middleware(['jwt']);

View File

@@ -3,24 +3,13 @@
namespace app\superadmin\controller\auth;
use app\common\model\Administrator as AdministratorModel;
use app\superadmin\controller\administrator\DeleteAdministratorController;
use app\common\util\JwtUtil;
use library\ResponseHelper;
use think\Controller;
use think\Validate;
use think\facade\Cookie;
class AuthLoginController extends Controller
{
/**
* 创建登录令牌
* @param DeleteAdministratorController $admin
* @return string
*/
protected function createToken(AdministratorModel $admin): string
{
return md5($admin->id . '|' . $admin->account . 'cunkebao_admin_secret');
}
/**
* 数据验证
*
@@ -87,57 +76,6 @@ class AuthLoginController extends Controller
return $this;
}
/**
* 设置登录Cookie有效期24小时
*
* @param AdministratorModel $admin
* @return void
*/
protected function setCookie(AdministratorModel $admin): void
{
// 获取当前环境
$env = app()->env->get('APP_ENV', 'production');
// 获取请求的域名
$origin = $this->request->header('origin');
$domain = '';
if ($origin) {
// 解析域名
$parsedUrl = parse_url($origin);
if (isset($parsedUrl['host'])) {
// 如果是测试环境,使用完整的域名
if ($env === 'testing') {
$domain = $parsedUrl['host'];
} else {
// 生产环境使用顶级域名
$parts = explode('.', $parsedUrl['host']);
if (count($parts) > 1) {
$domain = '.' . $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1];
}
}
}
}
// 设置cookie选项
$options = [
'expire' => 86400,
'path' => '/',
'httponly' => true,
'samesite' => 'None', // 允许跨域
'secure' => true // 仅 HTTPS 下有效
];
// 如果有域名,添加到选项
if ($domain) {
$options['domain'] = $domain;
}
// 设置cookies
Cookie::set('admin_id', $admin->id, $options);
Cookie::set('admin_token', $this->createToken($admin), $options);
}
/**
* 管理员登录
*
@@ -149,16 +87,28 @@ class AuthLoginController extends Controller
$params = $this->request->only(['account', 'password']);
$admin = $this->dataValidate($params)->getAdministrator($params);
$this->saveLoginInfo($admin)->setCookie($admin);
$this->saveLoginInfo($admin);
return ResponseHelper::success(
[
// 生成 JWT 令牌(默认有效期 24 小时)
$expire = 86400;
$payload = [
'id' => $admin->id,
'account' => $admin->account,
'username' => $admin->username,
'authId' => $admin->authId ?? 0,
'role' => 'superadmin',
];
$token = JwtUtil::createToken($payload, $expire);
return ResponseHelper::success([
'id' => $admin->id,
'name' => $admin->username,
'account' => $admin->account,
'token' => Cookie::get('admin_token')
]
);
'authId' => $admin->authId ?? 0,
'token' => $token,
'token_expired' => time() + $expire,
]);
} catch (\Exception $e) {
return ResponseHelper::error($e->getMessage(), $e->getCode());
}

View File

@@ -43,7 +43,22 @@ class GetCompanySubusersForProfileController extends Controller
$users = $this->getSubusers();
foreach ($users as &$user) {
// 处理 createTime如果是时间戳则转换如果是日期字符串则直接使用如果为空则设为空字符串
if (empty($user['createTime'])) {
$user['createTime'] = '';
} elseif (is_numeric($user['createTime']) && $user['createTime'] > 0) {
// 时间戳格式
$user['createTime'] = date('Y-m-d H:i:s', $user['createTime']);
} elseif (is_string($user['createTime'])) {
// 已经是日期字符串格式,直接使用
// 如果格式不正确,尝试转换
$timestamp = strtotime($user['createTime']);
if ($timestamp !== false) {
$user['createTime'] = date('Y-m-d H:i:s', $timestamp);
}
} else {
$user['createTime'] = '';
}
}
return ResponseHelper::success($users);

View File

@@ -84,7 +84,7 @@ class GetPoolListController extends BaseController
'wa.avatar', 'wa.gender', 'wa.nickname', 'wa.region',
'wt.tags'
])
->join('traffic_source ts', 'tp.identifier = ts.identifier', 'RIGHT')
->join('traffic_source_v1 ts', 'tp.identifier = ts.identifier', 'RIGHT')
->join('company c', 'ts.companyId = c.companyId', 'LEFT')
->join('wechat_account wa', 'tp.wechatId = wa.wechatId', 'LEFT')
->join('wechat_tag wt', 'wa.wechatId = wt.wechatId', 'LEFT');

View File

@@ -8,6 +8,7 @@
return [
// 任务配置格式:
// '任务标识' => [
// 'name' => '任务名称', // 必填:任务的中文名称,用于日志和显示
// 'command' => '命令名称', // 必填:执行的 ThinkPHP 命令(见 application/command.php
// 'schedule' => 'cron表达式', // 必填cron 表达式,如 '*/5 * * * *' 表示每5分钟
// 'options' => ['--option=value'], // 可选:命令参数(原来 crontab 里的 --xxx=yyy
@@ -23,6 +24,7 @@ return [
// 同步微信好友列表(未删除好友),用于保持系统中好友数据实时更新
'wechat_friends_active' => [
'name' => '同步微信好友列表(未删除)',
'command' => 'wechatFriends:list',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => ['--isDel=0'],
@@ -31,8 +33,9 @@ return [
'log_file' => 'crontab_wechatFriends_active.log',
],
// 拉取添加好友任务列表,驱动自动加好友的任务队列
// 拉取"添加好友任务"列表,驱动自动加好友的任务队列
'friend_task' => [
'name' => '拉取添加好友任务列表',
'command' => 'friendTask:list',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
@@ -43,6 +46,7 @@ return [
// 同步微信好友私聊消息列表,写入消息表,供客服工作台使用
'message_friends' => [
'name' => '同步微信好友私聊消息列表',
'command' => 'message:friendsList',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
@@ -53,6 +57,7 @@ return [
// 同步微信群聊消息列表,写入消息表,供群聊记录与风控分析
'message_chatroom' => [
'name' => '同步微信群聊消息列表',
'command' => 'message:chatroomList',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
@@ -63,6 +68,7 @@ return [
// 客服端消息提醒任务,负责给在线客服推送新消息通知
'kf_notice' => [
'name' => '客服端消息提醒',
'command' => 'kf:notice',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
@@ -75,8 +81,20 @@ return [
// 中频任务(每 2-5 分钟)
// ===========================
// 内容库同步
'content_collect' => [
'name' => '同步内容库',
'command' => 'content:collect',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'content_collect.log',
],
// 同步微信设备列表(未删除设备),用于设备管理与监控
'device_active' => [
'name' => '同步微信设备列表(未删除)',
'command' => 'device:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => ['--isDel=0'],
@@ -87,6 +105,7 @@ return [
// 同步微信群聊列表(未删除群),用于群管理与后续任务分配
'wechat_chatroom_active' => [
'name' => '同步微信群聊列表(未删除)',
'command' => 'wechatChatroom:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => ['--isDel=0'],
@@ -97,6 +116,7 @@ return [
// 同步微信群成员列表(群好友),维持群成员明细数据
'group_friends' => [
'name' => '同步微信群成员列表',
'command' => 'groupFriends:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
@@ -105,8 +125,9 @@ return [
'log_file' => 'crontab_groupFriends.log',
],
// 同步微信客服列表,获取绑定到公司的微信号,用于工作台与分配规则
// 同步"微信客服列表",获取绑定到公司的微信号,用于工作台与分配规则
'wechat_list' => [
'name' => '同步微信客服列表',
'command' => 'wechatList:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
@@ -117,6 +138,7 @@ return [
// 同步公司账号列表(企业/租户账号),供后台管理与统计
'account_list' => [
'name' => '同步公司账号列表',
'command' => 'account:list',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
@@ -125,18 +147,9 @@ return [
'log_file' => 'crontab_account.log',
],
// 内容采集任务,将外部或设备内容同步到系统内容库
'content_collect' => [
'command' => 'content:collect',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_contentCollect.log',
],
// 工作台:自动点赞好友/客户朋友圈,提高账号活跃度
'workbench_auto_like' => [
'name' => '工作台:自动点赞朋友圈',
'command' => 'workbench:autoLike',
'schedule' => '*/6 * * * *', // 每6分钟
'options' => [],
@@ -147,6 +160,7 @@ return [
// 工作台:自动建群任务,按规则批量创建微信群
'workbench_group_create' => [
'name' => '工作台:自动建群任务',
'command' => 'workbench:groupCreate',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
@@ -157,6 +171,7 @@ return [
// 工作台:自动导入通讯录到系统,生成加粉/建群等任务
'workbench_import_contact' => [
'name' => '工作台:自动导入通讯录',
'command' => 'workbench:import-contact',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => [],
@@ -171,6 +186,7 @@ return [
// 清洗并同步微信原始数据到存客宝业务表(数据治理任务)
'sync_wechat_data' => [
'name' => '同步微信原始数据到存客宝',
'command' => 'sync:wechatData',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
@@ -181,6 +197,7 @@ return [
// 工作台:流量分发任务,把流量池中的线索按规则分配给微信号或员工
'workbench_traffic_distribute' => [
'name' => '工作台:流量分发任务',
'command' => 'workbench:trafficDistribute',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
@@ -191,6 +208,7 @@ return [
// 工作台:朋友圈同步任务,拉取并落库朋友圈内容
'workbench_moments' => [
'name' => '工作台:朋友圈同步任务',
'command' => 'workbench:moments',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
@@ -201,6 +219,7 @@ return [
// 预防性切换好友任务,监控频繁/风控风险,自动切换加人对象,保护微信号
'switch_friends' => [
'name' => '预防性切换好友任务',
'command' => 'switch:friends',
'schedule' => '*/2 * * * *', // 每2分钟
'options' => [],
@@ -215,6 +234,7 @@ return [
// 拉取设备通话记录(语音/电话),用于质检、统计或标签打分
'call_recording' => [
'name' => '拉取设备通话记录',
'command' => 'call-recording:list',
'schedule' => '*/30 * * * *', // 每30分钟
'options' => [],
@@ -223,12 +243,29 @@ return [
'log_file' => 'call_recording.log',
],
// ===========================
// 低频任务(每 2 小时)
// ===========================
// V2 流量池数据同步,全量同步好友、群成员和标签数据到 V2 流量池系统
'traffic_pool_v2_sync' => [
'name' => 'V2 流量池数据同步',
'command' => 'migrate:trafficPoolV2',
'schedule' => '0 */2 * * *', // 每2小时的0分执行0:00, 2:00, 4:00...
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'timeout' => 3600, // 1小时超时
'log_file' => 'traffic_pool_v2_sync.log',
],
// ===========================
// 每日 / 每几天任务
// ===========================
// 每日 1:00 同步已删除设备列表,补齐历史状态
// 每日 1:00 同步"已删除设备"列表,补齐历史状态
'device_deleted' => [
'name' => '同步已删除设备列表',
'command' => 'device:list',
'schedule' => '0 1 * * *', // 每天1点
'options' => ['--isDel=1'],
@@ -237,8 +274,9 @@ return [
'log_file' => 'crontab_device_deleted.log',
],
// 每日 1:10 同步已停用设备列表,更新停用状态
// 每日 1:10 同步"已停用设备"列表,更新停用状态
'device_stopped' => [
'name' => '同步已停用设备列表',
'command' => 'device:list',
'schedule' => '10 1 * * *', // 每天1:10
'options' => ['--isDel=2'],
@@ -247,8 +285,9 @@ return [
'log_file' => 'crontab_device_stopped.log',
],
// 每日 1:30 同步已删除微信好友,用于历史恢复与报表
// 每日 1:30 同步"已删除微信好友",用于历史恢复与报表
'wechat_friends_deleted' => [
'name' => '同步已删除微信好友',
'command' => 'wechatFriends:list',
'schedule' => '30 1 * * *', // 每天1:30
'options' => ['--isDel=1'],
@@ -257,8 +296,9 @@ return [
'log_file' => 'crontab_wechatFriends_deleted.log',
],
// 每日 1:30 同步已删除微信群聊,用于统计与留痕
// 每日 1:30 同步"已删除微信群聊",用于统计与留痕
'wechat_chatroom_deleted' => [
'name' => '同步已删除微信群聊',
'command' => 'wechatChatroom:list',
'schedule' => '30 1 * * *', // 每天1:30
'options' => ['--isDel=1'],
@@ -269,6 +309,7 @@ return [
// 每日 2:00 统一计算所有微信账号健康分(基础分 + 动态分)
'wechat_calculate_score' => [
'name' => '计算微信账号健康分',
'command' => 'wechat:calculate-score',
'schedule' => '0 2 * * *', // 每天2点
'options' => [],
@@ -277,10 +318,23 @@ return [
'log_file' => 'calculate_score.log',
],
// 每日 3:00 清除过期日志文件默认保留10天可通过 --days 参数修改
'clean_logs' => [
'name' => '清除过期日志文件',
'command' => 'clean:logs',
'schedule' => '0 3 * * *', // 每天3点
'options' => ['--days=10'], // 默认保留10天可修改为其他天数如 ['--days=7'] 保留7天
'enabled' => true,
'max_concurrent' => 1,
'timeout' => 300, // 5分钟超时
'log_file' => 'clean_logs.log',
],
// 每 3 天执行的全量任务
// 每 3 天 3:00 全量同步所有在线好友,做一次大规模校准
'sync_all_friends' => [
'name' => '全量同步所有在线好友',
'command' => 'sync:allFriends',
'schedule' => '0 3 */3 * *', // 每3天的3点
'options' => [],
@@ -289,6 +343,84 @@ return [
'log_file' => 'all_friends.log',
],
// 检查未读/未回复消息并自动迁移好友每5分钟执行一次
'check_unread_message' => [
'name' => '检查未读/未回复消息并自动迁移好友',
'command' => 'check:unread-message',
'schedule' => '*/5 * * * *', // 每5分钟
'options' => ['--minutes=30'], // 30分钟未读/未回复
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'check_unread_message.log',
],
// 同步部门列表,用于部门管理与权限控制
'department_list' => [
'name' => '同步部门列表',
'command' => 'department:list',
'schedule' => '*/30 * * * *', // 每30分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_department.log',
],
// 朋友圈采集任务,采集好友朋友圈内容
'moments_collect' => [
'name' => '朋友圈采集任务',
'command' => 'moments:collect',
'schedule' => '0 6 * * *', // 每天6点
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_moments_collect.log',
],
// 分配规则列表,同步分配规则数据
'allotrule_list' => [
'name' => '同步分配规则列表',
'command' => 'allotrule:list',
'schedule' => '0 3 * * *', // 每天3点
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_allotrule_list.log',
],
// 自动创建分配规则,根据规则自动创建分配任务
'allotrule_autocreate' => [
'name' => '自动创建分配规则',
'command' => 'allotrule:autocreate',
'schedule' => '0 4 * * *', // 每天4点
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'crontab_allotrule_autocreate.log',
],
// 工作台:入群欢迎语任务,自动发送入群欢迎消息
'workbench_group_welcome' => [
'name' => '工作台:入群欢迎语任务',
'command' => 'workbench:groupWelcome',
'schedule' => '*/1 * * * *', // 每1分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'workbench_groupWelcome.log',
],
// 采集客服自己的朋友圈,同步客服账号的朋友圈内容
'own_moments_collect' => [
'name' => '采集客服自己的朋友圈',
'command' => 'own:moments:collect',
'schedule' => '*/30 * * * *', // 每30分钟
'options' => [],
'enabled' => true,
'max_concurrent' => 1,
'log_file' => 'own_moments_collect.log',
],
// 已禁用的任务(注释掉的任务)
// 'workbench_group_push' => [
// 'command' => 'workbench:groupPush',

View File

@@ -4,85 +4,85 @@
```bash
# 设备列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/device_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think device:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/device_list.log 2>&1
# 微信好友列表
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/wechat_friends_list.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatFriends:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/wechat_friends_list.log 2>&1
# 微信群列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/wechat_chatroom_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatChatroom:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/wechat_chatroom_list.log 2>&1
# 添加好友任务列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think friendTask:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/friend_task_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think friendTask:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/friend_task_list.log 2>&1
# 微信客服列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatList:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/wechat_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatList:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/wechat_list.log 2>&1
# 公司账号列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think account:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/account_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think account:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/account_list.log 2>&1
# 微信好友消息列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:friendsList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/message_friends_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think message:friendsList >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/message_friends_list.log 2>&1
# 微信群聊消息列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:chatroomList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/message_chatroom_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think message:chatroomList >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/message_chatroom_list.log 2>&1
# 部门列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think department:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/department_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think department:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/department_list.log 2>&1
# 同步内容库
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:sync >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/content_sync.log 2>&1
0 2 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think content:sync >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/content_sync.log 2>&1
# 微信群好友列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think groupFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/group_friends_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think groupFriends:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/group_friends_list.log 2>&1
# 获取通话记录
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think call-recording:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/call_recording.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think call-recording:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/call_recording.log 2>&1
# 分配规则列表
0 3 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think allotrule:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/allot_rule_list.log 2>&1
0 3 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think allotrule:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/allot_rule_list.log 2>&1
# 自动创建分配规则
0 4 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think allotrule:autocreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/allot_rule_autocreate.log 2>&1
0 4 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think allotrule:autocreate >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/allot_rule_autocreate.log 2>&1
# 内容采集任务
0 5 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/content_collect.log 2>&1
0 5 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think content:collect >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/content_collect.log 2>&1
# 朋友圈采集任务
0 6 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think moments:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/moments_collect.log 2>&1
0 6 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think moments:collect >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/moments_collect.log 2>&1
# 工作台自动点赞任务
0 7 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:autoLike >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_auto_like.log 2>&1
0 7 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:autoLike >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_auto_like.log 2>&1
# 工作台朋友圈同步任务
0 8 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:moments >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_moments.log 2>&1
0 8 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:moments >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_moments.log 2>&1
# 同步微信数据到存客宝
0 9 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:wechatData >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/sync_wechat_data.log 2>&1
0 9 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think sync:wechatData >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/sync_wechat_data.log 2>&1
# 工作台群发消息
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupPush >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupPush.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:groupPush >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_groupPush.log 2>&1
# 工作台建群
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:groupCreate >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_groupCreate.log 2>&1
# 工作台通讯录导入
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:import-contact >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/import_contact.log 2>&1
# 工作台流量分发
0 9 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:trafficDistribute >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/traffic_distribute.log 2>&1
0 9 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:trafficDistribute >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/traffic_distribute.log 2>&1
# 预防性切换好友
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think switch:friends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/switch_friends.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think switch:friends >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/switch_friends.log 2>&1
# 消息提醒
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think kf:notice >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/kf_notice.log 2>&1
# 客服评分
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechat:calculate-score >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/calculate_score.log 2>&1
0 2 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechat:calculate-score >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/calculate_score.log 2>&1
@@ -92,12 +92,12 @@
## 说明
- 所有命令都在 `/www/wwwroot/mckb_quwanzhi_com/Server` 目录下执行
- 所有命令都在 `/www/wwwroot/ckbapi.quwanzhi.com` 目录下执行
- 默认只获取未删除(活跃)的设备、微信好友和群聊
- 已注释的命令(以#开头)是获取已删除或已停用数据的任务,可根据需要取消注释启用
- 每个命令的执行结果都会记录到对应的日志文件中
- 日志文件名格式包含了数据状态(如 `_active`, `_deleted`, `_stopped`
- 日志文件位于 `/www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/` 目录下
- 日志文件位于 `/www/wwwroot/ckbapi.quwanzhi.com/runtime/log/` 目录下
- 大部分任务每5分钟执行一次`*/5 * * * *` 表示每小时的第0,5,10,15...55分钟执行)
- 设备列表的未删除设备任务每天凌晨1点执行一次`0 1 * * *`
- 自动创建分配规则每小时整点执行一次(`0 * * * *`
@@ -120,62 +120,64 @@ crontab -l
# 设备列表 - 未删除设备(每半小时执行)
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_active.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think device:list --isDel=0 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_device_active.log 2>&1
# 设备列表 - 已删除设备每天1点执行
0 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_deleted.log 2>&1
0 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think device:list --isDel=1 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_device_deleted.log 2>&1
# 设备列表 - 已停用设备每天1:10执行
10 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=2 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_stopped.log 2>&1
10 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think device:list --isDel=2 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_device_stopped.log 2>&1
# 微信好友列表 - 未删除好友每1分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatFriends_active.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatFriends:list --isDel=0 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_wechatFriends_active.log 2>&1
# 微信好友列表 - 已删除好友每天1:30分执行
30 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatFriends_deleted.log 2>&1
30 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatFriends:list --isDel=1 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_wechatFriends_deleted.log 2>&1
# 微信群列表 - 未删除群每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatChatroom_active.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatChatroom:list --isDel=0 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_wechatChatroom_active.log 2>&1
# 微信群列表 - 已删除群每天1:30分执行
30 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatChatroom_deleted.log 2>&1
30 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatChatroom:list --isDel=1 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_wechatChatroom_deleted.log 2>&1
# 微信群好友列表没5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think groupFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_groupFriends.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think groupFriends:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_groupFriends.log 2>&1
# 添加好友任务列表(每1分钟执行)
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think friendTask:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_friendTask.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think friendTask:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_friendTask.log 2>&1
# 微信客服列表每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatList:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatList.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechatList:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_wechatList.log 2>&1
# 公司账号列表每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think account:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_account.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think account:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_account.log 2>&1
# 微信好友消息列表每30分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:friendsList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_messageFriends.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think message:friendsList >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_messageFriends.log 2>&1
# 微信群聊消息列表每30分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:chatroomList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_messageChatroom.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think message:chatroomList >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_messageChatroom.log 2>&1
# 获取通话记录
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think call-recording:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/call_recording.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think call-recording:list >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/call_recording.log 2>&1
# 清洗微信数据
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:wechatData >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/sync_wechat_data.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think sync:wechatData >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/sync_wechat_data.log 2>&1
# 内容采集任务每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_contentCollect.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think content:collect >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_contentCollect.log 2>&1
# 工作台任务_自动点赞每10分钟执行
*/6 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:autoLike >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_workbench_autoLike.log 2>&1
*/6 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:autoLike >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/crontab_workbench_autoLike.log 2>&1
# 每3天的3点同步所有好友
0 3 */3 * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:allFriends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/all_friends.log 2>&1
0 3 */3 * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think sync:allFriends >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/all_friends.log 2>&1
# 工作台流量分发
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:trafficDistribute >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/traffic_distribute.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:trafficDistribute >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/traffic_distribute.log 2>&1
# 工作台朋友圈同步任务
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:moments >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_moments.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:moments >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_moments.log 2>&1
# 工作台群发消息
#*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupPush >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupPush.log 2>&1
#*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:groupPush >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_groupPush.log 2>&1
# 预防性切换好友
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think switch:friends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/switch_friends.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think switch:friends >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/switch_friends.log 2>&1
# 工作台建群
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:groupCreate >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_groupCreate.log 2>&1
# 工作台通讯录导入
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:import-contact >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/import_contact.log 2>&1
# 工作台入群欢迎语
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think workbench:groupWelcome >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/workbench_groupWelcome.log 2>&1
# 消息提醒
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think kf:notice >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/kf_notice.log 2>&1
# 客服评分
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechat:calculate-score >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/calculate_score.log 2>&1
0 2 * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think wechat:calculate-score >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/calculate_score.log 2>&1
# 采集客服自己的朋友圈
*/30 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think own:moments:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/own_moments_collect.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think own:moments:collect >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/own_moments_collect.log 2>&1
# 检查未读/未回复消息并自动迁移好友
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think check:unread-message --minutes=30 >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/check_unread_message.log 2>&1
# 每分钟执行一次调度器(调度器内部会自动判断哪些任务需要执行)
* * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think scheduler:run >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/scheduler.log 2>&1
* * * * * cd /www/wwwroot/ckbapi.quwanzhi.com && php think scheduler:run >> /www/wwwroot/ckbapi.quwanzhi.com/runtime/log/scheduler.log 2>&1

1103
docs/traffic_pool_design.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
- **接口协议**HTTP
- **请求方式**`POST`
- **请求地址** `http://ckbapi.quwanzhi.com/v1/api/scenarios`
- **请求地址** `https://ckbapi.quwanzhi.com/v1/api/scenarios`
> 具体 URL 以实际环境配置为准。