This commit is contained in:
Your Name
2026-09-07 10:07:47 +08:00
parent cf3fbdc5ef
commit d5164b7369
388 changed files with 19863 additions and 18720 deletions
@@ -422,9 +422,17 @@ SQL;
* multiplies facts. Enterprise tag channels use the normalized relation
* table, while legacy name-only channels keep a deduplicated JSON fallback.
*
* When the fact has an employee dimension, pass $followUserField so a tag
* applied by employee A cannot make employee B's event match the channel.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
public static function applyExternalUserChannelFilter(
Query $query,
string $field,
?array $channel,
?string $followUserField = null
): void
{
if ($channel === null) {
return;
@@ -437,10 +445,14 @@ SQL;
$tagPredicate = count($tagIds) === 1
? 'channel_tag.tag_id = ?'
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
$followUserPredicate = $followUserField === null
? ''
: "AND channel_tag.follow_user_id = {$followUserField} ";
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
. "WHERE channel_tag.external_userid = {$field} "
. $followUserPredicate
. "AND {$tagPredicate} "
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
@@ -472,6 +484,92 @@ SQL;
);
}
/**
* Filter an add-event fact by its append-only channel snapshot. Events that
* pre-date the snapshot migration explicitly fall back to the old projection,
* but the fallback is constrained to the event's exact employee.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserEventChannelFilter(
Query $query,
string $eventIdField,
string $externalUserField,
string $followUserField,
?array $channel,
bool $historicalContact = false
): void
{
if ($channel === null) {
return;
}
$tagIds = self::channelTagIds($channel);
if ($tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()) {
if ($historicalContact) {
self::applyHistoricalExternalUserChannelFilter(
$query,
$externalUserField,
$channel,
$followUserField
);
} else {
self::applyExternalUserChannelFilter($query, $externalUserField, $channel, $followUserField);
}
return;
}
$snapshotTable = self::tableWithPrefix('qywx_external_contact_event_tag');
$snapshotPredicate = count($tagIds) === 1
? 'event_channel_tag.tag_id = ?'
: 'event_channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
$snapshotMatch = "EXISTS (SELECT 1 FROM {$snapshotTable} event_channel_tag"
. " WHERE event_channel_tag.event_id = {$eventIdField}"
. " AND event_channel_tag.follow_user_id = {$followUserField}"
. " AND {$snapshotPredicate})";
$snapshotMissing = "NOT EXISTS (SELECT 1 FROM {$snapshotTable} captured_event_channel"
. " WHERE captured_event_channel.event_id = {$eventIdField}"
. " AND captured_event_channel.follow_user_id = {$followUserField}"
. " AND captured_event_channel.tag_id = '')";
$query->where(function ($channelQuery) use (
$snapshotMatch,
$snapshotMissing,
$tagIds,
$historicalContact,
$externalUserField,
$followUserField,
$channel
): void {
$channelQuery->whereRaw($snapshotMatch, $tagIds)
->whereOr(function ($legacyQuery) use (
$snapshotMissing,
$historicalContact,
$externalUserField,
$followUserField,
$channel
): void {
$legacyQuery->whereRaw($snapshotMissing);
if ($historicalContact) {
self::applyHistoricalExternalUserChannelFilter(
$legacyQuery,
$externalUserField,
$channel,
$followUserField
);
} else {
self::applyExternalUserChannelFilter(
$legacyQuery,
$externalUserField,
$channel,
$followUserField
);
}
});
});
}
/**
* Filter an external_userid fact by the channel snapshot retained in
* qywx_external_contact.follow_users, including soft-deleted contacts.
@@ -481,14 +579,48 @@ SQL;
* contact row itself retains follow_users and is the best available channel
* snapshot for this specific historical statistic.
*
* When $followUserField is provided, JSON_SEARCH first locates that exact
* employee object and JSON_CONTAINS checks only its tag array.
*
* @param array<string, mixed>|null $channel
*/
public static function applyHistoricalExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
public static function applyHistoricalExternalUserChannelFilter(
Query $query,
string $field,
?array $channel,
?string $followUserField = null
): void
{
if ($channel === null) {
return;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$tagIds = self::channelTagIds($channel);
if ($followUserField !== null && $tagIds !== []) {
$safeFollowUsers = "IF(JSON_VALID(historical_channel_contact.follow_users),"
. ' historical_channel_contact.follow_users, JSON_ARRAY())';
$userPath = "JSON_UNQUOTE(JSON_SEARCH({$safeFollowUsers}, 'one', {$followUserField},"
. " NULL, '$[*].userid'))";
$tagsPath = "IFNULL(REPLACE({$userPath}, '.userid', '.tags'), '$.__missing__')";
$tagsJson = "JSON_EXTRACT({$safeFollowUsers}, {$tagsPath})";
$tagPredicates = [];
$bindings = [];
foreach ($tagIds as $tagId) {
$tagPredicates[] = "JSON_CONTAINS({$tagsJson}, JSON_OBJECT('tag_id', ?))";
$bindings[] = $tagId;
}
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
. " WHERE historical_channel_contact.external_userid = {$field}"
. " AND {$userPath} IS NOT NULL"
. ' AND (' . implode(' OR ', $tagPredicates) . '))',
$bindings
);
return;
}
$patterns = self::buildLikePatterns($channel);
if ($patterns === []) {
$query->whereRaw('1 = 0');
@@ -502,7 +634,6 @@ SQL;
$segments[] = 'historical_channel_contact.follow_users LIKE ?';
$bindings[] = $pattern;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
. " WHERE historical_channel_contact.external_userid = {$field}"
@@ -139,6 +139,9 @@ class QywxPromotionAutomationStore
Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]);
}
});
// 标签动作成功/终止后,以任务入队时的配置冻结事件渠道;重复调用由完成标记幂等保护。
QywxExternalContactEventTagSnapshotService::captureFromPromotionTask($row);
}
public function localNames(array $task): array
@@ -167,7 +170,17 @@ class QywxPromotionAutomationStore
public function syncCustomer(array $task): void
{
$started = time();
CustomerLogic::upsertSingleExternalContactFromApi($task['external_userid']);
$eventId = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('user_id', (string) $task['userid'])
->where('external_userid', (string) $task['external_userid'])
->where('event_time', (int) $task['event_time'])
->value('id');
CustomerLogic::upsertSingleExternalContactFromApi(
$task['external_userid'],
$eventId,
(string) $task['userid']
);
// 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。
$updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time');
if ($updated < $started) {