'ignored_state', 'pool_id' => 0, 'member_id' => 0, 'next_member_id' => 0]; } return self::record($poolId, $userId, $externalUserId, $eventTime, $source); } /** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */ public static function recordFromRemoteLink( string $remoteLinkId, string $userId, string $externalUserId, int $eventTime = 0, string $source = 'customer_acquisition' ): array { $poolId = (int) (Db::name('qywx_promotion_link') ->where('remote_link_id', trim($remoteLinkId)) ->whereNull('delete_time') ->value('pool_id') ?? 0); if ($poolId <= 0) { return ['status' => 'ignored_link', 'pool_id' => 0, 'member_id' => 0, 'next_member_id' => 0]; } return self::record($poolId, $userId, $externalUserId, $eventTime, $source); } /** * 管理端变更成员开关、上限或有效期后,重新计算企业微信官方多人路由范围。 * * @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool} */ public static function reconcilePool(int $poolId): array { return Db::transaction(function () use ($poolId): array { $now = time(); $today = date('Y-m-d', $now); $linkId = self::promotionLinkId($poolId); $sync = self::lockedSyncRow($poolId); $members = self::lockedMembers($poolId); $syncStatus = (int) ($sync['status'] ?? 0); $syncError = (string) ($sync['last_error'] ?? ''); if ($syncStatus === 5 || ($syncStatus === 4 && str_starts_with($syncError, '企业微信官方获客链接删除失败'))) { return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; } if ($linkId <= 0 || $members === []) { return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; } return self::queueEligibleRangeLocked($poolId, $linkId, $sync, $members, $today, $now); }); } public static function initialisePool(int $poolId, int $promotionLinkId): int { if ($poolId <= 0 || $promotionLinkId <= 0) { return 0; } $now = time(); $existing = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find(); $data = [ 'promotion_link_id' => $promotionLinkId, 'desired_member_id' => 0, 'applied_member_id' => 0, 'status' => 0, 'attempts' => 0, 'next_retry' => 0, 'lock_token' => '', 'lock_until' => 0, 'last_error' => '', 'update_time' => $now, ]; if ($existing) { $version = max(1, (int) ($existing['desired_version'] ?? 0) + 1); $data['desired_version'] = $version; $data['applied_version'] = $version; Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data); } else { $data += ['pool_id' => $poolId, 'desired_version' => 1, 'applied_version' => 1, 'create_time' => $now]; Db::name('qywx_promotion_range_sync')->insert($data); } return $promotionLinkId; } /** 强制排队一次成员范围同步,也用于方案名称或免验证配置变化。 */ public static function requestPoolSync(int $poolId, int $promotionLinkId): void { if ($poolId <= 0 || $promotionLinkId <= 0) { return; } Db::transaction(function () use ($poolId, $promotionLinkId): void { $now = time(); $existing = self::lockedSyncRow($poolId); if ($existing === null) { Db::name('qywx_promotion_range_sync')->insert([ 'pool_id' => $poolId, 'promotion_link_id' => $promotionLinkId, 'desired_member_id' => 0, 'desired_version' => 1, 'applied_member_id' => 0, 'applied_version' => 0, 'status' => 1, 'attempts' => 0, 'next_retry' => $now, 'lock_token' => '', 'lock_until' => 0, 'last_error' => '', 'create_time' => $now, 'update_time' => $now, ]); return; } $existingStatus = (int) ($existing['status'] ?? 0); $leaseActive = in_array($existingStatus, [2, 5], true) && (int) ($existing['lock_until'] ?? 0) > $now; $data = [ 'promotion_link_id' => $promotionLinkId, 'desired_member_id' => 0, 'desired_version' => max(1, (int) ($existing['desired_version'] ?? 0) + 1), 'applied_member_id' => 0, 'status' => $leaseActive ? $existingStatus : 1, 'next_retry' => $leaseActive ? (int) ($existing['next_retry'] ?? 0) : $now, 'update_time' => $now, ]; if (!$leaseActive) { $data += ['attempts' => 0, 'last_error' => '']; } Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data); }); } /** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */ private static function record( int $poolId, string $userId, string $externalUserId, int $eventTime, string $source ): array { $userId = trim($userId); $externalUserId = trim($externalUserId); if ($poolId <= 0 || $userId === '' || $externalUserId === '') { return ['status' => 'ignored_identity', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0]; } return Db::transaction(function () use ($poolId, $userId, $externalUserId, $eventTime, $source): array { $pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find(); if (!$pool) { return ['status' => 'ignored_pool', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0]; } $members = self::lockedMembers($poolId); $actualMember = null; foreach ($members as $member) { if ((string) ($member['userid'] ?? '') === $userId) { $actualMember = $member; break; } } if ($actualMember === null) { return ['status' => 'ignored_member', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0]; } $actualMemberId = (int) $actualMember['id']; // 同一方案中的同一客户只计一次;后续更换跟进成员不能重复占用医助额度。 $eventKey = hash('sha256', $poolId . '|' . $externalUserId); try { Db::name('qywx_promotion_dispatch_event')->insert([ 'event_key' => $eventKey, 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'userid' => $userId, 'external_userid' => $externalUserId, 'source' => mb_substr($source, 0, 32), 'event_time' => max(0, $eventTime), 'create_time' => time(), ]); } catch (\Throwable $e) { if (!Db::name('qywx_promotion_dispatch_event')->where('event_key', $eventKey)->find()) { throw $e; } return ['status' => 'duplicate', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => 0]; } $now = time(); $today = date('Y-m-d', $now); foreach ($members as &$member) { if ((string) ($member['today_date'] ?? '') !== $today) { $member['today_date'] = $today; $member['today_count'] = 0; } if ((int) $member['id'] === $actualMemberId) { $member['today_count'] = (int) ($member['today_count'] ?? 0) + 1; $member['total_count'] = (int) ($member['total_count'] ?? 0) + 1; $member['last_assigned_time'] = max($now, max(0, $eventTime)); } } unset($member); self::persistMemberCursors($members, $now); $linkId = self::promotionLinkId($poolId); $sync = self::lockedSyncRow($poolId); if ($linkId <= 0) { return ['status' => 'counted_stale', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => 0]; } $planned = self::queueEligibleRangeLocked($poolId, $linkId, $sync, $members, $today, $now); return [ 'status' => $planned['blocked'] ? 'counted_blocked' : 'counted', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => $planned['next_member_id'], ]; }); } /** @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool} */ private static function queueEligibleRangeLocked( int $poolId, int $linkId, ?array $sync, array $members, string $today, int $now ): array { $range = QywxPromotionMemberRange::evaluate($members, $today, $now, QywxPromotionConfig::forPool($poolId)); self::persistMemberCursors($range['members'], $now); if ($range['userids'] === []) { self::upsertSync($poolId, $linkId, false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限'); return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true]; } $appliedUserIds = self::linkRangeUserIds($linkId); $departmentJson = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_department_json') ?? '[]'); $departmentIds = json_decode($departmentJson, true); $changed = !QywxPromotionMemberRange::same($range['userids'], $appliedUserIds) || !empty($departmentIds); // 活跃或超时的租约、尚未应用的版本都不能仅凭旧快照被重算成“已同步”。 $alreadyPending = in_array((int) ($sync['status'] ?? 0), [1, 2, 3], true) || (int) ($sync['desired_version'] ?? 0) > (int) ($sync['applied_version'] ?? 0); $needsSync = $changed || $alreadyPending; self::upsertSync($poolId, $linkId, $needsSync, $sync, $now); return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => $needsSync, 'blocked' => false]; } private static function upsertSync( int $poolId, int $linkId, bool $pending, ?array $existing, int $now, string $error = '' ): void { $desiredChanged = $pending || ($error !== '' && (int) ($existing['status'] ?? 0) !== 4); $version = max(1, (int) ($existing['desired_version'] ?? 0) + ($desiredChanged ? 1 : 0)); $existingStatus = (int) ($existing['status'] ?? 0); $leaseActive = in_array($existingStatus, [2, 5], true) && (int) ($existing['lock_until'] ?? 0) > $now; $data = [ 'promotion_link_id' => $linkId, 'desired_member_id' => 0, 'desired_version' => $version, // 活跃租约不能被回调或分钟重算抢占;版本变化会让当前工作完成后继续同步。 'status' => $leaseActive ? $existingStatus : ($error !== '' ? 4 : ($pending ? 1 : 0)), 'next_retry' => $leaseActive ? (int) ($existing['next_retry'] ?? 0) : ($error !== '' ? strtotime('tomorrow', $now) : ($pending ? $now : 0)), 'last_error' => $leaseActive ? (string) ($existing['last_error'] ?? '') : mb_substr($error, 0, 500), 'applied_member_id' => 0, 'update_time' => $now, ]; if (!$leaseActive) { $data['attempts'] = 0; } if ($existing) { Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data); } else { $data += [ 'pool_id' => $poolId, 'applied_member_id' => 0, 'applied_version' => 0, 'attempts' => 0, 'lock_token' => '', 'lock_until' => 0, 'create_time' => $now, ]; Db::name('qywx_promotion_range_sync')->insert($data); } } /** @return list> */ private static function lockedMembers(int $poolId): array { return Db::name('qywx_promotion_pool_member') ->where('pool_id', $poolId) ->whereNull('delete_time') ->order('id', 'asc') ->lock(true) ->select()->toArray(); } /** @return array|null */ private static function lockedSyncRow(int $poolId): ?array { $row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find(); return $row ?: null; } private static function promotionLinkId(int $poolId): int { $syncLinkId = (int) (Db::name('qywx_promotion_range_sync') ->where('pool_id', $poolId)->value('promotion_link_id') ?? 0); if ($syncLinkId > 0) { return $syncLinkId; } return (int) (Db::name('qywx_promotion_link') ->where('pool_id', $poolId) ->where('remote_link_id', '<>', '') ->where('remote_status', '<>', 2) ->whereNull('delete_time') ->order('id', 'desc') ->value('id') ?? 0); } /** @return list */ private static function linkRangeUserIds(int $linkId): array { $json = (string) (Db::name('qywx_promotion_link')->where('id', $linkId)->value('range_user_json') ?? '[]'); $decoded = json_decode($json, true); return array_values(array_filter(array_map( static fn (mixed $value): string => trim((string) $value), is_array($decoded) ? $decoded : [] ), static fn (string $value): bool => $value !== '')); } /** @param list> $members */ private static function persistMemberCursors(array $members, int $now): void { foreach ($members as $member) { Db::name('qywx_promotion_pool_member')->where('id', (int) $member['id'])->update([ 'current_weight' => (int) ($member['current_weight'] ?? 0), 'today_count' => max(0, (int) ($member['today_count'] ?? 0)), 'today_date' => (string) ($member['today_date'] ?? '') ?: null, 'total_count' => max(0, (int) ($member['total_count'] ?? 0)), 'last_assigned_time' => max(0, (int) ($member['last_assigned_time'] ?? 0)), 'update_time' => $now, ]); } } }