139 lines
5.1 KiB
PHP
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;
|
|
}
|
|
}
|