modelClass = new EmailBroadcastModel(); $this->assign('statusList', $this->modelClass->getStatusList()); $this->assign('itemStatusList', (new EmailBroadcastItemModel())->getStatusList()); $this->assign('langList', LangModel::getEnabledList()); $this->assign('defaultLang', LangModel::getDefaultMark()); $this->assign('templateList', EmailTemplateModel::where(['type' => 'marketing_broadcast', 'status' => 1]) ->order('listorder', 'asc') ->order('id', 'asc') ->select()); if (in_array(strtolower($this->request->action()), ['add', 'edit', 'items'], true)) { $config = $this->view->config ?: []; $config['jsname'] = 'backend/emailbroadcast/' . strtolower($this->request->action()); $this->view->config = $config; $this->assign('config', $config); } } public function index() { if ($this->request->isAjax()) { $page = (int)$this->request->param('page/d', 1); $limit = (int)$this->request->param('limit/d', 20); $sort = (string)$this->request->param('sort/s', 'id'); $order = (string)$this->request->param('order/s', 'desc'); $name = trim((string)$this->request->param('name/s', '')); $lang = trim((string)$this->request->param('lang/s', '')); $status = $this->request->param('status/s', ''); $templateId = (int)$this->request->param('template_id/d', 0); $query = $this->modelClass; if ($name !== '') { $query = $query->whereLike('name|subject', '%' . $name . '%'); } if ($lang !== '') { $query = $query->where('lang', strtolower($lang)); } if ($status !== '') { $query = $query->where('status', (int)$status); } if ($templateId > 0) { $query = $query->where('template_id', $templateId); } $count = $query->count(); $data = $query->order($sort, $order)->page($page, $limit)->select(); foreach ($data as $row) { $row->status_text = $this->modelClass->getStatusList()[(int)$row->status] ?? $row->status; } return json(['code' => 0, 'count' => $count, 'data' => $data]); } return $this->fetch(); } public function add() { if ($this->request->isPost()) { $this->token(); $params = $this->buildBroadcastParams(); $recipientRows = $this->getSubscribersForBroadcast($params); if (empty($recipientRows)) { $this->error('未筛选到可发送的订阅用户'); } Db::startTrans(); try { $now = time(); $params['recipient_count'] = count($recipientRows); $params['success_count'] = 0; $params['fail_count'] = 0; $params['status'] = 0; $params['createtime'] = $now; $params['updatetime'] = $now; $this->modelClass->save($params); $items = []; foreach ($recipientRows as $recipient) { $items[] = [ 'broadcast_id' => $this->modelClass->id, 'subscribe_id' => (int)$recipient['id'], 'email' => (string)$recipient['email'], 'lang' => (string)$recipient['lang'], 'status' => 0, 'response_code' => '', 'response_msg' => '待发送', 'retry_count' => 0, 'send_time' => 0, 'createtime' => $now, 'updatetime' => $now, ]; } (new EmailBroadcastItemModel())->saveAll($items); Db::commit(); } catch (\Throwable $e) { Db::rollback(); $this->error('创建群发任务失败:' . $e->getMessage()); } $this->success('群发任务创建成功', url('Emailbroadcast/index')); } return $this->fetch(); } public function edit() { $id = (int)$this->request->param('id/d', 0); $row = $this->modelClass->find($id); if (!$row) { $this->error('群发任务不存在'); } if ($this->request->isPost()) { $this->token(); if ((int)$row->status !== 0) { $this->error('仅待发送任务支持编辑'); } $params = $this->buildBroadcastParams(); $recipientRows = $this->getSubscribersForBroadcast($params); if (empty($recipientRows)) { $this->error('未筛选到可发送的订阅用户'); } Db::startTrans(); try { $now = time(); $params['recipient_count'] = count($recipientRows); $params['success_count'] = 0; $params['fail_count'] = 0; $params['status'] = 0; $params['updatetime'] = $now; $row->save($params); Db::name('email_broadcast_item')->where('broadcast_id', $row->id)->delete(); $items = []; foreach ($recipientRows as $recipient) { $items[] = [ 'broadcast_id' => $row->id, 'subscribe_id' => (int)$recipient['id'], 'email' => (string)$recipient['email'], 'lang' => (string)$recipient['lang'], 'status' => 0, 'response_code' => '', 'response_msg' => '待发送', 'retry_count' => 0, 'send_time' => 0, 'createtime' => $now, 'updatetime' => $now, ]; } (new EmailBroadcastItemModel())->saveAll($items); Db::commit(); } catch (\Throwable $e) { Db::rollback(); $this->error('编辑群发任务失败:' . $e->getMessage()); } $this->success('群发任务更新成功', url('Emailbroadcast/index')); } $this->assign('data', $row); return $this->fetch(); } public function items() { $id = (int)$this->request->param('id/d', 0); $broadcast = $this->modelClass->find($id); if (!$broadcast) { $this->error('群发任务不存在'); } if ($this->request->isAjax()) { $page = (int)$this->request->param('page/d', 1); $limit = (int)$this->request->param('limit/d', 20); $sort = (string)$this->request->param('sort/s', 'id'); $order = (string)$this->request->param('order/s', 'desc'); $email = trim((string)$this->request->param('email/s', '')); $status = $this->request->param('status/s', ''); $query = new EmailBroadcastItemModel(); $query = $query->where('broadcast_id', $id); if ($email !== '') { $query = $query->where('email', 'like', '%' . $email . '%'); } if ($status !== '') { $query = $query->where('status', (int)$status); } $count = $query->count(); $data = $query->order($sort, $order)->page($page, $limit)->select(); return json(['code' => 0, 'count' => $count, 'data' => $data]); } $this->assign('broadcast', $broadcast); return $this->fetch(); } public function send() { if (!$this->request->isPost()) { $this->error('请求方式错误'); } $id = (int)$this->request->post('id/d', 0); $batchSize = (int)$this->request->post('batch_size/d', 0); $batchSize = $batchSize > 0 ? min($batchSize, 500) : 100; $row = $this->modelClass->find($id); if (!$row) { $this->error('群发任务不存在'); } if (!in_array((int)$row->status, [0, 3, 4], true)) { $this->error('当前状态不允许发送'); } $template = EmailTemplateModel::where('id', (int)$row->template_id)->find(); if (!$template || (int)$template->status !== 1) { $this->error('关联模板不存在或已禁用'); } $now = time(); $row->status = 1; if ((int)$row->start_time <= 0) { $row->start_time = $now; } $row->updatetime = $now; $row->save(); $items = EmailBroadcastItemModel::where('broadcast_id', $row->id) ->where('status', 'in', [0, 2]) ->order('id', 'asc') ->limit($batchSize) ->select(); if ($items->isEmpty()) { $this->error('没有可发送的明细记录'); } $success = 0; $fail = 0; $processed = 0; foreach ($items as $item) { $subscribe = EmailSubscribeModel::where('id', (int)$item->subscribe_id)->find(); $context = $this->buildMailContext([ 'email' => (string)$item->email, 'lang' => (string)$item->lang, 'broadcast_name' => (string)$row->name, 'broadcast_id' => (string)$row->id, 'template_name' => (string)$template->name, 'unsubscribe_url' => $subscribe && (string)$subscribe->unsubscribe_token !== '' ? url('index/emailsubscribe/unsubscribe', ['token' => (string)$subscribe->unsubscribe_token], true, true) : '', ]); $subject = $this->renderTemplateContent((string)$template->subject, $context); $content = $this->renderTemplateContent((string)$template->content, $context); [$sendResult, $responseCode, $responseMsg] = $this->sendEmail((string)$item->email, $subject, $content); $sendTime = $sendResult ? time() : 0; $item->status = $sendResult ? 1 : 2; $item->response_code = $responseCode; $item->response_msg = $responseMsg ?: ($sendResult ? '发送成功' : '发送失败'); $item->retry_count = (int)$item->retry_count + ($sendResult ? 0 : 1); $item->send_time = $sendTime; $item->updatetime = time(); $item->save(); if ($subscribe) { $subscribe->last_send_status = $sendResult ? 1 : 2; if ($sendResult) { $subscribe->last_send_time = $sendTime; } $subscribe->updatetime = time(); $subscribe->save(); } EmailSubscribeLogModel::create([ 'subscribe_id' => (int)$item->subscribe_id, 'email' => (string)$item->email, 'mail_type' => 'marketing_broadcast', 'subject' => $subject, 'content' => $content, 'status' => $sendResult ? 1 : 2, 'response_code' => $responseCode, 'response_msg' => $responseMsg ?: ($sendResult ? '营销群发发送成功' : '营销群发发送失败'), 'retry_count' => $sendResult ? 0 : 1, 'send_time' => $sendTime, 'createtime' => time(), 'updatetime' => time(), ]); $sendResult ? $success++ : $fail++; $processed++; } $row->success_count = (int)$row->success_count + $success; $row->fail_count = (int)$row->fail_count + $fail; $pendingCount = EmailBroadcastItemModel::where('broadcast_id', $row->id) ->where('status', 0) ->count(); $failedCount = EmailBroadcastItemModel::where('broadcast_id', $row->id) ->where('status', 2) ->count(); if ($pendingCount > 0) { $row->status = $row->fail_count > 0 ? 3 : 1; $row->finish_time = 0; $message = '本次发送 ' . $processed . ' 条,任务未完成,仍有 ' . $pendingCount . ' 条待发送'; } else { $row->status = $failedCount > 0 ? ($row->success_count > 0 ? 3 : 4) : 2; $row->finish_time = time(); $message = $failedCount > 0 ? '群发执行完成,存在失败记录' : '群发执行完成'; } $row->updatetime = time(); $row->save(); $this->success($message, null, [ 'processed' => $processed, 'success' => $success, 'fail' => $fail, 'pending' => $pendingCount, 'batch_size' => $batchSize, ]); } public function del() { if (!$this->request->isPost()) { $this->error('请求方式错误'); } $ids = $this->request->post('ids/a', []); if (!$ids) { $this->error('请先选择群发任务'); } Db::startTrans(); try { Db::name('email_broadcast_item')->where('broadcast_id', 'in', $ids)->delete(); $this->modelClass->where('id', 'in', $ids)->delete(); Db::commit(); } catch (\Throwable $e) { Db::rollback(); $this->error('删除失败:' . $e->getMessage()); } $this->success('删除成功'); } protected function buildBroadcastParams(): array { $row = $this->request->post('row/a', [], 'trim'); $lang = strtolower((string)($row['lang'] ?? LangModel::getDefaultMark())); $templateId = (int)($row['template_id'] ?? 0); $template = EmailTemplateModel::where('id', $templateId)->find(); if (!$template) { $this->error('请选择有效的邮件模板'); } if ((string)$template->type !== 'marketing_broadcast') { $this->error('仅支持选择营销群发邮件模板'); } if ((int)$template->status !== 1) { $this->error('所选模板已禁用'); } $statusFilter = array_values(array_unique(array_map('intval', (array)($row['status_filter'] ?? [1])))); $statusFilter = array_values(array_intersect($statusFilter, [0, 1, 9])); $allowUserUnsubscribed = !empty($row['allow_user_unsubscribed']); if (!$allowUserUnsubscribed) { $statusFilter = array_values(array_diff($statusFilter, [9])); } if (empty($statusFilter)) { $statusFilter = [1]; } $confirmFilter = array_values(array_unique(array_map('intval', (array)($row['confirm_status_filter'] ?? [0, 1])))); $confirmFilter = array_values(array_intersect($confirmFilter, [0, 1])); if (empty($confirmFilter)) { $confirmFilter = [0, 1]; } $lastSendFilter = array_values(array_unique(array_map('intval', (array)($row['last_send_status_filter'] ?? [0, 1, 2])))); $lastSendFilter = array_values(array_intersect($lastSendFilter, [0, 1, 2])); if (empty($lastSendFilter)) { $lastSendFilter = [0, 1, 2]; } return [ 'name' => (string)($row['name'] ?? ''), 'template_id' => $templateId, 'template_name' => (string)$template->name, 'lang' => $lang, 'subject' => (string)$template->subject, 'content' => (string)$template->content, 'remark' => (string)($row['remark'] ?? ''), 'source_key' => (string)($row['source_key'] ?? ''), 'status_filter' => implode(',', $statusFilter), 'confirm_status_filter' => implode(',', $confirmFilter), 'last_send_status_filter' => implode(',', $lastSendFilter), 'exclude_sent' => empty($row['exclude_sent']) ? 0 : 1, 'allow_user_unsubscribed' => $allowUserUnsubscribed ? 1 : 0, ]; } protected function getSubscribersForBroadcast(array $params): array { $query = EmailSubscribeModel::where('lang', $params['lang']) ->whereIn('status', explode(',', $params['status_filter'])) ->whereIn('confirm_status', explode(',', $params['confirm_status_filter'])) ->whereIn('last_send_status', explode(',', $params['last_send_status_filter'])); if ($params['source_key'] !== '') { $query = $query->where('source_key', $params['source_key']); } if (!empty($params['exclude_sent'])) { $query = $query->whereNotIn('id', function ($subQuery) { $subQuery->name('email_subscribe_log')->where('mail_type', 'marketing_broadcast')->field('subscribe_id'); }); } return $query->order('id', 'asc')->select()->toArray(); } protected function sendEmail(string $email, string $subject, string $content): array { try { $config = get_addon_config('phpmailer'); if (!$config) { return [false, 'NO_CONFIG', '未找到 phpmailer 插件配置']; } $mailer = new \addons\phpmailer\library\Mailer($config); $result = $mailer->to($email)->subject($subject)->message($content, true)->send(); if ($result) { return [true, 'SUCCESS', '邮件发送成功']; } return [false, 'FAIL', $mailer->getError() ?: '邮件发送失败']; } catch (\Throwable $e) { return [false, 'EXCEPTION', $e->getMessage()]; } } protected function buildMailContext(array $data): array { $context = []; foreach ($data as $key => $value) { $context[$key] = is_scalar($value) || $value === null ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE); } return $context; } protected function renderTemplateContent(string $content, array $context): string { $replace = []; foreach ($context as $key => $value) { $replace['{{' . $key . '}}'] = (string)$value; $replace['{{ ' . $key . ' }}'] = (string)$value; } return strtr($content, $replace); } }