384 lines
19 KiB
PHP
384 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\qywx;
|
|
|
|
use RuntimeException;
|
|
|
|
/** 推广客户自动化:短时欢迎语与可补偿关系动作分开消费。 */
|
|
class QywxPromotionAutomationService
|
|
{
|
|
private QywxPromotionContactApiService $api;
|
|
private QywxPromotionMediaService $media;
|
|
private QywxPromotionAutomationStore $store;
|
|
private QywxPromotionCodeCipher $cipher;
|
|
private $clock;
|
|
private const TERMINAL = ['sent', 'success', 'skipped', 'expired', 'uncertain', 'failed'];
|
|
|
|
public function __construct(
|
|
?QywxPromotionContactApiService $api = null,
|
|
?QywxPromotionMediaService $media = null,
|
|
?QywxPromotionAutomationStore $store = null,
|
|
?QywxPromotionCodeCipher $cipher = null,
|
|
?callable $clock = null
|
|
) {
|
|
$this->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<int> $ids @param list<string>|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<string>|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)();
|
|
}
|
|
}
|