更新
This commit is contained in:
@@ -190,6 +190,10 @@ export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
|
||||
@@ -318,15 +318,22 @@ const targetChartOption = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
let latestDashboardRequestId = 0
|
||||
|
||||
async function loadDashboard() {
|
||||
const requestId = ++latestDashboardRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const result: any = await firstVisitConversionOverview(query)
|
||||
const result: any = await firstVisitConversionOverview({ ...query })
|
||||
if (requestId !== latestDashboardRequestId) return
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
// 服务端会规范化失效渠道;同步真实生效值,避免筛选框与数据口径不一致。
|
||||
query.media_channel_code = dashboard.meta.selected_media_channel_code || ''
|
||||
} catch (error: any) {
|
||||
if (requestId !== latestDashboardRequestId) return
|
||||
ElMessage.error(error?.message || '综合数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === latestDashboardRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
export const WECOM_WIDGET_TEMPLATE_IDS = [
|
||||
'bubble',
|
||||
'pill',
|
||||
'card',
|
||||
'message',
|
||||
'edge',
|
||||
'bar'
|
||||
] as const
|
||||
|
||||
export type WecomWidgetTemplateId = (typeof WECOM_WIDGET_TEMPLATE_IDS)[number]
|
||||
export type WecomWidgetPosition = 'bottom-right' | 'bottom-left'
|
||||
|
||||
export interface WecomWidgetConfig {
|
||||
v: 1
|
||||
enabled: boolean
|
||||
template: WecomWidgetTemplateId
|
||||
position: WecomWidgetPosition
|
||||
title: string
|
||||
subtitle: string
|
||||
button_text: string
|
||||
primary_color: string
|
||||
bottom_offset: number
|
||||
show_mobile: boolean
|
||||
}
|
||||
|
||||
export interface WecomWidgetTemplateOption {
|
||||
id: WecomWidgetTemplateId
|
||||
name: string
|
||||
description: string
|
||||
scene: string
|
||||
}
|
||||
|
||||
export const DEFAULT_WECOM_WIDGET_CONFIG: Readonly<WecomWidgetConfig> = Object.freeze({
|
||||
v: 1,
|
||||
enabled: false,
|
||||
template: 'bubble',
|
||||
position: 'bottom-right',
|
||||
title: '专属顾问在线',
|
||||
subtitle: '点击添加企业微信,获取一对一服务',
|
||||
button_text: '立即咨询',
|
||||
primary_color: '#139A8C',
|
||||
bottom_offset: 28,
|
||||
show_mobile: true
|
||||
})
|
||||
|
||||
export const WECOM_WIDGET_TEMPLATES: ReadonlyArray<WecomWidgetTemplateOption> = Object.freeze([
|
||||
{
|
||||
id: 'bubble',
|
||||
name: '轻巧气泡',
|
||||
description: '圆形入口,占用空间最少',
|
||||
scene: '内容型页面'
|
||||
},
|
||||
{
|
||||
id: 'pill',
|
||||
name: '行动胶囊',
|
||||
description: '图标与按钮文案同时露出',
|
||||
scene: '营销落地页'
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
name: '顾问名片',
|
||||
description: '完整呈现标题、说明与行动按钮',
|
||||
scene: '高意向咨询'
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
name: '消息提醒',
|
||||
description: '模拟新消息,视觉提醒更明确',
|
||||
scene: '活动推广页'
|
||||
},
|
||||
{
|
||||
id: 'edge',
|
||||
name: '贴边咨询',
|
||||
description: '沿浏览器边缘停靠,干扰更低',
|
||||
scene: '工具与内容页'
|
||||
},
|
||||
{
|
||||
id: 'bar',
|
||||
name: '底部咨询条',
|
||||
description: '宽幅行动区,移动端更醒目',
|
||||
scene: '移动端页面'
|
||||
}
|
||||
])
|
||||
|
||||
const TEMPLATE_SET = new Set<string>(WECOM_WIDGET_TEMPLATE_IDS)
|
||||
const POSITION_SET = new Set<string>(['bottom-right', 'bottom-left'])
|
||||
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/i
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
if (typeof value !== 'string' || !value.trim()) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (value === true || value === 1 || value === '1' || value === 'true') return true
|
||||
if (value === false || value === 0 || value === '0' || value === 'false') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, fallback: string, maxLength: number): string {
|
||||
if (value === undefined || value === null) return fallback
|
||||
return Array.from(String(value).trim()).slice(0, maxLength).join('')
|
||||
}
|
||||
|
||||
export function normalizeWecomWidgetConfig(value: unknown): WecomWidgetConfig {
|
||||
const source = asRecord(value)
|
||||
const template = String(source.template || '')
|
||||
const position = String(source.position || '')
|
||||
const color = String(source.primary_color || '').trim().toUpperCase()
|
||||
const rawOffset = Number(source.bottom_offset)
|
||||
const bottomOffset = Number.isFinite(rawOffset)
|
||||
? Math.min(160, Math.max(16, Math.round(rawOffset)))
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.bottom_offset
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
enabled: normalizeBoolean(source.enabled, DEFAULT_WECOM_WIDGET_CONFIG.enabled),
|
||||
template: TEMPLATE_SET.has(template)
|
||||
? template as WecomWidgetTemplateId
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.template,
|
||||
position: POSITION_SET.has(position)
|
||||
? position as WecomWidgetPosition
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.position,
|
||||
title: normalizeText(source.title, DEFAULT_WECOM_WIDGET_CONFIG.title, 24),
|
||||
subtitle: normalizeText(source.subtitle, DEFAULT_WECOM_WIDGET_CONFIG.subtitle, 48),
|
||||
button_text: normalizeText(source.button_text, DEFAULT_WECOM_WIDGET_CONFIG.button_text, 12),
|
||||
primary_color: HEX_COLOR_PATTERN.test(color)
|
||||
? color
|
||||
: DEFAULT_WECOM_WIDGET_CONFIG.primary_color,
|
||||
bottom_offset: bottomOffset,
|
||||
show_mobile: normalizeBoolean(source.show_mobile, DEFAULT_WECOM_WIDGET_CONFIG.show_mobile)
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneDefaultWecomWidgetConfig(): WecomWidgetConfig {
|
||||
return { ...DEFAULT_WECOM_WIDGET_CONFIG }
|
||||
}
|
||||
@@ -323,39 +323,20 @@
|
||||
<div v-else class="tab-content install-tab">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>安装 JS 到推广落地页</h2>
|
||||
<p>基础脚本只负责捕获点击并请求服务端分流,不携带授权凭证,也不在浏览器中计算随机规则。</p>
|
||||
<h2>安装 JS 与浮窗客服</h2>
|
||||
<p>为每个分流方案配置独立浮窗;基础脚本仍只负责展示入口、捕获点击并请求服务端分流。</p>
|
||||
</div>
|
||||
<el-select v-model="selectedInstallPoolId" placeholder="选择分流方案" class="install-pool-select">
|
||||
<el-option v-for="pool in overview.pools" :key="pool.id" :label="pool.name" :value="Number(pool.id)" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedInstallPool">
|
||||
<div class="install-grid">
|
||||
<article class="code-card">
|
||||
<div class="code-heading"><span>1</span><div><strong>安装基础脚本</strong><p>放在页面 <code></head></code> 标签之前,只需安装一次。</p></div></div>
|
||||
<pre><code>{{ selectedInstallPool.install_code }}</code></pre>
|
||||
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.install_code, '基础脚本')">复制代码</el-button>
|
||||
</article>
|
||||
<article class="code-card">
|
||||
<div class="code-heading"><span>2</span><div><strong>标记点击元素</strong><p>按钮、图片或文字链接都可以使用同一个数据属性。</p></div></div>
|
||||
<pre><code>{{ selectedInstallPool.trigger_code }}</code></pre>
|
||||
<el-button type="primary" plain :icon="DocumentCopy" @click="copyText(selectedInstallPool.trigger_code, '点击元素代码')">复制代码</el-button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="rule-panel">
|
||||
<div><el-icon><Select /></el-icon><span><strong>可用性筛选</strong>自动排除停用、超出时间段和达到每日上限的链接。</span></div>
|
||||
<div><el-icon><Opportunity /></el-icon><span><strong>权重随机</strong>权重越高,被选中的概率越大;没有可用链接时才使用兜底链接。</span></div>
|
||||
<div><el-icon><View /></el-icon><span><strong>隐私与安全</strong>前端看不到完整链接池;访问 IP 只保存带服务端密钥的不可逆哈希。</span></div>
|
||||
</div>
|
||||
|
||||
<div class="test-row">
|
||||
<div><strong>分流测试</strong><p>每次打开都会执行与线上相同的筛选和随机规则,并计入点击数据。</p></div>
|
||||
<el-button type="primary" :icon="TopRight" @click="openTestLink">打开测试链接</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<WecomFloatingWidgetBuilder
|
||||
v-if="selectedInstallPool"
|
||||
:key="Number(selectedInstallPool.id)"
|
||||
:pool="selectedInstallPool"
|
||||
@saved="handleWidgetSaved"
|
||||
/>
|
||||
<el-empty v-else description="请先创建分流方案,系统会自动生成 JS 安装代码" />
|
||||
</div>
|
||||
</section>
|
||||
@@ -412,8 +393,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown, ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Opportunity, Plus,
|
||||
Promotion, Refresh, Search, Select, SetUp, TopRight, User, View, Warning
|
||||
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
|
||||
Promotion, Refresh, Search, SetUp, User, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionCheckApiPermission,
|
||||
@@ -431,10 +412,12 @@ import {
|
||||
} from '@/api/first_visit'
|
||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
||||
|
||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||
|
||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||
const emptyOverview = () => ({
|
||||
meta: { scope_label: '', generated_at: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, official_doc: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', official_doc: '' },
|
||||
summary: { configured_apps: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
|
||||
pools: [] as any[], links: [] as any[], member_options: [] as any[], customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
|
||||
})
|
||||
@@ -930,8 +913,8 @@ async function copyText(value: string, label: string) {
|
||||
ElMessage.success(`${label}已复制`)
|
||||
}
|
||||
|
||||
function openTestLink() {
|
||||
if (selectedInstallPool.value?.go_url) window.open(selectedInstallPool.value.go_url, '_blank', 'noopener,noreferrer')
|
||||
async function handleWidgetSaved() {
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -998,10 +981,8 @@ h1, h2, h3, p { margin: 0; }
|
||||
.missing-fields { margin-top: 12px; color: #9c651f; font-size: 11px; }.missing-fields code { margin-left: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(224,153,63,.11); }
|
||||
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
|
||||
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
|
||||
.install-pool-select { width: 220px; }.install-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.code-card { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfd; }.code-heading { display: flex; align-items: center; gap: 10px; }.code-heading > span { display: grid; width: 30px; height: 30px; flex: 0 0 30px; place-items: center; border-radius: 8px; color: #fff; background: var(--teal); font-weight: 700; }.code-heading strong { font-size: 13px; }.code-heading p { margin-top: 3px; color: #7a889a; font-size: 10px; }.code-card pre { min-height: 92px; margin: 14px 0; padding: 13px; overflow: auto; border-radius: 7px; color: #cbe9e6; background: #172a36; white-space: pre-wrap; word-break: break-all; }.code-card pre code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.7; }
|
||||
.rule-panel { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; margin-top: 14px; }.rule-panel > div { display: flex; align-items: flex-start; gap: 9px; min-height: 68px; padding: 12px; border-radius: 9px; color: #69788b; background: #f3f7f8; font-size: 11px; line-height: 1.65; }.rule-panel .el-icon { margin-top: 2px; color: var(--teal); font-size: 17px; }.rule-panel strong { display: block; color: #26364a; }
|
||||
.test-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; padding: 14px 16px; border: 1px dashed #b8d9d4; border-radius: 9px; background: #f7fcfb; }.test-row strong { font-size: 13px; }.test-row p { margin-top: 4px; color: #7c8999; font-size: 10px; }
|
||||
.install-pool-select { width: 220px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; }.rule-panel { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .install-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
|
||||
</style>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 NULL;MySQL 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 NULL;MySQL 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_source(varchar),旧表写 channels(int),
|
||||
* 过渡库可能两列同时存在。不能因为运行库采用其中一种结构而漏统或报错。
|
||||
*
|
||||
* @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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
CORP_ID = "当前企业 CorpID"
|
||||
AGENT_ID = "内部自建应用 AgentID"
|
||||
CUSTOMER_ACQUISITION_SECRET = "获客助手可调用应用的 Secret"
|
||||
|
||||
[app]
|
||||
HOST = "https://公开访问域名"
|
||||
```
|
||||
|
||||
兼容已有项目:没有 `CUSTOMER_ACQUISITION_SECRET` 时,会依次回退读取 `AGENT_SECRET`、`SECRET`。如果现有 `SECRET` 就是获客助手中配置的“可调用应用”Secret,无需重复配置。
|
||||
@@ -47,7 +50,48 @@ https://work.weixin.qq.com/ca/xxxxxxxx
|
||||
|
||||
```html
|
||||
<script src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
|
||||
<a href="#" data-wecom-promotion="分流方案KEY">添加企业微信</a>
|
||||
<a href="https://你的域名/api/qywx-promotion/go/分流方案KEY" data-wecom-promotion="分流方案KEY">添加企业微信</a>
|
||||
```
|
||||
|
||||
## 公开浮窗
|
||||
|
||||
每个分流方案可选择是否由同一段公开 JS 自动挂载客服浮窗。关闭浮窗时,已有的
|
||||
`data-wecom-promotion`、`.wecom-promotion-link[data-pool]` 和
|
||||
`window.WecomPromotion[KEY].open()` 手动触发方式仍然可用。
|
||||
|
||||
浮窗配置保存在分流方案的 `widget_config_json` 中。当前配置版本为 `v=1`,支持:
|
||||
|
||||
- 模板:`bubble`、`pill`、`card`、`message`、`edge`、`bar`
|
||||
- 位置:`bottom-right`、`bottom-left`
|
||||
- 标题、副标题、按钮文案和 `#RRGGBB` 主题色
|
||||
- 16-160 像素底部距离、移动端展示开关和浮窗总开关
|
||||
|
||||
公开脚本仅下发经过白名单校验的展示配置,不下发兜底链接或真实获客链接池。模板
|
||||
由脚本内置,管理端文案通过 DOM `textContent` 写入,不接受自定义 HTML、CSS 或脚本。
|
||||
损坏配置、未知版本和非法枚举会按关闭浮窗处理。
|
||||
|
||||
脚本会暴露以下运行时方法:
|
||||
|
||||
```js
|
||||
window.WecomPromotion['分流方案KEY'].open()
|
||||
window.WecomPromotion['分流方案KEY'].show()
|
||||
window.WecomPromotion['分流方案KEY'].hide()
|
||||
window.WecomPromotion['分流方案KEY'].destroy()
|
||||
```
|
||||
|
||||
公开脚本缓存 60 秒,因此浮窗样式或开关更新最多延迟约 60 秒;方案运行状态仍会在
|
||||
每次服务端跳转时即时校验。脚本会从自身 `src` 解析跳转接口域名,不会把公开请求的
|
||||
Host 写入缓存内容。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的
|
||||
HTTPS 公开域名。
|
||||
|
||||
接入站点若启用了严格 CSP,需要允许脚本域名,并给安装 `<script>` 添加站点当前请求
|
||||
的 `nonce`。公开脚本会把该 `nonce` 传给 Shadow DOM 内的动态样式:
|
||||
|
||||
```html
|
||||
<script nonce="当前请求的 nonce" src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
|
||||
```
|
||||
|
||||
点击来源只上报页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也
|
||||
不应放置手机号、患者 ID、重置令牌等敏感信息。
|
||||
|
||||
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<!-- 字符编码:确保中文等复杂字符正确显示 -->
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<!-- 视口设置:确保移动端设备正确缩放,响应式设计的核心 -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- 页面标题:显示在浏览器标签页上 -->
|
||||
<title>网页标题</title>
|
||||
|
||||
<!-- 描述:用于 SEO 和社交媒体分享时的摘要 -->
|
||||
<meta name="description" content="这是一段关于网页内容的简短描述,有利于搜索引擎优化。">
|
||||
|
||||
<!-- 关键词(可选,现代SEO中权重较低,但可保留) -->
|
||||
<meta name="keywords" content="HTML, 网页结构, 前端">
|
||||
|
||||
<script src="https://css.zhenyangtang.com.cn/api/qywx-promotion/js/88ac0ab3a549ccc891bab091b800ac68" defer></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<!-- 语义化标签:头部区域 -->
|
||||
<a href="https://css.zhenyangtang.com.cn/api/qywx-promotion/go/88ac0ab3a549ccc891bab091b800ac68" data-wecom-promotion="88ac0ab3a549ccc891bab091b800ac68">添加企业微信</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_pool` (
|
||||
`public_key` char(32) NOT NULL COMMENT '公开 JS 分流键',
|
||||
`status` tinyint unsigned NOT NULL DEFAULT 1,
|
||||
`fallback_url` varchar(1000) NOT NULL DEFAULT '',
|
||||
`widget_config_json` text NULL COMMENT '公开浮窗配置 JSON v1',
|
||||
`click_count` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`owner_admin_id` int unsigned NOT NULL DEFAULT 0,
|
||||
`dept_id` int unsigned NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 企业微信获客助手:公开 JS 浮窗配置。
|
||||
-- 新安装环境已包含在 add_first_visit_wecom_promotion.sql,无需重复执行本文件。
|
||||
|
||||
SET @widget_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM `information_schema`.`COLUMNS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'zyt_qywx_promotion_pool'
|
||||
AND `COLUMN_NAME` = 'widget_config_json'
|
||||
);
|
||||
|
||||
SET @widget_upgrade_sql = IF(
|
||||
@widget_column_exists = 0,
|
||||
'ALTER TABLE `zyt_qywx_promotion_pool` ADD COLUMN `widget_config_json` text NULL COMMENT ''公开浮窗配置 JSON v1'' AFTER `fallback_url`',
|
||||
'SELECT ''widget_config_json already exists'' AS `migration_status`'
|
||||
);
|
||||
|
||||
PREPARE widget_upgrade_statement FROM @widget_upgrade_sql;
|
||||
EXECUTE widget_upgrade_statement;
|
||||
DEALLOCATE PREPARE widget_upgrade_statement;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 渠道统计按 tag_id 取去重 external_userid;组合索引避免回表扫描标签关系数据。
|
||||
-- 既有库切换统计前应执行一次:php think qywx:backfill-customer-tags --all --fast
|
||||
SET @idx_tag_ext_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'zyt_qywx_external_contact_tag'
|
||||
AND INDEX_NAME = 'idx_tag_ext'
|
||||
);
|
||||
|
||||
SET @add_idx_tag_ext_sql := IF(
|
||||
@idx_tag_ext_exists = 0,
|
||||
'ALTER TABLE `zyt_qywx_external_contact_tag` ADD INDEX `idx_tag_ext` (`tag_id`, `external_userid`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE add_idx_tag_ext_stmt FROM @add_idx_tag_ext_sql;
|
||||
EXECUTE add_idx_tag_ext_stmt;
|
||||
DEALLOCATE PREPARE add_idx_tag_ext_stmt;
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use think\facade\Db;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
$tagQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$tagQuery,
|
||||
'e.external_userid',
|
||||
[
|
||||
'source_tag_id' => 'tag-regression-id',
|
||||
'source_tag_name' => '回归渠道',
|
||||
]
|
||||
);
|
||||
$tagSql = (string)$tagQuery->fetchSql()->select();
|
||||
if (!str_contains($tagSql, 'qywx_external_contact_tag')) {
|
||||
throw new RuntimeException('tag 渠道未使用结构化客户标签关系表');
|
||||
}
|
||||
if (!str_contains($tagSql, ' IN (SELECT channel_tag.external_userid')) {
|
||||
throw new RuntimeException('tag 渠道未通过去重子查询过滤 external_userid');
|
||||
}
|
||||
if (str_contains($tagSql, 'follow_users') || str_contains($tagSql, 'LIKE')) {
|
||||
throw new RuntimeException('tag 渠道仍在扫描 follow_users JSON');
|
||||
}
|
||||
|
||||
$legacyQuery = Db::name('order')->alias('o');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$legacyQuery,
|
||||
'o.payer_external_userid',
|
||||
[
|
||||
'source_tag_id' => '',
|
||||
'source_tag_name' => '仅名称老渠道',
|
||||
]
|
||||
);
|
||||
$legacySql = (string)$legacyQuery->fetchSql()->select();
|
||||
if (!str_contains($legacySql, ' IN (SELECT channel_contact.external_userid')) {
|
||||
throw new RuntimeException('老渠道回退未使用去重 external_userid 子查询');
|
||||
}
|
||||
if (!str_contains($legacySql, 'channel_contact.delete_time IS NULL')) {
|
||||
throw new RuntimeException('老渠道回退包含了已删除客户记录');
|
||||
}
|
||||
|
||||
echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionWidgetService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function widgetAssert(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function expectInvalidWidget(array $overrides, string $message): void
|
||||
{
|
||||
try {
|
||||
QywxPromotionWidgetService::fromInput($overrides + QywxPromotionWidgetService::defaults());
|
||||
} catch (InvalidArgumentException) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$defaults = QywxPromotionWidgetService::defaults();
|
||||
widgetAssert($defaults === [
|
||||
'v' => 1,
|
||||
'enabled' => false,
|
||||
'template' => 'bubble',
|
||||
'position' => 'bottom-right',
|
||||
'title' => '专属顾问在线',
|
||||
'subtitle' => '点击添加企业微信,获取一对一服务',
|
||||
'button_text' => '立即咨询',
|
||||
'primary_color' => '#139A8C',
|
||||
'bottom_offset' => 28,
|
||||
'show_mobile' => true,
|
||||
], '默认浮窗配置与公开契约不一致');
|
||||
|
||||
$normalised = QywxPromotionWidgetService::fromInput([
|
||||
'enabled' => '1',
|
||||
'template' => 'card',
|
||||
'position' => 'bottom-left',
|
||||
'title' => " 在线\n顾问 ",
|
||||
'subtitle' => '',
|
||||
'button_text' => '去咨询',
|
||||
'primary_color' => '#a1b2c3',
|
||||
'bottom_offset' => '64',
|
||||
'show_mobile' => '0',
|
||||
]);
|
||||
widgetAssert($normalised['v'] === 1, '缺省输入未补齐配置版本');
|
||||
widgetAssert($normalised['enabled'] === true, '启用状态规范化失败');
|
||||
widgetAssert($normalised['title'] === '在线 顾问', '文案空白规范化失败');
|
||||
widgetAssert($normalised['primary_color'] === '#A1B2C3', '主题色未规范为大写');
|
||||
widgetAssert($normalised['bottom_offset'] === 64, '底部距离规范化失败');
|
||||
widgetAssert($normalised['show_mobile'] === false, '移动端开关规范化失败');
|
||||
widgetAssert(
|
||||
QywxPromotionWidgetService::decode(QywxPromotionWidgetService::encode($normalised)) === $normalised,
|
||||
'浮窗配置编解码不能稳定往返'
|
||||
);
|
||||
|
||||
expectInvalidWidget(['v' => 2], '未知版本没有被拒绝');
|
||||
expectInvalidWidget(['template' => 'html'], '未知模板没有被拒绝');
|
||||
expectInvalidWidget(['position' => 'top-right'], '未知位置没有被拒绝');
|
||||
expectInvalidWidget(['title' => ''], '空标题没有被拒绝');
|
||||
expectInvalidWidget(['title' => str_repeat('中', 25)], '超长标题没有被拒绝');
|
||||
expectInvalidWidget(['subtitle' => str_repeat('中', 49)], '超长副标题没有被拒绝');
|
||||
expectInvalidWidget(['button_text' => str_repeat('中', 13)], '超长按钮文案没有被拒绝');
|
||||
expectInvalidWidget(['primary_color' => 'red;background:url(x)'], 'CSS 注入色值没有被拒绝');
|
||||
expectInvalidWidget(['bottom_offset' => 15], '过小底部距离没有被拒绝');
|
||||
expectInvalidWidget(['bottom_offset' => 161], '过大底部距离没有被拒绝');
|
||||
expectInvalidWidget(['show_mobile' => 'yes'], '非法布尔值没有被拒绝');
|
||||
expectInvalidWidget(['template' => null], '显式 null 模板没有被拒绝');
|
||||
|
||||
$decodedInvalid = QywxPromotionWidgetService::decode('{broken');
|
||||
widgetAssert($decodedInvalid === $defaults && $decodedInvalid['enabled'] === false, '损坏 JSON 未 fail-closed');
|
||||
$decodedIncomplete = QywxPromotionWidgetService::decode('{"enabled":true}');
|
||||
widgetAssert($decodedIncomplete === $defaults && $decodedIncomplete['enabled'] === false, '字段缺失配置未 fail-closed');
|
||||
$decodedUnknown = QywxPromotionWidgetService::decode('{"v":2,"enabled":true}');
|
||||
widgetAssert($decodedUnknown === $defaults && $decodedUnknown['enabled'] === false, '未知版本未 fail-closed');
|
||||
$decodedIllegal = QywxPromotionWidgetService::decode('{"v":1,"enabled":true,"template":"raw-html"}');
|
||||
widgetAssert($decodedIllegal === $defaults && $decodedIllegal['enabled'] === false, '非法持久化配置未 fail-closed');
|
||||
|
||||
$xssConfig = QywxPromotionWidgetService::fromInput([
|
||||
'v' => 1,
|
||||
'enabled' => true,
|
||||
'template' => 'message',
|
||||
'position' => 'bottom-right',
|
||||
'title' => '<img onerror=x>',
|
||||
'subtitle' => '</script>',
|
||||
'button_text' => '咨询',
|
||||
'primary_color' => '#139A8C',
|
||||
'bottom_offset' => 28,
|
||||
'show_mobile' => true,
|
||||
]);
|
||||
$encoded = QywxPromotionWidgetService::encode($xssConfig);
|
||||
widgetAssert(!str_contains($encoded, '<img') && str_contains($encoded, '\\u003Cimg'), '持久化 JSON 未使用 HEX 转义');
|
||||
|
||||
$key = str_repeat('a', 32);
|
||||
$script = QywxPromotionWidgetService::renderScript(
|
||||
$key,
|
||||
'/api/qywx-promotion/go/' . $key,
|
||||
$xssConfig,
|
||||
true
|
||||
);
|
||||
widgetAssert(!str_contains($script, '<img onerror=x>'), 'XSS 文案以原始标签进入公开脚本');
|
||||
widgetAssert(!str_contains($script, 'innerHTML'), '公开脚本不得使用 innerHTML');
|
||||
widgetAssert(str_contains($script, 'node.textContent=value'), '公开脚本文案未通过 textContent 写入');
|
||||
widgetAssert(str_contains($script, 'data-wecom-promotion'), '旧 data-wecom-promotion 触发方式丢失');
|
||||
widgetAssert(str_contains($script, '.wecom-promotion-link[data-pool'), '旧 data-pool 触发方式丢失');
|
||||
widgetAssert(str_contains($script, 'w.WecomPromotion=w.WecomPromotion||{}'), '全局 WecomPromotion 注册表丢失');
|
||||
widgetAssert(str_contains($script, 'open:openPromotion'), '全局 open 方法丢失');
|
||||
widgetAssert(str_contains($script, 'show:show') && str_contains($script, 'hide:hide') && str_contains($script, 'destroy:destroy'), '浮窗生命周期方法不完整');
|
||||
widgetAssert(str_contains($script, 'location.origin') && str_contains($script, 'location.pathname'), '来源地址未限制为 origin + pathname');
|
||||
widgetAssert(!str_contains($script, 'location.href'), '公开脚本仍发送完整 location.href');
|
||||
widgetAssert(str_contains($script, 'attachShadow'), '公开脚本未隔离浮窗样式');
|
||||
widgetAssert(str_contains($script, 'd.currentScript') && str_contains($script, 'new w.URL(value,node.src)'), '跳转地址未从安装脚本来源解析');
|
||||
widgetAssert(str_contains($script, "style.setAttribute('nonce',nonce)"), '公开脚本未向动态样式传递 CSP nonce');
|
||||
widgetAssert(str_contains($script, 'event.composedPath'), '公开脚本未兼容 Shadow DOM 内的手动触发元素');
|
||||
widgetAssert(!str_contains($script, 'root.style.'), '公开脚本仍依赖会被严格 CSP 拦截的元素内联样式');
|
||||
widgetAssert(str_contains($script, 'safe-area-inset-bottom'), '公开脚本未适配移动端安全区');
|
||||
foreach (['bubble', 'pill', 'card', 'message', 'edge', 'bar'] as $template) {
|
||||
widgetAssert(str_contains($script, '.wcp-' . $template), '公开脚本缺少模板:' . $template);
|
||||
}
|
||||
|
||||
$disabledScript = QywxPromotionWidgetService::renderScript($key, 'https://example.test/go', $xssConfig, false);
|
||||
widgetAssert(str_contains($disabledScript, '"enabled":false'), '停用方案仍会自动挂载浮窗');
|
||||
widgetAssert(str_contains($disabledScript, 'open:openPromotion'), '停用方案脚本没有保留手动 open 兼容接口');
|
||||
|
||||
echo "QYWX_PROMOTION_WIDGET_SERVICE_OK\n";
|
||||
Reference in New Issue
Block a user