CRMEB Pro 客户群管理源码解析:群成员、退群统计和同步补偿到底怎么做?
CRMEB Pro 客户群管理源码解析:群成员、退群统计和同步补偿到底怎么做?
摘要
企业微信客户群管理是私域运营里的重模块:群列表、群主、管理员、群成员、退群人数、新增人数、群发范围、群统计,都不是一个接口能搞定的。
CRMEB Pro 的客户群链路采用“先拉群列表,再队列同步群详情”的方式;回调里还要处理建群、成员变化、群主变更、群名变更、公告变更和解散。今天第三篇把客户群管理拆透,尤其适合准备二开客户群统计、群运营看板、群发任务筛选和群成员归因的项目。
1. 客户群相关入口
后台路由在 `work` 分组里:
Route::get('group_chat', 'GroupChat/index') ->option(['real_name' => '获取企业微信客户群聊']); Route::get('group_chat/member', 'GroupChat/chatMember') ->option(['real_name' => '获取企业微信客户群聊成员']); Route::get('group_chat/statistics', 'GroupChat/chatStatistics') ->option(['real_name' => '客户群统计']); Route::get('group_chat/statisticsList', 'GroupChat/chatStatisticsList') ->option(['real_name' => '客户群统计列表数据']); Route::post('group_chat/synch', 'GroupChat/synchGroupChat') ->option(['real_name' => '同步客户群']);对应目录:
crmeb_pro/app/controller/admin/v1/work/GroupChat.php crmeb_pro/app/services/work/WorkGroupChatServices.php crmeb_pro/app/services/work/WorkGroupChatMemberServices.php crmeb_pro/app/services/work/WorkGroupChatStatisticServices.php crmeb_pro/app/dao/work/WorkGroupChatDao.php crmeb_pro/app/dao/work/WorkGroupChatMemberDao.php crmeb_pro/app/jobs/work/WorkGroupChatJob.php crmeb_pro/app/listener/wechat/WorkListener.php crmeb_pro_admin/src/pages/work/customerBase/index.vue crmeb_pro_admin/src/pages/work/customerBase/statistical.vue如果你要改客户群模块,基本绕不开这些文件。
2. 客户群列表怎么查
后台 Controller:
public function index() { $where = $this->request->getMore([ ['userids', []], ['time', ''], ['name', ''] ]); return $this->success($this->services->getList($where)); }服务层 `getList()` 会按群创建时间查询,并带出群主信息:
public function getList(array $where) { [$page, $limit] = $this->getPageValue(); $where['timeKey'] = 'group_create_time'; $where['status'] = [0, 2, 3]; $list = $this->dao->getDataList($where, ['*'], $page, $limit, 'group_create_time', [ 'ownerInfo' => function ($query) { $query->field(['userid', 'name']); }, ]); $count = $this->dao->count(); return compact('list', 'count'); }这段有两个注意点:
1. 群列表筛选用 group_create_time,而不是本地 create_time 2. 列表只查 status = [0, 2, 3] 的群,二开时要理解状态含义3. 管理员列表为什么要二次转换
企业微信群详情里有 `admin_list`,项目落库时会转成 JSON:
$groupInfo['admin_list'] = json_encode(array_column($groupInfo['admin_list'], 'userid'));列表展示时再把 `userid` 转成员工姓名:
$adminUserId = []; foreach ($list as $item) { $adminUserId = array_merge($adminUserId, $item['admin_list'] ?? []); } $adminUserId = array_merge(array_unique(array_filter($adminUserId))); if ($adminUserId) { $memberService = app()->make(WorkMemberServices::class); $adminUserList = $memberService->getColumn([ ['userid', 'in', $adminUserId], ], 'name', 'userid'); foreach ($list as &$item) { $newAdminUser = []; if (!empty($item['admin_list'])) { foreach ($adminUserList as $key => $value) { if (in_array($key, $item['admin_list'])) { $newAdminUser[] = ['name' => $value, 'userid' => $key]; } } } $item['admin_user_list'] = $newAdminUser; } }二开导出、搜索、统计时不要直接把 `admin_list` 原样给前端。它本质是员工 `userid` 数组,需要再关联 `WorkMember`。
4. 同步客户群:先清数据,再拉群列表,再队列拉详情
后台同步入口:
public function synchGroupChat(WorkGroupChatMemberServices $services) { $this->services->delete([["id","<>",0]]); $services->delete([["id","<>",0]]); $this->services->authGroupChat(); return $this->success('已加入消息队列,请稍后查看'); }同步逻辑在 `WorkGroupChatServices::authGroupChat()`:
public function authGroupChat(string $nextCursor = null) { $res = Work::getGroupChats([], 100, $nextCursor); if (0 !== $res['errcode']) { throw new ValidateException($res['errmsg']); } $groupChatList = $res['group_chat_list'] ?? []; $config = app()->make(WorkConfig::class); $corpId = $config->corpId; if (!$corpId) { throw new ValidateException('请先配置企业微信ID'); } if ($groupChatList) { foreach ($groupChatList as $item) { $item['corp_id'] = $corpId; if (($id = $this->dao->value(['chat_id' => $item['chat_id'], 'corp_id' => $corpId], 'id'))) { $this->dao->update($id, $item); } else { $item['create_time'] = time(); $groupChat[] = $item; } } foreach ($groupChatList as $item) { WorkGroupChatJob::dispatchDo('authChat', [$corpId, $item['chat_id']]); } if (!empty($res['next_cursor'])) { WorkGroupChatJob::dispatchDo('authGroupChat', [$res['next_cursor']]); } } return true; }这条链路是:
后台点击同步 清空群和成员本地数据 调用 getGroupChats 拉群列表 保存/更新群基础信息 每个 chat_id 派发 authChat 队列 有 next_cursor 时继续派发下一页任务所以同步群以后,群列表可能先出来,成员数据稍后才完整。这不是 bug,是队列分段同步。
5. 群详情和成员怎么保存
队列任务:
class WorkGroupChatJob extends BaseJobs { use QueueTrait; public function authChat($corpId, $chatId) { $make = app()->make(WorkGroupChatServices::class); return $make->saveWorkGroupChat($corpId, $chatId); } public function authGroupChat($nextCursor) { $make = app()->make(WorkGroupChatServices::class); return $make->authGroupChat($nextCursor); } }保存群详情:
public function saveWorkGroupChat(string $corpId, string $chatId) { $response = Work::getGroupChat($chatId); if (0 !== $response['errcode']) { throw new ValidateException($response['errmsg']); } $groupInfo = $response['group_chat'] ?? []; $groupInfo['admin_list'] = json_encode(array_column($groupInfo['admin_list'], 'userid')); $memberList = $groupInfo['member_list'] ?? []; unset($groupInfo['member_list']); $group = $this->dao->get(['corp_id' => $corpId, 'chat_id' => $chatId]); return $this->transaction(function () use ($chatId, $corpId, $group, $groupInfo, $memberList) { if ($group) { $group->name = $groupInfo['name']; $group->owner = $groupInfo['owner']; $group->notice = $groupInfo['notice'] ?? ''; $group->group_create_time = $groupInfo['create_time']; $group->member_num = count($memberList); $group->save(); } else { $group = $this->dao->save([ 'corp_id' => $corpId, 'chat_id' => $chatId, 'name' => $groupInfo['name'], 'owner' => $groupInfo['owner'], 'notice' => $groupInfo['notice'] ?? '', 'member_num' => count($memberList), 'group_create_time' => $groupInfo['create_time'], 'status' => $groupInfo['status'] ?? 0, ]); } $this->saveMember($memberList, $group->id, $group->member_num); return $group->id; }); }关键是 `saveMember()`。客户群成员有内部成员,也有外部联系人;外部联系人还可能携带 `unionid` 和 `state`。
foreach ($memberList as $item) { $item['group_id'] = $groupId; $state = $item['state'] ?? ''; unset($item['state']); $item['invitor_userid'] = $item['invitor']['userid'] ?? ''; $unionid = $item['unionid'] ?? ''; unset($item['invitor'], $item['unionid']); if ($chatMemberService->count(['group_id' => $groupId, 'userid' => $item['userid']])) { $chatMemberService->update(['group_id' => $groupId, 'userid' => $item['userid']], [ 'type' => $item['type'], 'unionid' => $unionid, 'chat_sum' => $sum, 'status' => 1, 'join_time' => $item['join_time'], 'join_scene' => $item['join_scene'], 'invitor_userid' => $item['invitor_userid'], 'group_nickname' => $item['group_nickname'], ]); } else { $item['unionid'] = $unionid; $item['chat_sum'] = $sum; $item['state'] = $state; $item['create_time'] = time(); $data[] = $item; } }6. 离群成员不是直接删除,而是标记状态
项目会用当前企业微信返回的成员列表和本地成员列表做差集:
$newUserIds = array_column($memberList, 'userid'); $userids = $chatMemberService->getColumn(['group_id' => $groupId], 'userid'); $unUserIds = array_diff($userids, $newUserIds); if ($unUserIds) { $chatMemberService->update([ ['userid', 'in', $unUserIds] ], ['status' => 0]); }这个设计非常适合做运营统计,因为离群成员还需要参与历史统计。如果直接删除,后面就很难复盘:
谁什么时候进群 谁邀请进群 后续是否退群 退群前所在群人数 某个渠道带来的客户是否容易退群二开客户群分析时,建议保留这种软状态思路。
7. 群变更回调怎么处理
企业微信客户群事件也在 `WorkListener`:
public function changeExternalChatEvent(array $payload) { switch ($payload['ChangeType']) { case 'create': app()->make(WorkGroupChatServices::class) ->saveWorkGroupChat($payload['ToUserName'], $payload['ChatId']); break; case 'update': app()->make(WorkGroupChatServices::class)->updateGroupChat($payload); break; case 'dismiss': app()->make(WorkGroupChatServices::class) ->dismissGroupChat($payload['ToUserName'], $payload['ChatId']); break; } }群更新时再根据 `UpdateDetail` 分支处理:
switch ($payload['UpdateDetail']) { case 'add_member': $groupInfo->member_num++; $this->saveMember($memberList, $groupInfo->id, $groupInfo->member_num, true); $statisticService->saveOrUpdate($groupInfo->id, true, false, $groupInfo->member_num, $groupInfo->retreat_group_num); break; case 'del_member': $groupInfo->member_num--; $groupInfo->retreat_group_num++; $this->saveMember($memberList, $groupInfo->id, $groupInfo->member_num, false); $statisticService->saveOrUpdate($groupInfo->id, false, true, $groupInfo->member_num, $groupInfo->retreat_group_num); break; case 'change_owner': $groupInfo->owner = $groupChat['owner']; break; case 'change_name': $groupInfo->name = $groupChat['name']; break; case 'change_notice': $groupInfo->notice = $groupChat['notice']; break; }这一段决定了后台客户群统计是否准确。
8. 今日新增、今日退群和趋势统计
客户群统计入口:
public function chatStatistics($id) { if (!$id) { return $this->fail('缺少参数'); } $time = $this->request->get('time', ''); return $this->success($this->services->getChatStatistics((int)$id, $time)); }服务层:
public function getChatStatistics(int $id, string $time) { $chatMemberService = app()->make(WorkGroupChatMemberServices::class); $data = [ 'toDaySum' => $chatMemberService->getToDaySum($id), 'toDayReturn' => $chatMemberService->getToDayReturn($id), 'groupChatSum' => $this->dao->value(['id' => $id], 'member_num'), 'groupChatReturnSum' => $this->dao->value(['id' => $id], 'retreat_group_num') ]; $data['groupChatList'] = $chatMemberService ->getChatMemberStatistics($id, 'join_time', ['count(*) as sum'], 1, $time); $data['groupChatReturnList'] = $chatMemberService ->getChatMemberStatistics($id, 'join_time', ['count(*) as sum'], 0, $time); return $data; }统计列表会把新增和退群按时间合并:
for ($i = 0; $i < $count; $i++) { $data[] = [ 'time' => $groupChatList[$i]['time'] ?? $groupChatReturnList[$i]['time'] ?? '', 'sum' => $groupChatList[$i]['sum'] ?? 0, 'retreat_chat_num' => $groupChatList[$i]['retreat_chat_num'] ?? 0, 'chat_sum' => $groupChatList[$i]['chat_sum'] ?? 0, 'retreat_sum' => $groupChatReturnList[$i]['retreat_sum'] ?? 0, ]; }如果你要做“群运营日报”或“群质量评分”,这几个字段就很有用:
新增人数 退群人数 当前群人数 累计退群人数 按日/周/月趋势 邀请人 userid 客户 unionid 客户 state9. 什么时候需要补偿同步
客户群容易出现“后台统计和实际群不一致”,常见原因:
队列没有消费完 企业微信回调失败 群详情接口临时失败 群成员更新事件漏处理 管理员或群主变更后没有刷新详情 本地手动清理了群成员数据项目里已经有两个补偿入口:
手动同步:POST work/group_chat/synch 队列同步群详情:WorkGroupChatJob::authChat如果要二开更稳的补偿机制,可以考虑:
1. 给每个群增加 last_sync_time 2. 同步失败记录 chat_id、errcode、errmsg 3. 后台支持单群重新同步 4. 群成员统计不直接覆盖历史,而是保留状态变更 5. 回调失败只记日志不够,可以补一张失败事件表这些都应该落在 Services/Dao 层,不建议在 Controller 里直接写数据修复逻辑。
10. 二开注意事项
客户群模块二开建议注意:
1. 群列表同步和群详情同步是两段,成员数据依赖队列 2. admin_list 是员工 userid 数组,展示前要关联成员姓名 3. 成员离群不要直接删除,保留 status 才能做历史统计 4. change_external_chat 回调要区分 create、update、dismiss 5. update 里还要看 UpdateDetail:add_member、del_member、change_owner、change_name、change_notice 6. 群统计要区分今日新增、今日退群、当前人数、累计退群 7. 补偿同步要以 chat_id 为核心,不要按群名匹配 8. 同步任务会调用企业微信接口,调试前确认环境,避免影响真实企业微信配置11. 标签建议
CRMEB Pro 企业微信 客户群 私域运营 二次开发 源码解析 ThinkPHP