calls[] = ['welcome', $text, $attachments]; if ($this->welcomeError) { $e = $this->welcomeError; $this->welcomeError = null; throw $e; } } public function markTags(string $userId, string $externalUserId, array $tagIds): void { $this->calls[] = ['tags', $userId, $externalUserId, $tagIds]; if ($this->tagsFail) { $this->tagsFail = false; throw new QywxPromotionContactApiException('mock tags failure', 45009); } } public function remark(string $userId, string $externalUserId, array $fields): void { $this->calls[] = ['remark', $fields]; } public function getExternalContact(string $externalUserId, string $cursor = ''): array { return ['external_contact' => ['name' => '客户昵称']]; } public function getUser(string $userId): array { return ['name' => '企微员工']; } } final class AutomationMediaFake extends QywxPromotionMediaService { public bool $expired = false; public function __construct() {} public function materialize(array $attachments, array $config): array { if ($this->expired) { throw new RuntimeException('mock media expired'); } return $attachments; } } final class AutomationMemoryStore extends QywxPromotionAutomationStore { public array $rows = []; public array $logs = []; public array $syncs = []; public array $config; public bool $schemaInstalled = true; public bool $schemaCheckFail = false; public bool $enqueueFail = false; public int $now; public function installed(): bool { if ($this->schemaCheckFail) { throw new RuntimeException('mock DB unavailable during schema check'); } return $this->schemaInstalled; } public function attribution(string $state, string $linkId, string $userId): ?array { if (($state === 'zyt_pool:1' || ($state === '' && $linkId === 'real_link')) && $userId === 'staff') { return ['pool_id' => 1, 'member_admin_id' => 7, 'config' => $this->config]; } return null; } public function enqueue(array $row): int { if ($this->enqueueFail) { throw new RuntimeException('mock DB failure'); } foreach ($this->rows as $id => $existing) { if ($existing['event_key'] === $row['event_key']) { return $id; } } $row['id'] = count($this->rows) + 1; $this->rows[$row['id']] = $row; return $row['id']; } public function due(string $lane, int $now, int $limit): array { return array_keys(array_filter($this->rows, static function (array $r) use ($lane, $now): bool { $pending = in_array($r['welcome_status'], ['pending', 'retry', 'running'], true); if ($r['status'] === 'done' || $r['lock_until'] > $now) { return false; } if ($lane === 'welcome') { return $pending && $r['welcome_next_retry'] <= $now; } if ($lane === 'inline_metadata') { return $r['next_retry'] <= $now; } return (!$pending || $r['welcome_expires_at'] <= $now) && $r['next_retry'] <= $now; })); } public function claim(int $id, string $lane, int $now): ?array { if (!in_array($id, $this->due($lane, $now, 100), true)) { return null; } $this->rows[$id]['lock_token'] = 'lease'; $this->rows[$id]['lock_until'] = $now + 30; return $this->rows[$id]; } public function save(array $row, ?array $log = null): void { automationCheck($row['lock_token'] === $this->rows[$row['id']]['lock_token'], 'lease token'); $this->rows[$row['id']] = $row; if ($log) { $this->logs[] = $log; } } public function localNames(array $task): array { return ['customer' => '', 'employee' => '本地员工']; } public function dispatch(array $task): void { $this->syncs[] = 'dispatch'; } public function syncRange(array $task): void { $this->syncs[] = 'range'; } public function syncCustomer(array $task): void { $this->syncs[] = 'sync'; } } $now = strtotime('2026-08-31 10:00:00 Asia/Shanghai'); $baseConfig = array_replace(QywxPromotionConfig::defaults(), [ 'welcome_mode' => 'channel', 'welcome' => ['text' => '您好 {customer_name},我是{employee_name}', 'attachments' => []], 'tags_enabled' => true, 'tag_ids' => ['tag1'], 'remark_enabled' => true, 'remark_template' => '{customer_name}-{employee_name}', 'description_enabled' => true, 'description' => '推广客户', ]); $fixture = static function () use (&$now, $baseConfig): array { $store = new AutomationMemoryStore(); $store->config = $baseConfig; $store->now = $now; $api = new AutomationApiFake(); $media = new AutomationMediaFake(); $cipher = new QywxPromotionCodeCipher(str_repeat('test-secret-key-', 4)); $service = new QywxPromotionAutomationService($api, $media, $store, $cipher, static function () use (&$now): int { return $now; }); return [$service, $store, $api, $media, $cipher]; }; $event = ['ToUserName' => 'corp', 'ChangeType' => 'add_external_contact', 'State' => 'zyt_pool:1', 'UserID' => 'staff', 'ExternalUserID' => 'customer', 'CreateTime' => $now, 'WelcomeCode' => 'NEVER_LOG_THIS_CODE']; [$service, $store, $api, $media, $cipher] = $fixture(); automationCheck($service->enqueueVerifiedEvent($event), 'valid promotion event handled'); automationCheck($api->calls === [] && $store->syncs === [], 'enqueue contains no API or slow sync'); automationCheck(!str_contains(json_encode($store->rows), 'NEVER_LOG_THIS_CODE'), 'code encrypted at rest'); automationCheck($cipher->decrypt($store->rows[1]['welcome_cipher']) === $event['WelcomeCode'], 'AES round trip'); $service->enqueueVerifiedEvent($event); automationCheck(count($store->rows) === 1, 'duplicate event idempotency'); $service->retryPending(); automationCheck($api->calls === [], 'minute lane cannot lock active welcome'); $service->processWelcomes(); automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '客户昵称') && str_contains($api->calls[0][1], '企微员工'), 'welcome first and template names'); automationCheck($store->syncs === [] && $store->rows[1]['welcome_status'] === 'sent' && $store->rows[1]['welcome_cipher'] === '', 'clear code on success and defer slow sync'); $api->tagsFail = true; $service->retryPending(); $actions = json_decode($store->rows[1]['actions_json'], true); automationCheck($actions['tags']['status'] === 'retry' && $actions['remark']['status'] === 'success' && $actions['description']['status'] === 'success', 'failed tags do not block enabled remarks'); automationCheck($store->syncs === ['dispatch', 'range', 'sync'], 'original sync preserved despite tag failure'); $now += 60; $service->retryPending(); $service->processWelcomes(); automationCheck($store->rows[1]['status'] === 'done', 'failed action retried to completion'); automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'welcome')) === 1, 'never resend success'); automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'remark')) === 2, 'do not repeat successful remark/description'); automationCheck(!str_contains(json_encode($store->logs), 'NEVER_LOG_THIS_CODE'), 'audit contains no code'); [$service, $store, $api] = $fixture(); $api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096); automationCheck($service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]), true), 'callback immediate lane handled'); $actions = json_decode($store->rows[1]['actions_json'], true); automationCheck(array_column($api->calls, 0) === ['welcome', 'tags'], 'callback immediately attempts welcome then tags'); automationCheck( $actions['welcome']['status'] === 'retry' && $actions['tags']['status'] === 'success', 'tag send is independent from retryable welcome: ' . json_encode([$actions['welcome'], $actions['tags']]) ); automationCheck($store->syncs === [], 'callback immediate lane leaves slow actions to background job'); foreach (['default', 'none'] as $mode) { [$service, $store, $api] = $fixture(); $store->config['welcome_mode'] = $mode; $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); automationCheck($store->rows[1]['welcome_status'] === 'skipped' && $api->calls === [] && $store->rows[1]['welcome_cipher'] === '', 'default/none do not send or retain code'); } [$service, $store, $api] = $fixture(); automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => 'zyt_pool:999'])), 'untrusted state cannot authorize'); automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['UserID' => 'outsider'])), 'nonmember cannot authorize'); automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => ''])), 'missing state and link stays legacy'); $store->schemaInstalled = false; automationCheck(!$service->enqueueVerifiedEvent($event), 'missing migration stays legacy'); $store->schemaInstalled = true; $store->enqueueFail = true; $failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; } automationCheck($failed, 'enqueue failure propagates for callback retry'); [$service, $store] = $fixture(); $store->schemaCheckFail = true; $failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; } automationCheck($failed, 'schema lookup failure propagates for callback retry instead of acknowledging lost event'); [$service, $store, $api] = $fixture(); $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'WelcomeCode' => ''])); automationCheck($store->rows[1]['welcome_status'] === 'skipped', 'missing code explicit skip'); [$service, $store, $api] = $fixture(); $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now - 21])); automationCheck($store->rows[1]['welcome_status'] === 'expired', 'expired code not queued for send'); [$service, $store, $api] = $fixture(); $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'ChangeType' => 'add_half_external_contact'])); $service->processWelcomes(); $service->retryPending(); automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '您好 您'), 'half-contact welcome uses safe nickname fallback'); automationCheck($store->syncs === [] && $store->rows[1]['status'] === 'done', 'half-contact does not create customer or modify relation'); [$service, $store, $api] = $fixture(); $api->welcomeError = new QywxPromotionContactApiException('network unknown', 0, true); $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); $service->processWelcomes(); automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && count($api->calls) === 1 && $store->rows[1]['welcome_cipher'] === '', 'uncertain network never retried'); [$service, $store, $api] = $fixture(); $api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096); $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); automationCheck($store->rows[1]['welcome_status'] === 'retry', 'explicit 41096 can retry within window'); $now++; $service->processWelcomes(); automationCheck($store->rows[1]['welcome_status'] === 'sent' && count($api->calls) === 2, 'safe explicit retry succeeded'); [$service, $store, $api] = $fixture(); $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $actions = json_decode($store->rows[1]['actions_json'], true); $actions['welcome']['status'] = 'running'; $store->rows[1]['actions_json'] = json_encode($actions); $store->rows[1]['welcome_status'] = 'running'; $service->processWelcomes(); automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && $api->calls === [], 'crashed running send is never replayed'); [$service, $store, $api, $media] = $fixture(); $media->expired = true; $service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); automationCheck($store->rows[1]['welcome_status'] === 'retry' && $api->calls === [], 'unprepared media never sends partial payload'); $now += 21; $service->retryPending(); automationCheck($store->rows[1]['welcome_status'] === 'expired' && $store->rows[1]['welcome_cipher'] === '', 'minute job expires and clears code'); $config = $baseConfig; $config['welcome_schedule_enabled'] = true; $config['welcome_schedule'] = [['weekdays' => [1], 'start' => '10:00', 'end' => '11:00', 'text' => '上午', 'attachments' => []]]; automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 10:00 Asia/Shanghai'))['text'] === '上午', 'schedule uses event time'); automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 11:00 Asia/Shanghai'))['text'] === $baseConfig['welcome']['text'], 'schedule fallback explicit base welcome'); echo "QYWX_PROMOTION_AUTOMATION_OK\n";