This commit is contained in:
Your Name
2026-09-01 11:26:47 +08:00
parent 5bd5eae62d
commit 398f9f3726
357 changed files with 733 additions and 343 deletions
@@ -445,6 +445,8 @@ class WecomPromotionLogic
),
'member_userids' => $userIds,
'range_userids' => $eligibleUserIds,
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null,
'sync_error' => $syncError,
];
}
@@ -130,10 +130,11 @@ class QywxExternalContactCallbackController extends BaseApiController
$failReason = (string) ($message['FailReason'] ?? '');
$eventTime = (int) ($message['CreateTime'] ?? 0);
// 只接管已保存新配置且可核验方案/成员的推广事件;这里无任何远端请求
// 只接管已保存新配置且可核验方案/成员的推广事件。
// 回调内立即尝试欢迎语和正式客户标签,常驻/分钟任务继续承担重试及其余慢动作。
// 同时处理带欢迎码的半客户,避免原来的半客户早返回吞掉20秒欢迎语窗口。
$event = $message instanceof Message ? $message->toArray() : (array) $message;
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event);
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event, true);
$auditEvent = $event;
unset($auditEvent['WelcomeCode']);
@@ -151,7 +152,7 @@ class QywxExternalContactCallbackController extends BaseApiController
]);
if ($queued) {
// 常驻worker先发欢迎语,分钟补偿完成标签/备注、成员记账、范围与客户资料同步。
// 即时通道已尝试欢迎语/标签;分钟补偿完成备注、成员记账、范围与客户资料同步。
return;
}
@@ -32,9 +32,9 @@ class QywxPromotionAutomationService
/**
* 仅供验签解密后的回调调用。false代表沿用旧同步流程;已接管的入队错误必须返回HTTP500。
* 此处无网络请求,保证回调不等待客户详情、范围更新或素材上传
* 默认仅入队;回调入口可启用即时通道,在当前请求内先发欢迎语并给正式客户打标
*/
public function enqueueVerifiedEvent(array $event): bool
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)) {
@@ -85,7 +85,7 @@ class QywxPromotionAutomationService
'sync' => self::action($half ? 'skipped' : 'pending', $half ? 'half_contact' : ''),
];
$corp = (string) ($event['ToUserName'] ?? config('pay.wechat_work.corp_id', ''));
$this->store->enqueue([
$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,
@@ -98,6 +98,14 @@ class QywxPromotionAutomationService
'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触发企微重试。
@@ -130,9 +138,15 @@ class QywxPromotionAutomationService
}
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 ($this->store->due($lane, $this->now(), $limit) as $id) {
foreach ($ids as $id) {
++$result['selected'];
try {
$row = $this->store->claim($id, $lane, $this->now());
@@ -141,7 +155,8 @@ class QywxPromotionAutomationService
}
$actions = json_decode($row['actions_json'], true, 512, JSON_THROW_ON_ERROR);
$config = json_decode($row['config_json'], true, 512, JSON_THROW_ON_ERROR);
if (!self::terminal($actions['welcome']['status'])) {
// 即时标签与欢迎语共用同一任务,但不能把仍在 20 秒窗口内待重试的欢迎语判为过期。
if ($lane !== 'inline_metadata' && !self::terminal($actions['welcome']['status'])) {
if ($lane === 'welcome') {
$this->welcome($row, $actions, $config);
} else {
@@ -151,7 +166,7 @@ class QywxPromotionAutomationService
}
}
if ($lane !== 'welcome') {
$this->metadata($row, $actions, $config);
$this->metadata($row, $actions, $config, $metadataActions);
}
$row['lock_until'] = 0;
$row['update_time'] = $this->now();
@@ -225,10 +240,15 @@ class QywxPromotionAutomationService
$this->transition($row, $actions, 'welcome', $expired ? 'expired' : 'retry', $reason, $code, $this->now() + 1);
}
private function metadata(array &$row, array &$actions, array $config): void
/** @param list<string>|null $only */
private function metadata(array &$row, array &$actions, array $config, ?array $only = null): void
{
$names = null;
foreach (['tags', 'remark', 'description', 'dispatch', 'range', 'sync'] as $name) {
$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;
}
@@ -109,11 +109,12 @@ class QywxPromotionAutomationStore
}
$pendingWelcome = in_array($row['welcome_status'], ['pending', 'retry', 'running'], true);
if (($lane === 'welcome' && (!$pendingWelcome || (int) $row['welcome_next_retry'] > $now))
|| ($lane !== 'welcome' && (($pendingWelcome && (int) $row['welcome_expires_at'] > $now) || (int) $row['next_retry'] > $now))) {
|| ($lane === 'metadata' && (($pendingWelcome && (int) $row['welcome_expires_at'] > $now) || (int) $row['next_retry'] > $now))
|| ($lane === 'inline_metadata' && (int) $row['next_retry'] > $now)) {
return null;
}
$row['lock_token'] = bin2hex(random_bytes(16));
$row['lock_until'] = $now + ($lane === 'welcome' ? 30 : 600);
$row['lock_until'] = $now + (in_array($lane, ['welcome', 'inline_metadata'], true) ? 30 : 600);
Db::name('qywx_promotion_automation_task')->where('id', $id)->update([
'lock_token' => $row['lock_token'], 'lock_until' => $row['lock_until'], 'update_time' => $now,
]);
@@ -54,7 +54,7 @@ class QywxPromotionCodeCipher
return $this->key();
}
$directory = root_path('runtime') . 'qywx_promotion_private';
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('无法创建欢迎码私有密钥目录');
}
$path = $directory . DIRECTORY_SEPARATOR . 'welcome.key';
@@ -28,7 +28,17 @@ class QywxPromotionConfig
public static function installed(): bool
{
try {
return Db::name('qywx_promotion_config')->getFields() !== [];
foreach ([
'qywx_promotion_config',
'qywx_promotion_media',
'qywx_promotion_automation_task',
'qywx_promotion_automation_action_log',
] as $table) {
if (Db::name($table)->getFields() === []) {
return false;
}
}
return true;
} catch (\Throwable $error) {
// 仅旧部署未建表时回退。数据库故障不能退回全天路由、忽略排班配置。
if (str_contains($error->getMessage(), '42S02')
@@ -64,13 +74,21 @@ class QywxPromotionConfig
public static function save(int $poolId, array $config): void
{
self::assertInstalled();
if ($poolId <= 0) {
throw new RuntimeException('分流方案不存在,无法保存自动化配置');
}
$row = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->find();
$data = ['config_json' => json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'update_time' => time()];
$json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$data = ['config_json' => $json, 'update_time' => time()];
if ($row) {
Db::name('qywx_promotion_config')->where('pool_id', $poolId)->update($data);
} else {
Db::name('qywx_promotion_config')->insert($data + ['pool_id' => $poolId, 'create_time' => time()]);
}
$saved = Db::name('qywx_promotion_config')->where('pool_id', $poolId)->value('config_json');
if (!is_string($saved) || !hash_equals($json, $saved)) {
throw new RuntimeException('获客标签与欢迎语配置未能完整落库,请重试');
}
}
/** 不接受浏览器提供的 userid;成员归属必须经过现有后台数据权限校验后再绑定。 */