205 lines
14 KiB
PHP
205 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use app\common\service\qywx\QywxPromotionAutomationService;
|
|
use app\common\service\qywx\QywxPromotionAutomationStore;
|
|
use app\common\service\qywx\QywxPromotionCodeCipher;
|
|
use app\common\service\qywx\QywxPromotionConfig;
|
|
use app\common\service\qywx\QywxPromotionContactApiException;
|
|
use app\common\service\qywx\QywxPromotionContactApiService;
|
|
use app\common\service\qywx\QywxPromotionEnqueueException;
|
|
use app\common\service\qywx\QywxPromotionMediaService;
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
new think\App();
|
|
function automationCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
|
final class AutomationApiFake extends QywxPromotionContactApiService
|
|
{
|
|
public array $calls = [];
|
|
public ?QywxPromotionContactApiException $welcomeError = null;
|
|
public bool $tagsFail = false;
|
|
public function __construct() {}
|
|
public function sendWelcome(string $code, string $text, array $attachments): void {
|
|
$this->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";
|