diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index c0ceccd..e544eba 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -1082,6 +1082,7 @@ class EmailClient extends Base * - min_interval, max_interval (seconds between emails) * - max_bounce_rate (%), no_repeat_days * - send_start_hour, send_end_hour (UTC, default 8-22) + * - send_date (Y-m-d,计划发送日期;有则「今日准备明日发」由定时任务处理,无则创建后需手动 startTask) */ public function createTask() { @@ -1097,16 +1098,17 @@ class EmailClient extends Base $minInterval = intval($this->request->param('min_interval', 30)); $maxInterval = intval($this->request->param('max_interval', 60)); $maxBounceRate = intval($this->request->param('max_bounce_rate', 5)); - $noRepeatDays = intval($this->request->param('no_repeat_days', 30)); + $noRepeatDays = intval($this->request->param('no_repeat_days', 7)); $sendStartHour = intval($this->request->param('send_start_hour', 8)); $sendEndHour = intval($this->request->param('send_end_hour', 22)); + $sendDate = trim($this->request->param('send_date', date("Y-m-d",strtotime('+1 day')))); if (!$journalId || !$templateId) { return jsonError('journal_id and template_id are required'); } - if (empty($expertIds) && empty($field)) { - return jsonError('expert_ids or field is required'); - } +// if (empty($expertIds) && empty($field)) { +// return jsonError('expert_ids or field is required'); +// } $tpl = Db::name('mail_template') ->where('template_id', $templateId) @@ -1123,36 +1125,88 @@ class EmailClient extends Base $experts = []; if (!empty($expertIds)) { + // 显式点名的专家,只按 state 过滤,ltime 由外部自行控制 $ids = array_map('intval', explode(',', $expertIds)); - $experts = Db::name('expert')->where('expert_id', 'in', $ids)->where('state', 0)->select(); + $experts = Db::name('expert') + ->where('expert_id', 'in', $ids) + ->where('state', 0) + ->select(); } else { + // 一般情况:不传 expert_ids,根据期刊实际领域自动选择专家 + $journal = Db::name('journal')->where('journal_id', $journalId)->find(); + if (!$journal || empty($journal['issn'])) { + return jsonError('Journal or ISSN not found'); + } + + // 期刊绑定的领域(major_title),与 dailyFetchAll 保持一致 + $majors = Db::name('major_to_journal') + ->alias('mtj') + ->join('t_major m', 'm.major_id = mtj.major_id', 'left') + ->where('mtj.journal_issn', $journal['issn']) + ->where('mtj.mtj_state', 0) + ->where('m.major_state', 0) + ->column('m.major_title'); + + $majors = array_unique(array_filter($majors)); + $query = Db::name('expert')->alias('e') ->join('t_expert_field ef', 'e.expert_id = ef.expert_id') ->where('e.state', 0) ->where('ef.state', 0); - if ($field) { - $query->where('ef.field', 'like', '%' . $field . '%'); - } - if ($majorId) { - $query->where('ef.major_id', $majorId); - } - - $experts = $query->field('e.*')->group('e.expert_id')->select(); - } - - if ($noRepeatDays > 0) { - $cutoff = time() - ($noRepeatDays * 86400); - $experts = array_filter($experts, function ($e) use ($cutoff) { - return intval($e['ltime']) < $cutoff; + // 领域条件: + // 1) 若前端显式传了 field/major_id,则优先按传入条件过滤 + // 2) 否则,按期刊绑定领域(major_title)在 ef.field 中模糊匹配 + $query->where(function ($q) use ($field, $majorId, $majors) { + if ($field !== '') { + $q->where('ef.field', 'like', '%' . $field . '%'); + } elseif ($majorId > 0) { + $q->where('ef.major_id', $majorId); + } elseif (!empty($majors)) { + $q->where(function ($qq) use ($majors) { + foreach ($majors as $idx => $title) { + $title = trim($title); + if ($title === '') { + continue; + } + if ($idx === 0) { + $qq->where('ef.field', 'like', '%' . $title . '%'); + } else { + $qq->whereOr('ef.field', 'like', '%' . $title . '%'); + } + } + }); + } }); - $experts = array_values($experts); + + // 不频繁发送:在 SQL 中直接使用 ltime + no_repeat_days 过滤 + if ($noRepeatDays > 0) { + $cutoff = time() - ($noRepeatDays * 86400); + $query->where(function ($q) use ($cutoff) { + $q->where('e.ltime', 0)->whereOr('e.ltime', '<', $cutoff); + }); + } + + // 去重同一个专家 + $experts = $query + ->field('e.*') + ->group('e.expert_id') + ->limit(100) + ->select(); } if (empty($experts)) { return jsonError('No eligible experts found (all may have been promoted recently)'); } + $sendDateVal = null; + if ($sendDate !== '') { + $ts = strtotime($sendDate); + if ($ts !== false) { + $sendDateVal = date('Y-m-d', $ts); + } + } + $now = time(); $taskId = Db::name('promotion_task')->insertGetId([ 'journal_id' => $journalId, @@ -1172,6 +1226,7 @@ class EmailClient extends Base 'no_repeat_days' => $noRepeatDays, 'send_start_hour' => $sendStartHour, 'send_end_hour' => $sendEndHour, + 'send_date' => $sendDateVal, 'ctime' => $now, 'utime' => $now, ]); @@ -1192,14 +1247,85 @@ class EmailClient extends Base } Db::name('promotion_email_log')->insertAll($logs); + $msg = 'Task created, call startTask to begin sending'; + if ($sendDateVal) { + $msg = 'Task created for send_date=' . $sendDateVal . ', will be prepared by cron and triggered on that day'; + } return jsonSuccess([ 'task_id' => $taskId, 'total_count' => count($experts), 'state' => 0, - 'msg' => 'Task created, call startTask to begin sending', + 'send_date' => $sendDateVal, + 'msg' => $msg, ]); } + /** + * 为单个任务预生成邮件(可手动或测试用) + * Params: task_id + */ + public function prepareTask() + { + $taskId = intval($this->request->param('task_id', 0)); + if (!$taskId) { + return jsonError('task_id is required'); + } + + $service = new PromotionService(); + $result = $service->prepareTask($taskId); + + if ($result['error']) { + return jsonError($result['error']); + } + return jsonSuccess($result); + } + + /** + * 定时任务:为指定日期的任务预生成邮件(默认明天) + * 建议每天 22:00 执行:curl .../EmailClient/prepareTasksForDate 或 prepareTasksForDate?date=2026-03-12 + */ + public function prepareTasksForDate() + { + $date = trim($this->request->param('date', '')); + if ($date === '') { + $date = date('Y-m-d', strtotime('+1 day')); + } else { + $ts = strtotime($date); + if ($ts === false) { + return jsonError('date invalid, use Y-m-d'); + } + $date = date('Y-m-d', $ts); + } + + $service = new PromotionService(); + $result = $service->prepareTasksForDate($date); + + return jsonSuccess($result); + } + + /** + * 定时任务:触发指定日期的已准备任务开始发送(默认今天) + * 建议每天 8:00 执行:curl .../EmailClient/triggerTasksForDate 或 triggerTasksForDate?date=2026-03-12 + */ + public function triggerTasksForDate() + { + $date = trim($this->request->param('date', '')); + if ($date === '') { + $date = date('Y-m-d'); + } else { + $ts = strtotime($date); + if ($ts === false) { + return jsonError('date invalid, use Y-m-d'); + } + $date = date('Y-m-d', $ts); + } + + $service = new PromotionService(); + $result = $service->startTasksForDate($date); + + return jsonSuccess($result); + } + /** * Start or resume a promotion task */ @@ -1223,6 +1349,10 @@ class EmailClient extends Base if ($task['state'] == 1) { return jsonError('Task is already running'); } + // state=0 草稿 或 state=5 已准备 均可启动 + if ($task['state'] != 0 && $task['state'] != 5) { + return jsonError('Task can only be started when state is draft(0) or prepared(5), current: ' . $task['state']); + } Db::name('promotion_task')->where('task_id', $taskId)->update([ 'state' => 1, diff --git a/application/api/controller/ExpertManage.php b/application/api/controller/ExpertManage.php new file mode 100644 index 0000000..8c9983f --- /dev/null +++ b/application/api/controller/ExpertManage.php @@ -0,0 +1,493 @@ + '待联系', + 1 => '已发邮件', + 2 => '已回复', + 3 => '已投稿', + 4 => '退信/无效', + 5 => '黑名单(退订)', + ]; + + public function __construct(\think\Request $request = null) + { + parent::__construct($request); + } + + /** + * 专家列表(支持多条件筛选 + 分页) + * + * 参数: + * keyword - 搜索姓名/邮箱/单位 + * field - 按领域关键词筛选 + * major_id - 按学科ID筛选 + * state - 状态筛选 (0-5, 传-1或不传则不过滤) + * source - 来源筛选 + * pageIndex - 页码 (默认1) + * pageSize - 每页条数 (默认20) + */ + public function getList() + { + $data = $this->request->param(); + $keyword = trim(isset($data['keyword']) ? $data['keyword'] : ''); + $field = trim(isset($data['field']) ? $data['field'] : ''); + $majorId = intval(isset($data['major_id']) ? $data['major_id'] : 0); + $state = isset($data['state']) ? $data['state'] : '-1'; + $source = trim(isset($data['source']) ? $data['source'] : ''); + $page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1)); + $pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20)); + + $query = Db::name('expert')->alias('e'); + $needJoin = ($field !== '' || $majorId > 0); + + if ($needJoin) { + $query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner'); + if ($field !== '') { + $query->where('ef.field', 'like', '%' . $field . '%'); + } + if ($majorId > 0) { + $query->where('ef.major_id', $majorId); + } + $query->group('e.expert_id'); + } + + if ($state !== '-1' && $state !== '') { + $query->where('e.state', intval($state)); + } + if ($keyword !== '') { + $query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%'); + } + if ($source !== '') { + $query->where('e.source', $source); + } + + $countQuery = clone $query; + $total = $countQuery->distinct('e.expert_id')->count(); + + $list = $query + ->field('e.*') + ->order('e.ctime desc') + ->page($page, $pageSize) + ->select(); + + foreach ($list as &$item) { + $item['fields'] = Db::name('expert_field') + ->where('expert_id', $item['expert_id']) + ->where('state', 0) + ->select(); + $item['state_text'] = isset($this->stateMap[$item['state']]) ? $this->stateMap[$item['state']] : '未知'; + $item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : ''; + $item['ltime_text'] = $item['ltime'] ? date('Y-m-d H:i:s', $item['ltime']) : ''; + } + + return jsonSuccess([ + 'list' => $list, + 'total' => $total, + 'pageIndex' => $page, + 'pageSize' => $pageSize, + 'totalPages' => $total > 0 ? ceil($total / $pageSize) : 0, + ]); + } + + /** + * 获取专家详情(含所有领域) + */ + public function getDetail() + { + $expertId = intval($this->request->param('expert_id', 0)); + if (!$expertId) { + return jsonError('expert_id is required'); + } + + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return jsonError('专家不存在'); + } + + $expert['fields'] = Db::name('expert_field') + ->where('expert_id', $expertId) + ->where('state', 0) + ->select(); + $expert['state_text'] = isset($this->stateMap[$expert['state']]) ? $this->stateMap[$expert['state']] : '未知'; + $expert['ctime_text'] = $expert['ctime'] ? date('Y-m-d H:i:s', $expert['ctime']) : ''; + $expert['ltime_text'] = $expert['ltime'] ? date('Y-m-d H:i:s', $expert['ltime']) : ''; + + return jsonSuccess($expert); + } + + /** + * 添加专家 + */ + public function addExpert() + { + $data = $this->request->post(); + $name = trim(isset($data['name']) ? $data['name'] : ''); + $email = trim(isset($data['email']) ? $data['email'] : ''); + + if ($name === '' || $email === '') { + return jsonError('name和email不能为空'); + } + + $exists = Db::name('expert')->where('email', $email)->find(); + if ($exists) { + return jsonError('该邮箱已存在,expert_id=' . $exists['expert_id']); + } + + $insert = [ + 'name' => $name, + 'email' => $email, + 'affiliation' => trim(isset($data['affiliation']) ? $data['affiliation'] : ''), + 'source' => trim(isset($data['source']) ? $data['source'] : 'manual'), + 'ctime' => time(), + 'ltime' => 0, + 'state' => 0, + ]; + + $expertId = Db::name('expert')->insertGetId($insert); + + if (!empty($data['fields'])) { + $this->saveExpertFields($expertId, $data['fields']); + } + + return jsonSuccess(['expert_id' => $expertId]); + } + + /** + * 编辑专家 + */ + public function editExpert() + { + $data = $this->request->post(); + $expertId = intval(isset($data['expert_id']) ? $data['expert_id'] : 0); + if (!$expertId) { + return jsonError('expert_id is required'); + } + + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return jsonError('专家不存在'); + } + + $update = []; + if (isset($data['name'])) $update['name'] = trim($data['name']); + if (isset($data['email'])) $update['email'] = trim($data['email']); + if (isset($data['affiliation'])) $update['affiliation'] = trim($data['affiliation']); + if (isset($data['source'])) $update['source'] = trim($data['source']); + if (isset($data['state'])) $update['state'] = intval($data['state']); + + if (!empty($update)) { + Db::name('expert')->where('expert_id', $expertId)->update($update); + } + + if (isset($data['fields'])) { + Db::name('expert_field')->where('expert_id', $expertId)->where('state', 0)->update(['state' => 1]); + if (!empty($data['fields'])) { + $this->saveExpertFields($expertId, $data['fields']); + } + } + + return jsonSuccess(['expert_id' => $expertId]); + } + + /** + * 批量修改状态 + * + * 参数: + * expert_ids - 逗号分隔的ID列表 "1,2,3" + * state - 目标状态 0-5 + */ + public function updateState() + { + $data = $this->request->post(); + $expertIds = isset($data['expert_ids']) ? $data['expert_ids'] : ''; + $state = intval(isset($data['state']) ? $data['state'] : -1); + + if (empty($expertIds)) { + return jsonError('expert_ids is required'); + } + if ($state < 0 || $state > 5) { + return jsonError('state取值范围0-5'); + } + + $ids = array_map('intval', explode(',', $expertIds)); + $count = Db::name('expert')->where('expert_id', 'in', $ids)->update(['state' => $state]); + + return jsonSuccess(['updated' => $count]); + } + + /** + * 删除专家(软删除,设为黑名单状态5) + * + * 参数: + * expert_ids - 逗号分隔的ID列表 + * hard - 传1则物理删除 + */ + public function deleteExpert() + { + $data = $this->request->post(); + $expertIds = isset($data['expert_ids']) ? $data['expert_ids'] : ''; + $hard = intval(isset($data['hard']) ? $data['hard'] : 0); + + if (empty($expertIds)) { + return jsonError('expert_ids is required'); + } + + $ids = array_map('intval', explode(',', $expertIds)); + + if ($hard) { + Db::name('expert_field')->where('expert_id', 'in', $ids)->delete(); + $count = Db::name('expert')->where('expert_id', 'in', $ids)->delete(); + } else { + $count = Db::name('expert')->where('expert_id', 'in', $ids)->update(['state' => 5]); + } + + return jsonSuccess(['affected' => $count]); + } + + /** + * 给专家添加领域 + */ + public function addField() + { + $data = $this->request->post(); + $expertId = intval(isset($data['expert_id']) ? $data['expert_id'] : 0); + $majorId = intval(isset($data['major_id']) ? $data['major_id'] : 0); + $field = trim(isset($data['field']) ? $data['field'] : ''); + + if (!$expertId || $field === '') { + return jsonError('expert_id和field不能为空'); + } + + $exists = Db::name('expert_field') + ->where('expert_id', $expertId) + ->where('field', $field) + ->where('state', 0) + ->find(); + if ($exists) { + return jsonError('该领域已存在'); + } + + $id = Db::name('expert_field')->insertGetId([ + 'expert_id' => $expertId, + 'major_id' => $majorId, + 'field' => $field, + 'state' => 0, + ]); + + return jsonSuccess(['expert_field_id' => $id]); + } + + /** + * 删除领域(软删除) + */ + public function removeField() + { + $efId = intval($this->request->param('expert_field_id', 0)); + if (!$efId) { + return jsonError('expert_field_id is required'); + } + + Db::name('expert_field')->where('expert_field_id', $efId)->update(['state' => 1]); + + return jsonSuccess([]); + } + + /** + * 获取所有不重复的领域列表(用于筛选下拉框) + */ + public function getFieldOptions() + { + $list = Db::name('expert_field') + ->where('state', 0) + ->group('field') + ->column('field'); + + return jsonSuccess($list); + } + + /** + * 获取所有来源列表(用于筛选下拉框) + */ + public function getSourceOptions() + { + $list = Db::name('expert') + ->where('source', '<>', '') + ->group('source') + ->column('source'); + + return jsonSuccess($list); + } + + /** + * 导出某个领域的专家为Excel + * + * 参数: + * field - 领域关键词(必填) + * major_id - 学科ID(可选) + * state - 状态筛选(可选,默认不过滤) + * keyword - 搜索姓名/邮箱/单位 + * source - 来源筛选 + */ + public function exportExcel() + { + $data = $this->request->param(); + $field = trim(isset($data['field']) ? $data['field'] : ''); + $majorId = intval(isset($data['major_id']) ? $data['major_id'] : 0); + $state = isset($data['state']) ? $data['state'] : '-1'; + $keyword = trim(isset($data['keyword']) ? $data['keyword'] : ''); + $source = trim(isset($data['source']) ? $data['source'] : ''); + + $query = Db::name('expert')->alias('e'); + + if ($field !== '' || $majorId > 0) { + $query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner'); + if ($field !== '') { + $query->where('ef.field', 'like', '%' . $field . '%'); + } + if ($majorId > 0) { + $query->where('ef.major_id', $majorId); + } + $query->group('e.expert_id'); + } + + if ($state !== '-1' && $state !== '') { + $query->where('e.state', intval($state)); + } + if ($keyword !== '') { + $query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%'); + } + if ($source !== '') { + $query->where('e.source', $source); + } + + $list = $query->field('e.*')->order('e.ctime desc')->select(); + + if (empty($list)) { + return jsonError('没有符合条件的数据可导出'); + } + + $expertIds = array_column($list, 'expert_id'); + $allFields = Db::name('expert_field') + ->where('expert_id', 'in', $expertIds) + ->where('state', 0) + ->select(); + + $fieldMap = []; + foreach ($allFields as $f) { + $fieldMap[$f['expert_id']][] = $f['field']; + } + + vendor("PHPExcel.PHPExcel"); + + $objPHPExcel = new \PHPExcel(); + $sheet = $objPHPExcel->getActiveSheet(); + $sheet->setTitle('Expert List'); + + $headers = [ + 'A' => '#', + 'B' => 'Name', + 'C' => 'Email', + 'D' => 'Affiliation', + 'E' => 'Source', + 'F' => 'Fields', + 'G' => 'State', + 'H' => 'Add Time', + 'I' => 'Last Promotion', + ]; + foreach ($headers as $col => $header) { + $sheet->setCellValue($col . '1', $header); + } + + $headerStyle = [ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['type' => \PHPExcel_Style_Fill::FILL_SOLID, 'startcolor' => ['rgb' => '4472C4']], + 'alignment' => ['horizontal' => \PHPExcel_Style_Alignment::HORIZONTAL_CENTER], + ]; + $sheet->getStyle('A1:I1')->applyFromArray($headerStyle); + + foreach ($list as $i => $item) { + $row = $i + 2; + $fields = isset($fieldMap[$item['expert_id']]) ? implode(', ', $fieldMap[$item['expert_id']]) : ''; + $stateText = isset($this->stateMap[$item['state']]) ? $this->stateMap[$item['state']] : '未知'; + + $sheet->setCellValue('A' . $row, $i + 1); + $sheet->setCellValue('B' . $row, $item['name']); + $sheet->setCellValueExplicit('C' . $row, $item['email'], \PHPExcel_Cell_DataType::TYPE_STRING); + $sheet->setCellValue('D' . $row, $item['affiliation']); + $sheet->setCellValue('E' . $row, $item['source']); + $sheet->setCellValue('F' . $row, $fields); + $sheet->setCellValue('G' . $row, $stateText); + $sheet->setCellValue('H' . $row, $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : ''); + $sheet->setCellValue('I' . $row, $item['ltime'] ? date('Y-m-d H:i:s', $item['ltime']) : ''); + } + + $sheet->getColumnDimension('A')->setWidth(6); + $sheet->getColumnDimension('B')->setWidth(25); + $sheet->getColumnDimension('C')->setWidth(35); + $sheet->getColumnDimension('D')->setWidth(40); + $sheet->getColumnDimension('E')->setWidth(15); + $sheet->getColumnDimension('F')->setWidth(50); + $sheet->getColumnDimension('G')->setWidth(15); + $sheet->getColumnDimension('H')->setWidth(20); + $sheet->getColumnDimension('I')->setWidth(20); + + $label = $field !== '' ? preg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fa5}]/u', '_', $field) : 'all'; + $filename = 'expert_' . $label . '_' . date('Ymd_His') . '.xlsx'; + + $dir = ROOT_PATH . 'public' . DS . 'exports'; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + $filepath = $dir . DS . $filename; + $writer = \PHPExcel_IOFactory::createWriter($objPHPExcel, 'Excel2007'); + $writer->save($filepath); + + return jsonSuccess([ + 'file_url' => '/exports/' . $filename, + 'file_name' => $filename, + 'count' => count($list), + ]); + } + + /** + * 批量保存专家领域 + * @param int $expertId + * @param array $fields [{"major_id":1,"field":"xxx"}, ...] + */ + private function saveExpertFields($expertId, $fields) + { + if (is_string($fields)) { + $fields = json_decode($fields, true); + } + if (!is_array($fields)) { + return; + } + + foreach ($fields as $f) { + $majorId = intval(isset($f['major_id']) ? $f['major_id'] : 0); + $fieldName = trim(isset($f['field']) ? $f['field'] : ''); + if ($fieldName === '') continue; + + $exists = Db::name('expert_field') + ->where('expert_id', $expertId) + ->where('field', $fieldName) + ->where('state', 0) + ->find(); + if ($exists) continue; + + Db::name('expert_field')->insert([ + 'expert_id' => $expertId, + 'major_id' => $majorId, + 'field' => $fieldName, + 'state' => 0, + ]); + } + } +} diff --git a/application/api/controller/MailTemplate.php b/application/api/controller/MailTemplate.php index 1021eab..aefb06b 100644 --- a/application/api/controller/MailTemplate.php +++ b/application/api/controller/MailTemplate.php @@ -118,6 +118,14 @@ class MailTemplate extends Base return jsonSuccess(['list' => $list]); } + public function listTemplatesAll(){ + $list = Db::name('mail_template') + ->where('state', 0) + ->order('is_active desc, utime desc, template_id desc') + ->select(); + return jsonSuccess(['list'=>$list]); + } + /** * Create or update a global mail style * 当前 style 表字段: diff --git a/application/common/ExpertFinderService.php b/application/common/ExpertFinderService.php index 150f93e..f743db6 100644 --- a/application/common/ExpertFinderService.php +++ b/application/common/ExpertFinderService.php @@ -519,10 +519,11 @@ class ExpertFinderService ->where('state', 0) ->find(); if ($exists) return 0; - + $major = Db::name("major")->where("major_title",$field)->where("state",0)->find(); + $major_id = $major ? $major['major_id'] : 0; Db::name('expert_field')->insert([ 'expert_id' => $expertId, - 'major_id' => 0, + 'major_id' => $major_id, 'field' => mb_substr($field, 0, 128), 'state' => 0, ]); diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php index c9dfa1e..ca8db4f 100644 --- a/application/common/PromotionService.php +++ b/application/common/PromotionService.php @@ -80,33 +80,43 @@ class PromotionService return ['done' => false, 'reason' => 'no_smtp_quota', 'retry_in' => 600]; } - $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); - $expertVars = $this->buildExpertVars($expert); - $journalVars = $this->buildJournalVars($journal); - $vars = array_merge($journalVars, $expertVars); + // 优先使用预生成内容;无则现场渲染 + $subject = ''; + $body = ''; + $hasPrepared = !empty($logEntry['subject_prepared']) && !empty($logEntry['body_prepared']); - $rendered = $this->renderFromTemplate( - $task['template_id'], - $task['journal_id'], - json_encode($vars, JSON_UNESCAPED_UNICODE), - $task['style_id'] - ); + if ($hasPrepared) { + $subject = $logEntry['subject_prepared']; + $body = $logEntry['body_prepared']; + } else { + $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); + $expertVars = $this->buildExpertVars($expert); + $journalVars = $this->buildJournalVars($journal); + $vars = array_merge($journalVars, $expertVars); - if ($rendered['code'] !== 0) { - Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ - 'state' => 2, - 'error_msg' => 'Template render failed: ' . $rendered['msg'], - 'send_time' => time(), - ]); - Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count'); - Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]); - $this->enqueueNextEmail($taskId, 2); - return ['done' => false, 'failed' => $logEntry['email_to'], 'reason' => 'template_error']; + $rendered = $this->renderFromTemplate( + $task['template_id'], + $task['journal_id'], + json_encode($vars, JSON_UNESCAPED_UNICODE), + $task['style_id'] + ); + + if ($rendered['code'] !== 0) { + Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ + 'state' => 2, + 'error_msg' => 'Template render failed: ' . $rendered['msg'], + 'send_time' => time(), + ]); + Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count'); + Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]); + $this->enqueueNextEmail($taskId, 2); + return ['done' => false, 'failed' => $logEntry['email_to'], 'reason' => 'template_error']; + } + + $subject = $rendered['data']['subject']; + $body = $rendered['data']['body']; } - $subject = $rendered['data']['subject']; - $body = $rendered['data']['body']; - $result = $this->doSendEmail($account, $logEntry['email_to'], $subject, $body); $now = time(); @@ -144,6 +154,165 @@ class PromotionService ]; } + // ==================== 准备与触发(今日准备、明日发送) ==================== + + /** + * 为指定任务预生成所有待发邮件的 subject/body,写入 log;完成后将任务置为 state=5(已准备) + * @param int $taskId + * @return array ['prepared' => int, 'failed' => int, 'error' => string|null] + */ + public function prepareTask($taskId) + { + $task = Db::name('promotion_task')->where('task_id', $taskId)->find(); + if (!$task) { + return ['prepared' => 0, 'failed' => 0, 'error' => 'task_not_found']; + } + if ($task['state'] != 0) { + return ['prepared' => 0, 'failed' => 0, 'error' => 'task_state_not_draft']; + } + + $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); + $logs = Db::name('promotion_email_log') + ->where('task_id', $taskId) + ->where('state', 0) + ->where('prepared_at', 0) + ->order('log_id asc') + ->select(); + + $prepared = 0; + $failed = 0; + $now = time(); + + foreach ($logs as $log) { + $expert = Db::name('expert')->where('expert_id', $log['expert_id'])->find(); + if (!$expert) { + Db::name('promotion_email_log')->where('log_id', $log['log_id'])->update([ + 'state' => 2, + 'error_msg' => 'Expert not found', + 'send_time' => $now, + ]); + $failed++; + continue; + } + + $expertVars = $this->buildExpertVars($expert); + $journalVars = $this->buildJournalVars($journal); + $vars = array_merge($journalVars, $expertVars); + $rendered = $this->renderFromTemplate( + $task['template_id'], + $task['journal_id'], + json_encode($vars, JSON_UNESCAPED_UNICODE), + $task['style_id'] + ); + + if ($rendered['code'] !== 0) { + Db::name('promotion_email_log')->where('log_id', $log['log_id'])->update([ + 'state' => 2, + 'error_msg' => 'Prepare failed: ' . $rendered['msg'], + 'send_time' => $now, + ]); + $failed++; + continue; + } + + Db::name('promotion_email_log')->where('log_id', $log['log_id'])->update([ + 'subject_prepared' => mb_substr($rendered['data']['subject'], 0, 512), + 'body_prepared' => $rendered['data']['body'], + 'prepared_at' => $now, + ]); + $prepared++; + } + + Db::name('promotion_task')->where('task_id', $taskId)->update([ + 'state' => 5, + 'utime' => $now, + ]); + $this->log("prepareTask task_id={$taskId} prepared={$prepared} failed={$failed}"); + + return ['prepared' => $prepared, 'failed' => $failed, 'error' => null]; + } + + /** + * 为指定日期的任务批量预生成邮件(供定时任务调用,如每天 22:00 准备明天的) + * @param string $date Y-m-d,如 2026-03-12 + * @return array ['tasks' => int, 'prepared' => int, 'failed' => int, 'details' => []] + */ + public function prepareTasksForDate($date) + { + $tasks = Db::name('promotion_task') + ->where('send_date', $date) + ->where('state', 0) + ->select(); + + $totalPrepared = 0; + $totalFailed = 0; + $details = []; + + foreach ($tasks as $task) { + $ret = $this->prepareTask($task['task_id']); + $totalPrepared += $ret['prepared']; + $totalFailed += $ret['failed']; + $details[] = [ + 'task_id' => $task['task_id'], + 'task_name' => $task['task_name'], + 'prepared' => $ret['prepared'], + 'failed' => $ret['failed'], + 'error' => $ret['error'], + ]; + } + + $this->log("prepareTasksForDate date={$date} tasks=" . count($tasks) . " prepared={$totalPrepared} failed={$totalFailed}"); + return [ + 'tasks' => count($tasks), + 'prepared' => $totalPrepared, + 'failed' => $totalFailed, + 'details' => $details, + ]; + } + + /** + * 触发指定日期的已准备任务开始发送(供定时任务调用,如每天 8:00 触发今天的) + * 会先对 send_date=date 且 state=0 的任务做一次补准备,再启动所有 state=5 的任务 + * @param string $date Y-m-d + * @return array ['prepared' => int, 'started' => int, 'task_ids' => []] + */ + public function startTasksForDate($date) + { + // 补准备:当天日期但尚未准备的任务(如 22:00 后创建) + $catchUpTasks = Db::name('promotion_task') + ->where('send_date', $date) + ->where('state', 0) + ->select(); + foreach ($catchUpTasks as $t) { + $this->prepareTask($t['task_id']); + } + + $tasks = Db::name('promotion_task') + ->where('send_date', $date) + ->where('state', 5) + ->select(); + + $started = 0; + $taskIds = []; + + foreach ($tasks as $task) { + Db::name('promotion_task')->where('task_id', $task['task_id'])->update([ + 'state' => 1, + 'utime' => time(), + ]); + $this->enqueueNextEmail($task['task_id'], 0); + $started++; + $taskIds[] = $task['task_id']; + } + + $this->log("startTasksForDate date={$date} started={$started} task_ids=" . implode(',', $taskIds)); + return [ + 'prepared' => count($catchUpTasks), + 'started' => $started, + 'task_ids' => $taskIds, + ]; + } + // ==================== Queue ==================== public function enqueueNextEmail($taskId, $delay = 0)