This commit is contained in:
Your Name
2026-08-08 15:42:45 +08:00
parent a968945057
commit d10f213573
21 changed files with 2610 additions and 279 deletions
@@ -39,6 +39,19 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function saveWidget()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('浮窗配置已保存', WecomPromotionLogic::saveWidget(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function deletePool()
{
if (!$this->hasPagePermission()) {
@@ -33,12 +33,11 @@ class FirstVisitConversionLogic
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
$selectedMediaChannelCode = MediaChannelService::normalizeStatsCode(
trim((string) ($params['media_channel_code'] ?? ''))
);
$selectedMediaChannel = $selectedMediaChannelCode !== ''
? MediaChannelService::getChannelByCode($selectedMediaChannelCode)
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
$selectedMediaChannel = $requestedMediaChannelCode !== ''
? MediaChannelService::getChannelByCode($requestedMediaChannelCode)
: null;
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
$deptSelectionValid = $selectedDeptId <= 0
|| $allowedDeptSet === null
@@ -7,6 +7,7 @@ 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\QywxPromotionWidgetService;
use RuntimeException;
use think\facade\Db;
@@ -22,7 +23,7 @@ class WecomPromotionLogic
->whereNull('p.delete_time');
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
$pools = $poolsQuery
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.widget_config_json,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
->order('p.id', 'desc')
->select()->toArray();
@@ -39,15 +40,21 @@ class WecomPromotionLogic
->select()->toArray();
}
$domain = rtrim($domain, '/');
$domain = self::publicDomain($domain);
foreach ($pools as &$pool) {
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
$pool['script_url'] = $scriptUrl;
$pool['go_url'] = $goUrl;
$pool['install_code'] = '<script src="' . $scriptUrl . '" defer></script>';
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
$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);
@@ -125,6 +132,21 @@ class WecomPromotionLogic
return ['id' => $id];
}
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
$input = $params['widget_config'] ?? $params;
$config = QywxPromotionWidgetService::fromInput($input);
Db::name('qywx_promotion_pool')->where('id', $id)->update([
'widget_config_json' => QywxPromotionWidgetService::encode($config),
'update_time' => time(),
]);
return ['id' => $id, 'widget_config' => $config];
}
public static function deletePool(int $id, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
@@ -669,6 +691,30 @@ class WecomPromotionLogic
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
}
private static function publicDomain(string $requestDomain): string
{
$configuredDomain = trim((string) config('app.app_host', ''));
foreach ([$configuredDomain, trim($requestDomain)] as $candidate) {
if ($candidate === '') {
continue;
}
$parts = parse_url($candidate);
if (!is_array($parts)) {
continue;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
continue;
}
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
return $scheme . '://' . $host . $port;
}
throw new RuntimeException('未配置有效的应用访问域名');
}
/**
* 内部应用直接复用项目现有 work_wechat 配置,不经过第三方服务商授权。
*
@@ -18,6 +18,14 @@ class ConversionLogic
private const VIRTUAL_DEPT_UNBOUND_ADMIN_ID = -1;
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
/**
* Per-overview raw aggregate cache. It is reset at the beginning of every
* overview call so long-running workers never reuse stale business data.
*
* @var array<string, array<int, array<string, mixed>>>
*/
private static array $requestRowsCache = [];
/**
* @param array $params
* @param int $adminId 当前操作 admin(来自 BaseAdminController
@@ -39,14 +47,19 @@ class ConversionLogic
?array $trustedCostAllocationAdminIdsOverride = null
): array
{
self::$requestRowsCache = [];
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
// 仅供需要“有效挂号”口径的内部看板调用;默认保持转换统计历史口径不变。
$excludeCancelledAppointments = (int)($params['exclude_cancelled_appointments'] ?? 0) === 1;
// 一诊综合转化复用处方订单页的业绩口径;其它调用方继续保留历史“双审完成单”口径。
$usePerformanceOrderMetrics = strtolower(trim((string)($params['order_metric_mode'] ?? ''))) === 'performance';
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
$requestedMediaChannelCode = trim((string)($params['media_channel_code'] ?? ''));
$mediaChannel = $requestedMediaChannelCode !== ''
? MediaChannelService::getChannelByCode($requestedMediaChannelCode)
: null;
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -175,9 +188,14 @@ class ConversionLogic
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
$scopeDeptIds = $restrictStatsByDept
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
? self::loadChannelBoundDeptIds($mediaChannelCode)
: [];
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
$restrictStatsByDept = $channelBoundDeptIds !== [];
$scopeDeptIds = $restrictStatsByDept
? $channelBoundDeptIds
: $accountCostDeptIds;
$eligibleDeptIds = $restrictAccountCostByDept ? self::expandDeptIdsWithDescendants($scopeDeptIds) : $scopeDeptIds;
@@ -909,29 +927,16 @@ class ConversionLogic
?array $mediaChannel,
?array $visibleAdminIds = null
): void {
$query = Db::name('qywx_external_contact_event')
->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('a.id AS admin_id, COUNT(*) AS add_fans_count')
->group('a.id');
if ($visibleAdminIds !== null) {
$query->whereIn('a.id', $visibleAdminIds);
} elseif ($dimension !== 'dept' && $entityIds !== []) {
$query->whereIn('a.id', $entityIds);
$queryAdminIds = $visibleAdminIds;
if ($queryAdminIds === null && $dimension !== 'dept') {
$queryAdminIds = $entityIds;
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
$rows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $queryAdminIds);
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($rows, 'user_id'));
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
$userId = (string)($row['user_id'] ?? '');
$adminId = (int)($adminByUserId[$userId]['id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($dimension === 'dept' && $adminId <= 0) {
@@ -968,6 +973,123 @@ class ConversionLogic
}
}
/**
* Aggregate add-contact events by WeCom user once, then project that raw
* snapshot to departments, members and virtual buckets in PHP.
*
* @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds null means all active/unbound WeCom users
* @return array<int, array{user_id: string, add_fans_count: int|string}>
*/
private static function loadFanRows(
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $adminIds = null
): array {
if ($adminIds !== null) {
$adminIds = array_values(array_unique(array_filter(
array_map('intval', $adminIds),
static fn (int $id): bool => $id > 0
)));
sort($adminIds);
if ($adminIds === []) {
return [];
}
}
$baseKey = self::requestRowsCacheKey('fans', [
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
]);
$allKey = $baseKey . ':all';
$cacheKey = $adminIds === null
? $allKey
: $baseKey . ':admins:' . implode(',', $adminIds);
if (isset(self::$requestRowsCache[$cacheKey])) {
return self::$requestRowsCache[$cacheKey];
}
$workWechatUserIds = null;
if ($adminIds !== null) {
$workWechatUserIds = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
->where('work_wechat_userid', '<>', '')
->column('work_wechat_userid');
$workWechatUserIds = array_values(array_unique(array_filter(array_map('strval', $workWechatUserIds))));
if ($workWechatUserIds === []) {
self::$requestRowsCache[$cacheKey] = [];
return [];
}
if (isset(self::$requestRowsCache[$allKey])) {
$allowed = array_fill_keys($workWechatUserIds, true);
self::$requestRowsCache[$cacheKey] = array_values(array_filter(
self::$requestRowsCache[$allKey],
static fn (array $row): bool => isset($allowed[(string)($row['user_id'] ?? '')])
));
return self::$requestRowsCache[$cacheKey];
}
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
->group('e.user_id');
if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds);
}
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
}
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
return self::$requestRowsCache[$cacheKey];
}
/**
* @param string[] $userIds
* @return array<string, array{id: int|string, name: string}>
*/
private static function loadActiveAdminByWorkWechatUserIds(array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
sort($userIds);
if ($userIds === []) {
return [];
}
$cacheKey = self::requestRowsCacheKey('active-admin-by-wecom-user', $userIds);
if (!isset(self::$requestRowsCache[$cacheKey])) {
self::$requestRowsCache[$cacheKey] = Db::name('admin')
->whereIn('work_wechat_userid', $userIds)
->whereNull('delete_time')
->field('id, name, work_wechat_userid')
->select()
->toArray();
}
$result = [];
foreach (self::$requestRowsCache[$cacheKey] as $row) {
$userId = (string)($row['work_wechat_userid'] ?? '');
if ($userId !== '' && !isset($result[$userId])) {
$result[$userId] = [
'id' => (int)($row['id'] ?? 0),
'name' => (string)($row['name'] ?? ''),
];
}
}
return $result;
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
@@ -989,46 +1111,56 @@ class ConversionLogic
bool $excludeCancelledAppointments = false,
bool $useRegistrationMetric = false
): void {
$sourceExpr = $dimension === 'doctor'
? 'a.doctor_id'
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group("{$sourceExpr}, a.patient_id");
$sourceType = $dimension === 'doctor' ? 'doctor' : 'assistant';
$rows = self::cachedRequestRows('appointments', [
$sourceType,
$startDate,
$endDate,
self::mediaChannelCacheKey($mediaChannel),
$excludeCancelledAppointments,
], static function () use (
$sourceType,
$startDate,
$endDate,
$mediaChannel,
$excludeCancelledAppointments
): array {
$sourceExpr = $sourceType === 'doctor'
? 'a.doctor_id'
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->where('a.patient_id', '>', 0)
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group($sourceExpr);
if ($excludeCancelledAppointments) {
$query->whereIn('a.status', [1, 3, 4]);
}
if ($excludeCancelledAppointments) {
$query->whereIn('a.status', [1, 3, 4]);
}
$query->where(static function (Query $subQuery): void {
$subQuery->whereNull('u.id')
->whereOr(static function (Query $orQuery): void {
$orQuery->whereNull('u.delete_time');
});
$query->where(static function (Query $subQuery): void {
$subQuery->whereNull('u.id')
->whereOr(static function (Query $orQuery): void {
$orQuery->whereNull('u.delete_time');
});
});
if ($mediaChannel !== null) {
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
if ($legacyChannelValues !== []) {
self::applyAppointmentChannelFilter($query, $legacyChannelValues);
} else {
MediaChannelService::applyExternalUserChannelFilter($query, 'u.external_userid', $mediaChannel);
}
}
return $query->select()->toArray();
});
if ($mediaChannel !== null) {
$legacyChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($mediaChannel);
if ($legacyChannelValues !== []) {
$query->whereIn('a.channels', $legacyChannelValues);
} else {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = u.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
}
$rows = $query->select()->toArray();
foreach ($rows as $row) {
$diagnosisId = (int)($row['diagnosis_id'] ?? 0);
if ($diagnosisId <= 0) {
continue;
}
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
@@ -1084,30 +1216,41 @@ class ConversionLogic
): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
$query = Db::name('order')
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('o.payment_time')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
$rows = self::cachedRequestRows('paid-appointments', [
$startDateTime,
$endDateTime,
self::mediaChannelCacheKey($mediaChannel),
$useRegistrationMetric,
], static function () use (
$startDateTime,
$endDateTime,
$mediaChannel,
$useRegistrationMetric
): array {
$query = Db::name('order')
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
// payment_time 是 DATETIME NULLMySQL 8 严格模式下不能与空字符串比较。
->whereNotNull('o.payment_time')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
if ($useRegistrationMetric) {
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
} else {
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
$query->where('o.order_type', 1)->where('o.amount', 5);
}
if ($useRegistrationMetric) {
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
} else {
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
$query->where('o.order_type', 1)->where('o.amount', 5);
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
if ($mediaChannel !== null) {
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
}
$rows = $query->select()->toArray();
return $query->select()->toArray();
});
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
@@ -1122,6 +1265,54 @@ class ConversionLogic
}
}
/**
* 挂号渠道兼容:新表写 channel_sourcevarchar),旧表写 channelsint),
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
*
* @param int[] $channelValues
*/
private static function applyAppointmentChannelFilter(Query $query, array $channelValues): void
{
$channelValues = array_values(array_unique(array_filter(
array_map('intval', $channelValues),
static fn (int $value): bool => $value > 0
)));
if ($channelValues === []) {
$query->whereRaw('0 = 1');
return;
}
try {
$fields = Db::name('doctor_appointment')->getTableFields();
} catch (\Throwable) {
$fields = [];
}
$fields = is_array($fields) ? $fields : [];
$hasChannelSource = in_array('channel_source', $fields, true);
$hasChannels = in_array('channels', $fields, true);
if (!$hasChannelSource && !$hasChannels) {
$query->whereRaw('0 = 1');
return;
}
$placeholders = implode(',', array_fill(0, count($channelValues), '?'));
$parts = [];
$bindings = [];
if ($hasChannelSource) {
$parts[] = "a.channel_source IN ({$placeholders})";
array_push($bindings, ...array_map('strval', $channelValues));
}
if ($hasChannels) {
$parts[] = "a.channels IN ({$placeholders})";
array_push($bindings, ...$channelValues);
}
$query->whereRaw('(' . implode(' OR ', $parts) . ')', $bindings);
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
@@ -1141,26 +1332,40 @@ class ConversionLogic
bool $usePerformanceOrderMetrics = false
): void {
if ($usePerformanceOrderMetrics) {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'po.creator_id';
$query = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL')
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id AND dg.delete_time IS NULL')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
$query
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
->group($sourceExpr);
if ($mediaChannel !== null) {
$isDoctorDimension = $dimension === 'doctor';
$rows = self::cachedRequestRows('performance-orders', [
$isDoctorDimension ? 'doctor' : 'assistant',
$startTimestamp,
$endTimestamp,
self::mediaChannelCacheKey($mediaChannel),
], static function () use (
$isDoctorDimension,
$startTimestamp,
$endTimestamp,
$mediaChannel
): array {
$sourceExpr = $isDoctorDimension ? 'rx.creator_id' : 'po.creator_id';
$query = Db::name('tcm_prescription_order')
->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
if ($isDoctorDimension) {
$query->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL');
}
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
$query
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
->group($sourceExpr);
foreach ($query->select()->toArray() as $row) {
if ($mediaChannel !== null) {
$query->leftJoin('order o', 'o.id = po.linked_pay_order_id');
MediaChannelService::applyExternalUserChannelFilter($query, 'o.payer_external_userid', $mediaChannel);
}
return $query->select()->toArray();
});
foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds(
$dimension,
@@ -1204,10 +1409,8 @@ class ConversionLogic
->group($completedSourceExpr);
if ($mediaChannel !== null) {
$completedQuery
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($completedQuery, 'q.follow_users', $mediaChannel);
$completedQuery->leftJoin('order o', 'o.id = po.linked_pay_order_id');
MediaChannelService::applyExternalUserChannelFilter($completedQuery, 'o.payer_external_userid', $mediaChannel);
}
$completedRows = $completedQuery->select()->toArray();
@@ -1241,10 +1444,8 @@ class ConversionLogic
->group($businessSourceExpr);
if ($mediaChannel !== null) {
$businessQuery
->leftJoin('order o2', 'o2.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q2', 'q2.external_userid = o2.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($businessQuery, 'q2.follow_users', $mediaChannel);
$businessQuery->leftJoin('order o2', 'o2.id = po.linked_pay_order_id');
MediaChannelService::applyExternalUserChannelFilter($businessQuery, 'o2.payer_external_userid', $mediaChannel);
}
$businessRows = $businessQuery->select()->toArray();
@@ -1911,22 +2112,13 @@ class ConversionLogic
*/
private static function buildUnboundFansRows(int $startTimestamp, int $endTimestamp, ?array $mediaChannel): array
{
$query = Db::name('qywx_external_contact_event')
->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->whereNull('a.id')
->where('e.user_id', '<>', '')
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
->group('e.user_id');
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel);
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
$rows = array_values(array_filter($fanRows, static function (array $row) use ($adminByUserId): bool {
$userId = (string)($row['user_id'] ?? '');
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
return $userId !== '' && !isset($adminByUserId[$userId]);
}));
if ($rows === []) {
return [];
}
@@ -1982,36 +2174,17 @@ class ConversionLogic
?array $visibleAdminIds = null
): array
{
$query = Db::name('qywx_external_contact_event')
->alias('e')
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count')
->group('a.id, a.name');
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
// 与 HasDataScopeFilter::applyDataScopeByOwner 对齐:空集合用 0=1 闸门让 SQL 自然返回空。
$query->whereRaw('0 = 1');
} else {
$query->whereIn('a.id', $visibleAdminIds);
}
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
$rows = $query->select()->toArray();
if ($rows === []) {
$fanRows = self::loadFanRows($startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
if ($fanRows === []) {
return [];
}
$adminByUserId = self::loadActiveAdminByWorkWechatUserIds(array_column($fanRows, 'user_id'));
$result = [];
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
foreach ($fanRows as $row) {
$userId = (string)($row['user_id'] ?? '');
$admin = $adminByUserId[$userId] ?? null;
$adminId = (int)($admin['id'] ?? 0);
$addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($adminId <= 0 || $addFansCount <= 0) {
continue;
@@ -2019,7 +2192,7 @@ class ConversionLogic
if (isset($assignedAdminIds[$adminId])) {
continue;
}
$name = trim((string)($row['admin_name'] ?? ''));
$name = trim((string)($admin['name'] ?? ''));
if ($name === '') {
$name = 'admin#' . $adminId;
}
@@ -2039,7 +2212,8 @@ class ConversionLogic
/**
* 反查企微员工 user_id(如 CaoTaDuo)对应的中文名。
* 来源依次:admin 表(含已软删的,避免离职后丢失映射)→ qywx_external_contact.follow_users JSON 中的 remark 字段。
* 来源:admin 表(含已软删的,避免离职后丢失映射)。未命中时直接展示原始 userid
* 避免仅为展示名称对十几万行 follow_users TEXT 做前导通配全表扫描。
*
* @param string[] $userIds
* @return array<string, string>
@@ -2068,61 +2242,6 @@ class ConversionLogic
$result[$userId] = $name;
}
$remaining = array_values(array_diff($userIds, array_keys($result)));
if ($remaining === []) {
return $result;
}
// 从 qywx_external_contact.follow_users JSON 的 remark/description 字段尽力反查。
$followRows = Db::name('qywx_external_contact')
->whereNull('delete_time')
->where('follow_users', 'like', '%' . $remaining[0] . '%')
->limit(0)
->field('follow_users')
->select()
->toArray();
if ($followRows === []) {
// 单条 LIKE 没命中再退化全表(量大时会慢,因此仅在极少数员工场景下兜底)。
$followRows = Db::name('qywx_external_contact')
->whereNull('delete_time')
->whereNotNull('follow_users')
->where('follow_users', '<>', '')
->limit(2000)
->field('follow_users')
->select()
->toArray();
}
$remainingMap = array_fill_keys($remaining, true);
foreach ($followRows as $row) {
if ($remainingMap === []) {
break;
}
$followUsers = json_decode((string)($row['follow_users'] ?? '[]'), true);
if (!is_array($followUsers)) {
continue;
}
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$uid = trim((string)($fu['userid'] ?? ''));
if ($uid === '' || !isset($remainingMap[$uid])) {
continue;
}
$name = trim((string)($fu['remark_corp_name'] ?? ''));
if ($name === '') {
$name = trim((string)($fu['remark'] ?? ''));
}
if ($name === '') {
$name = trim((string)($fu['description'] ?? ''));
}
if ($name !== '') {
$result[$uid] = $name;
unset($remainingMap[$uid]);
}
}
}
return $result;
}
@@ -2613,6 +2732,42 @@ class ConversionLogic
return round($numerator / $denominator, 2);
}
/**
* @param array<int, mixed> $parts
*/
private static function requestRowsCacheKey(string $namespace, array $parts): string
{
return $namespace . ':' . hash('sha256', serialize($parts));
}
/**
* @param array<int, mixed> $parts
* @param callable(): array<int, array<string, mixed>> $loader
* @return array<int, array<string, mixed>>
*/
private static function cachedRequestRows(string $namespace, array $parts, callable $loader): array
{
$cacheKey = self::requestRowsCacheKey($namespace, $parts);
if (!isset(self::$requestRowsCache[$cacheKey])) {
self::$requestRowsCache[$cacheKey] = $loader();
}
return self::$requestRowsCache[$cacheKey];
}
/**
* @param array<string, mixed>|null $mediaChannel
* @return array{code: string, tag_id: string, tag_name: string}
*/
private static function mediaChannelCacheKey(?array $mediaChannel): array
{
return [
'code' => (string)($mediaChannel['channel_code'] ?? ''),
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
];
}
/**
* @param int[]|null $visibleAdminIds
* @param int[] $eligibleDeptIds
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\api\controller;
use app\common\service\qywx\QywxPromotionRedirectService;
use app\common\service\qywx\QywxPromotionWidgetService;
/** 企业微信获客助手公开端点:JS 与随机跳转。 */
class QywxPromotionPublicController extends BaseApiController
@@ -14,31 +15,19 @@ class QywxPromotionPublicController extends BaseApiController
public function script(string $key)
{
if (!QywxPromotionRedirectService::poolExists($key)) {
$pool = QywxPromotionRedirectService::publicPoolConfig($key);
if ($pool === null) {
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
}
$goUrl = rtrim($this->request->domain(), '/') . '/api/qywx-promotion/go/' . $key;
$jsonKey = json_encode($key, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$jsonGo = json_encode($goUrl, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$javascript = <<<JS
(function(w,d){
'use strict';
var key={$jsonKey}, go={$jsonGo};
function openPromotion(){
var source=w.location.href;
w.location.assign(go+'?from='+encodeURIComponent(source));
}
d.addEventListener('click',function(event){
var node=event.target&&event.target.closest?event.target.closest('[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]'):null;
if(!node){return;}
event.preventDefault();
event.stopPropagation();
openPromotion();
},true);
w.WecomPromotion=w.WecomPromotion||{};
w.WecomPromotion[key]={open:openPromotion};
})(window,document);
JS;
// 由安装脚本自身的 src 解析 API 域名,避免把请求 Host 写入可公开缓存的 JavaScript。
$goUrl = '/api/qywx-promotion/go/' . $key;
$config = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
$javascript = QywxPromotionWidgetService::renderScript(
$key,
$goUrl,
$config,
(int) ($pool['status'] ?? 0) === 1
);
return response($javascript, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
@@ -173,6 +173,53 @@ class MediaChannelService
$query->whereRaw('(' . implode(' OR ', $segments) . ')', $bindings);
}
/**
* Filter a fact table by its external_userid without joining the denormalized
* contact rows. The contact table may contain several rows for one customer;
* a normal JOIN therefore both scans follow_users TEXT repeatedly and
* multiplies facts. Enterprise tag channels use the normalized relation
* table, while legacy name-only channels keep a deduplicated JSON fallback.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
{
if ($channel === null) {
return;
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"{$field} IN (SELECT channel_tag.external_userid FROM {$tagTable} channel_tag WHERE channel_tag.tag_id = ?)",
[$tagId]
);
return;
}
$patterns = self::buildLikePatterns($channel);
if ($patterns === []) {
$query->whereRaw('1 = 0');
return;
}
$segments = [];
$bindings = [];
foreach ($patterns as $pattern) {
$segments[] = 'channel_contact.follow_users LIKE ?';
$bindings[] = $pattern;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$query->whereRaw(
"{$field} IN (SELECT channel_contact.external_userid FROM {$contactTable} channel_contact"
. ' WHERE channel_contact.delete_time IS NULL AND (' . implode(' OR ', $segments) . '))',
$bindings
);
}
/**
* @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int}
*/
@@ -275,6 +322,13 @@ class MediaChannelService
return array_values(array_unique($patterns));
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
/**
* @param array<int, mixed> $followUsers
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
@@ -9,6 +9,30 @@ use think\facade\Db;
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
class QywxPromotionRedirectService
{
/** @return array{status:int,widget_config_json:?string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
return null;
}
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
->field('status,widget_config_json')
->find();
if (!$row) {
return null;
}
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
];
}
/** @return array{url:string,link_id:int}|null */
public static function pick(string $publicKey, array $context = []): ?array
{
@@ -76,8 +100,7 @@ class QywxPromotionRedirectService
public static function poolExists(string $publicKey): bool
{
return preg_match('/^[a-f0-9]{32}$/', $publicKey) === 1
&& Db::name('qywx_promotion_pool')->where('public_key', $publicKey)->whereNull('delete_time')->count() > 0;
return self::publicPoolConfig($publicKey) !== null;
}
/** @param array<int,array<string,mixed>> $links */
@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use InvalidArgumentException;
/**
* 获客助手公开浮窗配置与脚本。
*
* 管理端输入严格校验;数据库中的未知版本或损坏配置一律回退为关闭状态。
*/
class QywxPromotionWidgetService
{
private const VERSION = 1;
private const TEMPLATES = ['bubble', 'pill', 'card', 'message', 'edge', 'bar'];
private const POSITIONS = ['bottom-right', 'bottom-left'];
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'v' => self::VERSION,
'enabled' => false,
'template' => 'bubble',
'position' => 'bottom-right',
'title' => '专属顾问在线',
'subtitle' => '点击添加企业微信,获取一对一服务',
'button_text' => '立即咨询',
'primary_color' => '#139A8C',
'bottom_offset' => 28,
'show_mobile' => true,
];
}
/**
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
public static function fromInput(mixed $input): array
{
if (!is_array($input)) {
throw new InvalidArgumentException('浮窗配置格式无效');
}
$defaults = self::defaults();
$version = self::integerValue(self::inputValue($input, 'v', self::VERSION), '配置版本');
if ($version !== self::VERSION) {
throw new InvalidArgumentException('不支持的浮窗配置版本');
}
$template = self::textValue(self::inputValue($input, 'template', $defaults['template']), '模板', 1, 20);
if (!in_array($template, self::TEMPLATES, true)) {
throw new InvalidArgumentException('浮窗模板无效');
}
$position = self::textValue(self::inputValue($input, 'position', $defaults['position']), '位置', 1, 20);
if (!in_array($position, self::POSITIONS, true)) {
throw new InvalidArgumentException('浮窗位置无效');
}
$color = strtoupper(trim(self::stringValue(
self::inputValue($input, 'primary_color', $defaults['primary_color']),
'主题色'
)));
if (preg_match('/^#[0-9A-F]{6}$/D', $color) !== 1) {
throw new InvalidArgumentException('主题色必须是 #RRGGBB 格式');
}
$bottomOffset = self::integerValue(
self::inputValue($input, 'bottom_offset', $defaults['bottom_offset']),
'底部距离'
);
if ($bottomOffset < 16 || $bottomOffset > 160) {
throw new InvalidArgumentException('底部距离必须在 16-160 之间');
}
return [
'v' => self::VERSION,
'enabled' => self::booleanValue(self::inputValue($input, 'enabled', $defaults['enabled']), '启用状态'),
'template' => $template,
'position' => $position,
'title' => self::textValue(self::inputValue($input, 'title', $defaults['title']), '标题', 1, 24),
'subtitle' => self::textValue(self::inputValue($input, 'subtitle', $defaults['subtitle']), '副标题', 0, 48),
'button_text' => self::textValue(
self::inputValue($input, 'button_text', $defaults['button_text']),
'按钮文案',
1,
12
),
'primary_color' => $color,
'bottom_offset' => $bottomOffset,
'show_mobile' => self::booleanValue(
self::inputValue($input, 'show_mobile', $defaults['show_mobile']),
'移动端展示状态'
),
];
}
/** @return array<string, mixed> */
public static function decode(mixed $stored): array
{
if (!is_string($stored) || trim($stored) === '') {
return self::defaults();
}
try {
$decoded = json_decode($stored, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
return self::defaults();
}
foreach (array_keys(self::defaults()) as $key) {
if (!array_key_exists($key, $decoded)) {
return self::defaults();
}
}
return self::fromInput($decoded);
} catch (\Throwable) {
return self::defaults();
}
}
/** @param array<string, mixed> $config */
public static function encode(array $config): string
{
return self::jsonForScript(self::fromInput($config));
}
/**
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
*
* @param array<string, mixed> $config
*/
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
$config['enabled'] = false;
}
$jsonKey = self::jsonForScript($key);
$jsonGo = self::jsonForScript($goUrl);
$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 registry=w.WecomPromotion=w.WecomPromotion||{};
var previous=registry[key];
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
previous.destroy();
}
var root=null,mediaQuery=null,readyHandler=null,destroyed=false,manuallyHidden=false,api=null;
var rootId='wecom-promotion-widget-'+key;
var selector='[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]';
function findScriptNode(){
if(scriptNode&&scriptNode.src){return scriptNode;}
var scripts=d.getElementsByTagName('script');
var marker='/api/qywx-promotion/js/'+key;
for(var index=scripts.length-1;index>=0;index--){
if((scripts[index].src||'').indexOf(marker)!==-1){scriptNode=scripts[index];return scriptNode;}
}
return null;
}
function resolveGoUrl(value){
if(/^https?:\/\//i.test(value)){return value;}
var node=findScriptNode();
if(node&&node.src&&typeof w.URL==='function'){
try{return new w.URL(value,node.src).href;}catch(error){}
}
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()));
}
function handleDocumentClick(event){
var path=typeof event.composedPath==='function'?event.composedPath():[];
var node=null;
for(var index=0;index<path.length;index++){
var candidate=path[index];
if(candidate&&candidate.nodeType===1&&candidate.matches&&candidate.matches(selector)){node=candidate;break;}
}
var target=event.target;
if(!node){node=target&&target.closest?target.closest(selector):null;}
if(!node){return;}
event.preventDefault();
event.stopPropagation();
openPromotion();
}
function isMobileHidden(){
return config.show_mobile===false&&mediaQuery&&mediaQuery.matches;
}
function applyVisibility(){
if(root){root.hidden=manuallyHidden||isMobileHidden();}
}
function handleViewportChange(){
applyVisibility();
}
function appendText(parent,tag,className,value){
var node=d.createElement(tag);
node.className=className;
node.textContent=value;
parent.appendChild(node);
return node;
}
function mount(){
if(destroyed||root||!config.enabled||!d.body){return;}
var stale=d.getElementById(rootId);
if(stale&&stale.parentNode){stale.parentNode.removeChild(stale);}
root=d.createElement('div');
root.id=rootId;
root.className='wcp-host wcp-host-'+config.position+' wcp-host-'+config.template;
root.setAttribute('data-wecom-promotion-widget',key);
var surface=root.attachShadow?root.attachShadow({mode:'open'}):root;
var style=d.createElement('style');
var nonceNode=findScriptNode();
var nonce=nonceNode?(nonceNode.nonce||nonceNode.getAttribute('nonce')||''):'';
if(nonce){style.setAttribute('nonce',nonce);}
var hostRules='position:fixed;z-index:2147483000;right:20px;bottom:calc('+config.bottom_offset+'px + env(safe-area-inset-bottom, 0px));max-width:calc(100vw - 32px);pointer-events:none;--wcp-primary:'+config.primary_color+';font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#fff;line-height:1.4;-webkit-font-smoothing:antialiased';
style.textContent=':host{'+hostRules+'}.wcp-host{'+hostRules+'}' +
':host(.wcp-host-bottom-left){right:auto;left:20px}.wcp-host-bottom-left{right:auto;left:20px}' +
':host(.wcp-host-edge.wcp-host-bottom-right){right:0}.wcp-host-edge.wcp-host-bottom-right{right:0}' +
':host(.wcp-host-edge.wcp-host-bottom-left){right:auto;left:0}.wcp-host-edge.wcp-host-bottom-left{right:auto;left:0}' +
':host([hidden]){display:none!important}.wcp-host[hidden]{display:none!important}.wcp-root,.wcp-root *{box-sizing:border-box}.wcp-root{pointer-events:none}' +
'.wcp-button{pointer-events:auto;position:relative;display:flex;align-items:center;gap:12px;margin:0;border:0;cursor:pointer;color:#fff;background:var(--wcp-primary);font:inherit;text-align:left;box-shadow:0 14px 38px rgba(18,48,46,.24);transition:transform .2s ease,box-shadow .2s ease;appearance:none;-webkit-appearance:none}' +
'.wcp-button:hover{transform:translateY(-2px);box-shadow:0 18px 44px rgba(18,48,46,.3)}.wcp-button:active{transform:translateY(0)}.wcp-button:focus-visible{outline:3px solid rgba(255,255,255,.96);outline-offset:3px}' +
'.wcp-icon{display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.18);font-size:17px;font-weight:800}' +
'.wcp-copy{display:flex;min-width:0;flex-direction:column}.wcp-title{font-size:15px;font-weight:750;line-height:1.25}.wcp-subtitle{margin-top:2px;max-width:240px;font-size:12px;line-height:1.4;opacity:.86}' +
'.wcp-cta{flex:0 0 auto;padding:7px 11px;border-radius:999px;background:#fff;color:var(--wcp-primary);font-size:12px;font-weight:750;white-space:nowrap}' +
'.wcp-bubble .wcp-button{width:66px;height:66px;justify-content:center;padding:0;border-radius:50%}.wcp-bubble .wcp-icon{width:46px;height:46px;font-size:19px}.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{position:absolute;right:76px;visibility:hidden;opacity:0;transform:translateX(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:none}' +
'.wcp-bottom-left.wcp-bubble .wcp-copy,.wcp-bottom-left.wcp-bubble .wcp-cta{right:auto;left:76px}.wcp-bubble .wcp-copy{bottom:27px;width:220px;padding:11px 13px;border-radius:12px;background:#173f3b;box-shadow:0 12px 30px rgba(0,0,0,.2)}.wcp-bubble .wcp-cta{bottom:-1px;padding:5px 10px}' +
'.wcp-bubble .wcp-button:hover .wcp-copy,.wcp-bubble .wcp-button:hover .wcp-cta,.wcp-bubble .wcp-button:focus-visible .wcp-copy,.wcp-bubble .wcp-button:focus-visible .wcp-cta{visibility:visible;opacity:1;transform:translateX(0)}' +
'.wcp-pill .wcp-button{min-height:58px;padding:9px 12px;border-radius:999px}.wcp-pill .wcp-subtitle{display:none}' +
'.wcp-card .wcp-button{width:min(340px,calc(100vw - 40px));padding:15px;border-radius:18px}.wcp-card .wcp-icon{width:46px;height:46px}.wcp-card .wcp-copy{flex:1}.wcp-card .wcp-cta{border-radius:10px}' +
'.wcp-message .wcp-button{width:min(330px,calc(100vw - 40px));padding:13px 14px;border-radius:18px 18px 4px 18px}.wcp-bottom-left.wcp-message .wcp-button{border-radius:18px 18px 18px 4px}.wcp-message .wcp-copy{flex:1}.wcp-message .wcp-cta{padding:6px 9px}' +
'.wcp-edge .wcp-button{min-height:62px;max-width:270px;padding:10px 15px;border-radius:16px 0 0 16px}.wcp-bottom-left.wcp-edge .wcp-button{border-radius:0 16px 16px 0}.wcp-edge .wcp-subtitle{display:none}.wcp-edge .wcp-cta{padding:6px 9px}' +
'.wcp-bar .wcp-button{width:min(420px,calc(100vw - 40px));padding:11px 14px;border-radius:12px}.wcp-bar .wcp-copy{flex:1}.wcp-bar .wcp-icon{width:34px;height:34px}.wcp-bar .wcp-subtitle{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'@media(max-width:767px){.wcp-host{max-width:calc(100vw - 24px)}.wcp-card .wcp-button,.wcp-message .wcp-button,.wcp-bar .wcp-button{width:calc(100vw - 40px)}.wcp-subtitle{max-width:180px}.wcp-card .wcp-cta,.wcp-message .wcp-cta{display:none}}' +
'@media(prefers-reduced-motion:reduce){.wcp-button,.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{transition:none!important}}';
surface.appendChild(style);
var container=d.createElement('div');
container.className='wcp-root wcp-'+config.template+' wcp-'+config.position;
var button=d.createElement('button');
button.type='button';
button.className='wcp-button';
button.setAttribute('aria-label',config.title+''+config.button_text);
appendText(button,'span','wcp-icon','企');
var copy=d.createElement('span');
copy.className='wcp-copy';
appendText(copy,'strong','wcp-title',config.title);
if(config.subtitle!==''){appendText(copy,'span','wcp-subtitle',config.subtitle);}
button.appendChild(copy);
appendText(button,'span','wcp-cta',config.button_text);
button.addEventListener('click',function(event){
event.preventDefault();
event.stopPropagation();
openPromotion();
});
container.appendChild(button);
surface.appendChild(container);
d.body.appendChild(root);
if(config.show_mobile===false&&typeof w.matchMedia==='function'){
mediaQuery=w.matchMedia('(max-width: 767px)');
if(mediaQuery.addEventListener){mediaQuery.addEventListener('change',handleViewportChange);}
else if(mediaQuery.addListener){mediaQuery.addListener(handleViewportChange);}
}
applyVisibility();
}
function show(){
if(destroyed||!config.enabled){return;}
manuallyHidden=false;
if(root){applyVisibility();return;}
if(d.body){mount();}
else if(!readyHandler){
readyHandler=function(){readyHandler=null;mount();};
d.addEventListener('DOMContentLoaded',readyHandler,{once:true});
}
}
function hide(){
manuallyHidden=true;
applyVisibility();
}
function destroy(){
if(destroyed){return;}
destroyed=true;
d.removeEventListener('click',handleDocumentClick,true);
if(readyHandler){d.removeEventListener('DOMContentLoaded',readyHandler);readyHandler=null;}
if(mediaQuery){
if(mediaQuery.removeEventListener){mediaQuery.removeEventListener('change',handleViewportChange);}
else if(mediaQuery.removeListener){mediaQuery.removeListener(handleViewportChange);}
mediaQuery=null;
}
if(root&&root.parentNode){root.parentNode.removeChild(root);}
root=null;
if(registry[key]===api){delete registry[key];}
}
d.addEventListener('click',handleDocumentClick,true);
api={open:openPromotion,show:show,hide:hide,destroy:destroy,config:config,__widgetVersion:1};
registry[key]=api;
if(config.enabled){show();}
})(window,document);
JS;
}
private static function booleanValue(mixed $value, string $label): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1') {
return true;
}
if ($value === 0 || $value === '0') {
return false;
}
throw new InvalidArgumentException($label . '必须是布尔值');
}
private static function inputValue(array $input, string $key, mixed $default): mixed
{
return array_key_exists($key, $input) ? $input[$key] : $default;
}
private static function integerValue(mixed $value, string $label): int
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
return (int) $value;
}
throw new InvalidArgumentException($label . '必须是整数');
}
private static function stringValue(mixed $value, string $label): string
{
if (!is_string($value)) {
throw new InvalidArgumentException($label . '必须是字符串');
}
return $value;
}
private static function textValue(mixed $value, string $label, int $min, int $max): string
{
$value = self::stringValue($value, $label);
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? '';
$length = mb_strlen($value);
if ($length < $min || $length > $max) {
throw new InvalidArgumentException(sprintf('%s长度必须在 %d-%d 个字符之间', $label, $min, $max));
}
return $value;
}
private static function jsonForScript(mixed $value): string
{
return json_encode(
$value,
JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
| JSON_THROW_ON_ERROR
);
}
}