242 lines
7.7 KiB
PHP
Executable File
242 lines
7.7 KiB
PHP
Executable File
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use app\admin\model\split\Link;
|
||
use app\admin\model\split\Number;
|
||
use app\admin\model\split\Ticket;
|
||
use think\Collection;
|
||
|
||
/**
|
||
* 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
|
||
*
|
||
* 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码;
|
||
* 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。
|
||
*
|
||
* random_shuffle=0:跨工单合并池,全局严格轮转;
|
||
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
|
||
*/
|
||
class SplitRedirectService
|
||
{
|
||
private SplitRoundRobinStore $roundRobinStore;
|
||
|
||
private SplitNumberVisitRuleService $visitRuleService;
|
||
|
||
public function __construct(
|
||
?SplitRoundRobinStore $roundRobinStore = null,
|
||
?SplitNumberVisitRuleService $visitRuleService = null
|
||
) {
|
||
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
||
$this->visitRuleService = $visitRuleService ?? new SplitNumberVisitRuleService();
|
||
}
|
||
|
||
/**
|
||
* 根据链接码解析本次应跳转的加好友 URL;无效时返回 null
|
||
*
|
||
* @param string $linkCode 9 位链接码
|
||
* @param string $clientIp 访客 IP(IP 防护开启时用于国家校验)
|
||
*/
|
||
public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string
|
||
{
|
||
$linkCode = SplitLinkCodeService::normalize($linkCode);
|
||
if (!SplitLinkCodeService::isValidFormat($linkCode)) {
|
||
return null;
|
||
}
|
||
|
||
$link = Link::where('link_code', $linkCode)
|
||
->where('status', 'normal')
|
||
->find();
|
||
if (!$link) {
|
||
return null;
|
||
}
|
||
|
||
$ipProtect = (int) $link->getAttr('ip_protect');
|
||
$countries = (string) $link->getAttr('countries');
|
||
if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) {
|
||
return null;
|
||
}
|
||
|
||
$linkId = (int) $link->getAttr('id');
|
||
$list = $this->loadEligibleNumbers($linkId);
|
||
if ($list === []) {
|
||
return null;
|
||
}
|
||
|
||
if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
|
||
$picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
|
||
} else {
|
||
$picked = $this->pickNumberRow($linkId, $list);
|
||
}
|
||
if ($picked === null) {
|
||
return null;
|
||
}
|
||
|
||
$numberType = is_array($picked) ? (string) ($picked['number_type'] ?? '') : (string) $picked->getAttr('number_type');
|
||
$numberValue = is_array($picked) ? (string) ($picked['number'] ?? '') : (string) $picked->getAttr('number');
|
||
$numberCustom = is_array($picked)
|
||
? (string) ($picked['number_type_custom'] ?? '')
|
||
: (string) $picked->getAttr('number_type_custom');
|
||
|
||
$replyText = '';
|
||
if (in_array($numberType, ['whatsapp', 'telegram'], true)) {
|
||
$replyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
||
}
|
||
|
||
$redirectUrl = SplitFriendUrlBuilder::build(
|
||
$numberType,
|
||
$numberValue,
|
||
$numberCustom,
|
||
$replyText
|
||
);
|
||
if ($redirectUrl === '') {
|
||
return null;
|
||
}
|
||
|
||
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
|
||
if ($numberId > 0) {
|
||
Number::where('id', $numberId)->setInc('visit_count');
|
||
$this->visitRuleService->recordVisitAfterRedirect($numberId);
|
||
}
|
||
|
||
return $redirectUrl;
|
||
}
|
||
|
||
/**
|
||
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
|
||
*
|
||
* @return list<Number|array<string, mixed>>
|
||
*/
|
||
private function loadEligibleNumbers(int $linkId): array
|
||
{
|
||
if ($linkId <= 0) {
|
||
return [];
|
||
}
|
||
|
||
/** @var list<string> $hiddenTicketNames */
|
||
$hiddenTicketNames = Ticket::where('split_link_id', $linkId)
|
||
->where('status', 'hidden')
|
||
->column('ticket_name');
|
||
$hiddenTicketNames = array_values(array_unique(array_filter(array_map(
|
||
static function ($name): string {
|
||
return trim((string) $name);
|
||
},
|
||
$hiddenTicketNames
|
||
))));
|
||
|
||
$query = Number::where('split_link_id', $linkId)
|
||
->where('status', 'normal')
|
||
->order('id', 'asc')
|
||
->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name');
|
||
|
||
if ($hiddenTicketNames !== []) {
|
||
$query->where('ticket_name', 'not in', $hiddenTicketNames);
|
||
}
|
||
|
||
$numbers = $query->select();
|
||
return $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
||
}
|
||
|
||
/**
|
||
* random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号
|
||
*
|
||
* @param list<Number|array<string, mixed>> $rows
|
||
* @return array<string, mixed>|Number|null
|
||
*/
|
||
private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows)
|
||
{
|
||
$groups = $this->groupNumbersByTicket($rows);
|
||
if ($groups === []) {
|
||
return null;
|
||
}
|
||
|
||
$ticketKey = $this->pickTicketGroupKeyWeighted($groups);
|
||
if ($ticketKey === null || !isset($groups[$ticketKey])) {
|
||
return null;
|
||
}
|
||
|
||
return $this->pickNumberRow($linkId, $groups[$ticketKey]);
|
||
}
|
||
|
||
/**
|
||
* @param list<Number|array<string, mixed>> $rows
|
||
* @return array<string, list<Number|array<string, mixed>>>
|
||
*/
|
||
private function groupNumbersByTicket(array $rows): array
|
||
{
|
||
/** @var array<string, list<Number|array<string, mixed>>> $groups */
|
||
$groups = [];
|
||
foreach ($rows as $row) {
|
||
$name = is_array($row)
|
||
? trim((string) ($row['ticket_name'] ?? ''))
|
||
: trim((string) $row->getAttr('ticket_name'));
|
||
$key = $name !== '' ? $name : '__manual__';
|
||
$groups[$key][] = $row;
|
||
}
|
||
|
||
return $groups;
|
||
}
|
||
|
||
/**
|
||
* 按各分组号码数量加权随机选一个工单(分组 key)
|
||
*
|
||
* @param array<string, list<Number|array<string, mixed>>> $groups
|
||
*/
|
||
private function pickTicketGroupKeyWeighted(array $groups): ?string
|
||
{
|
||
$total = 0;
|
||
foreach ($groups as $rows) {
|
||
$total += count($rows);
|
||
}
|
||
if ($total <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$keys = array_keys($groups);
|
||
if (count($keys) === 1) {
|
||
return $keys[0];
|
||
}
|
||
|
||
$r = random_int(1, $total);
|
||
$acc = 0;
|
||
foreach ($groups as $key => $rows) {
|
||
$acc += count($rows);
|
||
if ($r <= $acc) {
|
||
return $key;
|
||
}
|
||
}
|
||
|
||
return $keys[0];
|
||
}
|
||
|
||
/**
|
||
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
||
*
|
||
* @param list<Number|array<string, mixed>> $rows
|
||
* @return array<string, mixed>|Number|null
|
||
*/
|
||
private function pickNumberRow(int $linkId, array $rows)
|
||
{
|
||
$preferred = [];
|
||
$deferred = [];
|
||
foreach ($rows as $row) {
|
||
$deferredFlag = is_array($row)
|
||
? (int) ($row['ratio_deferred'] ?? 0)
|
||
: (int) $row->getAttr('ratio_deferred');
|
||
if ($deferredFlag === 1) {
|
||
$deferred[] = $row;
|
||
} else {
|
||
$preferred[] = $row;
|
||
}
|
||
}
|
||
|
||
$picked = SplitNumberWeighService::pickRowFromList($linkId, $preferred, $this->roundRobinStore, 'preferred');
|
||
if ($picked !== null) {
|
||
return $picked;
|
||
}
|
||
|
||
return SplitNumberWeighService::pickRowFromList($linkId, $deferred, $this->roundRobinStore, 'deferred');
|
||
}
|
||
}
|