This commit is contained in:
Your Name
2026-08-08 15:42:45 +08:00
parent a968945057
commit d10f213573
21 changed files with 2610 additions and 279 deletions
@@ -173,6 +173,53 @@ class MediaChannelService
$query->whereRaw('(' . implode(' OR ', $segments) . ')', $bindings);
}
/**
* Filter a fact table by its external_userid without joining the denormalized
* contact rows. The contact table may contain several rows for one customer;
* a normal JOIN therefore both scans follow_users TEXT repeatedly and
* multiplies facts. Enterprise tag channels use the normalized relation
* table, while legacy name-only channels keep a deduplicated JSON fallback.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void
{
if ($channel === null) {
return;
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"{$field} IN (SELECT channel_tag.external_userid FROM {$tagTable} channel_tag WHERE channel_tag.tag_id = ?)",
[$tagId]
);
return;
}
$patterns = self::buildLikePatterns($channel);
if ($patterns === []) {
$query->whereRaw('1 = 0');
return;
}
$segments = [];
$bindings = [];
foreach ($patterns as $pattern) {
$segments[] = 'channel_contact.follow_users LIKE ?';
$bindings[] = $pattern;
}
$contactTable = self::tableWithPrefix('qywx_external_contact');
$query->whereRaw(
"{$field} IN (SELECT channel_contact.external_userid FROM {$contactTable} channel_contact"
. ' WHERE channel_contact.delete_time IS NULL AND (' . implode(' OR ', $segments) . '))',
$bindings
);
}
/**
* @return array{scanned_contacts: int, discovered_tags: int, inserted_or_updated: int}
*/
@@ -275,6 +322,13 @@ class MediaChannelService
return array_values(array_unique($patterns));
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
/**
* @param array<int, mixed> $followUsers
* @return array<int, array{source_tag_id: string, source_tag_name: string, source_group_name: string}>
@@ -9,6 +9,30 @@ use think\facade\Db;
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
class QywxPromotionRedirectService
{
/** @return array{status:int,widget_config_json:?string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
return null;
}
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
->field('status,widget_config_json')
->find();
if (!$row) {
return null;
}
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
];
}
/** @return array{url:string,link_id:int}|null */
public static function pick(string $publicKey, array $context = []): ?array
{
@@ -76,8 +100,7 @@ class QywxPromotionRedirectService
public static function poolExists(string $publicKey): bool
{
return preg_match('/^[a-f0-9]{32}$/', $publicKey) === 1
&& Db::name('qywx_promotion_pool')->where('public_key', $publicKey)->whereNull('delete_time')->count() > 0;
return self::publicPoolConfig($publicKey) !== null;
}
/** @param array<int,array<string,mixed>> $links */
@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use InvalidArgumentException;
/**
* 获客助手公开浮窗配置与脚本。
*
* 管理端输入严格校验;数据库中的未知版本或损坏配置一律回退为关闭状态。
*/
class QywxPromotionWidgetService
{
private const VERSION = 1;
private const TEMPLATES = ['bubble', 'pill', 'card', 'message', 'edge', 'bar'];
private const POSITIONS = ['bottom-right', 'bottom-left'];
/** @return array<string, mixed> */
public static function defaults(): array
{
return [
'v' => self::VERSION,
'enabled' => false,
'template' => 'bubble',
'position' => 'bottom-right',
'title' => '专属顾问在线',
'subtitle' => '点击添加企业微信,获取一对一服务',
'button_text' => '立即咨询',
'primary_color' => '#139A8C',
'bottom_offset' => 28,
'show_mobile' => true,
];
}
/**
* @return array<string, mixed>
* @throws InvalidArgumentException
*/
public static function fromInput(mixed $input): array
{
if (!is_array($input)) {
throw new InvalidArgumentException('浮窗配置格式无效');
}
$defaults = self::defaults();
$version = self::integerValue(self::inputValue($input, 'v', self::VERSION), '配置版本');
if ($version !== self::VERSION) {
throw new InvalidArgumentException('不支持的浮窗配置版本');
}
$template = self::textValue(self::inputValue($input, 'template', $defaults['template']), '模板', 1, 20);
if (!in_array($template, self::TEMPLATES, true)) {
throw new InvalidArgumentException('浮窗模板无效');
}
$position = self::textValue(self::inputValue($input, 'position', $defaults['position']), '位置', 1, 20);
if (!in_array($position, self::POSITIONS, true)) {
throw new InvalidArgumentException('浮窗位置无效');
}
$color = strtoupper(trim(self::stringValue(
self::inputValue($input, 'primary_color', $defaults['primary_color']),
'主题色'
)));
if (preg_match('/^#[0-9A-F]{6}$/D', $color) !== 1) {
throw new InvalidArgumentException('主题色必须是 #RRGGBB 格式');
}
$bottomOffset = self::integerValue(
self::inputValue($input, 'bottom_offset', $defaults['bottom_offset']),
'底部距离'
);
if ($bottomOffset < 16 || $bottomOffset > 160) {
throw new InvalidArgumentException('底部距离必须在 16-160 之间');
}
return [
'v' => self::VERSION,
'enabled' => self::booleanValue(self::inputValue($input, 'enabled', $defaults['enabled']), '启用状态'),
'template' => $template,
'position' => $position,
'title' => self::textValue(self::inputValue($input, 'title', $defaults['title']), '标题', 1, 24),
'subtitle' => self::textValue(self::inputValue($input, 'subtitle', $defaults['subtitle']), '副标题', 0, 48),
'button_text' => self::textValue(
self::inputValue($input, 'button_text', $defaults['button_text']),
'按钮文案',
1,
12
),
'primary_color' => $color,
'bottom_offset' => $bottomOffset,
'show_mobile' => self::booleanValue(
self::inputValue($input, 'show_mobile', $defaults['show_mobile']),
'移动端展示状态'
),
];
}
/** @return array<string, mixed> */
public static function decode(mixed $stored): array
{
if (!is_string($stored) || trim($stored) === '') {
return self::defaults();
}
try {
$decoded = json_decode($stored, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
return self::defaults();
}
foreach (array_keys(self::defaults()) as $key) {
if (!array_key_exists($key, $decoded)) {
return self::defaults();
}
}
return self::fromInput($decoded);
} catch (\Throwable) {
return self::defaults();
}
}
/** @param array<string, mixed> $config */
public static function encode(array $config): string
{
return self::jsonForScript(self::fromInput($config));
}
/**
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择。
*
* @param array<string, mixed> $config
*/
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
$config['enabled'] = false;
}
$jsonKey = self::jsonForScript($key);
$jsonGo = self::jsonForScript($goUrl);
$jsonConfig = self::jsonForScript($config);
return <<<JS
(function(w,d){
'use strict';
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
var go=resolveGoUrl(goPath);
var registry=w.WecomPromotion=w.WecomPromotion||{};
var previous=registry[key];
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
previous.destroy();
}
var root=null,mediaQuery=null,readyHandler=null,destroyed=false,manuallyHidden=false,api=null;
var rootId='wecom-promotion-widget-'+key;
var selector='[data-wecom-promotion="'+key+'"],.wecom-promotion-link[data-pool="'+key+'"]';
function findScriptNode(){
if(scriptNode&&scriptNode.src){return scriptNode;}
var scripts=d.getElementsByTagName('script');
var marker='/api/qywx-promotion/js/'+key;
for(var index=scripts.length-1;index>=0;index--){
if((scripts[index].src||'').indexOf(marker)!==-1){scriptNode=scripts[index];return scriptNode;}
}
return null;
}
function resolveGoUrl(value){
if(/^https?:\/\//i.test(value)){return value;}
var node=findScriptNode();
if(node&&node.src&&typeof w.URL==='function'){
try{return new w.URL(value,node.src).href;}catch(error){}
}
return value;
}
function sourceUrl(){
var location=w.location||{};
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
return origin+(location.pathname||'/');
}
function openPromotion(){
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
}
function handleDocumentClick(event){
var path=typeof event.composedPath==='function'?event.composedPath():[];
var node=null;
for(var index=0;index<path.length;index++){
var candidate=path[index];
if(candidate&&candidate.nodeType===1&&candidate.matches&&candidate.matches(selector)){node=candidate;break;}
}
var target=event.target;
if(!node){node=target&&target.closest?target.closest(selector):null;}
if(!node){return;}
event.preventDefault();
event.stopPropagation();
openPromotion();
}
function isMobileHidden(){
return config.show_mobile===false&&mediaQuery&&mediaQuery.matches;
}
function applyVisibility(){
if(root){root.hidden=manuallyHidden||isMobileHidden();}
}
function handleViewportChange(){
applyVisibility();
}
function appendText(parent,tag,className,value){
var node=d.createElement(tag);
node.className=className;
node.textContent=value;
parent.appendChild(node);
return node;
}
function mount(){
if(destroyed||root||!config.enabled||!d.body){return;}
var stale=d.getElementById(rootId);
if(stale&&stale.parentNode){stale.parentNode.removeChild(stale);}
root=d.createElement('div');
root.id=rootId;
root.className='wcp-host wcp-host-'+config.position+' wcp-host-'+config.template;
root.setAttribute('data-wecom-promotion-widget',key);
var surface=root.attachShadow?root.attachShadow({mode:'open'}):root;
var style=d.createElement('style');
var nonceNode=findScriptNode();
var nonce=nonceNode?(nonceNode.nonce||nonceNode.getAttribute('nonce')||''):'';
if(nonce){style.setAttribute('nonce',nonce);}
var hostRules='position:fixed;z-index:2147483000;right:20px;bottom:calc('+config.bottom_offset+'px + env(safe-area-inset-bottom, 0px));max-width:calc(100vw - 32px);pointer-events:none;--wcp-primary:'+config.primary_color+';font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#fff;line-height:1.4;-webkit-font-smoothing:antialiased';
style.textContent=':host{'+hostRules+'}.wcp-host{'+hostRules+'}' +
':host(.wcp-host-bottom-left){right:auto;left:20px}.wcp-host-bottom-left{right:auto;left:20px}' +
':host(.wcp-host-edge.wcp-host-bottom-right){right:0}.wcp-host-edge.wcp-host-bottom-right{right:0}' +
':host(.wcp-host-edge.wcp-host-bottom-left){right:auto;left:0}.wcp-host-edge.wcp-host-bottom-left{right:auto;left:0}' +
':host([hidden]){display:none!important}.wcp-host[hidden]{display:none!important}.wcp-root,.wcp-root *{box-sizing:border-box}.wcp-root{pointer-events:none}' +
'.wcp-button{pointer-events:auto;position:relative;display:flex;align-items:center;gap:12px;margin:0;border:0;cursor:pointer;color:#fff;background:var(--wcp-primary);font:inherit;text-align:left;box-shadow:0 14px 38px rgba(18,48,46,.24);transition:transform .2s ease,box-shadow .2s ease;appearance:none;-webkit-appearance:none}' +
'.wcp-button:hover{transform:translateY(-2px);box-shadow:0 18px 44px rgba(18,48,46,.3)}.wcp-button:active{transform:translateY(0)}.wcp-button:focus-visible{outline:3px solid rgba(255,255,255,.96);outline-offset:3px}' +
'.wcp-icon{display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.18);font-size:17px;font-weight:800}' +
'.wcp-copy{display:flex;min-width:0;flex-direction:column}.wcp-title{font-size:15px;font-weight:750;line-height:1.25}.wcp-subtitle{margin-top:2px;max-width:240px;font-size:12px;line-height:1.4;opacity:.86}' +
'.wcp-cta{flex:0 0 auto;padding:7px 11px;border-radius:999px;background:#fff;color:var(--wcp-primary);font-size:12px;font-weight:750;white-space:nowrap}' +
'.wcp-bubble .wcp-button{width:66px;height:66px;justify-content:center;padding:0;border-radius:50%}.wcp-bubble .wcp-icon{width:46px;height:46px;font-size:19px}.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{position:absolute;right:76px;visibility:hidden;opacity:0;transform:translateX(8px);transition:opacity .18s ease,transform .18s ease;pointer-events:none}' +
'.wcp-bottom-left.wcp-bubble .wcp-copy,.wcp-bottom-left.wcp-bubble .wcp-cta{right:auto;left:76px}.wcp-bubble .wcp-copy{bottom:27px;width:220px;padding:11px 13px;border-radius:12px;background:#173f3b;box-shadow:0 12px 30px rgba(0,0,0,.2)}.wcp-bubble .wcp-cta{bottom:-1px;padding:5px 10px}' +
'.wcp-bubble .wcp-button:hover .wcp-copy,.wcp-bubble .wcp-button:hover .wcp-cta,.wcp-bubble .wcp-button:focus-visible .wcp-copy,.wcp-bubble .wcp-button:focus-visible .wcp-cta{visibility:visible;opacity:1;transform:translateX(0)}' +
'.wcp-pill .wcp-button{min-height:58px;padding:9px 12px;border-radius:999px}.wcp-pill .wcp-subtitle{display:none}' +
'.wcp-card .wcp-button{width:min(340px,calc(100vw - 40px));padding:15px;border-radius:18px}.wcp-card .wcp-icon{width:46px;height:46px}.wcp-card .wcp-copy{flex:1}.wcp-card .wcp-cta{border-radius:10px}' +
'.wcp-message .wcp-button{width:min(330px,calc(100vw - 40px));padding:13px 14px;border-radius:18px 18px 4px 18px}.wcp-bottom-left.wcp-message .wcp-button{border-radius:18px 18px 18px 4px}.wcp-message .wcp-copy{flex:1}.wcp-message .wcp-cta{padding:6px 9px}' +
'.wcp-edge .wcp-button{min-height:62px;max-width:270px;padding:10px 15px;border-radius:16px 0 0 16px}.wcp-bottom-left.wcp-edge .wcp-button{border-radius:0 16px 16px 0}.wcp-edge .wcp-subtitle{display:none}.wcp-edge .wcp-cta{padding:6px 9px}' +
'.wcp-bar .wcp-button{width:min(420px,calc(100vw - 40px));padding:11px 14px;border-radius:12px}.wcp-bar .wcp-copy{flex:1}.wcp-bar .wcp-icon{width:34px;height:34px}.wcp-bar .wcp-subtitle{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'@media(max-width:767px){.wcp-host{max-width:calc(100vw - 24px)}.wcp-card .wcp-button,.wcp-message .wcp-button,.wcp-bar .wcp-button{width:calc(100vw - 40px)}.wcp-subtitle{max-width:180px}.wcp-card .wcp-cta,.wcp-message .wcp-cta{display:none}}' +
'@media(prefers-reduced-motion:reduce){.wcp-button,.wcp-bubble .wcp-copy,.wcp-bubble .wcp-cta{transition:none!important}}';
surface.appendChild(style);
var container=d.createElement('div');
container.className='wcp-root wcp-'+config.template+' wcp-'+config.position;
var button=d.createElement('button');
button.type='button';
button.className='wcp-button';
button.setAttribute('aria-label',config.title+''+config.button_text);
appendText(button,'span','wcp-icon','企');
var copy=d.createElement('span');
copy.className='wcp-copy';
appendText(copy,'strong','wcp-title',config.title);
if(config.subtitle!==''){appendText(copy,'span','wcp-subtitle',config.subtitle);}
button.appendChild(copy);
appendText(button,'span','wcp-cta',config.button_text);
button.addEventListener('click',function(event){
event.preventDefault();
event.stopPropagation();
openPromotion();
});
container.appendChild(button);
surface.appendChild(container);
d.body.appendChild(root);
if(config.show_mobile===false&&typeof w.matchMedia==='function'){
mediaQuery=w.matchMedia('(max-width: 767px)');
if(mediaQuery.addEventListener){mediaQuery.addEventListener('change',handleViewportChange);}
else if(mediaQuery.addListener){mediaQuery.addListener(handleViewportChange);}
}
applyVisibility();
}
function show(){
if(destroyed||!config.enabled){return;}
manuallyHidden=false;
if(root){applyVisibility();return;}
if(d.body){mount();}
else if(!readyHandler){
readyHandler=function(){readyHandler=null;mount();};
d.addEventListener('DOMContentLoaded',readyHandler,{once:true});
}
}
function hide(){
manuallyHidden=true;
applyVisibility();
}
function destroy(){
if(destroyed){return;}
destroyed=true;
d.removeEventListener('click',handleDocumentClick,true);
if(readyHandler){d.removeEventListener('DOMContentLoaded',readyHandler);readyHandler=null;}
if(mediaQuery){
if(mediaQuery.removeEventListener){mediaQuery.removeEventListener('change',handleViewportChange);}
else if(mediaQuery.removeListener){mediaQuery.removeListener(handleViewportChange);}
mediaQuery=null;
}
if(root&&root.parentNode){root.parentNode.removeChild(root);}
root=null;
if(registry[key]===api){delete registry[key];}
}
d.addEventListener('click',handleDocumentClick,true);
api={open:openPromotion,show:show,hide:hide,destroy:destroy,config:config,__widgetVersion:1};
registry[key]=api;
if(config.enabled){show();}
})(window,document);
JS;
}
private static function booleanValue(mixed $value, string $label): bool
{
if (is_bool($value)) {
return $value;
}
if ($value === 1 || $value === '1') {
return true;
}
if ($value === 0 || $value === '0') {
return false;
}
throw new InvalidArgumentException($label . '必须是布尔值');
}
private static function inputValue(array $input, string $key, mixed $default): mixed
{
return array_key_exists($key, $input) ? $input[$key] : $default;
}
private static function integerValue(mixed $value, string $label): int
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
return (int) $value;
}
throw new InvalidArgumentException($label . '必须是整数');
}
private static function stringValue(mixed $value, string $label): string
{
if (!is_string($value)) {
throw new InvalidArgumentException($label . '必须是字符串');
}
return $value;
}
private static function textValue(mixed $value, string $label, int $min, int $max): string
{
$value = self::stringValue($value, $label);
$value = preg_replace('/\s+/u', ' ', trim($value)) ?? '';
$length = mb_strlen($value);
if ($length < $min || $length > $max) {
throw new InvalidArgumentException(sprintf('%s长度必须在 %d-%d 个字符之间', $label, $min, $max));
}
return $value;
}
private static function jsonForScript(mixed $value): string
{
return json_encode(
$value,
JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
| JSON_THROW_ON_ERROR
);
}
}