This commit is contained in:
Your Name
2026-08-25 09:35:47 +08:00
parent 1f3e580cf8
commit 01c38d8c5b
13 changed files with 855 additions and 92 deletions
@@ -183,11 +183,18 @@ class QywxCustomerAcquisitionApiService
return $this->request($method, $path, $body, true);
}
$errorMessage = trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误';
if ($errcode === 60111) {
throw new RuntimeException(
'所选医助的企业微信 userid 不存在,或不在获客助手可调用应用的可见范围;'
. '请检查后台账号绑定和企业微信应用可见范围。企业微信返回:' . $errorMessage
);
}
throw new RuntimeException(sprintf(
'企业微信获客助手接口失败[%d]%s',
$errcode,
trim((string) ($decoded['errmsg'] ?? '未知错误')) ?: '未知错误'
$errorMessage
));
}
@@ -93,6 +93,13 @@ class QywxCustomerAcquisitionCustomerService
'event_time' => $eventTime,
'snapshot' => $message,
], false);
self::recordPromotionAssignment(
(string) ($message['State'] ?? $message['state'] ?? ''),
$remoteLinkId,
$userId,
$externalUserId,
$eventTime
);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
@@ -126,6 +133,13 @@ class QywxCustomerAcquisitionCustomerService
'event_time' => $eventTime,
'snapshot' => $chat,
], true);
self::recordPromotionAssignment(
(string) ($chatInfo['state'] ?? $message['State'] ?? ''),
$remoteLinkId,
$userId,
$externalUserId,
$eventTime
);
self::finishEvent($eventId, 1, '', $remoteLinkId, $externalUserId, $userId);
return ['duplicate' => false, 'status' => 'success'];
@@ -419,6 +433,40 @@ class QywxCustomerAcquisitionCustomerService
return [$adminId, $deptId];
}
private static function recordPromotionAssignment(
string $state,
string $remoteLinkId,
string $userId,
string $externalUserId,
int $eventTime
): void {
$result = QywxPromotionMemberSchedulerService::recordFromState(
$state,
$userId,
$externalUserId,
$eventTime,
'customer_acquisition'
);
if ((int) ($result['pool_id'] ?? 0) <= 0) {
$result = QywxPromotionMemberSchedulerService::recordFromRemoteLink(
$remoteLinkId,
$userId,
$externalUserId,
$eventTime,
'customer_acquisition'
);
}
if (($result['status'] ?? '') !== 'counted' || (int) ($result['pool_id'] ?? 0) <= 0) {
return;
}
try {
// 优先在本次回调完成后切换,后台分钟任务继续承担失败重试。
(new QywxPromotionRangeSyncService())->syncPool((int) $result['pool_id']);
} catch (\Throwable) {
// 同步服务已经保存失败原因和下次重试时间,不能让远端网络错误回滚已记账的客户。
}
}
private static function encodeJson(mixed $value): string
{
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 企业微信获客助手链接校验。 */
class QywxCustomerAcquisitionLinkService
{
@@ -39,4 +41,98 @@ class QywxCustomerAcquisitionLinkService
{
return 'https://work.weixin.qq.com/ca/xxxxxxxx';
}
/**
* 规范化企业微信 create_link/get 返回的链接详情。
*
* get 接口把 range、priority_option 放在响应根级,link 只包含链接本身;
* 旧响应或测试桩可能把这些字段放在 link 内,因此保留兼容回退。
*
* @return array{
* link_id:string,
* link_name:string,
* url:string,
* create_time:int,
* range_userids:list<string>,
* range_department_ids:list<string>,
* skip_verify:bool,
* priority_option:array<string,mixed>,
* snapshot:array<string,mixed>
* }
*/
public static function normaliseRemoteResponse(array $response, string $fallbackId = ''): array
{
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
$range = isset($response['range']) && is_array($response['range'])
? $response['range']
: (isset($link['range']) && is_array($link['range']) ? $link['range'] : []);
$priorityOption = isset($response['priority_option']) && is_array($response['priority_option'])
? $response['priority_option']
: (isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : []);
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
if ($linkId === '') {
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
}
if (!self::isAllowed($url)) {
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
}
return [
'link_id' => $linkId,
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
'url' => $url,
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
'skip_verify' => !empty($link['skip_verify']),
'priority_option' => $priorityOption,
'snapshot' => $response,
];
}
/** 追加或替换企业微信获客助手的自定义渠道标识。 */
public static function withCustomerChannel(string $url, string $channel): string
{
$url = trim($url);
$channel = trim($channel);
if (!self::isAllowed($url) || $channel === '' || strlen($channel) > 64) {
return '';
}
// 冒号在查询参数中是合法字符,保留“命名空间:编号”的可读结构;其余字符仍编码。
$parameter = 'customer_channel=' . str_replace('%3A', ':', rawurlencode($channel));
if (preg_match('/([?&])customer_channel=[^&#]*/i', $url) === 1) {
return (string) preg_replace_callback(
'/([?&])customer_channel=[^&#]*/i',
static fn (array $matches): string => $matches[1] . $parameter,
$url,
1
);
}
$fragment = '';
$fragmentPosition = strpos($url, '#');
if ($fragmentPosition !== false) {
$fragment = substr($url, $fragmentPosition);
$url = substr($url, 0, $fragmentPosition);
}
return $url . (str_contains($url, '?') ? '&' : '?') . $parameter . $fragment;
}
/** @return list<string> */
private static function normaliseScalarList(mixed $values): array
{
$result = [];
$seen = [];
foreach ((array) $values as $value) {
$normalised = trim((string) $value);
$key = 'value:' . $normalised;
if ($normalised !== '' && !isset($seen[$key])) {
$seen[$key] = true;
$result[] = $normalised;
}
}
return $result;
}
}
@@ -6,10 +6,10 @@ namespace app\common\service\qywx;
use think\facade\Db;
/** 公开获客助手链接分流:按权重随机,并在事务内维护当日限额与点击计数。 */
/** 公开获客助手链接:新方案直达单个官方链接,旧 /go 入口继续兼容历史分流。 */
class QywxPromotionRedirectService
{
/** @return array{status:int,widget_config_json:?string}|null */
/** @return array{status:int,widget_config_json:?string,target_url:string}|null */
public static function publicPoolConfig(string $publicKey): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $publicKey) !== 1) {
@@ -19,17 +19,32 @@ class QywxPromotionRedirectService
$row = Db::name('qywx_promotion_pool')
->where('public_key', $publicKey)
->whereNull('delete_time')
->field('status,widget_config_json')
->field('id,status,widget_config_json')
->find();
if (!$row) {
return null;
}
$link = Db::name('qywx_promotion_link')
->where('pool_id', (int) $row['id'])
->where('status', 1)
->where('remote_status', 1)
->where('remote_link_id', '<>', '')
->whereNull('delete_time')
->order('id', 'desc')
->field('wecom_url')
->find();
$targetUrl = QywxCustomerAcquisitionLinkService::withCustomerChannel(
(string) ($link['wecom_url'] ?? ''),
'zyt_pool:' . (int) $row['id']
);
return [
'status' => (int) ($row['status'] ?? 0),
'widget_config_json' => isset($row['widget_config_json'])
? (string) $row['widget_config_json']
: null,
'target_url' => $targetUrl,
];
}
@@ -131,11 +131,11 @@ class QywxPromotionWidgetService
}
/**
* 生成可直接跨站安装的完整脚本。真实获客链接始终只由跳转端点选择
* 生成可直接跨站安装的完整脚本,点击后直达当前方案的企业微信官方链接
*
* @param array<string, mixed> $config
*/
public static function renderScript(string $key, string $goUrl, array $config, bool $poolEnabled = true): string
public static function renderScript(string $key, string $targetUrl, array $config, bool $poolEnabled = true): string
{
$config = self::fromInput($config);
if (!$poolEnabled) {
@@ -143,14 +143,14 @@ class QywxPromotionWidgetService
}
$jsonKey = self::jsonForScript($key);
$jsonGo = self::jsonForScript($goUrl);
$jsonTarget = self::jsonForScript($targetUrl);
$jsonConfig = self::jsonForScript($config);
return <<<JS
(function(w,d){
'use strict';
var key={$jsonKey},goPath={$jsonGo},config={$jsonConfig},scriptNode=d.currentScript||null;
var go=resolveGoUrl(goPath);
var key={$jsonKey},targetPath={$jsonTarget},config={$jsonConfig},scriptNode=d.currentScript||null;
var targetUrl=resolveTargetUrl(targetPath);
var registry=w.WecomPromotion=w.WecomPromotion||{};
var previous=registry[key];
if(previous&&previous.__widgetVersion===1&&typeof previous.destroy==='function'){
@@ -170,7 +170,7 @@ class QywxPromotionWidgetService
return null;
}
function resolveGoUrl(value){
function resolveTargetUrl(value){
if(/^https?:\/\//i.test(value)){return value;}
var node=findScriptNode();
if(node&&node.src&&typeof w.URL==='function'){
@@ -179,14 +179,8 @@ class QywxPromotionWidgetService
return value;
}
function sourceUrl(){
var location=w.location||{};
var origin=location.origin||((location.protocol&&location.host)?location.protocol+'//'+location.host:'');
return origin+(location.pathname||'/');
}
function openPromotion(){
w.location.assign(go+'?from='+encodeURIComponent(sourceUrl()));
if(targetUrl){w.location.assign(targetUrl);}
}
function handleDocumentClick(event){