Files
links/application/common/service/SplitRedirectService.php
T

242 lines
7.7 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 访客 IPIP 防护开启时用于国家校验)
*/
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');
}
}