Files
zyt/server/app/common/service/qywx/QywxCustomerAcquisitionLinkService.php
2026-08-25 09:35:47 +08:00

139 lines
5.1 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\qywx;
use RuntimeException;
/** 企业微信获客助手链接校验。 */
class QywxCustomerAcquisitionLinkService
{
private const HOST = 'work.weixin.qq.com';
/**
* 只接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... 链接。
*/
public static function isAllowed(string $url, bool $allowEmpty = false): bool
{
$url = trim($url);
if ($url === '') {
return $allowEmpty;
}
$parts = parse_url($url);
if (!is_array($parts)
|| strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
|| strtolower((string) ($parts['host'] ?? '')) !== self::HOST
|| isset($parts['user'])
|| isset($parts['pass'])
|| (isset($parts['port']) && (int) $parts['port'] !== 443)
) {
return false;
}
$path = (string) ($parts['path'] ?? '');
return preg_match('#^/ca/[A-Za-z0-9_-]+/?$#', $path) === 1;
}
public static function example(): string
{
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;
}
}