diff --git a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php
index 139504aaa..7b27f4122 100644
--- a/server/app/adminapi/controller/firstvisit/WecomPromotionController.php
+++ b/server/app/adminapi/controller/firstvisit/WecomPromotionController.php
@@ -79,6 +79,33 @@ class WecomPromotionController extends BaseAdminController
)));
}
+ public function saveMember()
+ {
+ if (!$this->hasPagePermission()) {
+ return $this->fail('权限不足');
+ }
+
+ return $this->run(fn () => $this->success('成员分流规则已保存', WecomPromotionLogic::saveMember(
+ $this->request->post(),
+ $this->adminId,
+ $this->adminInfo
+ )));
+ }
+
+ public function toggleMember()
+ {
+ if (!$this->hasPagePermission()) {
+ return $this->fail('权限不足');
+ }
+ $id = (int) $this->request->post('id', 0);
+ $status = (int) $this->request->post('status', 0);
+
+ return $this->run(fn () => $this->success(
+ '成员状态已更新',
+ WecomPromotionLogic::toggleMember($id, $status, $this->adminId, $this->adminInfo)
+ ));
+ }
+
public function checkApiPermission()
{
if (!$this->hasPagePermission()) {
diff --git a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php
index 7788de43e..fdc01414b 100644
--- a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php
+++ b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php
@@ -7,6 +7,9 @@ namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
+use app\common\service\qywx\QywxPromotionMemberSchedulerService;
+use app\common\service\qywx\QywxPromotionRangeSyncService;
+use app\common\service\qywx\QywxPromotionWeightedRandom;
use app\common\service\qywx\QywxPromotionWidgetService;
use RuntimeException;
use think\facade\Db;
@@ -16,6 +19,7 @@ class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
+ self::assertMemberDispatchSchema();
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
->leftJoin('admin u', 'u.id = p.owner_admin_id')
@@ -46,15 +50,12 @@ class WecomPromotionLogic
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
- $goUrl = $domain . '/api/qywx-promotion/go/' . $key;
+ $compatGoUrl = $domain . '/api/qywx-promotion/go/' . $key;
$pool['script_url'] = $scriptUrl;
- $pool['go_url'] = $goUrl;
+ $pool['compat_go_url'] = $compatGoUrl;
$pool['install_code'] = '';
- $pool['trigger_code'] = '添加企业微信';
}
unset($pool);
@@ -70,12 +71,101 @@ class WecomPromotionLogic
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
$onlineLinks++;
}
- if ((string) ($link['today_date'] ?? '') === $today) {
- $todayClicks += (int) ($link['today_count'] ?? 0);
- }
}
unset($link);
+ $memberOptions = self::memberOptions($adminId, $adminInfo);
+ $adminIdByUserId = [];
+ $memberOptionByAdminId = [];
+ foreach ($memberOptions as $member) {
+ $adminIdByUserId[(string) $member['userid']] = (int) $member['id'];
+ $memberOptionByAdminId[(int) $member['id']] = $member;
+ }
+ $linksByPool = [];
+ foreach ($links as $link) {
+ $linksByPool[(int) $link['pool_id']][] = $link;
+ }
+ self::backfillPoolMembers($pools, $linksByPool, $adminIdByUserId);
+ $memberRulesByPool = [];
+ $syncByPool = [];
+ if ($poolIds !== []) {
+ $memberRows = Db::name('qywx_promotion_pool_member')
+ ->whereIn('pool_id', $poolIds)
+ ->whereNull('delete_time')
+ ->order('id', 'asc')
+ ->select()->toArray();
+ foreach ($memberRows as $memberRow) {
+ $memberAdminId = (int) ($memberRow['admin_id'] ?? 0);
+ $option = $memberOptionByAdminId[$memberAdminId] ?? [];
+ $memberRow['name'] = (string) ($option['name'] ?? $memberRow['userid'] ?? '未知成员');
+ $memberRow['dept_names'] = array_values((array) ($option['dept_names'] ?? []));
+ $memberRow['status'] = (int) ($memberRow['enabled'] ?? 0);
+ $memberRow['today_count'] = (string) ($memberRow['today_date'] ?? '') === $today
+ ? (int) ($memberRow['today_count'] ?? 0)
+ : 0;
+ $memberRulesByPool[(int) $memberRow['pool_id']][] = $memberRow;
+ $todayClicks += (int) $memberRow['today_count'];
+ }
+ foreach (Db::name('qywx_promotion_range_sync')->whereIn('pool_id', $poolIds)->select()->toArray() as $syncRow) {
+ $syncByPool[(int) $syncRow['pool_id']] = $syncRow;
+ }
+ }
+ foreach ($pools as &$pool) {
+ $poolLinks = $linksByPool[(int) $pool['id']] ?? [];
+ $officialLinks = array_values(array_filter(
+ $poolLinks,
+ static fn (array $link): bool => !empty($link['is_official'])
+ && (int) ($link['remote_status'] ?? 0) !== 2
+ ));
+ $legacyCount = count(array_filter(
+ $poolLinks,
+ static fn (array $link): bool => empty($link['is_official'])
+ ));
+ // links 已按 id 倒序返回;旧多链接方案暂以最新官方链接作为主链接,
+ // 保存方案时会把其余链接仅在本地下线,保留远端链接与历史客户归因。
+ $officialLink = $officialLinks[0] ?? null;
+ $memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
+ $sync = $syncByPool[(int) $pool['id']] ?? [];
+ foreach ($memberRules as &$memberRule) {
+ $memberRule['is_current'] = (int) ($memberRule['id'] ?? 0)
+ === (int) ($sync['desired_member_id'] ?? 0);
+ $memberRule['is_applied'] = (int) ($memberRule['id'] ?? 0)
+ === (int) ($sync['applied_member_id'] ?? 0);
+ $memberRule['sync_status'] = (int) ($sync['status'] ?? 0);
+ $memberRule['sync_error'] = (string) ($sync['last_error'] ?? '');
+ }
+ unset($memberRule);
+ $memberAdminIds = [];
+ foreach ($memberRules as $memberRule) {
+ if ((int) ($memberRule['admin_id'] ?? 0) > 0) {
+ $memberAdminIds[] = (int) $memberRule['admin_id'];
+ }
+ }
+ $mainUrl = $officialLink === null
+ ? ''
+ : QywxCustomerAcquisitionLinkService::withCustomerChannel(
+ (string) ($officialLink['wecom_url'] ?? ''),
+ 'zyt_pool:' . (int) $pool['id']
+ );
+ $pool['official_link'] = $officialLink;
+ $pool['official_link_count'] = count($officialLinks);
+ $pool['legacy_link_count'] = $legacyCount;
+ $pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
+ $pool['member_rules'] = $memberRules;
+ $pool['dispatch_sync'] = $sync;
+ $pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
+ $pool['migration_state'] = count($officialLinks) > 1
+ ? 'needs_resolution'
+ : ($officialLink === null ? ($legacyCount > 0 ? 'legacy_only' : 'missing') : 'ready');
+ $pool['main_url'] = $mainUrl;
+ // go_url 现在代表可直接分享的企业微信官方链接;compat_go_url 保留旧 302 入口。
+ $pool['go_url'] = $mainUrl;
+ $pool['trigger_code'] = $mainUrl === '' ? '' : '添加企业微信';
+ }
+ unset($pool);
+
$config = self::internalApplicationStatus($domain);
return [
@@ -92,14 +182,18 @@ class WecomPromotionLogic
],
'pools' => $pools,
'links' => $links,
- 'member_options' => self::memberOptions($adminId, $adminInfo),
+ 'member_options' => $memberOptions,
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
public static function savePool(array $params, int $adminId, array $adminInfo): array
{
+ self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
+ $existingPool = $id > 0
+ ? self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo)
+ : null;
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 60) {
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
@@ -108,28 +202,147 @@ class WecomPromotionLogic
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
+ $members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
+ $userIds = array_values(array_column($members, 'userid'));
+ $selectedUserId = self::preferredDispatchUserId($id, $members);
+ $skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
+ $status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
$now = time();
- $data = [
+ $poolData = [
'name' => $name,
- 'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
+ 'status' => $status,
'fallback_url' => $fallback,
'update_time' => $now,
];
- if ($id > 0) {
- self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
- Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
- } else {
- $data += [
- 'public_key' => bin2hex(random_bytes(16)),
- 'owner_admin_id' => $adminId,
- 'dept_id' => self::primaryDeptId($adminId),
- 'click_count' => 0,
- 'create_time' => $now,
- ];
- $id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
+
+ $existingLink = null;
+ if ($existingPool !== null) {
+ $existingLink = Db::name('qywx_promotion_link')
+ ->where('pool_id', $id)
+ ->whereNull('delete_time')
+ ->where('remote_link_id', '<>', '')
+ ->where('remote_status', '<>', 2)
+ ->order('id', 'desc')
+ ->find();
}
- return ['id' => $id];
+ $api = new QywxCustomerAcquisitionApiService();
+ $payload = [
+ 'link_name' => mb_substr($name, 0, 30),
+ // 对外始终是同一个官方直链;当前只激活调度器选中的成员,回调后再切换下一位。
+ 'range' => ['user_list' => [$selectedUserId]],
+ 'skip_verify' => $skipVerify === 1,
+ ];
+ $createdRemote = false;
+ if ($existingLink !== null) {
+ $remoteLinkId = trim((string) ($existingLink['remote_link_id'] ?? ''));
+ $payload['link_id'] = $remoteLinkId;
+ $api->updateLink($payload);
+ } else {
+ $created = $api->createLink($payload);
+ $remoteLinkId = self::extractRemoteLinkId($created);
+ if ($remoteLinkId === '') {
+ throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请执行“同步企业微信”确认结果');
+ }
+ $createdRemote = true;
+ }
+ try {
+ $remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
+ } catch (\Throwable $e) {
+ if ($createdRemote) {
+ try {
+ $api->deleteLink($remoteLinkId);
+ } catch (\Throwable) {
+ // 保留详情读取的原始异常;孤立链接仍可通过企业微信同步找回。
+ }
+ }
+ throw $e;
+ }
+ $remoteData = self::remoteColumns($remote, $now);
+
+ $linkId = (int) ($existingLink['id'] ?? 0);
+ try {
+ Db::transaction(function () use (
+ &$id,
+ &$linkId,
+ $poolData,
+ $remoteData,
+ $status,
+ $existingPool,
+ $members,
+ $selectedUserId,
+ $now,
+ $adminId
+ ): void {
+ if ($existingPool === null) {
+ $id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
+ 'public_key' => bin2hex(random_bytes(16)),
+ 'owner_admin_id' => $adminId,
+ 'dept_id' => self::primaryDeptId($adminId),
+ 'click_count' => 0,
+ 'create_time' => $now,
+ ]);
+ } else {
+ Db::name('qywx_promotion_pool')->where('id', $id)->update($poolData);
+ }
+
+ $linkCommon = [
+ 'pool_id' => $id,
+ 'group_name' => '方案官方链接',
+ 'status' => $status,
+ 'update_time' => $now,
+ ];
+ if ($linkId > 0) {
+ Db::name('qywx_promotion_link')->where('id', $linkId)->update($remoteData + $linkCommon);
+ } else {
+ $linkId = (int) Db::name('qywx_promotion_link')->insertGetId($remoteData + $linkCommon + [
+ 'account_id' => 0,
+ 'weight' => 1,
+ 'daily_limit' => 0,
+ 'today_count' => 0,
+ 'today_date' => null,
+ 'active_start' => 0,
+ 'active_end' => 0,
+ 'click_count' => 0,
+ 'last_click_time' => 0,
+ 'owner_admin_id' => (int) ($existingPool['owner_admin_id'] ?? 0) ?: $adminId,
+ 'dept_id' => (int) ($existingPool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
+ 'remark' => '',
+ 'create_time' => $now,
+ ]);
+ }
+ self::persistPoolMembers($id, $members, $now);
+ QywxPromotionMemberSchedulerService::initialisePool($id, $linkId, $selectedUserId);
+
+ // 旧多链接只在本地下线,企业微信远端与历史客户归因继续保留。
+ Db::name('qywx_promotion_link')
+ ->where('pool_id', $id)
+ ->where('id', '<>', $linkId)
+ ->whereNull('delete_time')
+ ->update(['status' => 0, 'update_time' => $now]);
+ });
+ } catch (\Throwable $e) {
+ if ($createdRemote) {
+ try {
+ $api->deleteLink($remoteLinkId);
+ } catch (\Throwable) {
+ // 企业微信补偿失败时保留原始异常;可通过官方列表找回孤立链接。
+ }
+ }
+ throw $e;
+ }
+
+ return [
+ 'id' => $id,
+ 'remote_link_id' => (string) ($remote['link_id'] ?? ''),
+ 'wecom_url' => (string) ($remote['url'] ?? ''),
+ 'main_url' => QywxCustomerAcquisitionLinkService::withCustomerChannel(
+ (string) ($remote['url'] ?? ''),
+ 'zyt_pool:' . $id
+ ),
+ 'member_userids' => $userIds,
+ 'current_userid' => $selectedUserId,
+ ];
}
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
@@ -154,15 +367,109 @@ class WecomPromotionLogic
Db::transaction(function () use ($id, $now): void {
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
+ Db::name('qywx_promotion_pool_member')->where('pool_id', $id)->whereNull('delete_time')->update([
+ 'enabled' => 0,
+ 'delete_time' => $now,
+ 'update_time' => $now,
+ ]);
+ Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update([
+ 'status' => 4,
+ 'next_retry' => 0,
+ 'last_error' => '分流方案已删除',
+ 'update_time' => $now,
+ ]);
});
}
+ public static function saveMember(array $params, int $adminId, array $adminInfo): array
+ {
+ self::assertMemberDispatchSchema();
+ $id = max(0, (int) ($params['id'] ?? 0));
+ $member = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
+ if (!$member) {
+ throw new RuntimeException('分流成员不存在或已移除');
+ }
+ $poolId = (int) $member['pool_id'];
+ self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
+ $enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
+ if ($enabled === 0) {
+ $otherEnabled = (int) Db::name('qywx_promotion_pool_member')
+ ->where('pool_id', $poolId)
+ ->where('id', '<>', $id)
+ ->where('enabled', 1)
+ ->whereNull('delete_time')
+ ->count();
+ if ($otherEnabled <= 0) {
+ throw new RuntimeException('至少需要保留一名启用的获客医助');
+ }
+ }
+ $startAt = self::parseTime($params['active_start'] ?? null);
+ $endAt = self::parseTime($params['active_end'] ?? null);
+ if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
+ throw new RuntimeException('生效结束时间必须晚于开始时间');
+ }
+ $now = time();
+ Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void {
+ Db::name('qywx_promotion_pool_member')->where('id', $id)->update([
+ 'enabled' => $enabled,
+ 'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
+ 'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
+ 'active_start' => $startAt,
+ 'active_end' => $endAt,
+ 'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
+ 'update_time' => $now,
+ ]);
+ });
+ $planned = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
+ $syncError = '';
+ if ($planned['queued']) {
+ try {
+ (new QywxPromotionRangeSyncService())->syncPool($poolId);
+ } catch (\Throwable $e) {
+ // 本地规则已保存;后台分钟任务会继续重试,不把成员状态回滚成错误值。
+ $syncError = $e->getMessage();
+ }
+ }
+
+ return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError];
+ }
+
+ public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array
+ {
+ self::assertMemberDispatchSchema();
+ $row = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
+ if (!$row) {
+ throw new RuntimeException('分流成员不存在或已移除');
+ }
+
+ return self::saveMember([
+ 'id' => $id,
+ 'status' => $status,
+ 'weight' => (int) ($row['weight'] ?? 1),
+ 'daily_limit' => (int) ($row['daily_limit'] ?? 0),
+ 'active_start' => (int) ($row['active_start'] ?? 0),
+ 'active_end' => (int) ($row['active_end'] ?? 0),
+ 'remark' => (string) ($row['remark'] ?? ''),
+ ], $adminId, $adminInfo);
+ }
+
public static function saveLink(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['id'] ?? 0));
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
+ if ($existing === null) {
+ $officialCount = (int) Db::name('qywx_promotion_link')
+ ->where('pool_id', $poolId)
+ ->whereNull('delete_time')
+ ->where('remote_link_id', '<>', '')
+ ->where('remote_status', '<>', 2)
+ ->count();
+ if ($officialCount > 0) {
+ throw new RuntimeException('一个分流方案只能有一个官方获客链接,请直接编辑分流方案中的获客成员');
+ }
+ }
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
@@ -438,8 +745,8 @@ class WecomPromotionLogic
return $result;
}
- /** @return list */
- private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
+ /** @return list,dept_names:list}> */
+ private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo): array
{
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
if ($requested === []) {
@@ -447,47 +754,225 @@ class WecomPromotionLogic
}
$available = [];
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
- $available[$member['id']] = $member['userid'];
+ $available[(int) $member['id']] = $member;
}
- $userIds = [];
+ $members = [];
foreach ($requested as $requestedId) {
if (!isset($available[$requestedId])) {
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
}
- $userIds[] = $available[$requestedId];
+ $members[] = $available[$requestedId];
}
- if (count($userIds) > 500) {
+ if (count($members) > 500) {
throw new RuntimeException('单个获客链接最多配置 500 名成员');
}
- return array_values(array_unique($userIds));
+ return $members;
+ }
+
+ /** @return list */
+ private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
+ {
+ return array_values(array_column(self::resolveMembers($adminIds, $adminId, $adminInfo), 'userid'));
+ }
+
+ /** @param list $members */
+ private static function preferredDispatchUserId(int $poolId, array $members): string
+ {
+ if ($members === []) {
+ throw new RuntimeException('请至少选择一名获客成员');
+ }
+ $selected = [];
+ foreach ($members as $member) {
+ $selected[(string) $member['userid']] = true;
+ }
+ $rules = $poolId > 0
+ ? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
+ : [];
+ $rulesById = [];
+ $rulesByUserId = [];
+ foreach ($rules as $rule) {
+ $rulesById[(int) $rule['id']] = $rule;
+ $rulesByUserId[(string) $rule['userid']] = $rule;
+ }
+ $desiredId = $poolId > 0
+ ? (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('desired_member_id') ?? 0)
+ : 0;
+ $now = time();
+ $today = date('Y-m-d', $now);
+ if (isset($rulesById[$desiredId])) {
+ $current = $rulesById[$desiredId];
+ if ((string) ($current['today_date'] ?? '') !== $today) {
+ $current['today_count'] = 0;
+ $current['today_date'] = $today;
+ }
+ if (isset($selected[(string) $current['userid']]) && QywxPromotionWeightedRandom::eligible($current, $now)) {
+ return (string) $current['userid'];
+ }
+ }
+ $randomCandidates = [];
+ $candidateUserIds = [];
+ foreach ($members as $index => $member) {
+ $userId = (string) $member['userid'];
+ $rule = $rulesByUserId[$userId] ?? [
+ 'enabled' => 1,
+ 'weight' => 1,
+ 'daily_limit' => 0,
+ 'today_count' => 0,
+ 'today_date' => $today,
+ 'active_start' => 0,
+ 'active_end' => 0,
+ ];
+ if ((string) ($rule['today_date'] ?? '') !== $today) {
+ $rule['today_count'] = 0;
+ $rule['today_date'] = $today;
+ }
+ $candidateId = $index + 1;
+ $rule['id'] = $candidateId;
+ $randomCandidates[] = $rule;
+ $candidateUserIds[$candidateId] = $userId;
+ }
+ $selection = QywxPromotionWeightedRandom::select($randomCandidates, $today, $now);
+ $selectedCandidateId = (int) ($selection['selected_id'] ?? 0);
+ if ($selectedCandidateId > 0 && isset($candidateUserIds[$selectedCandidateId])) {
+ return $candidateUserIds[$selectedCandidateId];
+ }
+ // 企业微信不接受空范围;仅在所有启用成员恰好达到上限时保留一名,队列会标记“无可用成员”。
+ foreach ($members as $member) {
+ $rule = $rulesByUserId[(string) $member['userid']] ?? null;
+ if ($rule === null || (int) ($rule['enabled'] ?? 1) === 1) {
+ return (string) $member['userid'];
+ }
+ }
+
+ throw new RuntimeException('至少需要启用一名获客医助');
+ }
+
+ /** @param list $members */
+ private static function persistPoolMembers(int $poolId, array $members, int $now): void
+ {
+ $selectedUserIds = [];
+ foreach ($members as $member) {
+ $userId = (string) $member['userid'];
+ $selectedUserIds[] = $userId;
+ $existing = Db::name('qywx_promotion_pool_member')
+ ->where('pool_id', $poolId)->where('userid', $userId)->find();
+ if ($existing) {
+ Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
+ 'admin_id' => (int) $member['id'],
+ 'userid' => $userId,
+ 'delete_time' => null,
+ 'update_time' => $now,
+ ]);
+ continue;
+ }
+ Db::name('qywx_promotion_pool_member')->insert([
+ 'pool_id' => $poolId,
+ 'admin_id' => (int) $member['id'],
+ 'userid' => $userId,
+ 'enabled' => 1,
+ 'weight' => 1,
+ 'current_weight' => 0,
+ 'daily_limit' => 0,
+ 'today_count' => 0,
+ 'today_date' => null,
+ 'total_count' => 0,
+ 'active_start' => 0,
+ 'active_end' => 0,
+ 'last_assigned_time' => 0,
+ 'remark' => '',
+ 'create_time' => $now,
+ 'update_time' => $now,
+ ]);
+ }
+ $removeQuery = Db::name('qywx_promotion_pool_member')
+ ->where('pool_id', $poolId)->whereNull('delete_time');
+ if ($selectedUserIds !== []) {
+ $removeQuery->whereNotIn('userid', $selectedUserIds);
+ }
+ $removeQuery->update(['delete_time' => $now, 'enabled' => 0, 'update_time' => $now]);
+ }
+
+ /**
+ * 旧版方案第一次打开时,把官方链接原有 range.user_list 回填为成员规则;不修改远端链接。
+ *
+ * @param list> $pools
+ * @param array>> $linksByPool
+ * @param array $adminIdByUserId
+ */
+ private static function backfillPoolMembers(array $pools, array $linksByPool, array $adminIdByUserId): void
+ {
+ $now = time();
+ foreach ($pools as $pool) {
+ $poolId = (int) ($pool['id'] ?? 0);
+ if ($poolId <= 0 || Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->count() > 0) {
+ continue;
+ }
+ $officialLink = null;
+ foreach ($linksByPool[$poolId] ?? [] as $link) {
+ if (!empty($link['is_official']) && (int) ($link['remote_status'] ?? 0) !== 2) {
+ $officialLink = $link;
+ break;
+ }
+ }
+ if ($officialLink === null) {
+ continue;
+ }
+ $firstUserId = '';
+ foreach ((array) ($officialLink['range_userids'] ?? []) as $userIdValue) {
+ $userId = trim((string) $userIdValue);
+ $mappedAdminId = (int) ($adminIdByUserId[$userId] ?? 0);
+ if ($userId === '' || $mappedAdminId <= 0) {
+ continue;
+ }
+ if ($firstUserId === '') {
+ $firstUserId = $userId;
+ }
+ try {
+ Db::name('qywx_promotion_pool_member')->insert([
+ 'pool_id' => $poolId,
+ 'admin_id' => $mappedAdminId,
+ 'userid' => $userId,
+ 'enabled' => 1,
+ 'weight' => 1,
+ 'current_weight' => 0,
+ 'daily_limit' => 0,
+ 'today_count' => 0,
+ 'today_date' => null,
+ 'total_count' => 0,
+ 'active_start' => 0,
+ 'active_end' => 0,
+ 'last_assigned_time' => 0,
+ 'remark' => '',
+ 'create_time' => $now,
+ 'update_time' => $now,
+ ]);
+ } catch (\Throwable) {
+ // 并发打开 overview 时唯一键会阻止重复回填。
+ }
+ }
+ if ($firstUserId !== '') {
+ QywxPromotionMemberSchedulerService::initialisePool(
+ $poolId,
+ (int) $officialLink['id'],
+ $firstUserId
+ );
+ // 旧链接可能仍包含多人范围;排队收敛为当前调度成员,官方 URL 本身保持不变。
+ Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
+ 'status' => 1,
+ 'applied_member_id' => 0,
+ 'applied_version' => 0,
+ 'next_retry' => $now,
+ 'update_time' => $now,
+ ]);
+ }
+ }
}
/** @return array */
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
{
- $link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
- $linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
- $url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
- if ($linkId === '') {
- throw new RuntimeException('企业微信获客链接详情缺少 link_id');
- }
- if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
- throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
- }
- $range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
-
- return [
- 'link_id' => $linkId,
- 'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
- 'url' => $url,
- 'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
- 'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
- 'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
- 'skip_verify' => !empty($link['skip_verify']),
- 'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
- 'snapshot' => $link,
- ];
+ return QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $fallbackId);
}
/** @return array */
@@ -681,6 +1166,19 @@ class WecomPromotionLogic
return $time === false ? 0 : $time;
}
+ private static function assertMemberDispatchSchema(): void
+ {
+ try {
+ Db::name('qywx_promotion_pool_member')->limit(1)->find();
+ Db::name('qywx_promotion_dispatch_event')->limit(1)->find();
+ Db::name('qywx_promotion_range_sync')->limit(1)->find();
+ } catch (\Throwable $e) {
+ throw new RuntimeException(
+ '获客成员调度数据表尚未安装,请先执行 server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql'
+ );
+ }
+ }
+
private static function mask(string $value): string
{
$length = strlen($value);
@@ -742,7 +1240,7 @@ class WecomPromotionLogic
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
// 复用自建应用现有的「API 接收消息」入口;获客助手事件与其他应用事件
// 由同一个控制器按 Event/ChangeType 分发,不需要再配置第二个回调地址。
- 'callback_url' => rtrim($domain, '/') . '/api/QywxExternalContactCallback/notify',
+ 'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
];
}
diff --git a/server/app/api/controller/QywxExternalContactCallbackController.php b/server/app/api/controller/QywxExternalContactCallbackController.php
index a4c405a3d..5e7f76dce 100755
--- a/server/app/api/controller/QywxExternalContactCallbackController.php
+++ b/server/app/api/controller/QywxExternalContactCallbackController.php
@@ -6,6 +6,8 @@ namespace app\api\controller;
use app\adminapi\logic\qywx\CustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
+use app\common\service\qywx\QywxPromotionMemberSchedulerService;
+use app\common\service\qywx\QywxPromotionRangeSyncService;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Work\Application;
use EasyWeChat\Work\Message;
@@ -178,6 +180,29 @@ class QywxExternalContactCallbackController extends BaseApiController
return;
}
+ if ($changeType === 'add_external_contact' && $state !== '' && $userId !== '') {
+ // customer_channel=zyt_pool:{id} 会原样进入 State;以实际 UserID 幂等记账并规划下一位。
+ try {
+ $dispatch = QywxPromotionMemberSchedulerService::recordFromState(
+ $state,
+ $userId,
+ $extId,
+ $eventTime,
+ 'external_contact'
+ );
+ if (($dispatch['status'] ?? '') === 'counted' && (int) ($dispatch['pool_id'] ?? 0) > 0) {
+ try {
+ // 回调后立即切换;分钟任务仍负责网络异常、并发版本变化等情况的兜底重试。
+ (new QywxPromotionRangeSyncService())->syncPool((int) $dispatch['pool_id']);
+ } catch (\Throwable $e) {
+ Log::warning('qywx promotion immediate range sync failed: ' . $e->getMessage());
+ }
+ }
+ } catch (\Throwable $e) {
+ // 客户资料同步与成员调度互不阻塞;调度异常保留日志,获客会话回调仍可补偿。
+ Log::error('qywx promotion callback dispatch failed: ' . $e->getMessage(), ['exception' => $e]);
+ }
+ }
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId);
}
diff --git a/server/app/api/controller/QywxPromotionPublicController.php b/server/app/api/controller/QywxPromotionPublicController.php
index e935e8d7f..660c41d98 100644
--- a/server/app/api/controller/QywxPromotionPublicController.php
+++ b/server/app/api/controller/QywxPromotionPublicController.php
@@ -20,13 +20,13 @@ class QywxPromotionPublicController extends BaseApiController
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
}
// 由安装脚本自身的 src 解析 API 域名,避免把请求 Host 写入可公开缓存的 JavaScript。
- $goUrl = '/api/qywx-promotion/go/' . $key;
+ $targetUrl = (string) ($pool['target_url'] ?? '');
$config = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
$javascript = QywxPromotionWidgetService::renderScript(
$key,
- $goUrl,
+ $targetUrl,
$config,
- (int) ($pool['status'] ?? 0) === 1
+ (int) ($pool['status'] ?? 0) === 1 && $targetUrl !== ''
);
return response($javascript, 200, [
diff --git a/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php b/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php
index 53eb28d80..469466b7f 100644
--- a/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php
+++ b/server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php
@@ -183,11 +183,18 @@ class QywxCustomerAcquisitionApiService
return $this->request($method, $path, $body, true);
}
+ $errorMessage = trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误';
+ if ($errcode === 60111) {
+ throw new RuntimeException(
+ '所选医助的企业微信 userid 不存在,或不在获客助手可调用应用的可见范围;'
+ . '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage
+ );
+ }
throw new RuntimeException(sprintf(
'企业微信获客助手接口失败[%d]:%s',
$errcode,
- trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
+ $errorMessage
));
}
diff --git a/server/app/common/service/qywx/QywxCustomerAcquisitionCustomerService.php b/server/app/common/service/qywx/QywxCustomerAcquisitionCustomerService.php
index 8a3c82ef4..1c669e3e2 100644
--- a/server/app/common/service/qywx/QywxCustomerAcquisitionCustomerService.php
+++ b/server/app/common/service/qywx/QywxCustomerAcquisitionCustomerService.php
@@ -93,6 +93,13 @@ class QywxCustomerAcquisitionCustomerService
'event_time' => $eventTime,
'snapshot' => $message,
], false);
+ self::recordPromotionAssignment(
+ (string) ($message['State'] ?? $message['state'] ?? ''),
+ $remoteLinkId,
+ $userId,
+ $externalUserId,
+ $eventTime
+ );
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
@@ -126,6 +133,13 @@ class QywxCustomerAcquisitionCustomerService
'event_time' => $eventTime,
'snapshot' => $chat,
], true);
+ self::recordPromotionAssignment(
+ (string) ($chatInfo['state'] ?? $message['State'] ?? ''),
+ $remoteLinkId,
+ $userId,
+ $externalUserId,
+ $eventTime
+ );
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
@@ -419,6 +433,40 @@ class QywxCustomerAcquisitionCustomerService
return [$adminId, $deptId];
}
+ private static function recordPromotionAssignment(
+ string $state,
+ string $remoteLinkId,
+ string $userId,
+ string $externalUserId,
+ int $eventTime
+ ): void {
+ $result = QywxPromotionMemberSchedulerService::recordFromState(
+ $state,
+ $userId,
+ $externalUserId,
+ $eventTime,
+ 'customer_acquisition'
+ );
+ if ((int) ($result['pool_id'] ?? 0) <= 0) {
+ $result = QywxPromotionMemberSchedulerService::recordFromRemoteLink(
+ $remoteLinkId,
+ $userId,
+ $externalUserId,
+ $eventTime,
+ 'customer_acquisition'
+ );
+ }
+ if (($result['status'] ?? '') !== 'counted' || (int) ($result['pool_id'] ?? 0) <= 0) {
+ return;
+ }
+ try {
+ // 优先在本次回调完成后切换,后台分钟任务继续承担失败重试。
+ (new QywxPromotionRangeSyncService())->syncPool((int) $result['pool_id']);
+ } catch (\Throwable) {
+ // 同步服务已经保存失败原因和下次重试时间,不能让远端网络错误回滚已记账的客户。
+ }
+ }
+
private static function encodeJson(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
diff --git a/server/app/common/service/qywx/QywxCustomerAcquisitionLinkService.php b/server/app/common/service/qywx/QywxCustomerAcquisitionLinkService.php
index f0c53b0af..cee443783 100644
--- a/server/app/common/service/qywx/QywxCustomerAcquisitionLinkService.php
+++ b/server/app/common/service/qywx/QywxCustomerAcquisitionLinkService.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace app\common\service\qywx;
+use RuntimeException;
+
/** 企业微信获客助手链接校验。 */
class QywxCustomerAcquisitionLinkService
{
@@ -39,4 +41,98 @@ class QywxCustomerAcquisitionLinkService
{
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
}
+
+ /**
+ * 规范化企业微信 create_link/get 返回的链接详情。
+ *
+ * get 接口把 range、priority_option 放在响应根级,link 只包含链接本身;
+ * 旧响应或测试桩可能把这些字段放在 link 内,因此保留兼容回退。
+ *
+ * @return array{
+ * link_id:string,
+ * link_name:string,
+ * url:string,
+ * create_time:int,
+ * range_userids:list,
+ * range_department_ids:list,
+ * skip_verify:bool,
+ * priority_option:array,
+ * snapshot:array
+ * }
+ */
+ public static function normaliseRemoteResponse(array $response, string $fallbackId = ''): array
+ {
+ $link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
+ $range = isset($response['range']) && is_array($response['range'])
+ ? $response['range']
+ : (isset($link['range']) && is_array($link['range']) ? $link['range'] : []);
+ $priorityOption = isset($response['priority_option']) && is_array($response['priority_option'])
+ ? $response['priority_option']
+ : (isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : []);
+ $linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
+ $url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
+ if ($linkId === '') {
+ throw new RuntimeException('企业微信获客链接详情缺少 link_id');
+ }
+ if (!self::isAllowed($url)) {
+ throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
+ }
+
+ return [
+ 'link_id' => $linkId,
+ 'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
+ 'url' => $url,
+ 'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
+ 'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
+ 'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
+ 'skip_verify' => !empty($link['skip_verify']),
+ 'priority_option' => $priorityOption,
+ 'snapshot' => $response,
+ ];
+ }
+
+ /** 追加或替换企业微信获客助手的自定义渠道标识。 */
+ public static function withCustomerChannel(string $url, string $channel): string
+ {
+ $url = trim($url);
+ $channel = trim($channel);
+ if (!self::isAllowed($url) || $channel === '' || strlen($channel) > 64) {
+ return '';
+ }
+ // 冒号在查询参数中是合法字符,保留“命名空间:编号”的可读结构;其余字符仍编码。
+ $parameter = 'customer_channel=' . str_replace('%3A', ':', rawurlencode($channel));
+ if (preg_match('/([?&])customer_channel=[^]*/i', $url) === 1) {
+ return (string) preg_replace_callback(
+ '/([?&])customer_channel=[^]*/i',
+ static fn (array $matches): string => $matches[1] . $parameter,
+ $url,
+ 1
+ );
+ }
+ $fragment = '';
+ $fragmentPosition = strpos($url, '#');
+ if ($fragmentPosition !== false) {
+ $fragment = substr($url, $fragmentPosition);
+ $url = substr($url, 0, $fragmentPosition);
+ }
+
+ return $url . (str_contains($url, '?') ? '&' : '?') . $parameter . $fragment;
+ }
+
+ /** @return list */
+ private static function normaliseScalarList(mixed $values): array
+ {
+ $result = [];
+ $seen = [];
+ foreach ((array) $values as $value) {
+ $normalised = trim((string) $value);
+ $key = 'value:' . $normalised;
+ if ($normalised !== '' && !isset($seen[$key])) {
+ $seen[$key] = true;
+ $result[] = $normalised;
+ }
+ }
+
+ return $result;
+ }
}
diff --git a/server/app/common/service/qywx/QywxPromotionRedirectService.php b/server/app/common/service/qywx/QywxPromotionRedirectService.php
index bcaddf355..fe484f793 100644
--- a/server/app/common/service/qywx/QywxPromotionRedirectService.php
+++ b/server/app/common/service/qywx/QywxPromotionRedirectService.php
@@ -6,10 +6,10 @@ namespace app\common\service\qywx;
use think\facade\Db;
-/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
+/** 公开获客助手链接:新方案直达单个官方链接,旧 /go 入口继续兼容历史分流。 */
class QywxPromotionRedirectService
{
- /** @return array{status:int,widget_config_json:?string}|null */
+ /** @return array{status:int,widget_config_json:?string,target_url:string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
@@ -19,17 +19,32 @@ class QywxPromotionRedirectService
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
- ->field('status,widget_config_json')
+ ->field('id,status,widget_config_json')
->find();
if (!$row) {
return null;
}
+ $link = Db::name('qywx_promotion_link')
+ ->where('pool_id', (int) $row['id'])
+ ->where('status', 1)
+ ->where('remote_status', 1)
+ ->where('remote_link_id', '<>', '')
+ ->whereNull('delete_time')
+ ->order('id', 'desc')
+ ->field('wecom_url')
+ ->find();
+ $targetUrl = QywxCustomerAcquisitionLinkService::withCustomerChannel(
+ (string) ($link['wecom_url'] ?? ''),
+ 'zyt_pool:' . (int) $row['id']
+ );
+
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
+ 'target_url' => $targetUrl,
];
}
diff --git a/server/app/common/service/qywx/QywxPromotionWidgetService.php b/server/app/common/service/qywx/QywxPromotionWidgetService.php
index a35cfdd25..5f05caf66 100644
--- a/server/app/common/service/qywx/QywxPromotionWidgetService.php
+++ b/server/app/common/service/qywx/QywxPromotionWidgetService.php
@@ -131,11 +131,11 @@ class QywxPromotionWidgetService
}
/**
- * 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
+ * 生成可直接跨站安装的完整脚本,点击后直达当前方案的企业微信官方链接。
*
* @param array $config
*/
- public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
+ public static function renderScript(string $key, string $targetUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
@@ -143,14 +143,14 @@ class QywxPromotionWidgetService
}
$jsonKey = self::jsonForScript($key);
- $jsonGo = self::jsonForScript($goUrl);
+ $jsonTarget = self::jsonForScript($targetUrl);
$jsonConfig = self::jsonForScript($config);
return << 'app\\command\\QywxScanMediaChannel',
// 企业微信会话内容存档同步(需开通会话存档 License 并配置 msgaudit_* 相关项 + 动态库)
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
- // 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
- 'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
+ // 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
+ 'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
+ // 回调确认实际承接成员后,按权重/上限切换同一条官方获客链接的成员范围
+ 'qywx:sync-promotion-ranges' => 'app\\command\\QywxSyncPromotionRanges',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST)
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
diff --git a/server/docs/qywx_promotion.md b/server/docs/qywx_promotion.md
index 38887c4c5..bb8ddf8e9 100644
--- a/server/docs/qywx_promotion.md
+++ b/server/docs/qywx_promotion.md
@@ -30,19 +30,51 @@ HOST = "https://公开访问域名"
- 更新获客链接 `update_link`
- 删除获客链接 `delete_link`
-“永久删除企业微信链接”会调用官方删除接口且无法恢复;“从本地移除”只退出当前分流池,不会修改企业微信后台。
+当前管理端按“一个分流方案对应一个官方获客链接”管理。删除分流方案只软删除本地记录,不调用企业微信 `delete_link`,因此不会破坏已有客户归因;官方永久删除接口仅保留给兼容接口使用,调用后无法恢复。
获客成员来自后台管理员的 `work_wechat_userid`。管理员可管理全量;组长、医助等账号只返回 `DataScopeService` 当前角色与部门范围内的成员。同步远端链接时,非全量账号只导入 `range.user_list` 与其可见成员有交集的数据;企业微信部门 ID 尚未建立本地映射时按安全原则隐藏,不会越权放行。
-`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面使用“创建官方获客链接”。
+`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面创建分流方案并选择获客成员。
-链接分流只接受企业微信获客助手生成的链接:
+创建分流方案时必须选择一名或多名医助。所有医助分别保存启用状态、权重、每日上限、有效时间和实际获客计数,但一个方案仍只创建一条企业微信官方链接。官方链接当前的 `range.user_list` 只放调度器选中的一名成员,回调确认实际承接结果后再按权重随机抽取下一名并更新同一个 `link_id`,因此对外 URL 始终不变。
+
+新建方案时的首名成员也会从所选医助中随机产生;编辑已有方案时,如果当前成员仍可用则保持不变,避免无获客事件时无故切换。
+
+系统只使用企业微信获客助手生成的链接:
```text
https://work.weixin.qq.com/ca/xxxxxxxx
```
-“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接也不会参与随机分流。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
+“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接不会作为方案主链接。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
+
+可复制的主链接会追加分流方案渠道参数,结构如下:
+
+```text
+https://work.weixin.qq.com/ca/xxxxxxxx?customer_channel=zyt_pool:123
+```
+
+其中 `customer_channel` 是本站写入的自定义渠道值,格式为 `zyt_pool:分流方案ID`;它与示例中的 `qywx_ca:...` 作用相同,但命名空间和数值由各系统自行定义。
+
+## 回调驱动成员调度
+
+部署时必须执行:
+
+```text
+server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql
+```
+
+并在企业微信后台把“API 接收消息”配置为:
+
+```text
+https://你的域名/api/qywx/external-contact/notify
+```
+
+调度优先使用 `change_external_contact/add_external_contact` 事件中的 `State`、`UserID` 和 `ExternalUserID`。`State` 来自主链接的 `customer_channel=zyt_pool:方案ID`,因此可以定位方案及实际承接医助;获客会话回调会通过 `ChatKey → get_chat_info` 作为补偿。方案、成员和客户组合使用唯一幂等键,同一实际获客不会因重复回调重复计数。
+
+每次确认实际承接后,系统执行加权随机抽取:权重越大,被抽中的概率越高;禁用、尚未生效、已过期或达到今日上限的成员不会进入随机池。随机允许连续抽中同一成员。待同步范围由 `qywx:sync-promotion-ranges` 每分钟重试,管理端主动禁用当前成员时也会立即尝试同步。
+
+权重和数量属于回调驱动的近实时控制,并非点击前的强事务:多个客户在企微回调或 `update_link` 生效前并发访问时,可能仍由同一成员承接;所有成员都达到上限时,企业微信不允许把 `range.user_list` 更新为空,系统会标记“无可用成员”并保留最后一次有效范围。因此数量上限用于自动退出后续调度,不承诺并发场景下绝对零超量。
如果需要为点击 IP 生成不可逆服务端哈希,可在 `[qywx_promotion]` 下额外设置独立的 `CREDENTIAL_KEY`。
@@ -50,9 +82,11 @@ https://work.weixin.qq.com/ca/xxxxxxxx
```html
-添加企业微信
+添加企业微信
```
+旧的 `/api/qywx-promotion/go/分流方案KEY` 地址继续保留,兼容已经投放的安装代码;新建方案、管理端复制链接和新版浮窗均直接打开官方获客链接。
+
## 公开浮窗
每个分流方案可选择是否由同一段公开 JS 自动挂载客服浮窗。关闭浮窗时,已有的
@@ -66,7 +100,7 @@ https://work.weixin.qq.com/ca/xxxxxxxx
- 标题、副标题、按钮文案和 `#RRGGBB` 主题色
- 16-160 像素底部距离、移动端展示开关和浮窗总开关
-公开脚本仅下发经过白名单校验的展示配置,不下发兜底链接或真实获客链接池。模板
+公开脚本仅下发经过白名单校验的展示配置和当前方案的单个官方目标链接,不下发历史链接池或 Secret。模板
由脚本内置,管理端文案通过 DOM `textContent` 写入,不接受自定义 HTML、CSS 或脚本。
损坏配置、未知版本和非法枚举会按关闭浮窗处理。
@@ -79,10 +113,7 @@ window.WecomPromotion['分流方案KEY'].hide()
window.WecomPromotion['分流方案KEY'].destroy()
```
-公开脚本缓存 60 秒,因此浮窗样式或开关更新最多延迟约 60 秒;方案运行状态仍会在
-每次服务端跳转时即时校验。脚本会从自身 `src` 解析跳转接口域名,不会把公开请求的
-Host 写入缓存内容。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的
-HTTPS 公开域名。
+公开脚本缓存 60 秒,因此浮窗样式、开关或官方目标链接更新最多延迟约 60 秒。新版浮窗直接打开企业微信官方链接,不再经过本站逐次 302;已经复制到外部的官方链接也不会因本地关闭方案而失效。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的 HTTPS 公开域名。
接入站点若启用了严格 CSP,需要允许脚本域名,并给安装 `
```
-点击来源只上报页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也
-不应放置手机号、患者 ID、重置令牌等敏感信息。
+旧 `/go` 兼容入口记录点击来源时,只保存页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也不应放置手机号、患者 ID、重置令牌等敏感信息。
-随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
+用户始终看到同一个企业微信官方获客链接。当前激活成员由本站根据实际回调、启用状态、权重和上限动态更新;企业微信仍可能根据成员可服务状态和已有好友关系等规则影响最终承接结果,后续回调会按实际结果自动纠偏。
diff --git a/server/tests/QywxCustomerAcquisitionApiServiceTest.php b/server/tests/QywxCustomerAcquisitionApiServiceTest.php
index 34c04d5ca..60fe15248 100644
--- a/server/tests/QywxCustomerAcquisitionApiServiceTest.php
+++ b/server/tests/QywxCustomerAcquisitionApiServiceTest.php
@@ -94,4 +94,23 @@ $assert(($customerPayload['link_id'] ?? '') === 'link_1' && ($customerPayload['l
$chatPayload = json_decode((string) $history[6]['request']->getBody(), true);
$assert(($chatPayload['chat_key'] ?? '') === 'chat_key_1', 'get_chat_info 请求体不正确');
+$invalidUserService = new QywxCustomerAcquisitionApiService(new Client([
+ 'base_uri' => 'https://qyapi.weixin.qq.com/',
+ 'handler' => HandlerStack::create(new MockHandler([
+ $json(['errcode' => 60111, 'errmsg' => "invalid string value `XiongCaiQian`: userid not found"]),
+ ])),
+ 'http_errors' => false,
+]), static fn (): string => 'mock_token');
+$invalidUserMessage = '';
+try {
+ $invalidUserService->createLink([
+ 'link_name' => '无效成员测试',
+ 'range' => ['user_list' => ['XiongCaiQian']],
+ ]);
+} catch (RuntimeException $e) {
+ $invalidUserMessage = $e->getMessage();
+}
+$assert(str_contains($invalidUserMessage, 'userid 不存在'), '60111 应返回明确的成员绑定诊断提示');
+$assert(str_contains($invalidUserMessage, '应用可见范围'), '60111 应提示检查应用可见范围');
+
echo "QYWX_CUSTOMER_ACQUISITION_API_TEST_OK\n";
diff --git a/server/tests/QywxPromotionWidgetServiceTest.php b/server/tests/QywxPromotionWidgetServiceTest.php
index 41c655b63..f2bc1bfb9 100644
--- a/server/tests/QywxPromotionWidgetServiceTest.php
+++ b/server/tests/QywxPromotionWidgetServiceTest.php
@@ -98,9 +98,10 @@ $encoded = QywxPromotionWidgetService::encode($xssConfig);
widgetAssert(!str_contains($encoded, '![]()