> /var/log/zyt-todo.log 2>&1 * * 行为: * 1. 取出 status=0 且 remind_time<=now 的待办,按 remind_time asc,限 200 条 * 2. 每条用乐观锁 (update set status=1 where id=? and status=0) 抢占,避免并发重复推送 * 3. 找创建人 admin.work_wechat_userid,未绑定 → status=3 + error * 4. 调 WechatWorkAppMessageService::sendTextToUser,失败 → status=3 + error(终态,不重试) * 5. 整批不中断;输出 处理 N / 成功 X / 失败 Y / 跳过未绑定 Z */ class DiagnosisTodoNotify extends Command { /** 单次扫描最大条数 */ private const BATCH_LIMIT = 200; protected function configure() { $this->setName('tcm:diagnosis-todo-notify') ->setDescription('诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息'); } protected function execute(Input $input, Output $output): int { $startTs = microtime(true); $now = time(); $output->writeln('[' . date('Y-m-d H:i:s', $now) . '] 开始扫描诊单待办事项...'); $totalProcessed = 0; $totalSent = 0; $totalFailed = 0; $totalSkipped = 0; // 被并发抢占 $totalUnbound = 0; // 创建人未绑定企微(计入 failed) try { $todoTable = (new DiagnosisTodo())->getTable(); $rows = Db::name(self::stripTablePrefix($todoTable)) ->where('status', DiagnosisTodo::STATUS_PENDING) ->where('remind_time', '<=', $now) ->whereNull('delete_time') ->order('remind_time', 'asc') ->limit(self::BATCH_LIMIT) ->select() ->toArray(); $output->writeln('待处理数量:' . count($rows)); foreach ($rows as $row) { $totalProcessed++; $todoId = (int) ($row['id'] ?? 0); if ($todoId <= 0) { continue; } try { // 乐观锁:抢占成功时影响 1 行;并发场景下另一个进程已抢走则影响 0 行 → 跳过 $affected = Db::name(self::stripTablePrefix($todoTable)) ->where('id', $todoId) ->where('status', DiagnosisTodo::STATUS_PENDING) ->update([ 'status' => DiagnosisTodo::STATUS_SENT, 'notified_at' => $now, 'update_time' => $now, ]); if ($affected <= 0) { $totalSkipped++; continue; } // 取创建人 work_wechat_userid $creatorId = (int) ($row['creator_id'] ?? 0); $wxId = ''; if ($creatorId > 0) { $wxId = (string) Admin::where('id', $creatorId)->value('work_wechat_userid'); } if ($wxId === '') { $this->markFailed($todoTable, $todoId, '创建人未绑定企业微信 userid'); $totalUnbound++; $totalFailed++; continue; } // 拼推送文本 $text = $this->buildText($row); $res = WechatWorkAppMessageService::sendTextToUser($wxId, $text); if (!($res['ok'] ?? false)) { $errMsg = (string) ($res['message'] ?? '企业微信推送失败'); $this->markFailed($todoTable, $todoId, $errMsg); $totalFailed++; continue; } $totalSent++; } catch (\Throwable $e) { Log::error('诊单待办推送异常 todo_id=' . $todoId . ' msg=' . $e->getMessage()); try { $this->markFailed($todoTable, $todoId, '系统异常: ' . $e->getMessage()); } catch (\Throwable $ee) { Log::error('回写失败状态出错 todo_id=' . $todoId . ' msg=' . $ee->getMessage()); } $totalFailed++; } } } catch (\Throwable $e) { Log::error('诊单待办批处理异常: ' . $e->getMessage()); $output->error('批处理异常: ' . $e->getMessage()); return 1; } $duration = round(microtime(true) - $startTs, 3); $output->writeln(sprintf( '处理完成。总数: %d, 成功: %d, 失败: %d (其中未绑定企微: %d), 并发跳过: %d, 耗时: %ss', $totalProcessed, $totalSent, $totalFailed, $totalUnbound, $totalSkipped, $duration )); return 0; } /** * 拼接推送文本 * * @param array $row 待办记录原始行 */ private function buildText(array $row): string { $patientName = ''; $diagnosisId = (int) ($row['diagnosis_id'] ?? 0); if ($diagnosisId > 0) { $patientName = (string) Diagnosis::where('id', $diagnosisId)->value('patient_name'); } $remindAt = (int) ($row['remind_time'] ?? 0); $content = trim((string) ($row['content'] ?? '')); $lines = [ '【患者跟踪提醒】', '患者:' . ($patientName !== '' ? $patientName : '-'), '时间:' . ($remindAt > 0 ? date('Y-m-d H:i', $remindAt) : '-'), '内容:' . ($content !== '' ? $content : '-'), '— 二中心跟踪系统', ]; return implode("\n", $lines); } /** * 标记一条待办为「发送失败」,写入 error 字段 */ private function markFailed(string $todoTable, int $todoId, string $errMsg): void { $errMsg = mb_substr($errMsg, 0, 500); Db::name(self::stripTablePrefix($todoTable)) ->where('id', $todoId) ->update([ 'status' => DiagnosisTodo::STATUS_FAILED, 'error' => $errMsg, 'update_time' => time(), ]); } /** * Db::name() 接收的是不带前缀的表名,BloodRecord 等 Model::getTable() 返回的是带前缀的全名。 * 这里去掉首段 `_`。注意:项目实际前缀是 `zyt_`,但 think-orm 的 Db::name() * 内部会用 config('database.prefix') 自动拼回,所以这里只需剥离一次即可。 */ private static function stripTablePrefix(string $tableWithPrefix): string { $prefix = (string) config('database.connections.mysql.prefix', ''); if ($prefix !== '' && str_starts_with($tableWithPrefix, $prefix)) { return substr($tableWithPrefix, strlen($prefix)); } return $tableWithPrefix; } }