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. 客户群列表怎么查后台 Controllerpublic 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. 群变更回调怎么处理企业微信客户群事件也在 WorkListenerpublic 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 里还要看 UpdateDetailadd_member、del_member、change_owner、change_name、change_notice 6. 群统计要区分今日新增、今日退群、当前人数、累计退群 7. 补偿同步要以 chat_id 为核心不要按群名匹配 8. 同步任务会调用企业微信接口调试前确认环境避免影响真实企业微信配置11. 标签建议CRMEB Pro 企业微信 客户群 私域运营 二次开发 源码解析 ThinkPHP