CRMEB Pro 企业微信群发源码拆解:客户群发、群群发和发送结果怎么闭环?
摘要
企业微信群发最容易被误解成“后台点一下发送,客户就都收到了”。但在 CRMEB Pro 里,群发是一个异步闭环:后台创建模板,调用企业微信生成群发任务,员工或群主确认发送,再通过 msgid 拉取成员任务和客户送达结果。
这篇把 CRMEB Pro 企业微信群发拆成两条线:客户群发和客户群群发。重点讲清楚后台保存、立即/定时发送、队列任务、msgid 关系表、成员执行结果、客户送达结果以及统计回填。
如果你准备二开私域群发、运营触达、发送提醒、失败重试或者群发效果看板,这条链路一定要先看懂。
1. 群发模块到底包含哪些文件
企业微信群发相关文件集中在:
crmeb_pro/route/admin.php
crmeb_pro/app/controller/admin/v1/work/GroupTemplate.php
crmeb_pro/app/services/work/WorkGroupTemplateServices.php
crmeb_pro/app/services/work/WorkGroupMsgRelationServices.php
crmeb_pro/app/services/work/WorkGroupMsgTaskServices.php
crmeb_pro/app/services/work/WorkGroupMsgSendResultServices.php
crmeb_pro/app/services/work/WorkGroupMsgSendResultGroupChatServices.php
crmeb_pro/app/jobs/work/WorkGroupMsgJob.php
crmeb_pro/app/listener/work/AutoGroupTask.php
crmeb_pro/crmeb/traits/service/ContactWayQrCode.php
crmeb_pro/crmeb/services/wechat/Work.php
crmeb_pro/crmeb/services/wechat/client/work/MessageClient.php
crmeb_pro_admin/src/pages/work/groupTemplate/index.vue
crmeb_pro_admin/src/pages/work/groupTemplate/add_template.vue
crmeb_pro_admin/src/pages/work/groupTemplate/groupTemplateInfo.vue
路由层有两组入口:
// 获取群发成员列表
Route::get('group_template/memberList/:id', 'GroupTemplate/memberList');
// 获取群发客户列表
Route::get('group_template/clientList/:id', 'GroupTemplate/clientList');
// 获取群发群列表
Route::get('group_template_chat/groupChatList/:id', 'GroupTemplate/groupChatList');
// 获取群发群主列表
Route::get('group_template_chat/groupChatOwnerList/:id', 'GroupTemplate/groupChatOwnerList');
// 获取群发群主下的群列表
Route::get('group_template_chat/getOwnerChatList', 'GroupTemplate/getOwnerChatList');
// 提醒发送
Route::post('group_template/sendMessage', 'GroupTemplate/sendMessage');
Route::resource('group_template', 'GroupTemplate')->except(['create', 'edit']);
Route::resource('group_template_chat', 'GroupTemplate')->except(['create', 'edit']);
可以看到,group_template 和 group_template_chat 共用同一个 Controller,只是通过 type 区分不同群发类型。
2. 先区分两个概念:客户群发和客户群群发
在保存参数里有一个关键字段:
['type', 0],
项目里对 type 的处理基本可以这样理解:
type = 0:客户群发,面向外部联系人,由成员确认发送给客户
type = 1:客户群群发,面向客户群,由群主确认发送到群聊
后台客户群群发页面里默认就是:
formItem: {
template_type: "0",
name: "",
type: "1",
client_type: "0",
where_time: "",
where_label: [],
ownerInfo: [],
send_time: "",
welcome_words: {
text: {
content: "",
},
attachments: [],
},
}
这里的 welcome_words 命名虽然叫欢迎语,但在群发里承载的是群发内容:
text.content:群发文案
attachments:图片、小程序等附件
这个命名历史包袱二开时要注意,不要看到 welcome_words 就以为只服务欢迎语模块。
3. 前端保存:立即发送和定时发送
后台新增群发页面支持立即发送和定时发送:
<RadioGroup v-model="formItem.template_type">
<Radio label="0">立即发送</Radio>
<Radio label="1">定时发送</Radio>
</RadioGroup>
<DatePicker
type="datetime"
v-model="formItem.send_time"
@on-change="snedChangeTime"
placeholder="请选择发送时间"
class="input-add"
></DatePicker>
前端 API:
export function workGroupTemplateSave(data) {
return request({
url: `work/group_template`,
method: 'post',
data
});
}
export function workGroupTemplateChatSave(data) {
return request({
url: `work/group_template_chat`,
method: 'post',
data
});
}
列表页展示字段也说明它不是“发完就结束”:
columns1: [
{ title: "群发名称", key: "name" },
{ title: "已发送群主", key: "user_count" },
{ title: "送达群聊", key: "external_user_count" },
{ title: "未发送群主", key: "unuser_count" },
{ title: "未送达群聊", key: "external_unuser_count" },
{ title: "是否发送", slot: "send_type" },
{ title: "群发类型", slot: "template_type" },
]
如果二开运营看板,这几个统计字段就是入口。
4. Controller 保存:先挡住无效任务
后台保存方法:
public function save(WorkClientServices $services)
{
$data = $this->request->postMore([
['type', 0],
['name', ''],
['userids', []],
['client_type', 0],
['where_time', 0],
['where_label', []],
['where_not_label', []],
['template_type', 0],
['send_time', 0],
['welcome_words', []],
]);
if ($data['template_type']) {
if (!$data['send_time']) {
return $this->fail('请设置定时发送时间');
}
$data['send_time'] = strtotime($data['send_time']);
}
if (!$data['name']) {
return $this->fail('请设置群发名称');
}
if ($data['type']) {
if (!$data['userids']) {
return $this->fail('请选择群主');
}
} else {
if ($data['client_type']) {
if (!$data['where_time'] && !$data['where_label'] && !$data['where_not_label']) {
return $this->fail('请设置查询客户条件');
}
}
}
if (empty($data['welcome_words']['text']['content'])) {
return $this->fail('请设置群发内容');
}
if (isset($data['userids'][0]['userid'])) {
$data['userids'] = array_column($data['userids'], 'userid');
}
$this->services->saveGroupTemplate($data);
return $this->success('保存成功');
}
这里有几条规则:
定时发送必须有 send_time
群发名称不能为空
客户群群发必须选择群主
客户群发如果按条件筛选客户,必须至少有筛选条件
群发文本不能为空
userids 如果是对象数组,会转成 userid 数组
二开时不要绕过这些校验直接调用服务层,否则可能创建出企业微信无法执行的任务。
5. 保存模板:立即发送就直接丢队列
核心服务:
public function saveGroupTemplate(array $data)
{
$this->checkWelcome($data['welcome_words'], 0);
$this->transaction(function () use ($data) {
$res = $this->dao->save($data);
//立即发送或者选择的时间小于当前时间
if (!$data['template_type'] || ($data['template_type'] == 1 && $data['send_time'] < time())) {
if ($data['type']) {
WorkGroupMsgJob::dispatchDo('batch', [$res->id, '', 0]);
} else {
foreach ($data['userids'] as $key => $userid) {
WorkGroupMsgJob::dispatchDo('batch', [$res->id, $userid, $key + 1]);
}
}
}
});
}
这里的分支非常关键:
type = 1:客户群群发,只派一个 batch 任务
type = 0:客户群发,按成员 userids 拆成多个 batch 任务
这也解释了为什么客户群发会出现多个 msgid:每个成员可能对应一个企业微信群发任务。
6. 定时发送:AutoGroupTask 每秒检查一次
定时任务监听器:
class AutoGroupTask extends Cron implements ListenerInterface
{
public function handle($event): void
{
$this->tick(1000, function () {
/** @var WorkGroupTemplateServices $service */
$service = app()->make(WorkGroupTemplateServices::class);
try {
$service->cornHandle();
} catch (\\Throwable $e) {
Log::error(json_encode([
'message' => '执行定时发送群发任务失败:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
]));
}
});
}
}
服务层定时处理:
public function cornHandle()
{
$time = time();
$list = $this->dao->getDataList(
['send_time' => $time, 'send_type' => 0],
['*'],
0,
0,
null,
['msgIds']
);
foreach ($list as $item) {
if ($item['type']) {
WorkGroupMsgJob::dispatchDo('batch', [$item['id'], '', 0]);
} else {
foreach ($item['userids'] as $count => $userid) {
WorkGroupMsgJob::dispatchDo('batch', [$item['id'], $userid, $count + 1]);
}
}
}
}
这里有一个实际二开注意点:send_time 是按秒精确匹配的。
如果你的队列或定时器不是常驻执行,可能错过某一秒。更稳的二开方式是增加“到期未发送”的范围查询,例如:
[
['send_time', '<=', time()],
['send_type', '=', 0],
]
并用状态字段避免重复执行。但这属于行为改造,要评估历史任务兼容。
7. 队列入口:WorkGroupMsgJob
队列类:
class WorkGroupMsgJob extends BaseJobs
{
use QueueTrait;
public function batch($id, $userId, $count)
{
/** @var WorkGroupTemplateServices $service */
$service = app()->make(WorkGroupTemplateServices::class);
return $service->batch((int)$id, $userId, (int)$count);
}
public function getTaks($type, $msgid, $cursor)
{
/** @var WorkGroupMsgTaskServices $service */
$service = app()->make(WorkGroupMsgTaskServices::class);
return $service->getTaks($type, $msgid, $cursor);
}
public function getSendResult($type, $userid, $msgid, $cursor)
{
/** @var WorkGroupMsgSendResultServices $service */
$service = app()->make(WorkGroupMsgSendResultServices::class);
return $service->getSendResult($type, $userid, $msgid, $cursor);
}
}
三类任务分别做:
batch:创建企业微信群发任务
getTaks:拉取成员发送任务列表
getSendResult:拉取客户或群聊送达结果
这就是群发闭环的骨架。
8. batch:筛选客户并创建企业微信群发
批量发送逻辑:
public function batch(int $id, string $userId, int $count)
{
try {
$groupTempInfo = $this->dao->get($id);
if (!$groupTempInfo) {
return true;
}
if ($groupTempInfo->send_type == 1) {
return true;
}
$groupTempInfo = $groupTempInfo->toArray();
$externalUserid = [];
if (!$groupTempInfo['type']) {
/** @var WorkClientServices $service */
$service = app()->make(WorkClientServices::class);
$where = ['userid' => $userId];
if ($groupTempInfo['client_type']) {
//条件筛选
if ($groupTempInfo['where_time']) {
$where['time'] = $groupTempInfo['where_time'];
$where['timeKey'] = 'create_time';
}
if ($groupTempInfo['where_label']) {
$where['label'] = $groupTempInfo['where_label'];
}
if ($groupTempInfo['notLabel']) {
$where['notLabel'] = $groupTempInfo['where_not_label'];
}
}
$externalUserid = $service->getClientUserIds($where);
}
$this->sendTask($id, $externalUserid, $groupTempInfo, $count);
return true;
} catch (\\Throwable $e) {
Log::error(json_encode([
'message' => '创建群发任务失败:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine()
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
try {
$this->dao->update($id, [
'send_type' => –1,
'fail_message' => '创建群发任务失败:' . $e->getMessage()
]);
} catch (\\Throwable $e) {
}
return true;
}
}
这里有一个细节:客户群发会先根据成员 userid 找外部联系人 external_userid,再创建群发任务。客户群群发则不走这个外部联系人列表,而是以群主维度创建群群发。
注意这里有个疑似历史字段问题:
if ($groupTempInfo['notLabel']) {
$where['notLabel'] = $groupTempInfo['where_not_label'];
}
保存字段是 where_not_label,这里判断用了 notLabel。如果你二开“排除标签客户”,建议复查这个字段是否能正常触发,必要时改成:
if ($groupTempInfo['where_not_label']) {
$where['notLabel'] = $groupTempInfo['where_not_label'];
}
这个属于功能修复点,改前要全链路回归客户筛选。
9. sendTask:客户群发和群群发分开处理
核心发送方法:
public function sendTask(int $id, array $externalUserid, array $data, int $count)
{
$update = [];
/** @var WorkGroupMsgRelationServices $msgRelationService */
$msgRelationService = app()->make(WorkGroupMsgRelationServices::class);
if ($data['type']) {
//群主群发
$failList = [];
$msgIdData = [];
foreach ($data['userids'] as $item) {
$res = $this->sendMsgTemplate([], $data['welcome_words'], 'group', $item);
$failList = array_merge($failList, $res['fail_list'] ?? []);
if (isset($res['msgid'])) {
$msgIdData[] = $res['msgid'];
}
}
$update['send_type'] = 1;
if ($failList) {
$update['fail_external_userid'] = json_encode($failList);
}
if ($update) {
$this->dao->update($id, $update);
}
$msgRelation = [];
foreach ($msgIdData as $msgId) {
$msgRelation[] = ['template_id' => $id, 'msg_id' => $msgId];
WorkGroupMsgJob::dispatchSece(60, 'getTaks', [(int)$data['type'], $msgId, null]);
}
$msgRelationService->saveAll($msgRelation);
} else {
//成员群发
$sendTemplateWelcome = $this->sendMsgTemplate($externalUserid, $data['welcome_words']);
$failList = $sendTemplateWelcome['fail_list'] ?? [];
if ($failList) {
$update['fail_external_userid'] = json_encode($failList);
}
if ($count == count($data['userids'])) {
$update['send_type'] = 1;
} else {
$update['send_type'] = 2;
}
if ($update) {
$this->dao->update($id, $update);
}
$msgRelationService->save([
'template_id' => $id,
'msg_id' => $sendTemplateWelcome['msgid']
]);
WorkGroupMsgJob::dispatchSece(60, 'getTaks', [
(int)$data['type'],
$sendTemplateWelcome['msgid'],
null
]);
}
}
这段是全篇最关键的代码。
客户群群发:
遍历群主 userids
每个群主调用 sendMsgTemplate
记录多个 msgid
60 秒后拉取成员任务
客户群发:
针对一个成员和一批 external_userid 创建任务
保存一个 msgid
根据 count 判断是否全部成员任务都已创建
60 秒后拉取成员任务
所以二开状态时不要只看 send_type = 1。它代表“企业微信群发任务创建完成”,不代表客户都已经收到。
10. sendMsgTemplate:统一转换素材并调用企业微信接口
创建企业微信群发的公共方法在 Trait:
public function sendMsgTemplate(array $externalUserid, array $attachments, string $chatType = 'single', string $sender = null)
{
$msg = [
'chat_type' => $chatType,
'external_userid' => $externalUserid,
];
if ('group' == $chatType) {
if (!$sender) {
throw new ValidateException('群发消息成员userid为必须填写');
}
}
if ($sender) {
$msg['sender'] = $sender;
}
if (empty($msg['external_userid'])) {
unset($msg['external_userid']);
}
/** @var WorkMediaServices $mediaService */
$mediaService = app()->make(WorkMediaServices::class);
$attachments = $mediaService->resolvingWelcome($attachments);
$msg = array_merge($msg, $attachments);
return Work::addMsgTemplate($msg);
}
企业微信 API 封装:
public static function addMsgTemplate(array $msg)
{
$response = self::instance()->message()->submit($msg);
self::logger('添加企业群发消息模板', compact('msg'), $response);
return new WechatResponse($response);
}
底层客户端:
public function submit(array $msg): ResponseInterface|Response
{
$params = $this->formatMessage($msg);
return $this->api->postJson('cgi-bin/externalcontact/add_msg_template', $params);
}
也就是最终调用:
cgi-bin/externalcontact/add_msg_template
创建成功后企业微信会返回 msgid。这个 msgid 后面用于拉取成员任务和发送结果。
11. msgid 关系表:为什么不能只存模板 ID
发送后会保存模板与 msgid 的关系:
$msgRelationService->save([
'template_id' => $id,
'msg_id' => $sendTemplateWelcome['msgid']
]);
客户群群发可能保存多条:
foreach ($msgIdData as $msgId) {
$msgRelation[] = [
'template_id' => $id,
'msg_id' => $msgId
];
WorkGroupMsgJob::dispatchSece(60, 'getTaks', [(int)$data['type'], $msgId, null]);
}
$msgRelationService->saveAll($msgRelation);
这也是详情页统计为什么先取 msgIds:
$info = $this->dao->get($id, ['*'], ['msgIds']);
if (!empty($info['msgIds'])) {
$msgIds = array_column($info['msgIds'], 'msg_id');
}
二开时不要偷懒只在模板表加一个 msgid 字段。因为一个模板可能对应多个企业微信任务,尤其是按多个成员或多个群主拆分时。
12. 拉取成员任务:getTaks
获取成员发送任务:
public function getTaks(int $type, string $msgid, string $cursor = null)
{
try {
$response = Work::getGroupmsgTask($msgid, 500, $cursor);
$taskList = $response['task_list'] ?? [];
foreach ($taskList as $item) {
$info = $this->dao->get([
'msg_id' => $msgid,
'userid' => $item['userid']
]);
if (!$info) {
$info = $this->dao->save([
'msg_id' => $msgid,
'userid' => $item['userid'],
'status' => $item['status'],
'create_time' => time(),
'send_time' => $item['send_time'] ?? 0
]);
} else {
$info->status = $item['status'];
$info->send_time = $item['send_time'] ?? 0;
$info->save();
}
WorkGroupMsgJob::dispatchDo('getSendResult', [
$type,
$item['userid'],
$msgid,
null
]);
}
if ($response['next_cursor']) {
WorkGroupMsgJob::dispatchDo('getTaks', [$type, $msgid, $cursor]);
}
return true;
} catch (\\Throwable $e) {
Log::error(json_encode([
'message' => '获取群发成员发送任务列表失败:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return false;
}
}
这里有一个需要关注的点:
if ($response['next_cursor']) {
WorkGroupMsgJob::dispatchDo('getTaks', [$type, $msgid, $cursor]);
}
如果企业微信返回了下一页游标,这里传入的仍是旧 $cursor,理论上更合理的是:
WorkGroupMsgJob::dispatchDo('getTaks', [$type, $msgid, $response['next_cursor']]);
如果你遇到群发成员任务分页不完整,优先排查这个位置。
13. 拉取客户或群聊送达结果:getSendResult
成员任务拿到后,会继续拉取发送结果:
public function getSendResult(int $type, string $userid, string $msgid, string $cursor = null)
{
try {
$response = Work::getGroupmsgSendResult($msgid, $userid, 500, $cursor);
$sendList = $response['send_list'] ?? [];
foreach ($sendList as $item) {
$where = ['msg_id' => $msgid, 'userid' => $userid];
if ($type) {
$where['chat_id'] = $item['chat_id'];
} else {
$where['external_userid'] = $item['external_userid'];
}
$info = $this->dao->get($where);
if (!$info) {
$this->dao->save([
'msg_id' => $msgid,
'userid' => $item['userid'],
'chat_id' => $item['chat_id'] ?? null,
'status' => $item['status'],
'send_time' => $item['send_time'] ?? 0,
'external_userid' => $item['external_userid'] ?? null,
]);
} else {
$info->status = $item['status'];
$info->send_time = $item['send_time'] ?? 0;
$info->save();
}
}
if ($response['next_cursor']) {
WorkGroupMsgJob::dispatchDo('getSendResult', [
$type,
$userid,
$msgid,
$response['next_cursor']
]);
}
return true;
} catch (\\Throwable $e) {
Log::error(json_encode([
'message' => '获取企业群发成员执行结果失败:' . $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return false;
}
}
type 决定唯一条件:
客户群发:msg_id + userid + external_userid
客户群群发:msg_id + userid + chat_id
这就是“客户送达”和“群聊送达”不能混在一起统计的原因。
14. 企业微信结果接口
API 封装在 Work.php:
public static function getGroupmsgTask(string $msgId, ?int $limit = null, ?string $cursor = null)
{
$response = self::instance()->message()->getGroupmsgTask($msgId, $limit, $cursor);
self::logger('获取群发成员发送任务列表', compact('msgId', 'limit', 'cursor'), $response);
return new WechatResponse($response);
}
public static function getGroupmsgSendResult(string $msgId, string $userid, ?int $limit = null, ?string $cursor = null)
{
$response = self::instance()->message()->getGroupmsgSendResult($msgId, $userid, $limit, $cursor);
self::logger('获取企业群发成员执行结果', compact('msgId', 'userid', 'limit', 'cursor'), $response);
return new WechatResponse($response);
}
底层客户端:
public function getGroupmsgTask(string $msgId, ?int $limit = null, ?string $cursor = null): ResponseInterface|Response
{
$data = [
'msgid' => $msgId,
'limit' => $limit,
'cursor' => $cursor,
];
$writableData = array_filter($data, function (string $key) use ($data) {
return !is_null($data[$key]);
}, ARRAY_FILTER_USE_KEY);
return $this->api->postJson('cgi-bin/externalcontact/get_groupmsg_task', $writableData);
}
public function getGroupmsgSendResult(string $msgId, string $userid, ?int $limit = null, ?string $cursor = null): ResponseInterface|Response
{
$data = [
'msgid' => $msgId,
'userid' => $userid,
'limit' => $limit,
'cursor' => $cursor,
];
$writableData = array_filter($data, function (string $key) use ($data) {
return !is_null($data[$key]);
}, ARRAY_FILTER_USE_KEY);
return $this->api->postJson('cgi-bin/externalcontact/get_groupmsg_send_result', $writableData);
}
用到的企业微信接口:
cgi-bin/externalcontact/get_groupmsg_task
cgi-bin/externalcontact/get_groupmsg_send_result
15. 列表统计:为什么打开列表会触发结果刷新
列表查询:
public function getGroupTemplate(array $where)
{
[$page, $limit] = $this->getPageValue();
$list = $this->dao->getDataList($where, ['*'], $page, $limit, 'create_time', ['msgIds']);
/** @var WorkGroupMsgTaskServices $taskService */
$taskService = app()->make(WorkGroupMsgTaskServices::class);
foreach ($list as &$item) {
$item['user_count'] = $item['unuser_count'] = $item['external_user_count'] = $item['external_unuser_count'] = 0;
if (!empty($item['msgIds'])) {
$msgIds = [];
foreach ($item['msgIds'] as $value) {
$msgIds[] = $value['msg_id'];
if ($value['msg_id']) {
WorkGroupMsgJob::dispatchDo('getTaks', [(int)$item['type'], $value['msg_id'], null]);
}
}
$item = array_merge($item, $taskService->getSendMsgStatistics($msgIds, (int)$item['type']));
}
}
$count = $this->dao->count($where);
return compact('list', 'count');
}
这意味着:打开群发列表时,如果模板已有 msgid,系统会再次派发 getTaks 刷新任务结果。
这是一个“懒刷新”设计,好处是数据会逐步更新;缺点是列表访问频繁时可能重复派发任务。二开时可以考虑增加:
最近刷新时间
刷新中状态
手动刷新按钮
后台定时补偿任务
但不要直接删掉这段刷新逻辑,否则列表统计可能长期不更新。
16. 统计口径:成员和客户分开算
统计方法:
public function getSendMsgStatistics(array $msgIds, int $type = 0)
{
$data = [];
$data['user_count'] = $data['unuser_count'] = $data['external_user_count'] = $data['external_unuser_count'] = 0;
$data['user_count'] = $this->dao->count(['msg_id' => $msgIds, 'status' => 2]);
$data['unuser_count'] = $this->dao->count(['msg_id' => $msgIds, 'status' => 0]);
/** @var WorkGroupMsgSendResultServices $service */
$service = app()->make(WorkGroupMsgSendResultServices::class);
if ($type) {
$data['external_user_count'] = $service->count([
'msg_id' => $msgIds,
'notChatId' => true,
'status' => 1
]);
$data['external_unuser_count'] = $service->count([
'msg_id' => $msgIds,
'notChatId' => true,
'status' => [0, 2, 3]
]);
} else {
$data['external_user_count'] = $service->count([
'msg_id' => $msgIds,
'status' => 1
]);
$data['external_unuser_count'] = $service->count([
'msg_id' => $msgIds,
'status' => [0, 2, 3]
]);
}
return $data;
}
这里字段含义:
user_count:已发送成员/群主数
unuser_count:未发送成员/群主数
external_user_count:已送达客户或群聊数
external_unuser_count:未送达客户或群聊数
二开报表时要分清楚:
成员确认发送,不等于客户收到
客户送达成功,不等于客户已读
客户群群发统计对象是 chat_id,不是 external_userid
17. 提醒发送:不是重新群发
列表页有“提醒发送”:
export function workGroupTemplateSendMsg(data) {
return request({
url: `work/group_template/sendMessage`,
method: 'post',
data
});
}
服务层:
public function sendMessage(int $id, string $userid, string $sendTime)
{
if ($id) {
$template = $this->dao->get(['id' => $id], ['userids', 'type', 'name', 'create_time']);
if (!$template) {
throw new ValidateException('没有查到群发模板');
}
$template = $template->toArray();
if (isset($template[0]['userid'])) {
$template['userids'] = array_column($template['userids'], 'userid');
}
$userids = $template['userids'];
$task = $template['type'] ? '客户群群发任务' : '客户群发任务';
$text = "【任务提醒】有新的任务啦!\\n" .
"任务类型:{$task}\\n" .
"任务名称:{$template['name']}\\n" .
"创建时间:{$template['create_time']}\\n" .
"可前往【群发助手】中确认发送,记得及时完成哦\\n";
} else {
$userids = [$userid];
$text = "【任务提醒】有新的任务啦!\\n" .
"任务类型:客户群发任务\\n" .
"创建时间:{$sendTime}\\n" .
"可前往【群发助手】中确认发送,记得及时完成哦\\n";
}
$res = Event::until('work.message', [
'text', $text, ['toUser' => $userids], []
]);
if ($res === false) {
throw new ValidateException('发送消息失败');
}
}
提醒发送只是给员工发应用消息,提醒他们去企业微信确认任务,不是重新创建群发任务。二开时不要把“提醒发送”做成“再次群发”,否则可能造成重复触达。
18. 删除模板:关联数据也要删
删除逻辑:
public function deleteGroupTemplate(int $id)
{
/** @var WorkGroupMsgTaskServices $taskService */
$taskService = app()->make(WorkGroupMsgTaskServices::class);
/** @var WorkGroupMsgRelationServices $msgRelationService */
$msgRelationService = app()->make(WorkGroupMsgRelationServices::class);
/** @var WorkGroupMsgSendResultServices $sendResultService */
$sendResultService = app()->make(WorkGroupMsgSendResultServices::class);
$msgIds = $msgRelationService->getColumn(['template_id' => $id], 'msg_id');
$this->transaction(function () use ($id, $msgRelationService, $msgIds, $taskService, $sendResultService) {
$this->dao->delete($id);
if ($msgIds) {
$taskService->delete(['msg_id' => $msgIds]);
$sendResultService->delete(['msg_id' => $msgIds]);
}
});
return true;
}
这里删除了:
群发模板
成员任务记录
客户/群聊发送结果
但注意:企业微信侧已经创建的任务不一定被撤回。删除本地模板更像“删除后台记录”,不是取消企业微信已经下发的任务。二开时如果要做“取消定时任务”,要针对 send_type = 0 且未创建 msgid 的任务单独处理。
19. 一条完整群发链路
客户群发:
后台创建群发
选择成员、客户筛选条件、素材
保存 work_group_template
按成员拆分 batch 队列
查询成员下 external_userid
调用 add_msg_template
保存 template_id 与 msgid 关系
60 秒后拉 get_groupmsg_task
记录成员任务状态
继续拉 get_groupmsg_send_result
记录客户 external_userid 送达状态
列表统计已发送成员和已送达客户
客户群群发:
后台创建客户群群发
选择群主、素材
保存 work_group_template
遍历群主创建 group 类型群发任务
调用 add_msg_template
保存多个 msgid
拉成员任务
拉群聊 chat_id 送达结果
列表统计已发送群主和送达群聊
这就是为什么群发模块一定要有 msgid 关系表、成员任务表和发送结果表。
20. 二开建议:怎么做得更稳
可以优先做这些增强:
1. 修复 next_cursor 递归传参,避免分页任务拉不完整
2. 为群发结果增加刷新时间,避免列表重复派发刷新任务
3. 增加群发失败原因字段,单独展示 fail_list
4. 对定时发送改成 send_time <= time() 的补偿式扫描
5. 给群发模板增加复制功能,便于运营复用素材
6. 发送前预估客户数和群聊数,避免大范围误触达
7. 增加“仅创建任务,不提醒员工”的高级开关
8. 增加操作日志,记录创建人、发送范围和素材摘要
不建议这样做:
不要创建模板后直接认为客户已收到
不要只存一个 msgid
不要把客户群发和群群发混用一套统计口径
不要在 Controller 里直接查客户或调用企业微信接口
不要把企业微信返回的失败列表吞掉
不要让提醒发送变成重复群发
注意事项
- send_type = 1 只能说明任务创建完成,不代表客户或群聊送达。
- 一个群发模板可能对应多个 msgid,不要只设计单字段。
- 客户群发统计用 external_userid,客户群群发统计用 chat_id。
- 素材发送前仍要经过 WorkMediaServices::resolvingWelcome() 转换。
- 定时发送按秒匹配,生产环境建议考虑补偿式查询。
- 列表页会触发任务结果刷新,二开时要避免重复派发过多队列。
- 结果分页要特别关注 next_cursor 传参是否正确。
标签建议
CRMEB Pro、企业微信、客户群发、客户群群发、私域运营、二次开发、PHP、ThinkPHP、源码解析




