278 lines
9.7 KiB
PHP
Executable File
278 lines
9.7 KiB
PHP
Executable File
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Categories;
|
||
use App\Models\Articles;
|
||
use App\Models\ArticleLikes;
|
||
use App\Models\Tags;
|
||
use App\Services\CacheService;
|
||
|
||
class ArticlesService
|
||
{
|
||
/**
|
||
* 第一步:文章创建时,生成一个“7天点赞计划”
|
||
* 规则:第一天占 70%,剩下 6 天分摊 30%,全部为整数
|
||
*/
|
||
public function generateSevenDayPlan(int $iTargetTotal): string
|
||
{
|
||
if ($iTargetTotal <= 0) return '[]';
|
||
|
||
$aPlan = array_fill(0, 7, 0);
|
||
|
||
// 1. 第一天分配 70% (向上取整,保证第一天绝对有动作)
|
||
$iDay1Likes = (int) ceil($iTargetTotal * 0.7);
|
||
$aPlan[0] = $iDay1Likes;
|
||
|
||
// 2. 剩下的 30% 分配给后面 6 天
|
||
$iRemaining = $iTargetTotal - $iDay1Likes;
|
||
if ($iRemaining > 0) {
|
||
// 随机把剩下的点赞塞进后 6 天的坑位里
|
||
for ($i = 0; $i < $iRemaining; $i++) {
|
||
$iRandomDay = mt_rand(1, 6);
|
||
$aPlan[$iRandomDay]++;
|
||
}
|
||
}
|
||
|
||
// 返回匈牙利命名法下的 json 字符串
|
||
return json_encode($aPlan);
|
||
}
|
||
|
||
/**
|
||
* 第二步:执行点赞任务 (建议每小时跑一次)
|
||
* 只负责把今天该加的赞加进去
|
||
*/
|
||
public function executeLikeTask(Article $oArticle): void
|
||
{
|
||
if (empty($oArticle->sLikePlan)) return;
|
||
|
||
$aPlan = json_decode($oArticle->sLikePlan, true);
|
||
$iDaysPassed = $oArticle->created_at->diffInDays(now());
|
||
|
||
// 如果已经超过7天,或者计划里没这一天,直接撤退
|
||
if ($iDaysPassed > 6 || !isset($aPlan[$iDaysPassed])) {
|
||
return;
|
||
}
|
||
|
||
$iShouldHaveAdded = $aPlan[$iDaysPassed];
|
||
|
||
// 重点:为了不让点赞在凌晨一瞬间加上去,我们可以配合小时数
|
||
// 比如现在是 14 点,我们应该加到今天总量的 14/24
|
||
$iHour = (int) date('H');
|
||
$iCurrentTarget = (int) floor($iShouldHaveAdded * ($iHour / 24));
|
||
|
||
// 这里需要一个逻辑记录今天已经加了多少,或者直接根据小时计算增量
|
||
// 简单处理:我们每天分 24 次把 $iShouldHaveAdded 加完
|
||
// 为保简洁,这里展示核心逻辑更新:
|
||
if ($iShouldHaveAdded > 0) {
|
||
// 算出每小时平均加几个,剩下的随机掉落
|
||
$iAddToday = $this->calculateHourlyIncrement($iShouldHaveAdded, $iHour);
|
||
// 这里更新 i7like 字段
|
||
// 注意:i7like 应该是这篇文章总共通过这个逻辑加了多少赞
|
||
// ... 具体的数据库 Update 逻辑
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 365天自动点赞:高标准随机触发
|
||
*/
|
||
public function applyYearlyGrowth(Article $oArticle): void
|
||
{
|
||
$iYearlyTarget = 10; // 哪怕一年只要 10 个
|
||
|
||
// 计算今天中奖的概率:10 / 365
|
||
// 我们放大一万倍来摇号
|
||
$iProbability = (int)(($iYearlyTarget / 365) * 10000);
|
||
$iMagicNumber = mt_rand(1, 10000);
|
||
|
||
if ($iMagicNumber <= $iProbability) {
|
||
// 中奖了,加 1 个赞
|
||
$oArticle->increment('i365like');
|
||
}
|
||
}
|
||
|
||
public function like(int $iArticleId, string $sIp, string $sFingerprint): array
|
||
{
|
||
// 1. 检查是否已经点过
|
||
$bExists = ArticleLikes::where('iArticleId', $iArticleId)
|
||
->where(function ($q) use ($sIp, $sFingerprint) {
|
||
$q->where('sIp', $sIp)
|
||
->orWhere('sFingerprint', $sFingerprint);
|
||
})->exists();
|
||
|
||
if ($bExists) {
|
||
return ['status' => false, 'code' => 200, 'msg' => '感谢支持,点过赞了。'];
|
||
}
|
||
|
||
// 2. 事务扣杀
|
||
try {
|
||
// 定义一个变量来接收最新的点赞数
|
||
$iNewLikeCount = 0;
|
||
|
||
\DB::transaction(function () use ($iArticleId, $sIp, $sFingerprint, &$iNewLikeCount) {
|
||
ArticleLikes::create([
|
||
'iArticleId' => $iArticleId,
|
||
'sIp' => $sIp,
|
||
'sFingerprint' => $sFingerprint
|
||
]);
|
||
|
||
// 执行增加并直接获取最新的模型实例
|
||
$oArticle = Articles::where('id', $iArticleId);
|
||
$oArticle->increment('iLikeCount');
|
||
|
||
// 拿到自增后的最新数字
|
||
$iNewLikeCount = $oArticle->value('iLikeCount');
|
||
});
|
||
|
||
CacheService::updateOrderLikes();
|
||
|
||
return [
|
||
'status' => true,
|
||
'code' => 200,
|
||
'msg' => '感谢支持!',
|
||
'iLikeCount' => $iNewLikeCount // 药水哥绝杀:把热腾腾的数字发回去
|
||
];
|
||
} catch (\Exception $e) {
|
||
\Log::error("点赞失败: " . $e->getMessage());
|
||
return ['status' => false, 'code' => 500, 'msg' => '系统打了个盹,稍后再试。'];
|
||
}
|
||
}
|
||
|
||
public function search(string $sKeywords, string $sResultType)
|
||
{
|
||
if (empty($sKeywords)) {
|
||
return [];
|
||
}
|
||
|
||
$sKeywords = strip_tags(trim($sKeywords));
|
||
|
||
$oArticles = Articles::search($sKeywords)
|
||
->options([
|
||
'attributesToHighlight' => ['sTitle', 'sContent', 'sCategoryName'],
|
||
// 1. 把 attributesToSnippet 改成 attributesToCrop
|
||
'attributesToCrop' => ['sContent'],
|
||
// 2. 设置裁剪后的长度(单位是字符,按需调整)
|
||
'cropLength' => 100,
|
||
'highlightPreTag' => '<mark class="highlight">',
|
||
'highlightPostTag' => '</mark>',
|
||
])
|
||
->orderByDesc('iIsTop')
|
||
->paginate(config('app.page_limit'));
|
||
|
||
$oArticles->getCollection()->transform(function ($oArticle) {
|
||
|
||
$aMeta = $oArticle->scoutMetadata();
|
||
$aFormatted = $aMeta['_formatted'] ?? [];
|
||
|
||
if (!empty($aFormatted)) {
|
||
$oArticle->sTitle = $aFormatted['sTitle'] ?? $oArticle->sTitle;
|
||
$oArticle->sContent = $aFormatted['sContent'] ?? $oArticle->sContent;
|
||
|
||
if (isset($aFormatted['sCategoryName'])) {
|
||
$oArticle->sCategoryName = $aFormatted['sCategoryName'];
|
||
$oArticle->setRelation('oCategory', (object)[
|
||
'sName' => $aFormatted['sCategoryName'],
|
||
'sSlug' => $oArticle->sCategorySlug ?? ''
|
||
]);
|
||
}
|
||
}
|
||
|
||
return $oArticle;
|
||
});
|
||
|
||
if ($sResultType === "ajax") {
|
||
$sHtml = '';
|
||
foreach ($oArticles as $oArticle) {
|
||
$sHtml .= view('components.article-item', ['oArticle' => $oArticle])->render();
|
||
}
|
||
$xResult = $sHtml;
|
||
} else {
|
||
$xResult = [
|
||
'oArticles' => $oArticles,
|
||
];
|
||
}
|
||
|
||
return $xResult;
|
||
}
|
||
|
||
public function getTagArticles(string $sSlug, string $sResultType)
|
||
{
|
||
$oTags = Tags::where('sSlug', $sSlug)->firstOrFail();
|
||
|
||
$oArticles = $oTags->oArticles()
|
||
->where('iStatus', 1) // 只拿已发布的
|
||
->with(['oCategory']) // 预加载关联,拒绝 N+1
|
||
->orderByDesc('iIsTop') // 置顶优先
|
||
->orderByDesc('created_at') // 最新优先
|
||
->paginate(config('app.page_limit')); // 必须分页,否则数据多了会爆内存
|
||
|
||
if ($sResultType === "ajax") {
|
||
$sHtml = '';
|
||
foreach ($oArticles as $oArticle) {
|
||
$sHtml .= view('components.article-item', ['oArticle' => $oArticle])->render();
|
||
}
|
||
$xResult = $sHtml;
|
||
} else {
|
||
$xResult = [
|
||
'oArticles' => $oArticles,
|
||
];
|
||
}
|
||
|
||
return $xResult;
|
||
}
|
||
|
||
public function getCategoryArticles(string $sSlug, string $sResultType)
|
||
{
|
||
$oCategory = Categories::where('sSlug', $sSlug)->firstOrFail();
|
||
|
||
if ($oCategory->iShowChildren) {
|
||
$aCategoryIds = Categories::where('iParentId', $oCategory->id)
|
||
->pluck('id')
|
||
->push($oCategory->id)
|
||
->toArray();
|
||
} else {
|
||
$aCategoryIds = [$oCategory->id];
|
||
}
|
||
|
||
$oArticles = Articles::whereIn('iCategoryId', $aCategoryIds)
|
||
->with(['oUser', 'oCategory'])
|
||
->orderBy('iIsTop', 'desc')
|
||
->orderBy('created_at', 'desc')
|
||
->simplePaginate(config('app.page_limit'));
|
||
|
||
if ($sResultType === "ajax") {
|
||
$sHtml = '';
|
||
foreach ($oArticles as $oArticle) {
|
||
$sHtml .= view('components.article-item', ['oArticle' => $oArticle])->render();
|
||
}
|
||
$aResult = $sHtml;
|
||
} else {
|
||
$aResult = [
|
||
'oCategory' => $oCategory,
|
||
'oArticles' => $oArticles,
|
||
];
|
||
}
|
||
|
||
return $aResult;
|
||
}
|
||
|
||
public function getArticle(string $sArticleSlug, string $sIp, string $sFingerprint)
|
||
{
|
||
$oArticle = Articles::where('sSlug', $sArticleSlug)
|
||
->where('iStatus', 1)
|
||
->with(['oCategory.oParent', 'oTags'])
|
||
->firstOrFail();
|
||
|
||
$oArticle->bIsLiked = ArticleLikes::where('iArticleId', $oArticle->id)
|
||
->where(function($q) use ($sIp, $sFingerprint) {
|
||
$q->where('sIp', $sIp)
|
||
->orWhere('sFingerprint', $sFingerprint);
|
||
})->exists();
|
||
|
||
// return $oArticle;
|
||
return [
|
||
'oArticle' => $oArticle
|
||
];
|
||
}
|
||
}
|