Compare commits

..
3 Commits
Author SHA1 Message Date
Your Name 47094cc617 更新 2026-08-25 09:36:25 +08:00
Your Name 01c38d8c5b 更新 2026-08-25 09:35:47 +08:00
Your Name 1f3e580cf8 更新 2026-08-25 09:34:39 +08:00
25 changed files with 1990 additions and 460 deletions
+14 -6
View File
@@ -190,9 +190,9 @@ export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
@@ -202,9 +202,17 @@ export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveMember', params, timeout: 120000 })
}
export function wecomPromotionToggleMember(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleMember', params, timeout: 120000 })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
@@ -239,7 +239,7 @@
<div class="install-tip">
<el-icon><CircleCheck /></el-icon>
<span><strong>无需暴露链接</strong>浮窗点击后仍由服务端执行可用性筛选权重随机和访问统计</span>
<span><strong>一个方案一个官方链接</strong>浮窗会直接打开企业微信链接多名医助由企业微信执行均衡分流</span>
</div>
</section>
</section>
@@ -274,6 +274,8 @@ interface PromotionPool {
name?: string
public_key?: string
script_url?: string
main_url?: string
compat_go_url?: string
go_url?: string
install_code?: string
trigger_code?: string
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
}
function openTestLink() {
if (!props.pool.go_url) {
const targetUrl = props.pool.main_url || props.pool.go_url || props.pool.compat_go_url
if (!targetUrl) {
ElMessage.warning('当前方案暂无测试链接')
return
}
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
window.open(targetUrl, '_blank', 'noopener,noreferrer')
}
</script>
@@ -5,7 +5,7 @@
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
<div>
<h1>企业微信获客助手</h1>
<p>对接企业微信获客助手官方 API按角色与部门管理获客成员并将链接安全分流到推广页面</p>
<p>一个方案保留一条企业微信官方直链按回调结果自动调度医助权重与数量</p>
</div>
</div>
<div class="heading-actions">
@@ -22,15 +22,15 @@
</article>
<article class="metric-card">
<span class="metric-icon is-blue"><el-icon><SetUp /></el-icon></span>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案生成独立 JS</p></div>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案对应一个官方链接</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-green"><el-icon><Link /></el-icon></span>
<div><small>在线链接</small><strong>{{ overview.summary.online_links }}</strong><p>停用或超限自动排除</p></div>
<div><small>官方链接</small><strong>{{ overview.summary.online_links }}</strong><p>链接不变成员范围自动切换</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-orange"><el-icon><Mouse /></el-icon></span>
<div><small>今日分流点击</small><strong>{{ overview.summary.today_clicks }}</strong><p>只记录脱敏访问数据</p></div>
<div><small>今日实际获客</small><strong>{{ overview.summary.today_clicks }}</strong><p>按企业微信回调确认承接成员</p></div>
</article>
</section>
@@ -52,9 +52,9 @@
<div v-if="activeTab === 'links'" class="tab-content links-tab">
<div class="section-heading">
<div>
<h2>获客助手链接分流</h2>
<p>访客点击时由服务端从当前可用的获客助手链接中按权重随机选择页面端不会暴露完整链接</p>
<div>
<h2>获客助手分流方案</h2>
<p>选择几名医助就生成几条成员规则官方链接只在方案顶部复制成员行不重复展示链接</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
</div>
@@ -69,7 +69,7 @@
@click="selectedPoolId = Number(pool.id)"
>
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
<span><strong>{{ pool.name }}</strong><small>{{ linkCount(pool.id) }} 条链接 · {{ formatNumber(pool.click_count) }} 次点击</small></span>
<span><strong>{{ pool.name }}</strong><small>{{ memberSummary(pool) }} · {{ pool.main_url ? '官方链接已生成' : '待生成链接' }}</small></span>
<el-icon><ArrowRight /></el-icon>
</button>
</aside>
@@ -84,100 +84,77 @@
</span>
<span class="owner-tag">{{ selectedPool.dept_name || '未分部门' }} · {{ selectedPool.owner_name || '系统' }}</span>
</div>
<p>公开键{{ selectedPool.public_key }}</p>
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button :icon="Refresh" :loading="syncingRemote" @click="syncRemoteLinks">同步企业微信</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
<el-button type="primary" :icon="Plus" @click="openLinkDialog()">创建官方获客链接</el-button>
</div>
</div>
<el-alert
v-if="selectedLegacyCount > 0 && selectedOfficialCount === 0"
class="legacy-sync-alert"
type="warning"
show-icon
:closable="false"
:title="`当前方案的 ${selectedLegacyCount} 条链接都是历史手工链接,企业微信同步不会返回它们`"
description="官方接口只返回当前应用通过 API 创建的获客链接。现有链接仍可参与本地分流;如需同步 link_id、客户和官方统计,请点击“创建官方获客链接”。"
/>
<el-table :data="selectedLinks" class="link-table" stripe>
<el-table-column label="推广成员 / 分组" min-width="180" fixed="left">
<template #default="{ row }">
<div class="member-cell"><strong>{{ row.name }}</strong><small>{{ row.group_name || '默认分组' }}</small></div>
</template>
</el-table-column>
<el-table-column label="企业微信链接 ID" min-width="280">
<template #default="{ row }">
<div class="link-id-cell">
<span v-if="row.remote_link_id" class="remote-id" :title="row.remote_link_id">{{ row.remote_link_id }}</span>
<span v-else class="legacy-tag">历史手工链接</span>
<a
v-if="row.wecom_url"
class="wecom-url"
:href="row.wecom_url"
:title="row.wecom_url"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ row.wecom_url }}</a>
<el-button
v-if="row.wecom_url"
type="primary"
link
class="copy-url-btn"
@click.stop="copyText(row.wecom_url, '企业微信链接')"
>复制链接</el-button>
<span v-else class="muted">暂无链接地址</span>
</div>
</template>
</el-table-column>
<el-table-column label="可用成员" min-width="150">
<template #default="{ row }">
<span>{{ remoteMemberLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="权重" prop="weight" width="84" align="center" />
<el-table-column label="今日 / 上限" min-width="120" align="center">
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
</el-table-column>
<el-table-column label="累计点击" prop="click_count" min-width="105" align="center" />
<el-table-column label="当前可用性" min-width="130">
<template #default="{ row }">
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
</template>
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.status) === 1" :disabled="Number(row.remote_status) === 2" @change="(value) => handleToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="245" fixed="right">
<template #default="{ row }">
<el-button v-if="row.is_official && Number(row.remote_status) !== 2" type="primary" link :loading="refreshingLinkId === Number(row.id)" @click="refreshRemoteDetail(row)">刷新详情</el-button>
<el-button v-if="!row.is_official || Number(row.remote_status) !== 2" type="primary" link @click="openLinkDialog(row)">编辑</el-button>
<el-dropdown v-if="row.is_official" trigger="click" @command="(command) => handleLinkCommand(command, row)">
<el-button type="primary" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="local">从本地移除</el-dropdown-item>
<el-dropdown-item v-if="Number(row.remote_status) !== 2" command="remote" divided class="danger-command">永久删除企业微信链接</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-else type="danger" link @click="removeLink(row)">从本地移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="当前方案还没有获客助手链接" /></template>
</el-table>
<p>公开键{{ selectedPool.public_key }}</p>
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
</div>
</div>
<el-alert
v-if="selectedPool.migration_state === 'needs_resolution'"
class="legacy-sync-alert"
type="warning"
show-icon
:closable="false"
title="检测到旧版方案存在多个官方链接"
description="当前显示最新链接;保存方案后,其余旧链接只会在本地下线,企业微信端与历史客户归因仍会保留。"
/>
<el-alert
class="legacy-sync-alert"
type="info"
show-icon
:closable="false"
title="回调驱动分流"
description="企业微信确认实际承接成员后,系统累计今日数量,并在符合启用状态、有效期和上限的成员中按权重随机抽取下一位;随机结果可能连续命中同一人,并发访问及企微同步存在短暂延迟。"
/>
<el-table :data="selectedMemberRules" class="link-table" stripe>
<el-table-column label="推广成员" min-width="210" fixed="left">
<template #default="{ row }">
<div class="member-cell">
<strong>{{ row.name || row.userid }}</strong>
<small>{{ memberRuleDetail(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="调度" width="105" align="center">
<template #default="{ row }">
<span v-if="row.is_current" class="status-tag is-online">{{ Number(row.sync_status) === 0 ? '当前命中' : '待切换' }}</span>
<span v-else-if="row.is_applied" class="status-tag is-offline">切换中</span>
<span v-else class="muted">候选</span>
</template>
</el-table-column>
<el-table-column label="权重" prop="weight" width="85" align="center" />
<el-table-column label="今日 / 上限" width="125" align="center">
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
</el-table-column>
<el-table-column label="累计获客" prop="total_count" width="105" align="center" />
<el-table-column label="当前可用性" min-width="130">
<template #default="{ row }">
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
</template>
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.enabled) === 1" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
</el-table>
</div>
</div>
<el-empty v-else description="创建一个分流方案,再添加企业微信获客助手链接">
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
</el-empty>
</div>
@@ -220,7 +197,7 @@
show-icon
:closable="false"
title="消息次数回调尚未配置完成"
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 QywxExternalContactCallback/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法得到精确接收消息次数。"
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 /api/qywx/external-contact/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法自动累计成员获客数量和切换下一位。"
/>
<el-alert
@@ -363,75 +340,89 @@
</div>
</section>
<el-dialog v-model="poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="兜底获客助手链接">
<el-input v-model="poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" />
<span class="form-tip">仅接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。</span>
</el-form-item>
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">保存方案</el-button></template>
</el-dialog>
<el-dialog v-model="linkDialogVisible" :title="linkForm.id ? '编辑获客助手链接' : '创建企业微信官方获客链接'" width="720px" destroy-on-close>
<el-form label-position="top" class="link-form">
<div class="form-grid">
<el-form-item label="获客链接名称" required><el-input v-model="linkForm.name" maxlength="80" placeholder="用于企业微信和后台识别" /></el-form-item>
<el-form-item label="分组"><el-input v-model="linkForm.group_name" maxlength="60" placeholder="默认分组" /></el-form-item>
</div>
<el-form-item v-if="!linkForm.legacy" label="获客成员" required>
<el-select v-model="linkForm.member_admin_ids" multiple filterable collapse-tags collapse-tags-tooltip placeholder="仅显示当前角色和部门可管理、且已绑定企业微信的成员" style="width: 100%">
<el-option v-for="member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" />
</el-select>
<span class="form-tip">官方链接按这里的企业微信 userid 建立可见范围越权成员不会出现在列表中</span>
</el-form-item>
<el-form-item v-else label="企业微信获客助手链接" required>
<el-input v-model="linkForm.wecom_url" type="textarea" :rows="2" :placeholder="overview.customer_acquisition_link_example" />
<span class="form-tip">这是历史手工链接只更新本地分流规则不会调用企业微信创建或修改接口</span>
</el-form-item>
<div class="form-grid">
<el-form-item label="分流权重" required><el-input-number v-model="linkForm.weight" :min="1" :max="100" controls-position="right" /></el-form-item>
<el-form-item label="每日点击上限"><el-input-number v-model="linkForm.daily_limit" :min="0" :max="1000000" controls-position="right" /><span class="form-tip">0 表示不限</span></el-form-item>
</div>
<el-form-item v-if="!linkForm.legacy" label="添加客户时跳过验证">
<el-switch v-model="linkForm.skip_verify" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
</el-form-item>
<el-form-item label="上线状态"><el-switch v-model="linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" /></el-form-item>
<el-form-item label="生效时间段">
<el-date-picker v-model="linkForm.active_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" />
<span class="form-tip">不选择表示长期有效</span>
</el-form-item>
<el-form-item label="备注"><el-input v-model="linkForm.remark" maxlength="255" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="linkDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingLink" @click="saveLink">保存链接</el-button></template>
</el-dialog>
<el-dialog
v-model="poolDialogVisible"
:title="poolForm.id ? '编辑分流方案' : '新建分流方案'"
width="680px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!savingPool"
>
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" :disabled="savingPool" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="获客医助" required>
<el-select
v-model="poolForm.member_admin_ids"
:disabled="savingPool"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择后由企业微信生成一个多人分流链接"
style="width: 100%"
>
<el-option v-for="member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" />
</el-select>
<span class="form-tip">保存后会为每名医助生成一条成员规则官方链接地址始终只有一个</span>
</el-form-item>
<el-form-item label="添加客户时跳过验证">
<el-switch v-model="poolForm.skip_verify" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
</el-form-item>
<el-form-item label="兜底获客助手链接">
<el-input v-model="poolForm.fallback_url" :disabled="savingPool" placeholder="可选;仅供旧版兼容跳转使用" />
<span class="form-tip">新生成的官方直链不经过本站跳转此项仅兼容旧安装代码</span>
</el-form-item>
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :disabled="savingPool" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="savingPool" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
</el-dialog>
<el-dialog v-model="memberDialogVisible" title="编辑医助分流规则" width="560px" destroy-on-close :close-on-click-modal="false">
<el-form label-position="top">
<el-form-item label="获客医助">
<el-input :model-value="memberForm.name ? `${memberForm.name} · ${memberForm.userid}` : memberForm.userid" readonly />
</el-form-item>
<div class="rule-form-grid">
<el-form-item label="权重" required>
<el-input-number v-model="memberForm.weight" :min="1" :max="100" :disabled="savingMember" style="width: 100%" />
<span class="form-tip">权重越高在后续回调调度中被选中的频率越高</span>
</el-form-item>
<el-form-item label="每日获客上限">
<el-input-number v-model="memberForm.daily_limit" :min="0" :max="1000000" :disabled="savingMember" style="width: 100%" />
<span class="form-tip">0 表示不限数量按实际获客回调累计</span>
</el-form-item>
</div>
<el-form-item label="有效时间">
<el-date-picker v-model="memberForm.active_range" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" :disabled="savingMember" style="width: 100%" />
</el-form-item>
<el-form-item label="上线状态">
<el-switch v-model="memberForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" :disabled="savingMember" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="memberForm.remark" type="textarea" :rows="2" maxlength="255" show-word-limit :disabled="savingMember" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="savingMember" @click="memberDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingMember" @click="saveMemberRule">保存规则</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="firstVisitWecomPromotionPage">
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, Plus,
Promotion, Refresh, Search, SetUp, User, Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeleteLink,
wecomPromotionDeleteRemoteLink,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionRemoteLinkDetail,
wecomPromotionSaveLink,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncRemoteLinks,
wecomPromotionToggleLink
} from '@/api/first_visit'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
Promotion, Refresh, Search, SetUp, User, Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionSaveMember,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionToggleMember
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
@@ -447,17 +438,16 @@ const emptyOverview = () => ({
const overview = reactive(emptyOverview())
const loading = ref(false)
const activeTab = ref<TabName>('links')
const selectedPoolId = ref<number>()
const selectedInstallPoolId = ref<number>()
const poolDialogVisible = ref(false)
const linkDialogVisible = ref(false)
const savingPool = ref(false)
const savingLink = ref(false)
const checkingApi = ref(false)
const syncingRemote = ref(false)
const refreshingLinkId = ref(0)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1 })
const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分组', wecom_url: '', member_admin_ids: [] as number[], skip_verify: 0, legacy: false, weight: 1, status: 1, daily_limit: 0, active_range: [] as string[], remark: '' })
const selectedPoolId = ref<number>()
const selectedInstallPoolId = ref<number>()
const poolDialogVisible = ref(false)
const savingPool = ref(false)
const checkingApi = ref(false)
const memberDialogVisible = ref(false)
const savingMember = ref(false)
const togglingMemberId = ref(0)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
const memberForm = reactive({ id: 0, name: '', userid: '', weight: 1, daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
const customerStatsLoading = ref(false)
const customerStatsLoaded = ref(false)
const syncingCustomers = ref(false)
@@ -475,10 +465,8 @@ const customerStats = reactive({
meta: { last_synced_at: '' }
})
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value))
const selectedOfficialCount = computed(() => selectedLinks.value.filter((item: any) => Boolean(item.remote_link_id)).length)
const selectedLegacyCount = computed(() => selectedLinks.value.length - selectedOfficialCount.value)
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const customerLinkOptions = computed(() => customerStats.link_options.length
? customerStats.link_options
@@ -516,24 +504,37 @@ async function loadOverview() {
}
}
function linkCount(poolId: number) {
return overview.links.filter((item: any) => Number(item.pool_id) === Number(poolId)).length
}
function openPoolDialog(pool?: any) {
Object.assign(poolForm, pool ? { id: Number(pool.id), name: pool.name, fallback_url: pool.fallback_url || '', status: Number(pool.status) } : { id: 0, name: '', fallback_url: '', status: 1 })
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
savingPool.value = true
function openPoolDialog(pool?: any) {
Object.assign(poolForm, pool ? {
id: Number(pool.id),
name: pool.name,
fallback_url: pool.fallback_url || '',
status: Number(pool.status),
member_admin_ids: Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids.map(Number) : [],
skip_verify: Number(pool.skip_verify) === 1 ? 1 : 0,
main_url: pool.main_url || ''
} : {
id: 0,
name: '',
fallback_url: '',
status: 1,
member_admin_ids: [],
skip_verify: 0,
main_url: ''
})
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
if (!poolForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客医助')
savingPool.value = true
try {
const result: any = await wecomPromotionSavePool({ ...poolForm })
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
ElMessage.success('分流方案已保存')
ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
} catch (error: any) {
ElMessage.error(error?.message || '分流方案保存失败')
} finally {
@@ -541,8 +542,8 @@ async function savePool() {
}
}
async function removePool(pool: any) {
await ElMessageBox.confirm(`删除“${pool.name}”后,该方案下的获客助手链接也会停用。确认继续?`, '删除分流方案', { type: 'warning' })
async function removePool(pool: any) {
await ElMessageBox.confirm(`删除“${pool.name}”后,本站将停止使用该链接,但不会永久删除企业微信后台中的官方链接。确认继续?`, '删除分流方案', { type: 'warning' })
try {
await wecomPromotionDeletePool({ id: Number(pool.id) })
ElMessage.success('分流方案已删除')
@@ -550,50 +551,60 @@ async function removePool(pool: any) {
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function openLinkDialog(row?: any) {
if (!selectedPool.value) return
const range = row && Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)] : []
const selectedUserIds = new Set<string>((row?.range_userids || []).map((item: unknown) => String(item)))
const selectedMemberIds = overview.member_options
.filter((member: any) => selectedUserIds.has(String(member.userid)))
.map((member: any) => Number(member.id))
Object.assign(linkForm, row ? {
id: Number(row.id), pool_id: Number(row.pool_id),
name: row.name, group_name: row.group_name || '默认分组', wecom_url: row.wecom_url,
member_admin_ids: selectedMemberIds, skip_verify: Number(row.skip_verify) === 1 ? 1 : 0, legacy: !row.is_official,
weight: Number(row.weight) || 1, status: Number(row.status), daily_limit: Number(row.daily_limit) || 0,
active_range: range, remark: row.remark || ''
} : {
id: 0, pool_id: Number(selectedPool.value.id), name: '', group_name: '默认分组',
wecom_url: '', member_admin_ids: [], skip_verify: 0, legacy: false,
weight: 1, status: 1, daily_limit: 0, active_range: [], remark: ''
})
linkDialogVisible.value = true
}
async function saveLink() {
if (!linkForm.name.trim()) return ElMessage.warning('请输入获客链接名称')
if (linkForm.legacy && !linkForm.wecom_url.trim()) return ElMessage.warning('请填写企业微信获客助手链接')
if (!linkForm.legacy && !linkForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客成员')
savingLink.value = true
try {
await wecomPromotionSaveLink({
...linkForm,
active_start: linkForm.active_range?.[0] || '',
active_end: linkForm.active_range?.[1] || ''
})
linkDialogVisible.value = false
await loadOverview()
ElMessage.success(linkForm.legacy ? '本地分流规则已保存' : '企业微信官方获客链接已保存')
} catch (error: any) {
ElMessage.error(error?.message || '获客助手链接保存失败')
} finally {
savingLink.value = false
}
}
}
function openMemberDialog(row: any) {
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
: []
Object.assign(memberForm, {
id: Number(row.id),
name: row.name || '',
userid: row.userid || '',
weight: Number(row.weight) || 1,
daily_limit: Number(row.daily_limit) || 0,
status: Number(row.enabled) === 1 ? 1 : 0,
active_range: range,
remark: row.remark || ''
})
memberDialogVisible.value = true
}
async function saveMemberRule() {
savingMember.value = true
try {
const result: any = await wecomPromotionSaveMember({
...memberForm,
active_start: memberForm.active_range?.[0] || '',
active_end: memberForm.active_range?.[1] || ''
})
memberDialogVisible.value = false
await loadOverview()
result?.sync_error
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success('医助分流规则已保存')
} catch (error: any) {
ElMessage.error(error?.message || '医助分流规则保存失败')
} finally {
savingMember.value = false
}
}
async function handleMemberToggle(row: any, value: unknown) {
togglingMemberId.value = Number(row.id)
try {
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
await loadOverview()
result?.sync_error
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
: ElMessage.success(value ? '医助已启用' : '医助已禁用并退出后续调度')
} catch (error: any) {
ElMessage.error(error?.message || '成员状态更新失败')
await loadOverview()
} finally {
togglingMemberId.value = 0
}
}
async function checkApiPermission() {
checkingApi.value = true
@@ -710,119 +721,39 @@ async function syncCustomers() {
}
}
async function syncRemoteLinks() {
if (!selectedPool.value) return ElMessage.warning('请先选择分流方案')
syncingRemote.value = true
try {
const result: any = await wecomPromotionSyncRemoteLinks({ pool_id: Number(selectedPool.value.id) })
const message = `扫描 ${Number(result?.scanned || 0)} 条,新建 ${Number(result?.created || 0)} 条,更新 ${Number(result?.updated || 0)} 条,按权限跳过 ${Number(result?.skipped || 0)} 条,失败 ${Number(result?.failed || 0)}`
if (Number(result?.scanned || 0) === 0) {
await ElMessageBox.alert(
`${result?.empty_reason || '当前应用没有可同步的官方获客链接'}\n\n${result?.suggestion || ''}`,
'没有可同步的官方链接',
{ type: 'warning', confirmButtonText: '知道了' }
)
} else {
result?.failed ? ElMessage.warning(message) : ElMessage.success(message)
}
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '同步企业微信获客链接失败')
} finally {
syncingRemote.value = false
}
}
async function refreshRemoteDetail(row: any) {
refreshingLinkId.value = Number(row.id)
try {
await wecomPromotionRemoteLinkDetail({ id: Number(row.id) })
ElMessage.success('企业微信官方详情已刷新')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '刷新官方详情失败')
} finally {
refreshingLinkId.value = 0
}
}
async function handleLinkCommand(command: string, row: any) {
if (command === 'local') {
await removeLink(row)
return
}
if (command !== 'remote') return
await ElMessageBox.confirm(
`将永久删除企业微信后台中的“${row.name}”,现有推广页面会立即失效,且无法恢复。确认继续?`,
'永久删除企业微信获客链接',
{ type: 'error', confirmButtonText: '永久删除', cancelButtonText: '取消' }
)
try {
await wecomPromotionDeleteRemoteLink({ id: Number(row.id) })
ElMessage.success('企业微信获客链接已永久删除,本地记录已保留并停止分流')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '永久删除失败')
}
}
async function handleToggle(row: any, value: unknown) {
try {
await wecomPromotionToggleLink({ id: Number(row.id), status: value ? 1 : 0 })
row.status = value ? 1 : 0
ElMessage.success(value ? '链接已上线' : '链接已下线')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '状态更新失败')
await loadOverview()
}
}
async function removeLink(row: any) {
await ElMessageBox.confirm(`仅从本地分流池移除“${row.name}”,不会删除企业微信后台的官方链接。确认继续?`, '从本地移除', { type: 'warning' })
try {
await wecomPromotionDeleteLink({ id: Number(row.id) })
ElMessage.success('已从本地分流池移除')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function eligibility(row: any) {
const now = Math.floor(Date.now() / 1000)
if (Number(row.remote_status) === 2) return { label: '企业微信已删除', className: 'is-error' }
if (row.valid_customer_acquisition_link === false) return { label: '非获客助手链接', className: 'is-error' }
if (Number(row.status) !== 1) return { label: '已下线', className: 'is-muted' }
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
function eligibility(row: any) {
const now = Math.floor(Date.now() / 1000)
if (Number(row.enabled) !== 1) return { label: '已禁用', className: 'is-muted' }
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' }
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
return { label: '可参与分流', className: 'is-ok' }
}
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
const isSyncTarget = Boolean(row.is_current || row.is_applied)
if (isSyncTarget && Number(row.sync_status) === 3) return { label: '企微同步重试中', className: 'is-waiting' }
if (isSyncTarget && Number(row.sync_status) === 4) return { label: '暂无可用调度', className: 'is-error' }
return { label: '可参与分流', className: 'is-ok' }
}
function todayCount(row: any) {
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
return row.today_date === today ? Number(row.today_count || 0) : 0
}
function memberOptionLabel(member: any) {
function memberOptionLabel(member: any) {
const departments = Array.isArray(member.dept_names) && member.dept_names.length
? member.dept_names.join(' / ')
: '未分部门'
return `${member.name} · ${departments} · ${member.userid}`
}
return `${member.name} · ${departments} · ${member.userid}`
}
function memberSummary(pool: any) {
const ids = Array.isArray(pool?.member_admin_ids) ? pool.member_admin_ids : []
return ids.length ? `${ids.length} 名医助` : '未选择医助'
}
function remoteMemberLabel(row: any) {
const userIds = Array.isArray(row.range_userids) ? row.range_userids : []
const names = userIds.map((userid: unknown) => {
const member = overview.member_options.find((item: any) => String(item.userid) === String(userid))
return member?.name || String(userid)
})
if (names.length) return names.length > 2 ? `${names.slice(0, 2).join('、')}${names.length}` : names.join('、')
const departments = Array.isArray(row.range_department_ids) ? row.range_department_ids : []
if (departments.length) return `企微部门 ${departments.join('、')}`
return row.is_official ? '未返回成员范围' : '手工配置'
}
function memberRuleDetail(row: any) {
const departments = Array.isArray(row.dept_names) && row.dept_names.length ? row.dept_names.join(' / ') : '未分部门'
return `${departments} · ${row.userid || '未绑定 userid'}`
}
function memberFilterLabel(member: any) {
const department = member.department_name || member.dept_name || ''
@@ -909,17 +840,17 @@ function formatCustomerRate(value: unknown) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN')
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN')
}
function formatPickerTime(value: unknown) {
const date = new Date(Number(value || 0) * 1000)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
function formatPickerTime(value: unknown) {
const date = new Date(Number(value || 0) * 1000)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function copyText(value: string, label: string) {
async function copyText(value: string, label: string) {
if (!value) return ElMessage.warning(`${label}暂无内容`)
try {
await navigator.clipboard.writeText(value)
@@ -930,8 +861,9 @@ async function copyText(value: string, label: string) {
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
const copied = document.execCommand('copy')
textarea.remove()
if (!copied) throw new Error('copy command failed')
}
ElMessage.success(`${label}已复制`)
}
@@ -1010,7 +942,7 @@ h1, h2, h3, p { margin: 0; }
.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; }
.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%; }
.form-grid, .rule-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; } }
@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; } }
@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, .rule-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
+4 -3
View File
@@ -45,9 +45,10 @@ export default defineConfig(({ mode }) => {
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
base: '/admin/',
server: {
port: 5555,
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
@@ -79,6 +79,33 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function saveMember()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('成员分流规则已保存', WecomPromotionLogic::saveMember(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function toggleMember()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
$status = (int) $this->request->post('status', 0);
return $this->run(fn () => $this->success(
'成员状态已更新',
WecomPromotionLogic::toggleMember($id, $status, $this->adminId, $this->adminInfo)
));
}
public function checkApiPermission()
{
if (!$this->hasPagePermission()) {
@@ -7,6 +7,9 @@ namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionWeightedRandom;
use app\common\service\qywx\QywxPromotionWidgetService;
use RuntimeException;
use think\facade\Db;
@@ -16,6 +19,7 @@ class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
self::assertMemberDispatchSchema();
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
->leftJoin('admin u', 'u.id = p.owner_admin_id')
@@ -46,15 +50,12 @@ class WecomPromotionLogic
unset($pool['widget_config_json']);
$key = (string) $pool['public_key'];
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
$compatGoUrl = $domain . '/api/qywx-promotion/go/' . $key;
$pool['script_url'] = $scriptUrl;
$pool['go_url'] = $goUrl;
$pool['compat_go_url'] = $compatGoUrl;
$pool['install_code'] = '<script src="'
. htmlspecialchars($scriptUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" defer></script>';
$pool['trigger_code'] = '<a href="'
. htmlspecialchars($goUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" data-wecom-promotion="' . $key . '">添加企业微信</a>';
}
unset($pool);
@@ -70,12 +71,101 @@ class WecomPromotionLogic
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
$onlineLinks++;
}
if ((string) ($link['today_date'] ?? '') === $today) {
$todayClicks += (int) ($link['today_count'] ?? 0);
}
}
unset($link);
$memberOptions = self::memberOptions($adminId, $adminInfo);
$adminIdByUserId = [];
$memberOptionByAdminId = [];
foreach ($memberOptions as $member) {
$adminIdByUserId[(string) $member['userid']] = (int) $member['id'];
$memberOptionByAdminId[(int) $member['id']] = $member;
}
$linksByPool = [];
foreach ($links as $link) {
$linksByPool[(int) $link['pool_id']][] = $link;
}
self::backfillPoolMembers($pools, $linksByPool, $adminIdByUserId);
$memberRulesByPool = [];
$syncByPool = [];
if ($poolIds !== []) {
$memberRows = Db::name('qywx_promotion_pool_member')
->whereIn('pool_id', $poolIds)
->whereNull('delete_time')
->order('id', 'asc')
->select()->toArray();
foreach ($memberRows as $memberRow) {
$memberAdminId = (int) ($memberRow['admin_id'] ?? 0);
$option = $memberOptionByAdminId[$memberAdminId] ?? [];
$memberRow['name'] = (string) ($option['name'] ?? $memberRow['userid'] ?? '未知成员');
$memberRow['dept_names'] = array_values((array) ($option['dept_names'] ?? []));
$memberRow['status'] = (int) ($memberRow['enabled'] ?? 0);
$memberRow['today_count'] = (string) ($memberRow['today_date'] ?? '') === $today
? (int) ($memberRow['today_count'] ?? 0)
: 0;
$memberRulesByPool[(int) $memberRow['pool_id']][] = $memberRow;
$todayClicks += (int) $memberRow['today_count'];
}
foreach (Db::name('qywx_promotion_range_sync')->whereIn('pool_id', $poolIds)->select()->toArray() as $syncRow) {
$syncByPool[(int) $syncRow['pool_id']] = $syncRow;
}
}
foreach ($pools as &$pool) {
$poolLinks = $linksByPool[(int) $pool['id']] ?? [];
$officialLinks = array_values(array_filter(
$poolLinks,
static fn (array $link): bool => !empty($link['is_official'])
&& (int) ($link['remote_status'] ?? 0) !== 2
));
$legacyCount = count(array_filter(
$poolLinks,
static fn (array $link): bool => empty($link['is_official'])
));
// links 已按 id 倒序返回;旧多链接方案暂以最新官方链接作为主链接,
// 保存方案时会把其余链接仅在本地下线,保留远端链接与历史客户归因。
$officialLink = $officialLinks[0] ?? null;
$memberRules = $memberRulesByPool[(int) $pool['id']] ?? [];
$sync = $syncByPool[(int) $pool['id']] ?? [];
foreach ($memberRules as &$memberRule) {
$memberRule['is_current'] = (int) ($memberRule['id'] ?? 0)
=== (int) ($sync['desired_member_id'] ?? 0);
$memberRule['is_applied'] = (int) ($memberRule['id'] ?? 0)
=== (int) ($sync['applied_member_id'] ?? 0);
$memberRule['sync_status'] = (int) ($sync['status'] ?? 0);
$memberRule['sync_error'] = (string) ($sync['last_error'] ?? '');
}
unset($memberRule);
$memberAdminIds = [];
foreach ($memberRules as $memberRule) {
if ((int) ($memberRule['admin_id'] ?? 0) > 0) {
$memberAdminIds[] = (int) $memberRule['admin_id'];
}
}
$mainUrl = $officialLink === null
? ''
: QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($officialLink['wecom_url'] ?? ''),
'zyt_pool:' . (int) $pool['id']
);
$pool['official_link'] = $officialLink;
$pool['official_link_count'] = count($officialLinks);
$pool['legacy_link_count'] = $legacyCount;
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
$pool['member_rules'] = $memberRules;
$pool['dispatch_sync'] = $sync;
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
$pool['migration_state'] = count($officialLinks) > 1
? 'needs_resolution'
: ($officialLink === null ? ($legacyCount > 0 ? 'legacy_only' : 'missing') : 'ready');
$pool['main_url'] = $mainUrl;
// go_url 现在代表可直接分享的企业微信官方链接;compat_go_url 保留旧 302 入口。
$pool['go_url'] = $mainUrl;
$pool['trigger_code'] = $mainUrl === '' ? '' : '<a href="'
. htmlspecialchars($mainUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. '" data-wecom-promotion="' . (string) $pool['public_key'] . '">添加企业微信</a>';
}
unset($pool);
$config = self::internalApplicationStatus($domain);
return [
@@ -92,14 +182,18 @@ class WecomPromotionLogic
],
'pools' => $pools,
'links' => $links,
'member_options' => self::memberOptions($adminId, $adminInfo),
'member_options' => $memberOptions,
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
public static function savePool(array $params, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
$existingPool = $id > 0
? self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo)
: null;
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 60) {
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
@@ -108,28 +202,147 @@ class WecomPromotionLogic
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
$userIds = array_values(array_column($members, 'userid'));
$selectedUserId = self::preferredDispatchUserId($id, $members);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$status = (int) ($params['status'] ?? 1) === 1 ? 1 : 0;
$now = time();
$data = [
$poolData = [
'name' => $name,
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
'status' => $status,
'fallback_url' => $fallback,
'update_time' => $now,
];
if ($id > 0) {
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
} else {
$data += [
'public_key' => bin2hex(random_bytes(16)),
'owner_admin_id' => $adminId,
'dept_id' => self::primaryDeptId($adminId),
'click_count' => 0,
'create_time' => $now,
];
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
$existingLink = null;
if ($existingPool !== null) {
$existingLink = Db::name('qywx_promotion_link')
->where('pool_id', $id)
->whereNull('delete_time')
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->order('id', 'desc')
->find();
}
return ['id' => $id];
$api = new QywxCustomerAcquisitionApiService();
$payload = [
'link_name' => mb_substr($name, 0, 30),
// 对外始终是同一个官方直链;当前只激活调度器选中的成员,回调后再切换下一位。
'range' => ['user_list' => [$selectedUserId]],
'skip_verify' => $skipVerify === 1,
];
$createdRemote = false;
if ($existingLink !== null) {
$remoteLinkId = trim((string) ($existingLink['remote_link_id'] ?? ''));
$payload['link_id'] = $remoteLinkId;
$api->updateLink($payload);
} else {
$created = $api->createLink($payload);
$remoteLinkId = self::extractRemoteLinkId($created);
if ($remoteLinkId === '') {
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请执行“同步企业微信”确认结果');
}
$createdRemote = true;
}
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
} catch (\Throwable $e) {
if ($createdRemote) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 保留详情读取的原始异常;孤立链接仍可通过企业微信同步找回。
}
}
throw $e;
}
$remoteData = self::remoteColumns($remote, $now);
$linkId = (int) ($existingLink['id'] ?? 0);
try {
Db::transaction(function () use (
&$id,
&$linkId,
$poolData,
$remoteData,
$status,
$existingPool,
$members,
$selectedUserId,
$now,
$adminId
): void {
if ($existingPool === null) {
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
'public_key' => bin2hex(random_bytes(16)),
'owner_admin_id' => $adminId,
'dept_id' => self::primaryDeptId($adminId),
'click_count' => 0,
'create_time' => $now,
]);
} else {
Db::name('qywx_promotion_pool')->where('id', $id)->update($poolData);
}
$linkCommon = [
'pool_id' => $id,
'group_name' => '方案官方链接',
'status' => $status,
'update_time' => $now,
];
if ($linkId > 0) {
Db::name('qywx_promotion_link')->where('id', $linkId)->update($remoteData + $linkCommon);
} else {
$linkId = (int) Db::name('qywx_promotion_link')->insertGetId($remoteData + $linkCommon + [
'account_id' => 0,
'weight' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'active_start' => 0,
'active_end' => 0,
'click_count' => 0,
'last_click_time' => 0,
'owner_admin_id' => (int) ($existingPool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($existingPool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'remark' => '',
'create_time' => $now,
]);
}
self::persistPoolMembers($id, $members, $now);
QywxPromotionMemberSchedulerService::initialisePool($id, $linkId, $selectedUserId);
// 旧多链接只在本地下线,企业微信远端与历史客户归因继续保留。
Db::name('qywx_promotion_link')
->where('pool_id', $id)
->where('id', '<>', $linkId)
->whereNull('delete_time')
->update(['status' => 0, 'update_time' => $now]);
});
} catch (\Throwable $e) {
if ($createdRemote) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 企业微信补偿失败时保留原始异常;可通过官方列表找回孤立链接。
}
}
throw $e;
}
return [
'id' => $id,
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
'wecom_url' => (string) ($remote['url'] ?? ''),
'main_url' => QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($remote['url'] ?? ''),
'zyt_pool:' . $id
),
'member_userids' => $userIds,
'current_userid' => $selectedUserId,
];
}
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
@@ -154,15 +367,109 @@ class WecomPromotionLogic
Db::transaction(function () use ($id, $now): void {
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
Db::name('qywx_promotion_pool_member')->where('pool_id', $id)->whereNull('delete_time')->update([
'enabled' => 0,
'delete_time' => $now,
'update_time' => $now,
]);
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update([
'status' => 4,
'next_retry' => 0,
'last_error' => '分流方案已删除',
'update_time' => $now,
]);
});
}
public static function saveMember(array $params, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
$member = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
if (!$member) {
throw new RuntimeException('分流成员不存在或已移除');
}
$poolId = (int) $member['pool_id'];
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
if ($enabled === 0) {
$otherEnabled = (int) Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->where('id', '<>', $id)
->where('enabled', 1)
->whereNull('delete_time')
->count();
if ($otherEnabled <= 0) {
throw new RuntimeException('至少需要保留一名启用的获客医助');
}
}
$startAt = self::parseTime($params['active_start'] ?? null);
$endAt = self::parseTime($params['active_end'] ?? null);
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间');
}
$now = time();
Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void {
Db::name('qywx_promotion_pool_member')->where('id', $id)->update([
'enabled' => $enabled,
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $startAt,
'active_end' => $endAt,
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'update_time' => $now,
]);
});
$planned = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
$syncError = '';
if ($planned['queued']) {
try {
(new QywxPromotionRangeSyncService())->syncPool($poolId);
} catch (\Throwable $e) {
// 本地规则已保存;后台分钟任务会继续重试,不把成员状态回滚成错误值。
$syncError = $e->getMessage();
}
}
return ['id' => $id, 'pool_id' => $poolId, 'dispatch' => $planned, 'sync_error' => $syncError];
}
public static function toggleMember(int $id, int $status, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
$row = Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->find();
if (!$row) {
throw new RuntimeException('分流成员不存在或已移除');
}
return self::saveMember([
'id' => $id,
'status' => $status,
'weight' => (int) ($row['weight'] ?? 1),
'daily_limit' => (int) ($row['daily_limit'] ?? 0),
'active_start' => (int) ($row['active_start'] ?? 0),
'active_end' => (int) ($row['active_end'] ?? 0),
'remark' => (string) ($row['remark'] ?? ''),
], $adminId, $adminInfo);
}
public static function saveLink(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['id'] ?? 0));
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
if ($existing === null) {
$officialCount = (int) Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->count();
if ($officialCount > 0) {
throw new RuntimeException('一个分流方案只能有一个官方获客链接,请直接编辑分流方案中的获客成员');
}
}
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
@@ -438,8 +745,8 @@ class WecomPromotionLogic
return $result;
}
/** @return list<string> */
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo): array
{
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
if ($requested === []) {
@@ -447,47 +754,225 @@ class WecomPromotionLogic
}
$available = [];
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
$available[$member['id']] = $member['userid'];
$available[(int) $member['id']] = $member;
}
$userIds = [];
$members = [];
foreach ($requested as $requestedId) {
if (!isset($available[$requestedId])) {
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
}
$userIds[] = $available[$requestedId];
$members[] = $available[$requestedId];
}
if (count($userIds) > 500) {
if (count($members) > 500) {
throw new RuntimeException('单个获客链接最多配置 500 名成员');
}
return array_values(array_unique($userIds));
return $members;
}
/** @return list<string> */
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
{
return array_values(array_column(self::resolveMembers($adminIds, $adminId, $adminInfo), 'userid'));
}
/** @param list<array{id:int,userid:string}> $members */
private static function preferredDispatchUserId(int $poolId, array $members): string
{
if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员');
}
$selected = [];
foreach ($members as $member) {
$selected[(string) $member['userid']] = true;
}
$rules = $poolId > 0
? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
: [];
$rulesById = [];
$rulesByUserId = [];
foreach ($rules as $rule) {
$rulesById[(int) $rule['id']] = $rule;
$rulesByUserId[(string) $rule['userid']] = $rule;
}
$desiredId = $poolId > 0
? (int) (Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->value('desired_member_id') ?? 0)
: 0;
$now = time();
$today = date('Y-m-d', $now);
if (isset($rulesById[$desiredId])) {
$current = $rulesById[$desiredId];
if ((string) ($current['today_date'] ?? '') !== $today) {
$current['today_count'] = 0;
$current['today_date'] = $today;
}
if (isset($selected[(string) $current['userid']]) && QywxPromotionWeightedRandom::eligible($current, $now)) {
return (string) $current['userid'];
}
}
$randomCandidates = [];
$candidateUserIds = [];
foreach ($members as $index => $member) {
$userId = (string) $member['userid'];
$rule = $rulesByUserId[$userId] ?? [
'enabled' => 1,
'weight' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => $today,
'active_start' => 0,
'active_end' => 0,
];
if ((string) ($rule['today_date'] ?? '') !== $today) {
$rule['today_count'] = 0;
$rule['today_date'] = $today;
}
$candidateId = $index + 1;
$rule['id'] = $candidateId;
$randomCandidates[] = $rule;
$candidateUserIds[$candidateId] = $userId;
}
$selection = QywxPromotionWeightedRandom::select($randomCandidates, $today, $now);
$selectedCandidateId = (int) ($selection['selected_id'] ?? 0);
if ($selectedCandidateId > 0 && isset($candidateUserIds[$selectedCandidateId])) {
return $candidateUserIds[$selectedCandidateId];
}
// 企业微信不接受空范围;仅在所有启用成员恰好达到上限时保留一名,队列会标记“无可用成员”。
foreach ($members as $member) {
$rule = $rulesByUserId[(string) $member['userid']] ?? null;
if ($rule === null || (int) ($rule['enabled'] ?? 1) === 1) {
return (string) $member['userid'];
}
}
throw new RuntimeException('至少需要启用一名获客医助');
}
/** @param list<array{id:int,userid:string}> $members */
private static function persistPoolMembers(int $poolId, array $members, int $now): void
{
$selectedUserIds = [];
foreach ($members as $member) {
$userId = (string) $member['userid'];
$selectedUserIds[] = $userId;
$existing = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)->where('userid', $userId)->find();
if ($existing) {
Db::name('qywx_promotion_pool_member')->where('id', (int) $existing['id'])->update([
'admin_id' => (int) $member['id'],
'userid' => $userId,
'delete_time' => null,
'update_time' => $now,
]);
continue;
}
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId,
'admin_id' => (int) $member['id'],
'userid' => $userId,
'enabled' => 1,
'weight' => 1,
'current_weight' => 0,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'total_count' => 0,
'active_start' => 0,
'active_end' => 0,
'last_assigned_time' => 0,
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
}
$removeQuery = Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)->whereNull('delete_time');
if ($selectedUserIds !== []) {
$removeQuery->whereNotIn('userid', $selectedUserIds);
}
$removeQuery->update(['delete_time' => $now, 'enabled' => 0, 'update_time' => $now]);
}
/**
* 旧版方案第一次打开时,把官方链接原有 range.user_list 回填为成员规则;不修改远端链接。
*
* @param list<array<string,mixed>> $pools
* @param array<int,list<array<string,mixed>>> $linksByPool
* @param array<string,int> $adminIdByUserId
*/
private static function backfillPoolMembers(array $pools, array $linksByPool, array $adminIdByUserId): void
{
$now = time();
foreach ($pools as $pool) {
$poolId = (int) ($pool['id'] ?? 0);
if ($poolId <= 0 || Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->count() > 0) {
continue;
}
$officialLink = null;
foreach ($linksByPool[$poolId] ?? [] as $link) {
if (!empty($link['is_official']) && (int) ($link['remote_status'] ?? 0) !== 2) {
$officialLink = $link;
break;
}
}
if ($officialLink === null) {
continue;
}
$firstUserId = '';
foreach ((array) ($officialLink['range_userids'] ?? []) as $userIdValue) {
$userId = trim((string) $userIdValue);
$mappedAdminId = (int) ($adminIdByUserId[$userId] ?? 0);
if ($userId === '' || $mappedAdminId <= 0) {
continue;
}
if ($firstUserId === '') {
$firstUserId = $userId;
}
try {
Db::name('qywx_promotion_pool_member')->insert([
'pool_id' => $poolId,
'admin_id' => $mappedAdminId,
'userid' => $userId,
'enabled' => 1,
'weight' => 1,
'current_weight' => 0,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'total_count' => 0,
'active_start' => 0,
'active_end' => 0,
'last_assigned_time' => 0,
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
} catch (\Throwable) {
// 并发打开 overview 时唯一键会阻止重复回填。
}
}
if ($firstUserId !== '') {
QywxPromotionMemberSchedulerService::initialisePool(
$poolId,
(int) $officialLink['id'],
$firstUserId
);
// 旧链接可能仍包含多人范围;排队收敛为当前调度成员,官方 URL 本身保持不变。
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => 1,
'applied_member_id' => 0,
'applied_version' => 0,
'next_retry' => $now,
'update_time' => $now,
]);
}
}
}
/** @return array<string,mixed> */
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
{
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
if ($linkId === '') {
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
}
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
}
$range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
return [
'link_id' => $linkId,
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
'url' => $url,
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
'skip_verify' => !empty($link['skip_verify']),
'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
'snapshot' => $link,
];
return QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $fallbackId);
}
/** @return array<string,mixed> */
@@ -681,6 +1166,19 @@ class WecomPromotionLogic
return $time === false ? 0 : $time;
}
private static function assertMemberDispatchSchema(): void
{
try {
Db::name('qywx_promotion_pool_member')->limit(1)->find();
Db::name('qywx_promotion_dispatch_event')->limit(1)->find();
Db::name('qywx_promotion_range_sync')->limit(1)->find();
} catch (\Throwable $e) {
throw new RuntimeException(
'获客成员调度数据表尚未安装,请先执行 server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql'
);
}
}
private static function mask(string $value): string
{
$length = strlen($value);
@@ -742,7 +1240,7 @@ class WecomPromotionLogic
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
// 复用自建应用现有的「API 接收消息」入口;获客助手事件与其他应用事件
// 由同一个控制器按 Event/ChangeType 分发,不需要再配置第二个回调地址。
'callback_url' => rtrim($domain, '/') . '/api/QywxExternalContactCallback/notify',
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
];
}
@@ -6,6 +6,8 @@ namespace app\api\controller;
use app\adminapi\logic\qywx\CustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Work\Application;
use EasyWeChat\Work\Message;
@@ -178,6 +180,29 @@ class QywxExternalContactCallbackController extends BaseApiController
return;
}
if ($changeType === 'add_external_contact' && $state !== '' && $userId !== '') {
// customer_channel=zyt_pool:{id} 会原样进入 State;以实际 UserID 幂等记账并规划下一位。
try {
$dispatch = QywxPromotionMemberSchedulerService::recordFromState(
$state,
$userId,
$extId,
$eventTime,
'external_contact'
);
if (($dispatch['status'] ?? '') === 'counted' && (int) ($dispatch['pool_id'] ?? 0) > 0) {
try {
// 回调后立即切换;分钟任务仍负责网络异常、并发版本变化等情况的兜底重试。
(new QywxPromotionRangeSyncService())->syncPool((int) $dispatch['pool_id']);
} catch (\Throwable $e) {
Log::warning('qywx promotion immediate range sync failed: ' . $e->getMessage());
}
}
} catch (\Throwable $e) {
// 客户资料同步与成员调度互不阻塞;调度异常保留日志,获客会话回调仍可补偿。
Log::error('qywx promotion callback dispatch failed: ' . $e->getMessage(), ['exception' => $e]);
}
}
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId);
}
@@ -20,13 +20,13 @@ class QywxPromotionPublicController extends BaseApiController
return response('/* promotion pool not found */', 404, ['Content-Type' => 'application/javascript; charset=utf-8']);
}
// 由安装脚本自身的 src 解析 API 域名,避免把请求 Host 写入可公开缓存的 JavaScript。
$goUrl = '/api/qywx-promotion/go/' . $key;
$targetUrl = (string) ($pool['target_url'] ?? '');
$config = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
$javascript = QywxPromotionWidgetService::renderScript(
$key,
$goUrl,
$targetUrl,
$config,
(int) ($pool['status'] ?? 0) === 1
(int) ($pool['status'] ?? 0) === 1 && $targetUrl !== ''
);
return response($javascript, 200, [
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\qywx\QywxPromotionRangeSyncService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
/** 重试回调调度后尚未同步到企业微信的获客链接成员范围。 */
class QywxSyncPromotionRanges extends Command
{
protected function configure()
{
$this->setName('qywx:sync-promotion-ranges')
->setDescription('同步企业微信官方获客链接的下一名承接成员');
}
protected function execute(Input $input, Output $output): int
{
$result = (new QywxPromotionRangeSyncService())->syncPending(100);
$output->writeln(sprintf(
'QYWX_PROMOTION_RANGE_SYNC selected=%d synced=%d failed=%d',
$result['selected'],
$result['synced'],
$result['failed']
));
return 0;
}
}
@@ -183,11 +183,18 @@ class QywxCustomerAcquisitionApiService
return $this->request($method, $path, $body, true);
}
$errorMessage = trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误';
if ($errcode === 60111) {
throw new RuntimeException(
'所选医助的企业微信 userid 不存在,或不在获客助手可调用应用的可见范围;'
. '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage
);
}
throw new RuntimeException(sprintf(
'企业微信获客助手接口失败[%d]%s',
$errcode,
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
$errorMessage
));
}
@@ -93,6 +93,13 @@ class QywxCustomerAcquisitionCustomerService
'event_time' => $eventTime,
'snapshot' => $message,
], false);
self::recordPromotionAssignment(
(string) ($message['State'] ?? $message['state'] ?? ''),
$remoteLinkId,
$userId,
$externalUserId,
$eventTime
);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
@@ -126,6 +133,13 @@ class QywxCustomerAcquisitionCustomerService
'event_time' => $eventTime,
'snapshot' => $chat,
], true);
self::recordPromotionAssignment(
(string) ($chatInfo['state'] ?? $message['State'] ?? ''),
$remoteLinkId,
$userId,
$externalUserId,
$eventTime
);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
@@ -419,6 +433,40 @@ class QywxCustomerAcquisitionCustomerService
return [$adminId, $deptId];
}
private static function recordPromotionAssignment(
string $state,
string $remoteLinkId,
string $userId,
string $externalUserId,
int $eventTime
): void {
$result = QywxPromotionMemberSchedulerService::recordFromState(
$state,
$userId,
$externalUserId,
$eventTime,
'customer_acquisition'
);
if ((int) ($result['pool_id'] ?? 0) <= 0) {
$result = QywxPromotionMemberSchedulerService::recordFromRemoteLink(
$remoteLinkId,
$userId,
$externalUserId,
$eventTime,
'customer_acquisition'
);
}
if (($result['status'] ?? '') !== 'counted' || (int) ($result['pool_id'] ?? 0) <= 0) {
return;
}
try {
// 优先在本次回调完成后切换,后台分钟任务继续承担失败重试。
(new QywxPromotionRangeSyncService())->syncPool((int) $result['pool_id']);
} catch (\Throwable) {
// 同步服务已经保存失败原因和下次重试时间,不能让远端网络错误回滚已记账的客户。
}
}
private static function encodeJson(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 企业微信获客助手链接校验。 */
class QywxCustomerAcquisitionLinkService
{
@@ -39,4 +41,98 @@ class QywxCustomerAcquisitionLinkService
{
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
}
/**
* 规范化企业微信 create_link/get 返回的链接详情。
*
* get 接口把 range、priority_option 放在响应根级,link 只包含链接本身;
* 旧响应或测试桩可能把这些字段放在 link 内,因此保留兼容回退。
*
* @return array{
* link_id:string,
* link_name:string,
* url:string,
* create_time:int,
* range_userids:list<string>,
* range_department_ids:list<string>,
* skip_verify:bool,
* priority_option:array<string,mixed>,
* snapshot:array<string,mixed>
* }
*/
public static function normaliseRemoteResponse(array $response, string $fallbackId = ''): array
{
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
$range = isset($response['range']) && is_array($response['range'])
? $response['range']
: (isset($link['range']) && is_array($link['range']) ? $link['range'] : []);
$priorityOption = isset($response['priority_option']) && is_array($response['priority_option'])
? $response['priority_option']
: (isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : []);
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
if ($linkId === '') {
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
}
if (!self::isAllowed($url)) {
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
}
return [
'link_id' => $linkId,
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
'url' => $url,
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
'skip_verify' => !empty($link['skip_verify']),
'priority_option' => $priorityOption,
'snapshot' => $response,
];
}
/** 追加或替换企业微信获客助手的自定义渠道标识。 */
public static function withCustomerChannel(string $url, string $channel): string
{
$url = trim($url);
$channel = trim($channel);
if (!self::isAllowed($url) || $channel === '' || strlen($channel) > 64) {
return '';
}
// 冒号在查询参数中是合法字符,保留“命名空间:编号”的可读结构;其余字符仍编码。
$parameter = 'customer_channel=' . str_replace('%3A', ':', rawurlencode($channel));
if (preg_match('/([?&])customer_channel=[^&#]*/i', $url) === 1) {
return (string) preg_replace_callback(
'/([?&])customer_channel=[^&#]*/i',
static fn (array $matches): string => $matches[1] . $parameter,
$url,
1
);
}
$fragment = '';
$fragmentPosition = strpos($url, '#');
if ($fragmentPosition !== false) {
$fragment = substr($url, $fragmentPosition);
$url = substr($url, 0, $fragmentPosition);
}
return $url . (str_contains($url, '?') ? '&' : '?') . $parameter . $fragment;
}
/** @return list<string> */
private static function normaliseScalarList(mixed $values): array
{
$result = [];
$seen = [];
foreach ((array) $values as $value) {
$normalised = trim((string) $value);
$key = 'value:' . $normalised;
if ($normalised !== '' && !isset($seen[$key])) {
$seen[$key] = true;
$result[] = $normalised;
}
}
return $result;
}
}
@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use think\facade\Db;
/** 根据实际获客回调记账,并为同一个官方链接规划下一名承接成员。 */
class QywxPromotionMemberSchedulerService
{
public static function poolIdFromState(string $state): int
{
$state = trim($state);
return preg_match('/^zyt_pool:(\d+)$/', $state, $matches) === 1
? max(0, (int) $matches[1])
: 0;
}
/** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */
public static function recordFromState(
string $state,
string $userId,
string $externalUserId,
int $eventTime = 0,
string $source = 'external_contact'
): array {
$poolId = self::poolIdFromState($state);
if ($poolId <= 0) {
return ['status' => 'ignored_state', 'pool_id' => 0, 'member_id' => 0, 'next_member_id' => 0];
}
return self::record($poolId, $userId, $externalUserId, $eventTime, $source);
}
/** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */
public static function recordFromRemoteLink(
string $remoteLinkId,
string $userId,
string $externalUserId,
int $eventTime = 0,
string $source = 'customer_acquisition'
): array {
$poolId = (int) (Db::name('qywx_promotion_link')
->where('remote_link_id', trim($remoteLinkId))
->whereNull('delete_time')
->value('pool_id') ?? 0);
if ($poolId <= 0) {
return ['status' => 'ignored_link', 'pool_id' => 0, 'member_id' => 0, 'next_member_id' => 0];
}
return self::record($poolId, $userId, $externalUserId, $eventTime, $source);
}
/**
* 管理端变更成员开关、权重或上限后,确保当前远端成员仍可用;必要时立即排队切换。
*
* @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool}
*/
public static function reconcilePool(int $poolId): array
{
return Db::transaction(function () use ($poolId): array {
$now = time();
$today = date('Y-m-d', $now);
$linkId = self::promotionLinkId($poolId);
$sync = self::lockedSyncRow($poolId);
$members = self::lockedMembers($poolId);
if ($linkId <= 0 || $members === []) {
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
}
$desiredId = (int) ($sync['desired_member_id'] ?? 0);
$desiredEligible = false;
foreach ($members as &$member) {
if ((string) ($member['today_date'] ?? '') !== $today) {
$member['today_date'] = $today;
$member['today_count'] = 0;
}
if ((int) $member['id'] === $desiredId) {
$desiredEligible = QywxPromotionWeightedRandom::eligible($member, $now);
}
}
unset($member);
self::persistMemberCursors($members, $now);
if ($desiredEligible) {
if ((int) ($sync['status'] ?? 0) === 4) {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => 0,
'next_retry' => 0,
'last_error' => '',
'update_time' => $now,
]);
}
return ['pool_id' => $poolId, 'next_member_id' => $desiredId, 'queued' => false, 'blocked' => false];
}
return self::selectAndQueueLocked($poolId, $linkId, $sync, $members, $today, $now);
});
}
public static function initialisePool(int $poolId, int $promotionLinkId, string $selectedUserId): int
{
$memberId = (int) (Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->where('userid', $selectedUserId)
->whereNull('delete_time')
->value('id') ?? 0);
if ($memberId <= 0 || $promotionLinkId <= 0) {
return 0;
}
$now = time();
$existing = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find();
$data = [
'promotion_link_id' => $promotionLinkId,
'desired_member_id' => $memberId,
'applied_member_id' => $memberId,
'status' => 0,
'attempts' => 0,
'next_retry' => 0,
'lock_token' => '',
'lock_until' => 0,
'last_error' => '',
'update_time' => $now,
];
if ($existing) {
$version = max(1, (int) ($existing['desired_version'] ?? 0) + 1);
$data['desired_version'] = $version;
$data['applied_version'] = $version;
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data);
} else {
$data += ['pool_id' => $poolId, 'desired_version' => 1, 'applied_version' => 1, 'create_time' => $now];
Db::name('qywx_promotion_range_sync')->insert($data);
}
return $memberId;
}
/** @return array{status:string,pool_id:int,member_id:int,next_member_id:int} */
private static function record(
int $poolId,
string $userId,
string $externalUserId,
int $eventTime,
string $source
): array {
$userId = trim($userId);
$externalUserId = trim($externalUserId);
if ($poolId <= 0 || $userId === '' || $externalUserId === '') {
return ['status' => 'ignored_identity', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0];
}
return Db::transaction(function () use ($poolId, $userId, $externalUserId, $eventTime, $source): array {
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
if (!$pool) {
return ['status' => 'ignored_pool', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0];
}
$members = self::lockedMembers($poolId);
$actualMember = null;
foreach ($members as $member) {
if ((string) ($member['userid'] ?? '') === $userId) {
$actualMember = $member;
break;
}
}
if ($actualMember === null) {
return ['status' => 'ignored_member', 'pool_id' => $poolId, 'member_id' => 0, 'next_member_id' => 0];
}
$actualMemberId = (int) $actualMember['id'];
$eventKey = hash('sha256', $poolId . '|' . $userId . '|' . $externalUserId);
try {
Db::name('qywx_promotion_dispatch_event')->insert([
'event_key' => $eventKey,
'pool_id' => $poolId,
'member_id' => $actualMemberId,
'userid' => $userId,
'external_userid' => $externalUserId,
'source' => mb_substr($source, 0, 32),
'event_time' => max(0, $eventTime),
'create_time' => time(),
]);
} catch (\Throwable $e) {
if (!Db::name('qywx_promotion_dispatch_event')->where('event_key', $eventKey)->find()) {
throw $e;
}
return ['status' => 'duplicate', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => 0];
}
$now = time();
$today = date('Y-m-d', $now);
foreach ($members as &$member) {
if ((string) ($member['today_date'] ?? '') !== $today) {
$member['today_date'] = $today;
$member['today_count'] = 0;
}
if ((int) $member['id'] === $actualMemberId) {
$member['today_count'] = (int) ($member['today_count'] ?? 0) + 1;
$member['total_count'] = (int) ($member['total_count'] ?? 0) + 1;
$member['last_assigned_time'] = max($now, max(0, $eventTime));
}
}
unset($member);
self::persistMemberCursors($members, $now);
$linkId = self::promotionLinkId($poolId);
$sync = self::lockedSyncRow($poolId);
$desiredId = (int) ($sync['desired_member_id'] ?? 0);
if ($linkId <= 0 || ($desiredId > 0 && $desiredId !== $actualMemberId)) {
return ['status' => 'counted_stale', 'pool_id' => $poolId, 'member_id' => $actualMemberId, 'next_member_id' => $desiredId];
}
$planned = self::selectAndQueueLocked($poolId, $linkId, $sync, $members, $today, $now);
return [
'status' => $planned['blocked'] ? 'counted_blocked' : 'counted',
'pool_id' => $poolId,
'member_id' => $actualMemberId,
'next_member_id' => $planned['next_member_id'],
];
});
}
/** @return array{pool_id:int,next_member_id:int,queued:bool,blocked:bool} */
private static function selectAndQueueLocked(
int $poolId,
int $linkId,
?array $sync,
array $members,
string $today,
int $now
): array {
$selection = QywxPromotionWeightedRandom::select($members, $today, $now);
self::persistMemberCursors($selection['members'], $now);
$selectedId = (int) $selection['selected_id'];
if ($selectedId <= 0) {
self::upsertSync($poolId, $linkId, (int) ($sync['desired_member_id'] ?? 0), false, $sync, $now, '所有成员均已禁用、未生效或达到今日上限');
return ['pool_id' => $poolId, 'next_member_id' => 0, 'queued' => false, 'blocked' => true];
}
$changed = $selectedId !== (int) ($sync['desired_member_id'] ?? 0);
self::upsertSync($poolId, $linkId, $selectedId, $changed, $sync, $now);
return ['pool_id' => $poolId, 'next_member_id' => $selectedId, 'queued' => $changed, 'blocked' => false];
}
private static function upsertSync(
int $poolId,
int $linkId,
int $desiredMemberId,
bool $pending,
?array $existing,
int $now,
string $error = ''
): void {
$version = max(1, (int) ($existing['desired_version'] ?? 0) + ($pending ? 1 : 0));
$data = [
'promotion_link_id' => $linkId,
'desired_member_id' => $desiredMemberId,
'desired_version' => $version,
'status' => $error !== '' ? 4 : ($pending ? 1 : (int) ($existing['status'] ?? 0)),
'next_retry' => $error !== '' ? strtotime('tomorrow', $now) : ($pending ? $now : 0),
'last_error' => mb_substr($error, 0, 500),
'update_time' => $now,
];
if ($pending) {
$data['attempts'] = 0;
}
if ($existing) {
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update($data);
} else {
$data += [
'pool_id' => $poolId,
'applied_member_id' => 0,
'applied_version' => 0,
'attempts' => 0,
'lock_token' => '',
'lock_until' => 0,
'create_time' => $now,
];
Db::name('qywx_promotion_range_sync')->insert($data);
}
}
/** @return list<array<string,mixed>> */
private static function lockedMembers(int $poolId): array
{
return Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->order('id', 'asc')
->lock(true)
->select()->toArray();
}
/** @return array<string,mixed>|null */
private static function lockedSyncRow(int $poolId): ?array
{
$row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
return $row ?: null;
}
private static function promotionLinkId(int $poolId): int
{
$syncLinkId = (int) (Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)->value('promotion_link_id') ?? 0);
if ($syncLinkId > 0) {
return $syncLinkId;
}
return (int) (Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->whereNull('delete_time')
->order('id', 'desc')
->value('id') ?? 0);
}
/** @param list<array<string,mixed>> $members */
private static function persistMemberCursors(array $members, int $now): void
{
foreach ($members as $member) {
Db::name('qywx_promotion_pool_member')->where('id', (int) $member['id'])->update([
'current_weight' => (int) ($member['current_weight'] ?? 0),
'today_count' => max(0, (int) ($member['today_count'] ?? 0)),
'today_date' => (string) ($member['today_date'] ?? '') ?: null,
'total_count' => max(0, (int) ($member['total_count'] ?? 0)),
'last_assigned_time' => max(0, (int) ($member['last_assigned_time'] ?? 0)),
'update_time' => $now,
]);
}
}
}
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
use think\facade\Db;
/** 把回调调度出的单个目标成员同步到同一条企业微信官方获客链接。 */
class QywxPromotionRangeSyncService
{
private QywxCustomerAcquisitionApiService $api;
public function __construct(?QywxCustomerAcquisitionApiService $api = null)
{
$this->api = $api ?? new QywxCustomerAcquisitionApiService();
}
/** @return array{status:string,pool_id:int,member_id:int} */
public function syncPool(int $poolId): array
{
$claim = Db::transaction(function () use ($poolId): ?array {
$row = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
if (!$row) {
return null;
}
$status = (int) ($row['status'] ?? 0);
$now = time();
if ($status === 0 || $status === 4 || ($status === 2 && (int) ($row['lock_until'] ?? 0) > $now)) {
return null;
}
if ($status === 3 && (int) ($row['next_retry'] ?? 0) > $now) {
return null;
}
$token = bin2hex(random_bytes(16));
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->update([
'status' => 2,
'lock_token' => $token,
'lock_until' => $now + 90,
'attempts' => (int) ($row['attempts'] ?? 0) + 1,
'last_error' => '',
'update_time' => $now,
]);
$row['lock_token'] = $token;
return $row;
});
if ($claim === null) {
return ['status' => 'noop', 'pool_id' => $poolId, 'member_id' => 0];
}
$token = (string) $claim['lock_token'];
$desiredVersion = (int) ($claim['desired_version'] ?? 0);
$memberId = (int) ($claim['desired_member_id'] ?? 0);
try {
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->find();
$link = Db::name('qywx_promotion_link')
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
->whereNull('delete_time')->find();
$member = Db::name('qywx_promotion_pool_member')
->where('id', $memberId)->where('pool_id', $poolId)->whereNull('delete_time')->find();
if (!$pool || !$link || !$member || (int) ($member['enabled'] ?? 0) !== 1) {
throw new RuntimeException('分流方案、官方链接或目标成员已失效');
}
$remoteLinkId = trim((string) ($link['remote_link_id'] ?? ''));
$userId = trim((string) ($member['userid'] ?? ''));
if ($remoteLinkId === '' || $userId === '') {
throw new RuntimeException('官方链接 ID 或目标成员 userid 为空');
}
$this->api->updateLink([
'link_id' => $remoteLinkId,
'link_name' => mb_substr((string) ($pool['name'] ?? '获客分流方案'), 0, 30),
'range' => ['user_list' => [$userId]],
'skip_verify' => (int) ($link['skip_verify'] ?? 0) === 1,
]);
$response = $this->api->getLink($remoteLinkId);
$remote = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse($response, $remoteLinkId);
$actualUserIds = $remote['range_userids'];
if ($actualUserIds !== [$userId]) {
throw new RuntimeException('企业微信返回的成员范围与待同步成员不一致');
}
$url = $remote['url'];
$snapshot = json_encode($remote['snapshot'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Db::name('qywx_promotion_link')->where('id', (int) $link['id'])->update([
'wecom_url' => $url,
'remote_status' => 1,
'range_user_json' => json_encode($actualUserIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'range_department_json' => json_encode($remote['range_department_ids'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remote_snapshot' => $snapshot === false ? null : $snapshot,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
$freshVersion = (int) (Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)->value('desired_version') ?? 0);
Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)
->where('lock_token', $token)
->update([
'status' => $freshVersion === $desiredVersion ? 0 : 1,
'applied_member_id' => $memberId,
'applied_version' => $desiredVersion,
'next_retry' => $freshVersion === $desiredVersion ? 0 : time(),
'attempts' => 0,
'lock_token' => '',
'lock_until' => 0,
'last_error' => '',
'update_time' => time(),
]);
return ['status' => 'synced', 'pool_id' => $poolId, 'member_id' => $memberId];
} catch (\Throwable $e) {
$attempts = max(1, (int) ($claim['attempts'] ?? 0) + 1);
Db::name('qywx_promotion_range_sync')
->where('pool_id', $poolId)
->where('lock_token', $token)
->update([
'status' => 3,
'next_retry' => time() + min(300, 15 * $attempts),
'lock_token' => '',
'lock_until' => 0,
'last_error' => mb_substr($e->getMessage(), 0, 500),
'update_time' => time(),
]);
Db::name('qywx_promotion_link')
->where('id', (int) ($claim['promotion_link_id'] ?? 0))
->update(['sync_error' => mb_substr($e->getMessage(), 0, 500), 'update_time' => time()]);
throw $e;
}
}
/** @return array{selected:int,synced:int,failed:int} */
public function syncPending(int $limit = 100): array
{
$now = time();
$blockedPoolIds = Db::name('qywx_promotion_range_sync')
->where('status', 4)
->where('next_retry', '>', 0)
->where('next_retry', '<=', $now)
->limit(min(500, max(1, $limit)))
->column('pool_id');
foreach ($blockedPoolIds as $blockedPoolId) {
QywxPromotionMemberSchedulerService::reconcilePool((int) $blockedPoolId);
}
$poolIds = Db::name('qywx_promotion_range_sync')
->where(function ($query) use ($now): void {
$query->whereIn('status', [1, 3])->where('next_retry', '<=', $now)
->whereOr(function ($running) use ($now): void {
$running->where('status', 2)->where('lock_until', '<=', $now);
});
})
->order('next_retry', 'asc')
->limit(min(500, max(1, $limit)))
->column('pool_id');
$synced = 0;
$failed = 0;
foreach ($poolIds as $poolId) {
try {
$result = $this->syncPool((int) $poolId);
if ($result['status'] === 'synced') {
$synced++;
}
} catch (\Throwable) {
$failed++;
}
}
return ['selected' => count($poolIds), 'synced' => $synced, 'failed' => $failed];
}
}
@@ -6,10 +6,10 @@ namespace app\common\service\qywx;
use think\facade\Db;
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
/** 公开获客助手链接:新方案直达单个官方链接,旧 /go 入口继续兼容历史分流。 */
class QywxPromotionRedirectService
{
/** @return array{status:int,widget_config_json:?string}|null */
/** @return array{status:int,widget_config_json:?string,target_url:string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
@@ -19,17 +19,32 @@ class QywxPromotionRedirectService
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
->field('status,widget_config_json')
->field('id,status,widget_config_json')
->find();
if (!$row) {
return null;
}
$link = Db::name('qywx_promotion_link')
->where('pool_id', (int) $row['id'])
->where('status', 1)
->where('remote_status', 1)
->where('remote_link_id', '<>', '')
->whereNull('delete_time')
->order('id', 'desc')
->field('wecom_url')
->find();
$targetUrl = QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($link['wecom_url'] ?? ''),
'zyt_pool:' . (int) $row['id']
);
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
'target_url' => $targetUrl,
];
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use InvalidArgumentException;
/** 获客方案成员的加权随机选择;数据库锁与持久化由调度服务负责。 */
class QywxPromotionWeightedRandom
{
/**
* @param list<array<string,mixed>> $members
* @param int|null $draw 测试用固定抽签值;生产环境留空并使用 random_int。
* @return array{selected_id:int,members:list<array<string,mixed>>,eligible_count:int,total_weight:int}
*/
public static function select(array $members, string $today, int $now, ?int $draw = null): array
{
$eligibleIndexes = [];
$totalWeight = 0;
foreach ($members as $index => &$member) {
if ((string) ($member['today_date'] ?? '') !== $today) {
$member['today_date'] = $today;
$member['today_count'] = 0;
}
// current_weight 是旧版平滑轮询游标;随机模式不再使用,统一归零。
$member['current_weight'] = 0;
if (!self::eligible($member, $now)) {
continue;
}
$weight = max(1, (int) ($member['weight'] ?? 1));
$eligibleIndexes[] = ['index' => $index, 'weight' => $weight];
$totalWeight += $weight;
}
unset($member);
if ($eligibleIndexes === []) {
return [
'selected_id' => 0,
'members' => array_values($members),
'eligible_count' => 0,
'total_weight' => 0,
];
}
$ticket = $draw ?? random_int(1, $totalWeight);
if ($ticket < 1 || $ticket > $totalWeight) {
throw new InvalidArgumentException('加权随机抽签值超出有效范围');
}
$cursor = 0;
$selectedIndex = (int) $eligibleIndexes[0]['index'];
foreach ($eligibleIndexes as $candidate) {
$cursor += (int) $candidate['weight'];
if ($ticket <= $cursor) {
$selectedIndex = (int) $candidate['index'];
break;
}
}
return [
'selected_id' => (int) ($members[$selectedIndex]['id'] ?? 0),
'members' => array_values($members),
'eligible_count' => count($eligibleIndexes),
'total_weight' => $totalWeight,
];
}
/** @param array<string,mixed> $member */
public static function eligible(array $member, int $now): bool
{
if ((int) ($member['enabled'] ?? 0) !== 1 || (int) ($member['weight'] ?? 0) <= 0) {
return false;
}
$start = max(0, (int) ($member['active_start'] ?? 0));
$end = max(0, (int) ($member['active_end'] ?? 0));
if (($start > 0 && $start > $now) || ($end > 0 && $end < $now)) {
return false;
}
$limit = max(0, (int) ($member['daily_limit'] ?? 0));
return $limit === 0 || (int) ($member['today_count'] ?? 0) < $limit;
}
}
@@ -131,11 +131,11 @@ class QywxPromotionWidgetService
}
/**
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择
* 生成可直接跨站安装的完整脚本,点击后直达当前方案的企业微信官方链接
*
* @param array<string, mixed> $config
*/
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
public static function renderScript(string $key, string $targetUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
@@ -143,14 +143,14 @@ class QywxPromotionWidgetService
}
$jsonKey = self::jsonForScript($key);
$jsonGo = self::jsonForScript($goUrl);
$jsonTarget = self::jsonForScript($targetUrl);
$jsonConfig = self::jsonForScript($config);
return <<<JS
(function(w,d){
'use strict';
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
var go=resolveGoUrl(goPath);
var key={$jsonKey},targetPath={$jsonTarget},config={$jsonConfig},scriptNode=d.currentScript||null;
var targetUrl=resolveTargetUrl(targetPath);
var registry=w.WecomPromotion=w.WecomPromotion||{};
var previous=registry[key];
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
@@ -170,7 +170,7 @@ class QywxPromotionWidgetService
return null;
}
function resolveGoUrl(value){
function resolveTargetUrl(value){
if(/^https?:\/\//i.test(value)){return value;}
var node=findScriptNode();
if(node&&node.src&&typeof w.URL==='function'){
@@ -179,14 +179,8 @@ class QywxPromotionWidgetService
return value;
}
function sourceUrl(){
var location=w.location||{};
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
return origin+(location.pathname||'/');
}
function openPromotion(){
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
if(targetUrl){w.location.assign(targetUrl);}
}
function handleDocumentClick(event){
+4 -2
View File
@@ -30,8 +30,10 @@ return [
'qywx:scan-media-channel' => 'app\\command\\QywxScanMediaChannel',
// 企业微信会话内容存档同步(需开通会话存档 License 并配置 msgaudit_* 相关项 + 动态库)
'qywx:sync-msg-archive' => 'app\\command\\QywxSyncMsgArchive',
// 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
// 获客助手 ChatKey 仅 30 分钟有效,部署时应每分钟执行一次
'qywx:retry-customer-acquisition-events' => 'app\\command\\QywxRetryCustomerAcquisitionEvents',
// 回调确认实际承接成员后,按权重/上限切换同一条官方获客链接的成员范围
'qywx:sync-promotion-ranges' => 'app\\command\\QywxSyncPromotionRanges',
// 甘草订单物流路由同步(GET_TASK_ROUTE_LIST
'gancao:sync-logistics' => 'app\\command\\GancaoSyncLogisticsRoute',
'ej-pharmacy:sync-catalog' => 'app\\command\\EjPharmacySyncCatalog',
+43 -13
View File
@@ -30,19 +30,51 @@ HOST = "https://公开访问域名"
- 更新获客链接 `update_link`
- 删除获客链接 `delete_link`
“永久删除企业微信链接”会调用官方删除接口且无法恢复;“从本地移除”只退出当前分流池,不会修改企业微信后台
当前管理端按“一个分流方案对应一个官方获客链接”管理。删除分流方案只软删除本地记录,不调用企业微信 `delete_link`,因此不会破坏已有客户归因;官方永久删除接口仅保留给兼容接口使用,调用后无法恢复
获客成员来自后台管理员的 `work_wechat_userid`。管理员可管理全量;组长、医助等账号只返回 `DataScopeService` 当前角色与部门范围内的成员。同步远端链接时,非全量账号只导入 `range.user_list` 与其可见成员有交集的数据;企业微信部门 ID 尚未建立本地映射时按安全原则隐藏,不会越权放行。
`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面使用“创建官方获客链接”
`list_link` 只返回当前获客助手可调用应用通过 API 创建的官方链接。后台历史手工粘贴的 `work.weixin.qq.com/ca/...` 链接,以及其他应用创建的链接,不会出现在当前应用的同步列表中,也无法仅凭 URL 反查为官方 `link_id`。需要官方客户、统计和消息归因时,应在本页面创建分流方案并选择获客成员
链接分流只接受企业微信获客助手生成的链接:
创建分流方案时必须选择一名或多名医助。所有医助分别保存启用状态、权重、每日上限、有效时间和实际获客计数,但一个方案仍只创建一条企业微信官方链接。官方链接当前的 `range.user_list` 只放调度器选中的一名成员,回调确认实际承接结果后再按权重随机抽取下一名并更新同一个 `link_id`,因此对外 URL 始终不变。
新建方案时的首名成员也会从所选医助中随机产生;编辑已有方案时,如果当前成员仍可用则保持不变,避免无获客事件时无故切换。
系统只使用企业微信获客助手生成的链接:
```text
https://work.weixin.qq.com/ca/xxxxxxxx
```
“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接不会参与随机分流。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
“联系我”、客户群、自有网页或其他外部链接均会被拒绝;已有的非获客助手历史链接不会作为方案主链接。Secret 与 access_token 不会返回到浏览器,也不会写入接口错误日志。
可复制的主链接会追加分流方案渠道参数,结构如下:
```text
https://work.weixin.qq.com/ca/xxxxxxxx?customer_channel=zyt_pool:123
```
其中 `customer_channel` 是本站写入的自定义渠道值,格式为 `zyt_pool:分流方案ID`;它与示例中的 `qywx_ca:...` 作用相同,但命名空间和数值由各系统自行定义。
## 回调驱动成员调度
部署时必须执行:
```text
server/sql/1.9.20260824/upgrade_qywx_promotion_member_dispatch.sql
```
并在企业微信后台把“API 接收消息”配置为:
```text
https://你的域名/api/qywx/external-contact/notify
```
调度优先使用 `change_external_contact/add_external_contact` 事件中的 `State``UserID``ExternalUserID``State` 来自主链接的 `customer_channel=zyt_pool:方案ID`,因此可以定位方案及实际承接医助;获客会话回调会通过 `ChatKey → get_chat_info` 作为补偿。方案、成员和客户组合使用唯一幂等键,同一实际获客不会因重复回调重复计数。
每次确认实际承接后,系统执行加权随机抽取:权重越大,被抽中的概率越高;禁用、尚未生效、已过期或达到今日上限的成员不会进入随机池。随机允许连续抽中同一成员。待同步范围由 `qywx:sync-promotion-ranges` 每分钟重试,管理端主动禁用当前成员时也会立即尝试同步。
权重和数量属于回调驱动的近实时控制,并非点击前的强事务:多个客户在企微回调或 `update_link` 生效前并发访问时,可能仍由同一成员承接;所有成员都达到上限时,企业微信不允许把 `range.user_list` 更新为空,系统会标记“无可用成员”并保留最后一次有效范围。因此数量上限用于自动退出后续调度,不承诺并发场景下绝对零超量。
如果需要为点击 IP 生成不可逆服务端哈希,可在 `[qywx_promotion]` 下额外设置独立的 `CREDENTIAL_KEY`
@@ -50,9 +82,11 @@ https://work.weixin.qq.com/ca/xxxxxxxx
```html
<script src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
<a href="https://你的域名/api/qywx-promotion/go/分流方案KEY" data-wecom-promotion="分流方案KEY">添加企业微信</a>
<a href="https://work.weixin.qq.com/ca/xxxxxxxx?customer_channel=zyt_pool:123">添加企业微信</a>
```
旧的 `/api/qywx-promotion/go/分流方案KEY` 地址继续保留,兼容已经投放的安装代码;新建方案、管理端复制链接和新版浮窗均直接打开官方获客链接。
## 公开浮窗
每个分流方案可选择是否由同一段公开 JS 自动挂载客服浮窗。关闭浮窗时,已有的
@@ -66,7 +100,7 @@ https://work.weixin.qq.com/ca/xxxxxxxx
- 标题、副标题、按钮文案和 `#RRGGBB` 主题色
- 16-160 像素底部距离、移动端展示开关和浮窗总开关
公开脚本仅下发经过白名单校验的展示配置,不下发兜底链接或真实获客链接池。模板
公开脚本仅下发经过白名单校验的展示配置和当前方案的单个官方目标链接,不下发历史链接池或 Secret。模板
由脚本内置,管理端文案通过 DOM `textContent` 写入,不接受自定义 HTML、CSS 或脚本。
损坏配置、未知版本和非法枚举会按关闭浮窗处理。
@@ -79,10 +113,7 @@ window.WecomPromotion['分流方案KEY'].hide()
window.WecomPromotion['分流方案KEY'].destroy()
```
公开脚本缓存 60 秒,因此浮窗样式开关更新最多延迟约 60 秒;方案运行状态仍会在
每次服务端跳转时即时校验。脚本会从自身 `src` 解析跳转接口域名,不会把公开请求的
Host 写入缓存内容。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的
HTTPS 公开域名。
公开脚本缓存 60 秒,因此浮窗样式开关或官方目标链接更新最多延迟约 60 秒。新版浮窗直接打开企业微信官方链接,不再经过本站逐次 302;已经复制到外部的官方链接也不会因本地关闭方案而失效。管理端安装代码优先使用 `[app] HOST`,请在生产环境配置唯一的 HTTPS 公开域名。
接入站点若启用了严格 CSP,需要允许脚本域名,并给安装 `<script>` 添加站点当前请求
`nonce`。公开脚本会把该 `nonce` 传给 Shadow DOM 内的动态样式:
@@ -91,7 +122,6 @@ HTTPS 公开域名。
<script nonce="当前请求的 nonce" src="https://你的域名/api/qywx-promotion/js/分流方案KEY" defer></script>
```
点击来源只上报页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也
不应放置手机号、患者 ID、重置令牌等敏感信息。
`/go` 兼容入口记录点击来源时,只保存页面的 origin 与 pathname,不包含查询参数或 fragment。推广页路径中也不应放置手机号、患者 ID、重置令牌等敏感信息。
随机分流在服务端完成。候选链接必须同时满足:方案启用、链接上线、处于有效时间段、未超过当日上限。权重越大,被选中的概率越高
用户始终看到同一个企业微信官方获客链接。当前激活成员由本站根据实际回调、启用状态、权重和上限动态更新;企业微信仍可能根据成员可服务状态和已有好友关系等规则影响最终承接结果,后续回调会按实际结果自动纠偏
@@ -0,0 +1,81 @@
-- 企业微信获客助手:单官方直链的成员规则与回调驱动调度
START TRANSACTION;
CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_pool_member` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`pool_id` int unsigned NOT NULL DEFAULT 0,
`admin_id` int unsigned NOT NULL DEFAULT 0,
`userid` varchar(128) NOT NULL DEFAULT '' COMMENT '企业微信 userid 快照',
`enabled` tinyint unsigned NOT NULL DEFAULT 1,
`weight` smallint unsigned NOT NULL DEFAULT 1,
`current_weight` bigint NOT NULL DEFAULT 0 COMMENT '旧版平滑轮询游标,随机调度模式保留兼容',
`daily_limit` int unsigned NOT NULL DEFAULT 0 COMMENT '0为不限;按回调确认的实际获客计数',
`today_count` int unsigned NOT NULL DEFAULT 0,
`today_date` date NULL DEFAULT NULL,
`total_count` bigint unsigned NOT NULL DEFAULT 0,
`active_start` int unsigned NOT NULL DEFAULT 0,
`active_end` int unsigned NOT NULL DEFAULT 0,
`last_assigned_time` int unsigned NOT NULL DEFAULT 0,
`remark` varchar(255) NOT NULL DEFAULT '',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
`delete_time` int unsigned NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pool_userid` (`pool_id`,`userid`),
KEY `idx_pool_enabled` (`pool_id`,`enabled`,`delete_time`),
KEY `idx_admin` (`admin_id`),
KEY `idx_today` (`today_date`,`today_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='获客分流方案成员规则';
CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_dispatch_event` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`event_key` char(64) NOT NULL COMMENT '方案+成员+客户的幂等键',
`pool_id` int unsigned NOT NULL DEFAULT 0,
`member_id` bigint unsigned NOT NULL DEFAULT 0,
`userid` varchar(128) NOT NULL DEFAULT '',
`external_userid` varchar(128) NOT NULL DEFAULT '',
`source` varchar(32) NOT NULL DEFAULT '',
`event_time` int unsigned NOT NULL DEFAULT 0,
`create_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_key` (`event_key`),
KEY `idx_pool_time` (`pool_id`,`event_time`),
KEY `idx_member_time` (`member_id`,`event_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='获客成员实际承接幂等流水';
CREATE TABLE IF NOT EXISTS `zyt_qywx_promotion_range_sync` (
`pool_id` int unsigned NOT NULL,
`promotion_link_id` int unsigned NOT NULL DEFAULT 0,
`desired_member_id` bigint unsigned NOT NULL DEFAULT 0,
`desired_version` bigint unsigned NOT NULL DEFAULT 0,
`applied_member_id` bigint unsigned NOT NULL DEFAULT 0,
`applied_version` bigint unsigned NOT NULL DEFAULT 0,
`status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0已同步 1待同步 2同步中 3失败待重试 4无可用成员',
`attempts` int unsigned NOT NULL DEFAULT 0,
`next_retry` int unsigned NOT NULL DEFAULT 0,
`lock_token` char(32) NOT NULL DEFAULT '',
`lock_until` int unsigned NOT NULL DEFAULT 0,
`last_error` varchar(500) NOT NULL DEFAULT '',
`create_time` int unsigned NOT NULL DEFAULT 0,
`update_time` int unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`pool_id`),
KEY `idx_pending` (`status`,`next_retry`,`lock_until`),
KEY `idx_link` (`promotion_link_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='获客官方链接当前成员范围同步队列';
INSERT INTO `zyt_dev_crontab` (
`name`, `type`, `system`, `remark`, `command`, `params`,
`status`, `expression`, `create_time`, `update_time`
)
SELECT
'同步企微获客分流成员', 1, 0,
'按回调实际承接结果、成员权重与每日上限更新同一条企业微信官方获客链接',
'qywx:sync-promotion-ranges', '', 1, '* * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `zyt_dev_crontab`
WHERE `command` = 'qywx:sync-promotion-ranges' AND `delete_time` IS NULL
);
COMMIT;
@@ -94,4 +94,23 @@ $assert(($customerPayload['link_id'] ?? '') === 'link_1' && ($customerPayload['l
$chatPayload = json_decode((string) $history[6]['request']->getBody(), true);
$assert(($chatPayload['chat_key'] ?? '') === 'chat_key_1', 'get_chat_info 请求体不正确');
$invalidUserService = new QywxCustomerAcquisitionApiService(new Client([
'base_uri' => 'https://qyapi.weixin.qq.com/',
'handler' => HandlerStack::create(new MockHandler([
$json(['errcode' => 60111, 'errmsg' => "invalid string value `XiongCaiQian`: userid not found"]),
])),
'http_errors' => false,
]), static fn (): string => 'mock_token');
$invalidUserMessage = '';
try {
$invalidUserService->createLink([
'link_name' => '无效成员测试',
'range' => ['user_list' => ['XiongCaiQian']],
]);
} catch (RuntimeException $e) {
$invalidUserMessage = $e->getMessage();
}
$assert(str_contains($invalidUserMessage, 'userid 不存在'), '60111 应返回明确的成员绑定诊断提示');
$assert(str_contains($invalidUserMessage, '应用可见范围'), '60111 应提示检查应用可见范围');
echo "QYWX_CUSTOMER_ACQUISITION_API_TEST_OK\n";
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
require dirname(__DIR__) . '/vendor/autoload.php';
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$base = 'https://work.weixin.qq.com/ca/cawcdea778939b9097';
$channelUrl = QywxCustomerAcquisitionLinkService::withCustomerChannel($base, 'zyt_pool:123');
$assert(
$channelUrl === $base . '?customer_channel=zyt_pool:123',
'官方获客链接未按约定追加 customer_channel'
);
$replaced = QywxCustomerAcquisitionLinkService::withCustomerChannel(
$base . '?foo=1&customer_channel=old#wechat_redirect',
'zyt_pool:456'
);
$assert(
$replaced === $base . '?foo=1&customer_channel=zyt_pool:456#wechat_redirect',
'已有 customer_channel 未被安全替换'
);
$assert(
QywxCustomerAcquisitionLinkService::withCustomerChannel('https://example.com/ca/test', 'zyt_pool:1') === '',
'非企业微信获客链接不应生成渠道地址'
);
$assert(
QywxCustomerAcquisitionLinkService::withCustomerChannel($base, str_repeat('a', 65)) === '',
'超过 64 字节的渠道标识不应被接受'
);
$officialDetail = QywxCustomerAcquisitionLinkService::normaliseRemoteResponse([
'errcode' => 0,
'errmsg' => 'ok',
'link' => [
'link_name' => '官网获客',
'url' => $base,
'create_time' => 1787610000,
'skip_verify' => true,
],
'range' => [
'user_list' => ['HuoYiSheng', 'HuoYiSheng', 'Li'],
'department_list' => ['2'],
],
'priority_option' => ['priority_type' => 1],
], 'cawcdea778939b9097');
$assert(
$officialDetail['range_userids'] === ['HuoYiSheng', 'Li'],
'get 接口根级 range.user_list 未被正确解析'
);
$assert(
$officialDetail['range_department_ids'] === ['2'],
'get 接口根级 range.department_list 未被正确解析'
);
$assert(
($officialDetail['priority_option']['priority_type'] ?? 0) === 1,
'get 接口根级 priority_option 未被正确解析'
);
$assert(
isset($officialDetail['snapshot']['range']),
'远端快照应保留完整响应,而不是只保留 link 节点'
);
echo "QYWX_CUSTOMER_ACQUISITION_LINK_SERVICE_OK\n";
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionWeightedRandom;
require dirname(__DIR__) . '/vendor/autoload.php';
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$today = '2026-08-25';
$now = strtotime($today . ' 12:00:00');
$members = [
['id' => 1, 'enabled' => 1, 'weight' => 2, 'current_weight' => 9, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
['id' => 2, 'enabled' => 1, 'weight' => 1, 'current_weight' => -3, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
['id' => 3, 'enabled' => 1, 'weight' => 1, 'current_weight' => 4, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
];
$assert(QywxPromotionWeightedRandom::select($members, $today, $now, 1)['selected_id'] === 1, '权重 2 的第一个区间映射错误');
$assert(QywxPromotionWeightedRandom::select($members, $today, $now, 2)['selected_id'] === 1, '权重 2 的第二个区间映射错误');
$assert(QywxPromotionWeightedRandom::select($members, $today, $now, 3)['selected_id'] === 2, '第二名成员的随机区间映射错误');
$fourth = QywxPromotionWeightedRandom::select($members, $today, $now, 4);
$assert($fourth['selected_id'] === 3, '第三名成员的随机区间映射错误');
$assert($fourth['total_weight'] === 4 && $fourth['eligible_count'] === 3, '随机池权重合计错误');
$assert(array_column($fourth['members'], 'current_weight') === [0, 0, 0], '旧版平滑游标没有归零');
$limited = QywxPromotionWeightedRandom::select([
['id' => 1, 'enabled' => 0, 'weight' => 10, 'current_weight' => 0, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
['id' => 2, 'enabled' => 1, 'weight' => 5, 'current_weight' => 0, 'daily_limit' => 3, 'today_count' => 3, 'today_date' => $today],
['id' => 3, 'enabled' => 1, 'weight' => 1, 'current_weight' => 0, 'daily_limit' => 0, 'today_count' => 8, 'today_date' => $today],
], $today, $now, 1);
$assert($limited['selected_id'] === 3 && $limited['eligible_count'] === 1, '禁用成员或达到上限的成员仍进入随机池');
$reset = QywxPromotionWeightedRandom::select([
['id' => 4, 'enabled' => 1, 'weight' => 1, 'current_weight' => 0, 'daily_limit' => 1, 'today_count' => 1, 'today_date' => '2026-08-24'],
], $today, $now, 1);
$assert($reset['selected_id'] === 4 && $reset['members'][0]['today_count'] === 0, '跨日数量没有自动重置');
$assert(QywxPromotionMemberSchedulerService::poolIdFromState('zyt_pool:123') === 123, 'customer_channel 方案 ID 解析失败');
$assert(QywxPromotionMemberSchedulerService::poolIdFromState('qywx_ca:123') === 0, '不应接管其他系统的 customer_channel');
echo "QYWX_PROMOTION_WEIGHTED_RANDOM_OK\n";
@@ -98,9 +98,10 @@ $encoded = QywxPromotionWidgetService::encode($xssConfig);
widgetAssert(!str_contains($encoded, '<img') && str_contains($encoded, '\\u003Cimg'), '持久化 JSON 未使用 HEX 转义');
$key = str_repeat('a', 32);
$officialUrl = 'https://work.weixin.qq.com/ca/test?customer_channel=zyt_pool:123';
$script = QywxPromotionWidgetService::renderScript(
$key,
'/api/qywx-promotion/go/' . $key,
$officialUrl,
$xssConfig,
true
);
@@ -112,8 +113,9 @@ widgetAssert(str_contains($script, '.wecom-promotion-link[data-pool'), '旧 data
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, 'location.assign(targetUrl)'), '公开脚本没有直达企业微信官方链接');
widgetAssert(str_contains($script, 'work.weixin.qq.com/ca/test?customer_channel=zyt_pool:123'), '公开脚本缺少官方渠道链接');
widgetAssert(!str_contains($script, '?from='), '公开脚本仍追加本站跳转来源参数');
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');