api = $api ?? new QywxCustomerAcquisitionApiService(); } /** @return array{status:string,pool_id:int,member_id:int} */ public function syncPool(int $poolId): array { $claim = Db::transaction(function () use ($poolId): ?array { $row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); if (!$row) { return null; } $status = (int) ($row['status'] ?? 0); $now = time(); if ($status === 0 || $status === 4 || $status === 5 || ($status === 2 && (int) ($row['lock_until'] ?? 0) > $now)) { return null; } if ($status === 3 && (int) ($row['next_retry'] ?? 0) > $now) { return null; } $token = bin2hex(random_bytes(16)); Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ 'status' => 2, 'lock_token' => $token, 'lock_until' => $now + 90, 'attempts' => (int) ($row['attempts'] ?? 0) + 1, 'last_error' => '', 'update_time' => $now, ]); $row['lock_token'] = $token; return $row; }); if ($claim === null) { return ['status' => 'noop', 'pool_id' => $poolId, 'member_id' => 0]; } $token = (string) $claim['lock_token']; $desiredVersion = (int) ($claim['desired_version'] ?? 0); try { $pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->find(); $link = Db::name('qywx_promotion_link') ->where('id', (int) ($claim['promotion_link_id'] ?? 0)) ->whereNull('delete_time')->find(); $members = Db::name('qywx_promotion_pool_member') ->where('pool_id', $poolId)->whereNull('delete_time')->order('id', 'asc')->select()->toArray(); if (!$pool || !$link || $members === []) { throw new RuntimeException('分流方案、官方链接或方案成员已失效'); } $remoteLinkId = trim((string) ($link['remote_link_id'] ?? '')); if ($remoteLinkId === '') { throw new RuntimeException('官方链接 ID 为空'); } $range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), QywxPromotionConfig::forPool($poolId)); $desiredUserIds = $range['userids']; if ($desiredUserIds === []) { $message = '所有成员均已禁用、未生效或达到今日上限;企业微信官方链接至少需要保留一名成员'; Db::name('qywx_promotion_range_sync') ->where('pool_id', $poolId)->where('lock_token', $token)->update([ 'status' => 4, 'desired_member_id' => 0, 'applied_member_id' => 0, 'next_retry' => strtotime('tomorrow'), 'attempts' => 0, 'lock_token' => '', 'lock_until' => 0, 'last_error' => $message, 'update_time' => time(), ]); Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([ 'sync_error' => $message, 'update_time' => time(), ]); return ['status' => 'blocked', 'pool_id' => $poolId, 'member_id' => 0]; } $this->api->updateLink([ 'link_id' => $remoteLinkId, 'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30), 'range' => ['user_list' => $desiredUserIds, 'department_list' => []], 'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1, ]); $response = $this->api->getLink($remoteLinkId); $remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId); $actualUserIds = $remote['range_userids']; if (!QywxPromotionMemberRange::same($actualUserIds, $desiredUserIds) || $remote['range_department_ids'] !== []) { throw new RuntimeException('企业微信返回的多人路由成员范围与方案可用医助不一致'); } $url = $remote['url']; $snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $confirmed = Db::transaction(function () use ( $poolId, $token, $desiredVersion, $link, $url, $actualUserIds, $remote, $snapshot ): bool { $fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); if (!$fresh || (int) ($fresh['status'] ?? 0) === 5) { return false; } if ((string) ($fresh['lock_token'] ?? '') !== $token || (int) ($fresh['lock_until'] ?? 0) <= time()) { // 过期工作不能覆盖新工作的确认结果。它可能较晚触达企微,需再推一次最新范围。 QywxPromotionMemberSchedulerService::requestPoolSync($poolId, (int) $fresh['promotion_link_id']); return false; } Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([ 'wecom_url' => $url, 'remote_status' => 1, 'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'remote_snapshot' => $snapshot === false ? null : $snapshot, 'last_sync_time' => time(), 'sync_error' => '', 'update_time' => time(), ]); $isLatest = (int) ($fresh['desired_version'] ?? 0) === $desiredVersion; Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ 'status' => $isLatest ? 0 : 1, 'desired_member_id' => 0, 'applied_member_id' => 0, 'applied_version' => $desiredVersion, 'next_retry' => $isLatest ? 0 : time(), 'attempts' => 0, 'lock_token' => '', 'lock_until' => 0, 'last_error' => '', 'update_time' => time(), ]); return $isLatest; }); return ['status' => $confirmed ? 'synced' : 'pending', 'pool_id' => $poolId, 'member_id' => 0]; } catch (\Throwable $e) { $attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1); Db::transaction(function () use ($poolId, $token, $attempts, $claim, $e): void { $fresh = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); if (!$fresh || (string) ($fresh['lock_token'] ?? '') !== $token || (int) ($fresh['status'] ?? 0) === 5) { return; } Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([ 'status' => 3, 'next_retry' => time() + min(300, 15 * $attempts), 'lock_token' => '', 'lock_until' => 0, 'last_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time(), ]); Db::name('qywx_promotion_link') ->where('id', (int) ($claim['promotion_link_id'] ?? 0)) ->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]); }); throw $e; } } /** @return array{selected:int,synced:int,failed:int} */ public function syncPending(int $limit = 100): array { // 每分钟分页重算全部方案,避免有效期、跨日上限变化漏同步,也避免方案数超过批次后饥饿。 $lastPoolId = 0; do { $reconcilePoolIds = Db::name('qywx_promotion_range_sync') ->where('pool_id', '>', $lastPoolId) ->order('pool_id', 'asc') ->limit(500) ->column('pool_id'); foreach ($reconcilePoolIds as $reconcilePoolId) { $lastPoolId = (int) $reconcilePoolId; QywxPromotionMemberSchedulerService::reconcilePool($lastPoolId); } } while (count($reconcilePoolIds) === 500); $now = time(); $poolIds = Db::name('qywx_promotion_range_sync') ->where(function ($query) use ($now): void { $query->whereIn('status', [1, 3])->where('next_retry', '<=', $now) ->whereOr(function ($running) use ($now): void { $running->where('status', 2)->where('lock_until', '<=', $now); }); }) ->order('next_retry', 'asc') ->limit(min(500, max(1, $limit))) ->column('pool_id'); $synced = 0; $failed = 0; foreach ($poolIds as $poolId) { try { $result = $this->syncPool((int) $poolId); if ($result['status'] === 'synced') { $synced++; } } catch (\Throwable) { $failed++; } } return ['selected' => count($poolIds), 'synced' => $synced, 'failed' => $failed]; } }