更新
This commit is contained in:
@@ -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()) {
|
||||
|
||||
@@ -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'] = '<script src="'
|
||||
. htmlspecialchars($scriptUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" defer></script>';
|
||||
$pool['trigger_code'] = '<a href="'
|
||||
. htmlspecialchars($goUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" data-wecom-promotion="' . $key . '">添加企业微信</a>';
|
||||
}
|
||||
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 === '' ? '' : '<a href="'
|
||||
. htmlspecialchars($mainUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" data-wecom-promotion="' . (string) $pool['public_key'] . '">添加企业微信</a>';
|
||||
}
|
||||
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<string> */
|
||||
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
|
||||
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
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<string> */
|
||||
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
|
||||
{
|
||||
return array_values(array_column(self::resolveMembers($adminIds, $adminId, $adminInfo), 'userid'));
|
||||
}
|
||||
|
||||
/** @param list<array{id:int,userid:string}> $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<array{id:int,userid:string}> $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<array<string,mixed>> $pools
|
||||
* @param array<int,list<array<string,mixed>>> $linksByPool
|
||||
* @param array<string,int> $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<string,mixed> */
|
||||
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<string,mixed> */
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>,
|
||||
* range_department_ids:list<string>,
|
||||
* skip_verify:bool,
|
||||
* priority_option:array<string,mixed>,
|
||||
* snapshot:array<string,mixed>
|
||||
* }
|
||||
*/
|
||||
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<string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -131,11 +131,11 @@ class QywxPromotionWidgetService
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
|
||||
* 生成可直接跨站安装的完整脚本,点击后直达当前方案的企业微信官方链接。
|
||||
*
|
||||
* @param array<string, mixed> $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 <<<JS
|
||||
(function(w,d){
|
||||
'use strict';
|
||||
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
|
||||
var go=resolveGoUrl(goPath);
|
||||
var key={$jsonKey},targetPath={$jsonTarget},config={$jsonConfig},scriptNode=d.currentScript||null;
|
||||
var targetUrl=resolveTargetUrl(targetPath);
|
||||
var registry=w.WecomPromotion=w.WecomPromotion||{};
|
||||
var previous=registry[key];
|
||||
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
|
||||
@@ -170,7 +170,7 @@ class QywxPromotionWidgetService
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGoUrl(value){
|
||||
function resolveTargetUrl(value){
|
||||
if(/^https?:\/\//i.test(value)){return value;}
|
||||
var node=findScriptNode();
|
||||
if(node&&node.src&&typeof w.URL==='function'){
|
||||
@@ -179,14 +179,8 @@ class QywxPromotionWidgetService
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceUrl(){
|
||||
var location=w.location||{};
|
||||
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
|
||||
return origin+(location.pathname||'/');
|
||||
}
|
||||
|
||||
function openPromotion(){
|
||||
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
|
||||
if(targetUrl){w.location.assign(targetUrl);}
|
||||
}
|
||||
|
||||
function handleDocumentClick(event){
|
||||
|
||||
Reference in New Issue
Block a user