api = $api ?? new QywxPromotionContactApiService(); $this->media = $media ?? new QywxPromotionMediaService($this->api); $this->store = $store ?? new QywxPromotionAutomationStore(); $this->cipher = $cipher ?? new QywxPromotionCodeCipher(); $this->clock = $clock ?? static fn (): int => time(); } /** * 仅供验签解密后的回调调用。false代表沿用旧同步流程;已接管的入队错误必须返回HTTP500。 * 默认仅入队;回调入口可启用即时通道,在当前请求内先发欢迎语并给正式客户打标。 */ public function enqueueVerifiedEvent(array $event, bool $processImmediately = false): bool { $change = (string) ($event['ChangeType'] ?? ''); if (!in_array($change, ['add_external_contact', 'add_half_external_contact'], true)) { return false; } $state = trim((string) ($event['State'] ?? '')); $linkId = trim((string) ($event['LinkId'] ?? $event['LinkID'] ?? '')); $userid = trim((string) ($event['UserID'] ?? $event['UserId'] ?? '')); $external = trim((string) ($event['ExternalUserID'] ?? $event['ExternalUserId'] ?? '')); if (($state === '' && $linkId === '') || $userid === '' || $external === '') { return false; } try { if (!$this->store->installed()) { return false; } $attribution = $this->store->attribution($state, $linkId, $userid); if ($attribution === null) { return false; } $now = $this->now(); $eventTime = max(0, (int) ($event['CreateTime'] ?? 0)); $code = (string) ($event['WelcomeCode'] ?? ''); $config = $attribution['config']; $half = $change === 'add_half_external_contact'; $welcomeStatus = 'pending'; $reason = ''; if (($config['welcome_mode'] ?? 'default') !== 'channel') { $welcomeStatus = 'skipped'; $reason = 'mode_' . ($config['welcome_mode'] ?? 'default'); } elseif ($code === '') { $welcomeStatus = 'skipped'; $reason = 'missing_welcome_code'; } elseif (strlen($code) > 1024) { $welcomeStatus = 'failed'; $reason = 'invalid_welcome_code'; } elseif ($eventTime <= 0 || $eventTime > $now + 5 || $eventTime + 20 <= $now) { $welcomeStatus = 'expired'; $reason = 'welcome_window_elapsed_or_invalid_event_time'; } $actions = [ 'welcome' => self::action($welcomeStatus, $reason), 'tags' => self::action(!$half && !empty($config['tags_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'), 'remark' => self::action(!$half && !empty($config['remark_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'), 'description' => self::action(!$half && !empty($config['description_enabled']) ? 'pending' : 'skipped', $half ? 'half_contact' : 'disabled'), 'dispatch' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''), 'range' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''), 'sync' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''), ]; $corp = (string) ($event['ToUserName'] ?? config('pay.wechat_work.corp_id', '')); $taskId = $this->store->enqueue([ 'event_key' => hash('sha256', implode('|', [$corp, $change, $userid, $external, (string) $eventTime])), 'pool_id' => $attribution['pool_id'], 'member_admin_id' => $attribution['member_admin_id'], 'change_type' => $change, 'userid' => $userid, 'external_userid' => $external, 'event_time' => $eventTime, 'received_at' => $now, 'config_json' => self::json($config), 'actions_json' => self::json($actions), 'welcome_cipher' => $welcomeStatus === 'pending' ? $this->cipher->encrypt($code) : '', 'welcome_code_hash' => $code !== '' ? hash('sha256', $code) : '', 'welcome_expires_at' => $eventTime > 0 ? min($eventTime + 20, $now + 20) : 0, 'welcome_status' => $welcomeStatus, 'welcome_next_retry' => 0, 'status' => self::allTerminal($actions) ? 'done' : 'pending', 'next_retry' => 0, 'lock_token' => '', 'lock_until' => 0, 'create_time' => $now, 'update_time' => $now, ]); if ($processImmediately) { // 部署环境暂未启动常驻 worker 时仍要抢住 20 秒欢迎码窗口。 // 标签只在正式客户事件执行;其余慢动作仍由分钟补偿处理。 $this->consumeIds('welcome', [$taskId]); if (!$half) { $this->consumeIds('inline_metadata', [$taskId], ['tags']); } } return true; } catch (\Throwable) { // 不附原异常,入库SQL可能包含密文和配置;回调层返回500触发企微重试。 throw new QywxPromotionEnqueueException('推广自动化事件未能持久化,请检查数据库迁移和私有存储'); } } /** 常驻秒级worker仅处理欢迎语,不被范围/客户同步或大文件上传阻塞。 */ public function processWelcomes(int $limit = 100): array { return $this->consume('welcome', $limit); } /** 分钟补偿:过期欢迎语只记过期,绝不尝试补发。 */ public function retryPending(int $limit = 100): array { return $this->consume('metadata', $limit); } public static function selectWelcome(array $config, int $eventTime): array { if (!empty($config['welcome_schedule_enabled'])) { foreach ((array) ($config['welcome_schedule'] ?? []) as $slot) { if (QywxPromotionConfig::matches($slot, $eventTime)) { return ['text' => (string) ($slot['text'] ?? ''), 'attachments' => (array) ($slot['attachments'] ?? [])]; } } } return ['text' => (string) ($config['welcome']['text'] ?? ''), 'attachments' => (array) ($config['welcome']['attachments'] ?? [])]; } private function consume(string $lane, int $limit): array { return $this->consumeIds($lane, $this->store->due($lane, $this->now(), $limit)); } /** @param list $ids @param list|null $metadataActions */ private function consumeIds(string $lane, array $ids, ?array $metadataActions = null): array { $result = ['selected' => 0, 'processed' => 0, 'failed' => 0]; foreach ($ids as $id) { ++$result['selected']; try { $row = $this->store->claim($id, $lane, $this->now()); if ($row === null) { continue; } $actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR); $config = json_decode($row['config_json'], true, 512, JSON_THROW_ON_ERROR); // 即时标签与欢迎语共用同一任务,但不能把仍在 20 秒窗口内待重试的欢迎语判为过期。 if ($lane !== 'inline_metadata' && !self::terminal($actions['welcome']['status'])) { if ($lane === 'welcome') { $this->welcome($row, $actions, $config); } else { $running = $actions['welcome']['status'] === 'running'; $this->transition($row, $actions, 'welcome', $running ? 'uncertain' : 'expired', $running ? 'worker_interrupted_after_send_started' : 'welcome_worker_not_available_in_window'); } } if ($lane !== 'welcome') { $this->metadata($row, $actions, $config, $metadataActions); } $row['lock_until'] = 0; $row['update_time'] = $this->now(); $this->store->save($row); ++$result['processed']; } catch (\Throwable) { // 失去DB/租约时保留running状态;欢迎语恢复时视为不确定,防止重复推送。 ++$result['failed']; } } return $result; } private function welcome(array &$row, array &$actions, array $config): void { if ($actions['welcome']['status'] === 'running') { $this->transition($row, $actions, 'welcome', 'uncertain', 'worker_interrupted_after_send_started'); return; } if ((int) $row['welcome_expires_at'] <= $this->now() + 1) { $this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed'); return; } $sendStarted = false; try { $message = self::selectWelcome($config, (int) $row['event_time']); $text = $message['text']; if (str_contains($text, '{customer_name}') || str_contains($text, '{employee_name}') || str_contains($text, '{add_time}')) { $names = $this->names($row, $text, true); $text = QywxPromotionConfig::render($text, $names['customer'], $names['employee'], (int) $row['event_time'], 1200); } $truncated = strlen($text) > 4000; $text = mb_strcut($text, 0, 4000, 'UTF-8'); $attachments = $this->media->materialize($message['attachments'], $config); $code = $this->cipher->decrypt($row['welcome_cipher']); if ((int) $row['welcome_expires_at'] <= $this->now() + 1) { $this->transition($row, $actions, 'welcome', 'expired', 'welcome_window_elapsed_during_prepare'); return; } // running先持久化:如果HTTP成功后进程/DB断开,恢复时绝不再次使用同一code。 $this->transition($row, $actions, 'welcome', 'running', 'send_started'); $sendStarted = true; try { $this->api->sendWelcome($code, $text, $attachments); $this->transition($row, $actions, 'welcome', 'sent', $truncated ? 'sent_text_truncated_4000_bytes' : 'sent'); } catch (QywxPromotionContactApiException $e) { if ($e->uncertain) { $this->transition($row, $actions, 'welcome', 'uncertain', 'network_result_unknown_do_not_resend', $e->getCode()); } elseif ($e->getCode() === 41051) { $this->transition($row, $actions, 'welcome', 'skipped', 'welcome_code_already_consumed', 41051); } else { $this->welcomeRetry($row, $actions, 'explicit_api_rejection', $e->getCode()); } } catch (\Throwable) { $this->transition($row, $actions, 'welcome', 'uncertain', 'send_or_persist_result_unknown_do_not_resend'); } finally { unset($code); } } catch (\Throwable $e) { // 准备阶段没有执行发送,可以安全重试,且不会把错误原文/欢迎码写日志。 if ($sendStarted || $actions['welcome']['status'] === 'running') { throw $e; } $this->welcomeRetry($row, $actions, 'prepare_failed_check_media_credentials_or_key', (int) $e->getCode()); } } private function welcomeRetry(array &$row, array &$actions, string $reason, int $code): void { $expired = (int) $row['welcome_expires_at'] <= $this->now() + 2; $this->transition($row, $actions, 'welcome', $expired ? 'expired' : 'retry', $reason, $code, $this->now() + 1); } /** @param list|null $only */ private function metadata(array &$row, array &$actions, array $config, ?array $only = null): void { $names = null; $namesToProcess = ['tags', 'remark', 'description', 'dispatch', 'range', 'sync']; if ($only !== null) { $namesToProcess = array_values(array_intersect($namesToProcess, $only)); } foreach ($namesToProcess as $name) { if (self::terminal($actions[$name]['status']) || (int) ($actions[$name]['next_retry'] ?? 0) > $this->now()) { continue; } $this->transition($row, $actions, $name, 'running', 'started'); try { switch ($name) { case 'tags': $this->api->markTags($row['userid'], $row['external_userid'], (array) $config['tag_ids']); break; case 'remark': $names = $names ?? $this->names($row, (string) $config['remark_template'], false); $remark = QywxPromotionConfig::render($config['remark_template'], $names['customer'], $names['employee'], (int) $row['event_time'], 20); $this->api->remark($row['userid'], $row['external_userid'], ['remark' => $remark]); break; case 'description': $this->api->remark($row['userid'], $row['external_userid'], ['description' => (string) $config['description']]); break; case 'dispatch': $this->store->dispatch($row); break; case 'range': $this->store->syncRange($row); break; case 'sync': $this->store->syncCustomer($row); break; } $this->transition($row, $actions, $name, 'success', 'completed'); } catch (\Throwable $e) { $attempt = (int) $actions[$name]['attempts']; $failed = $attempt >= 10; $this->transition($row, $actions, $name, $failed ? 'failed' : 'retry', $failed ? 'retry_limit_reached' : 'action_failed', (int) $e->getCode(), $this->now() + min(3600, 15 * (2 ** min(8, $attempt)))); } } } private function names(array $row, string $template, bool $welcome): array { $names = ['customer' => '', 'employee' => '']; try { $names = $this->store->localNames($row); } catch (\Throwable) { // 本地资料失败不妨碍欢迎语使用明确的文案兜底。 } $budget = fn (): bool => !$welcome || (int) $row['welcome_expires_at'] > $this->now() + 7; if (str_contains($template, '{customer_name}') && $names['customer'] === '' && $row['change_type'] !== 'add_half_external_contact' && $budget()) { try { $detail = $this->api->getExternalContact($row['external_userid']); $names['customer'] = (string) ($detail['external_contact']['name'] ?? ''); } catch (\Throwable) { } } if (str_contains($template, '{employee_name}') && $budget()) { try { $user = $this->api->getUser($row['userid']); $names['employee'] = trim((string) ($user['name'] ?? '')) ?: $names['employee']; } catch (\Throwable) { // 通讯录姓名接口权限不足时回退后台成员称呼。 } } $names['customer'] = $names['customer'] !== '' ? $names['customer'] : '您'; $names['employee'] = $names['employee'] !== '' ? $names['employee'] : '客户顾问'; return $names; } private function transition(array &$row, array &$actions, string $name, string $status, string $reason, int $code = 0, int $retryAt = 0): void { $now = $this->now(); $action = $actions[$name]; if ($status === 'running' || ($name === 'welcome' && $status === 'retry' && $action['status'] !== 'running')) { ++$action['attempts']; } $action = array_replace($action, ['status' => $status, 'reason' => $reason, 'error_code' => $code, 'next_retry' => $retryAt, 'update_time' => $now]); if (self::terminal($status)) { $action['finished_at'] = $now; } $actions[$name] = $action; if ($name === 'welcome') { $row['welcome_status'] = $status; $row['welcome_next_retry'] = $retryAt; if (self::terminal($status)) { $row['welcome_cipher'] = ''; } } $row['status'] = self::allTerminal($actions) ? 'done' : 'pending'; $retry = []; foreach ($actions as $key => $value) { if ($key !== 'welcome' && !self::terminal($value['status'])) { $retry[] = (int) ($value['next_retry'] ?? 0); } } $row['next_retry'] = $retry === [] ? 0 : min($retry); $row['actions_json'] = self::json($actions); $row['update_time'] = $now; $this->store->save($row, ['action' => $name, 'status' => $status, 'attempt' => $action['attempts'], 'reason' => $reason, 'error_code' => $code, 'create_time' => $now]); } private static function action(string $status, string $reason = ''): array { return ['status' => $status, 'reason' => $status === 'pending' ? '' : $reason, 'attempts' => 0, 'error_code' => 0, 'next_retry' => 0]; } private static function allTerminal(array $actions): bool { foreach ($actions as $action) { if (!self::terminal($action['status'])) { return false; } } return true; } private static function terminal(string $status): bool { return in_array($status, self::TERMINAL, true); } private static function json(array $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } private function now(): int { return (int) ($this->clock)(); } }