Add bounded one-year article likes and editorial defaults
This commit is contained in:
parent
cbb847aed8
commit
ea39498706
52
app/Console/Commands/BackfillEditorialLikes.php
Normal file
52
app/Console/Commands/BackfillEditorialLikes.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillEditorialLikes extends Command
|
||||
{
|
||||
protected $signature = 'article:backfill-editorial-likes {--apply : Apply missing settings; otherwise only list candidates}';
|
||||
|
||||
protected $description = '为已发布编辑文章补齐缺失点赞配置,保留已有参数和进度';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = DB::table('editorial_documents')->whereNotNull('article_id')
|
||||
->whereNotNull('published_revision')->orderBy('article_id')->pluck('article_id');
|
||||
$result = [];
|
||||
foreach ($ids as $id) {
|
||||
$row = DB::transaction(function () use ($id) {
|
||||
$a = DB::table('articles')->where('id', $id)->lockForUpdate()->first();
|
||||
if (! $a || (int) $a->iStatus !== 1 || (int) $a->iYearLikeTarget !== 0
|
||||
|| (int) $a->iYearLikeAdded !== 0 || $a->sYearLikeStartedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
$missingDefaults = (int) $a->i7like === 0 && (int) $a->i365like === 0;
|
||||
$row = ['id' => $id, 'slug' => $a->sSlug, 'fill_defaults' => $missingDefaults,
|
||||
'before' => ['seven_day' => (int) $a->i7like, 'yearly' => (int) $a->i365like],
|
||||
'applied' => (bool) $this->option('apply')];
|
||||
if ($this->option('apply')) {
|
||||
$fields = ['iYearLikeTarget' => random_int(100, 500), 'sYearLikeStartedAt' => now()];
|
||||
if ($missingDefaults) {
|
||||
// Expired short-term campaigns are not restarted by changing article dates.
|
||||
$fields['i7like'] = now()->lt(\Carbon\Carbon::parse($a->created_at)->addDays(7)) ? random_int(5, 35) : 0;
|
||||
$fields['i365like'] = 50;
|
||||
}
|
||||
DB::table('articles')->where('id', $id)->update($fields);
|
||||
$row['settings'] = $fields;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}, 3);
|
||||
if ($row) {
|
||||
$result[] = $row;
|
||||
}
|
||||
}
|
||||
$this->line(json_encode(['mode' => $this->option('apply') ? 'apply' : 'preview', 'count' => count($result),
|
||||
'articles' => $result], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -6,14 +6,15 @@
|
||||
use App\Models\Articles;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\CacheService;
|
||||
use App\Services\OneYearArticleLikes;
|
||||
|
||||
class SyncArticleLikes extends Command
|
||||
{
|
||||
// 在 Kernel.php 中建议设置 ->everyFiveMinutes()
|
||||
protected $signature = 'article:sync-likes';
|
||||
protected $description = '高标准点赞:7天自动碎化 + 全年天级随机';
|
||||
protected $description = '文章点赞:短期额度、持续年度随机增长、一次性年度额度';
|
||||
|
||||
public function handle()
|
||||
public function handle(OneYearArticleLikes $oneYearLikes)
|
||||
{
|
||||
$iBurstCount = 0; // 记录本次执行爆发了多少文章
|
||||
$iYearlyCount = 0; // 记录本次执行有多少文章中了全年奖
|
||||
@ -61,9 +62,11 @@ public function handle()
|
||||
Cache::put($sLockKey, 1, now()->addDay());
|
||||
}
|
||||
|
||||
$oneYearAdded = $oneYearLikes->sync();
|
||||
CacheService::updateOrderLikes();
|
||||
|
||||
$this->comment("本次任务结束:{$iBurstCount} 篇爆发增长,{$iYearlyCount} 篇全年喜提。");
|
||||
$this->comment("一次性年度额度本次增加 {$oneYearAdded} 赞,额度用完后停止。");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -33,6 +33,9 @@ public function source(int $article)
|
||||
'slug' => $a->sSlug, 'body_html' => $a->sContent, 'category_slug' => $category->sSlug,
|
||||
'tag_ids' => DB::table('article_tag')->where('iArticleId', $a->id)->pluck('iTagId'),
|
||||
'cover' => $a->sThumb, 'show_cover' => (bool) $a->bPageThumb,
|
||||
'like_growth' => ['seven_day_remaining' => (int) $a->i7like, 'yearly' => (int) $a->i365like,
|
||||
'one_year_target' => (int) $a->iYearLikeTarget, 'one_year_added' => (int) $a->iYearLikeAdded,
|
||||
'one_year_started_at' => $a->sYearLikeStartedAt],
|
||||
'management' => DB::table('editorial_documents')->where('article_id', $a->id)->first()]);
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,10 @@ public function draft(array $input): array
|
||||
'articles.*.cover' => 'sometimes|nullable|string|max:255',
|
||||
'articles.*.show_cover' => 'sometimes|boolean',
|
||||
'articles.*.adopt_article_id' => 'sometimes|integer|min:1',
|
||||
'articles.*.like_settings' => 'sometimes|array:seven_day,yearly,one_year',
|
||||
'articles.*.like_settings.seven_day' => 'sometimes|integer|min:0|max:1000000',
|
||||
'articles.*.like_settings.yearly' => 'sometimes|integer|min:0|max:365',
|
||||
'articles.*.like_settings.one_year' => 'sometimes|integer|min:0|max:1000000',
|
||||
])->validate();
|
||||
$data['operation'] = $input['_operation'] ?? 'draft';
|
||||
$hash = $this->hash($data);
|
||||
@ -59,6 +63,7 @@ public function draft(array $input): array
|
||||
}
|
||||
abort_if($doc->article_id && ! $article, 409, 'Managed article was removed');
|
||||
if ($article) {
|
||||
abort_if(array_key_exists('like_settings', $row), 422, 'Like settings are only accepted for new articles');
|
||||
abort_unless($article->sSlug === $row['slug'], 409, 'Stable article URL cannot change');
|
||||
$sourceCategory = DB::table('categories')->where('id', $article->iCategoryId)->value('sSlug');
|
||||
abort_if(in_array($sourceCategory, ['freenode', 'shadowrocketids']), 422, 'Resource articles cannot be adopted by the editorial API');
|
||||
@ -78,6 +83,14 @@ public function draft(array $input): array
|
||||
'show_cover' => (bool) ($row['show_cover'] ?? ($article->bPageThumb ?? false)),
|
||||
'base_article_hash' => $article ? $this->articleHash($article) : null,
|
||||
];
|
||||
if (! $article) {
|
||||
// Freeze the random defaults once per draft, so a retry/publication cannot redraw them.
|
||||
$item['like_settings'] = [
|
||||
'seven_day' => $row['like_settings']['seven_day'] ?? random_int(5, 35),
|
||||
'yearly' => $row['like_settings']['yearly'] ?? 50,
|
||||
'one_year' => $row['like_settings']['one_year'] ?? random_int(100, 500),
|
||||
];
|
||||
}
|
||||
$item['html'] = $this->renderer->render($item);
|
||||
if ($item['cover']) {
|
||||
$this->renderer->render(['format' => 'html', 'body' => '<img src="'.htmlspecialchars($item['cover'], ENT_QUOTES).'">']);
|
||||
@ -101,7 +114,8 @@ public function getDraft(string $id): array
|
||||
|
||||
return ['draft_id' => $id, 'hash' => $d->payload_hash, 'status' => $d->status, 'publication_id' => $d->publication_id,
|
||||
'articles' => array_map(fn ($i) => ['content_key' => $i['content_key'], 'revision' => $i['revision'], 'title' => $i['title'],
|
||||
'path' => '/a/'.$i['slug'].'.html', 'preview_url' => $base.URL::temporarySignedRoute('editorial.preview', now()->addMinutes(config('editorial.preview_minutes')), ['draft' => $id, 'key' => $i['content_key']], absolute: false)], $items)];
|
||||
'path' => '/a/'.$i['slug'].'.html', 'like_settings' => $i['like_settings'] ?? null,
|
||||
'preview_url' => $base.URL::temporarySignedRoute('editorial.preview', now()->addMinutes(config('editorial.preview_minutes')), ['draft' => $id, 'key' => $i['content_key']], absolute: false)], $items)];
|
||||
}
|
||||
|
||||
public function article(string $key): array
|
||||
@ -169,7 +183,13 @@ public function publish(string $id, array $input): array
|
||||
$articleId = $doc->article_id;
|
||||
DB::table('articles')->where('id', $articleId)->update($fields);
|
||||
} else {
|
||||
$articleId = DB::table('articles')->insertGetId($fields + ['iUserId' => 1, 'iViews' => 0, 'iLikeCount' => 0, 'i7like' => 0, 'i365like' => 0, 'iIsTop' => 0, 'created_at' => now()]);
|
||||
// Older frozen drafts retain their original zero defaults.
|
||||
$likes = $item['like_settings'] ?? ['seven_day' => 0, 'yearly' => 0, 'one_year' => 0];
|
||||
$articleId = DB::table('articles')->insertGetId($fields + ['iUserId' => 1, 'iViews' => 0, 'iLikeCount' => 0,
|
||||
'i7like' => $likes['seven_day'], 'i365like' => $likes['yearly'],
|
||||
'iYearLikeTarget' => $likes['one_year'], 'iYearLikeAdded' => 0,
|
||||
'sYearLikeStartedAt' => $likes['one_year'] > 0 ? now() : null,
|
||||
'iIsTop' => 0, 'created_at' => now()]);
|
||||
}
|
||||
$oldTags = $this->tags($articleId);
|
||||
DB::table('article_tag')->where('iArticleId', $articleId)->delete();
|
||||
|
||||
59
app/Services/OneYearArticleLikes.php
Normal file
59
app/Services/OneYearArticleLikes.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class OneYearArticleLikes
|
||||
{
|
||||
/** Each article owns its progress; retries and overlapping workers cannot pay twice. */
|
||||
public function sync(): int
|
||||
{
|
||||
$total = 0;
|
||||
DB::table('articles')->where('iStatus', 1)
|
||||
->whereColumn('iYearLikeTarget', '>', 'iYearLikeAdded')
|
||||
->select('id')->chunkById(100, function ($articles) use (&$total) {
|
||||
foreach ($articles as $article) {
|
||||
$total += $this->syncArticle($article->id);
|
||||
}
|
||||
});
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
public function syncArticle(int $id): int
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$article = DB::table('articles')->where('id', $id)->lockForUpdate()->first();
|
||||
if (! $article || (int) $article->iStatus !== 1
|
||||
|| $article->iYearLikeTarget <= $article->iYearLikeAdded) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
if (! $article->sYearLikeStartedAt) {
|
||||
DB::table('articles')->where('id', $id)->update(['sYearLikeStartedAt' => $now]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$start = CarbonImmutable::parse($article->sYearLikeStartedAt);
|
||||
$end = $start->addYearNoOverflow();
|
||||
$duration = $end->getTimestamp() - $start->getTimestamp();
|
||||
$elapsed = max(0, min($duration, $now->getTimestamp() - $start->getTimestamp()));
|
||||
$due = intdiv((int) $article->iYearLikeTarget * $elapsed, $duration);
|
||||
$add = max(0, $due - (int) $article->iYearLikeAdded);
|
||||
if ($add > 0) {
|
||||
// Query-builder arithmetic preserves real/other automatic likes and content timestamps.
|
||||
DB::table('articles')->where('id', $id)->update([
|
||||
'iLikeCount' => DB::raw('iLikeCount + '.$add),
|
||||
'iYearLikeAdded' => DB::raw('iYearLikeAdded + '.$add),
|
||||
'sLastLikedAt' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $add;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('articles', function (Blueprint $table) {
|
||||
$table->unsignedInteger('iYearLikeTarget')->default(0);
|
||||
$table->unsignedInteger('iYearLikeAdded')->default(0);
|
||||
$table->timestamp('sYearLikeStartedAt')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('articles', fn (Blueprint $table) => $table->dropColumn([
|
||||
'iYearLikeTarget', 'iYearLikeAdded', 'sYearLikeStartedAt',
|
||||
]));
|
||||
}
|
||||
};
|
||||
41
docs/maintenance/ARTICLE_LIKES.md
Normal file
41
docs/maintenance/ARTICLE_LIKES.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 文章自动点赞维护
|
||||
|
||||
适用FNA_V3;2026-10-29用户要求新增“一年内增加指定额度,完成后停止”,并为后续AI新文章配置默认参数。AI写作默认值与业务口径的唯一正文为[写作规范](../content/WRITING.md#ai-新文章的点赞参数2026-10-29),发布调用入口为[独立文章API](EDITORIAL_API.md)。本次仅本地实现,未修改已部署test或正式站,未启动本机调度,也未对现有文章补额度。
|
||||
|
||||
## 三项独立机制
|
||||
|
||||
- 原短期机制:`i7like`为剩余量,`article:sync-likes`按原随机逻辑扣减。保留原年龄窗口及频率实现,没有借本次改造重写短期算法。
|
||||
- 原持续年度机制:`i365like`为每日中奖概率的分子,每日只按原缓存日期锁执行一轮,不扣余额、不以一年为终点。参数50是统计期望,不是精确年度配额;该原有缓存锁不等同于数据库并发锁。
|
||||
- 新一次性年度机制:`iYearLikeTarget`是本轮额外增加量,`iYearLikeAdded`是本轮已增加量,`sYearLikeStartedAt`是开始时间。三项分别保存,不能用总点赞数倒推本轮进度,也不能拿其他机制或真实点赞抵扣本轮额度。
|
||||
|
||||
## 新年度额度的执行
|
||||
|
||||
`app/Services/OneYearArticleLikes.php`负责进度,沿用现有每5分钟的`article:sync-likes`调度,不新增独立cron。`due = floor(target × 已过秒数 / 本日历年总秒数)`,实际增加`max(0, due - added)`;已过时间限制在0~一年长度之间。起点到周年日计算使用`addYearNoOverflow`,闰日对应下一年2月28日。完成后保留target和added供核对,后续扫描不再选中满额文章。
|
||||
|
||||
未公开文章不执行;暂停期间仍按原开始时间计时,重新公开后补至应有进度,不自动延长周期。没有开始时间但额度非零的手动计划,首次执行只填当前时间,不按旧文章创建日追补。调度延误时下一次执行补到应有量,周年后恢复则补足余额;停机时不能保证准点完成。
|
||||
|
||||
每篇通过事务与`lockForUpdate`读取最新进度,额度计数和总点赞数在同一次SQL更新中累加,防止本项重入重复支付,并保留同时发生的真实点赞。使用Query Builder避免触发文章内容`updated_at`、模型搜索同步等副作用;仅更新`iLikeCount`、`iYearLikeAdded`、`sLastLikedAt`,任务结束后刷新原“点赞活跃”缓存。其他两项原有代码保留。
|
||||
|
||||
## 独立文章API
|
||||
|
||||
`Publisher::draft()`支持可选`like_settings`对象,键为`seven_day`、`yearly`、`one_year`;创建新文章时缺省键使用写作规范默认值。`seven_day`与`one_year`接受0~1000000整数,`yearly`接受0~365整数;0关闭对应机制。保存草稿时为每篇独立取随机值并保存在冻结items中,`getDraft()`返回实际设置,查询及同请求重试不再随机。
|
||||
|
||||
发布新文章时将冻结值落入对应字段,已支付为0,年度开始时间为实际发布时间。更改现有文章、显式接管和内容回退保留全部计数、参数与年度进度;已存在文章的普通改稿不接受`like_settings`,返回422,防止重写时意外重置。旧版本冻结草稿没有该对象的,保留其原来全0语义,不在发布时临时抽值。
|
||||
|
||||
鉴权`GET source-articles/{id}`返回`like_growth`:`seven_day_remaining`、`yearly`、`one_year_target`、`one_year_added`、`one_year_started_at`,只用于维护回读,不进入公开模板或文章正文。内容回退不回退点赞进度。
|
||||
|
||||
## 迁移、验证与回退
|
||||
|
||||
新增迁移`database/migrations/2026_10_29_200000_add_one_year_article_likes.php`仅给articles增加三列,默认0/0/null,不启用旧文计划。部署时先备份并执行这一条指定迁移,再启用依赖新字段的应用/任务代码,不执行整库重置或导入。
|
||||
|
||||
```sh
|
||||
php artisan migrate --path=database/migrations/2026_10_29_200000_add_one_year_article_likes.php --force
|
||||
```
|
||||
|
||||
2026-10-29已在本地`fna_v3`执行此迁移;迁移前后170篇原有行字段SHA256一致,现有启用新计划数为0。证据为`output/article-like-growth-20261029/local-before.json`、`local-after.json`。正常命令依赖现有系统调度器;本次没有手工运行真实数据的加赞任务或安装cron。
|
||||
|
||||
测试位于`tests/Feature/OneYearArticleLikesTest.php`、`tests/Feature/EditorialApiTest.php`,使用SQLite内存数据库。覆盖渐进增加、满额停止、重复执行、真实赞独立、原年度机制独立继续、停机补进度、禁用/未来开始时间、闰日、空起点、CLI任务接入、草稿随机默认值稳定、发布映射、编辑保留和非法输入;结果为22项测试、207次断言全部通过,回执见输出目录tests.xml。原项目存在命令类文件名与类名不一致的遗留注册问题,测试沿用原编辑API测试的独立进程模式避免重复注册,不修改无关遗留命令。
|
||||
|
||||
回退功能时先停用调用新service的任务代码及新版发布代码,保留新字段和历史进度。不要在仍有新版写入者时drop列;需要撤销迁移时先备份新字段、确认没有活动计划和依赖调用方,再在独立维护窗口执行其down。已有文章总点赞数不会自动减回,避免删除真实/其他来源的赞。
|
||||
|
||||
本地鉴权`source-articles/365`只读HTTP回读200,新进度字段返回正确的0/0/null,原有文章字段哈希保持一致;证据为输出目录source-check.json。未发布临时文章、未触发真实文章加赞。
|
||||
@ -74,6 +74,7 @@ protected function setUp(): void
|
||||
$t->integer('iTagId');
|
||||
});
|
||||
(require database_path('migrations/2026_10_27_190000_create_editorial_tables.php'))->up();
|
||||
(require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up();
|
||||
DB::table('categories')->insert([
|
||||
['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'],
|
||||
['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'],
|
||||
@ -110,6 +111,61 @@ private function publish(array $draft, ?string $id = null)
|
||||
return $this->postJson('/api/editorial/v1/drafts/'.$draft['draft_id'].'/publish', ['request_id' => $id ?? (string) Str::uuid(), 'expected_hash' => $draft['hash']]);
|
||||
}
|
||||
|
||||
public function test_new_article_like_defaults_are_frozen_and_updates_preserve_progress(): void
|
||||
{
|
||||
$request = (string) Str::uuid();
|
||||
$d = $this->draft([$this->item()], $request);
|
||||
$settings = $d['articles'][0]['like_settings'];
|
||||
$this->assertGreaterThanOrEqual(5, $settings['seven_day']);
|
||||
$this->assertLessThanOrEqual(35, $settings['seven_day']);
|
||||
$this->assertSame(50, $settings['yearly']);
|
||||
$this->assertGreaterThanOrEqual(100, $settings['one_year']);
|
||||
$this->assertLessThanOrEqual(500, $settings['one_year']);
|
||||
$this->assertSame($settings, $this->draft([$this->item()], $request)['articles'][0]['like_settings']);
|
||||
$this->publish($d)->assertOk();
|
||||
$a = DB::table('articles')->first();
|
||||
$this->assertSame($settings['seven_day'], (int) $a->i7like);
|
||||
$this->assertSame(50, (int) $a->i365like);
|
||||
$this->assertSame($settings['one_year'], (int) $a->iYearLikeTarget);
|
||||
$this->assertNotNull($a->sYearLikeStartedAt);
|
||||
$this->assertSame(0, (int) $a->iLikeCount);
|
||||
$start = $a->sYearLikeStartedAt;
|
||||
DB::table('articles')->where('id', $a->id)->update(['iLikeCount' => 72, 'iYearLikeAdded' => 25, 'i7like' => 2]);
|
||||
$updated = $this->draft([$this->item('example', 1)]);
|
||||
$this->assertNull($updated['articles'][0]['like_settings']);
|
||||
$this->publish($updated)->assertOk();
|
||||
$a = DB::table('articles')->first();
|
||||
$this->assertSame(72, (int) $a->iLikeCount);
|
||||
$this->assertSame(25, (int) $a->iYearLikeAdded);
|
||||
$this->assertSame(2, (int) $a->i7like);
|
||||
$this->assertSame($settings['one_year'], (int) $a->iYearLikeTarget);
|
||||
$this->assertSame($start, $a->sYearLikeStartedAt);
|
||||
$this->getJson('/api/editorial/v1/source-articles/'.$a->id)->assertOk()
|
||||
->assertJsonPath('like_growth.one_year_added', 25)->assertJsonPath('like_growth.yearly', 50);
|
||||
$this->publish($d)->assertOk();
|
||||
$this->assertSame(25, (int) DB::table('articles')->value('iYearLikeAdded'));
|
||||
}
|
||||
|
||||
public function test_explicit_like_settings_zero_and_invalid_values(): void
|
||||
{
|
||||
foreach ([['yearly' => 366], ['one_year' => -1], ['seven_day' => 1.5], ['unknown' => 100]] as $settings) {
|
||||
$bad = $this->item();
|
||||
$bad['like_settings'] = $settings;
|
||||
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$bad]])->assertUnprocessable();
|
||||
}
|
||||
$item = $this->item();
|
||||
$item['like_settings'] = ['seven_day' => 0, 'yearly' => 0, 'one_year' => 0];
|
||||
$d = $this->draft([$item]);
|
||||
$this->publish($d)->assertOk();
|
||||
$a = DB::table('articles')->first();
|
||||
$this->assertSame(0, (int) $a->iYearLikeTarget);
|
||||
$this->assertSame(0, (int) $a->i7like);
|
||||
$this->assertSame(0, (int) $a->i365like);
|
||||
$this->assertNull($a->sYearLikeStartedAt);
|
||||
$item['expected_revision'] = 1;
|
||||
$this->postJson('/api/editorial/v1/drafts', ['request_id' => (string) Str::uuid(), 'articles' => [$item]])->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_existing_tutorial_flowchart_small_labels_are_supported(): void
|
||||
{
|
||||
$item = $this->item();
|
||||
|
||||
136
tests/Feature/OneYearArticleLikesTest.php
Normal file
136
tests/Feature/OneYearArticleLikesTest.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\OneYearArticleLikes;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
|
||||
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
|
||||
class OneYearArticleLikesTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
|
||||
'scout.driver' => null, 'cache.default' => 'array']);
|
||||
DB::purge('sqlite');
|
||||
Schema::create('articles', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->integer('iStatus')->default(1);
|
||||
foreach (['iLikeCount', 'i7like', 'i365like'] as $field) {
|
||||
$t->integer($field)->default(0);
|
||||
}
|
||||
$t->timestamp('sLastLikedAt')->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
(require database_path('migrations/2026_10_29_200000_add_one_year_article_likes.php'))->up();
|
||||
$this->travelTo(CarbonImmutable::parse('2026-01-01 12:00:00'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->travelBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function article(array $values = []): int
|
||||
{
|
||||
return DB::table('articles')->insertGetId($values + [
|
||||
'iYearLikeTarget' => 200, 'sYearLikeStartedAt' => now(),
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_quota_is_paid_gradually_exactly_once_and_stops(): void
|
||||
{
|
||||
$id = $this->article(['iLikeCount' => 17]);
|
||||
$service = app(OneYearArticleLikes::class);
|
||||
$this->assertSame(0, $service->sync());
|
||||
$this->travel(183)->days();
|
||||
$this->assertSame(100, $service->sync());
|
||||
$this->assertSame(0, $service->sync());
|
||||
DB::table('articles')->where('id', $id)->increment('iLikeCount', 3);
|
||||
$this->travelTo(CarbonImmutable::parse('2027-01-01 12:00:00'));
|
||||
$this->assertSame(100, $service->sync());
|
||||
$this->assertSame(200, (int) DB::table('articles')->value('iYearLikeAdded'));
|
||||
$this->assertSame(220, (int) DB::table('articles')->value('iLikeCount'));
|
||||
$this->assertSame('2026-01-01 12:00:00', DB::table('articles')->value('updated_at'));
|
||||
$this->travel(400)->days();
|
||||
$this->assertSame(0, $service->sync());
|
||||
$this->assertSame(220, (int) DB::table('articles')->value('iLikeCount'));
|
||||
}
|
||||
|
||||
public function test_deadline_catches_up_after_downtime_but_ignores_disabled_and_future_plans(): void
|
||||
{
|
||||
$this->article(['iYearLikeTarget' => 500]);
|
||||
$this->article(['iYearLikeTarget' => 0]);
|
||||
$this->article(['iStatus' => 0]);
|
||||
$this->article(['sYearLikeStartedAt' => '2030-01-01 00:00:00']);
|
||||
$this->travel(370)->days();
|
||||
$this->assertSame(500, app(OneYearArticleLikes::class)->sync());
|
||||
$this->assertSame(500, (int) DB::table('articles')->sum('iYearLikeAdded'));
|
||||
$this->assertSame(0, app(OneYearArticleLikes::class)->sync());
|
||||
}
|
||||
|
||||
public function test_leap_day_uses_calendar_year_and_null_start_is_initialized_without_backfill(): void
|
||||
{
|
||||
$this->travelTo(CarbonImmutable::parse('2024-02-29 12:00:00'));
|
||||
$id = $this->article(['iYearLikeTarget' => 365]);
|
||||
$this->travelTo(CarbonImmutable::parse('2025-02-28 12:00:00'));
|
||||
$this->assertSame(365, app(OneYearArticleLikes::class)->syncArticle($id));
|
||||
$new = $this->article(['sYearLikeStartedAt' => null, 'created_at' => '2020-01-01 00:00:00']);
|
||||
$this->assertSame(0, app(OneYearArticleLikes::class)->syncArticle($new));
|
||||
$this->assertSame('2025-02-28 12:00:00', DB::table('articles')->where('id', $new)->value('sYearLikeStartedAt'));
|
||||
}
|
||||
|
||||
public function test_backfill_is_limited_to_editorial_missing_settings_and_is_idempotent(): void
|
||||
{
|
||||
Schema::table('articles', fn (Blueprint $t) => $t->string('sSlug')->nullable());
|
||||
Schema::create('editorial_documents', function (Blueprint $t) {
|
||||
$t->integer('article_id');
|
||||
$t->integer('published_revision')->nullable();
|
||||
});
|
||||
$blank = $this->article(['iYearLikeTarget' => 0, 'sYearLikeStartedAt' => null, 'iLikeCount' => 12]);
|
||||
$legacy = $this->article(['iYearLikeTarget' => 0, 'sYearLikeStartedAt' => null, 'i365like' => 30]);
|
||||
$resource = $this->article(['iYearLikeTarget' => 0, 'sYearLikeStartedAt' => null]);
|
||||
DB::table('editorial_documents')->insert([
|
||||
['article_id' => $blank, 'published_revision' => 1],
|
||||
['article_id' => $legacy, 'published_revision' => 1],
|
||||
]);
|
||||
$this->artisan('article:backfill-editorial-likes')->assertSuccessful();
|
||||
$this->assertSame(0, (int) DB::table('articles')->sum('iYearLikeTarget'));
|
||||
$this->artisan('article:backfill-editorial-likes --apply')->assertSuccessful();
|
||||
$a = DB::table('articles')->where('id', $blank)->first();
|
||||
$this->assertGreaterThanOrEqual(5, (int) $a->i7like);
|
||||
$this->assertLessThanOrEqual(35, (int) $a->i7like);
|
||||
$this->assertSame(50, (int) $a->i365like);
|
||||
$this->assertSame(12, (int) $a->iLikeCount);
|
||||
$this->assertGreaterThanOrEqual(100, (int) $a->iYearLikeTarget);
|
||||
$this->assertLessThanOrEqual(500, (int) $a->iYearLikeTarget);
|
||||
$this->assertSame(30, (int) DB::table('articles')->where('id', $legacy)->value('i365like'));
|
||||
$this->assertSame(0, (int) DB::table('articles')->where('id', $legacy)->value('i7like'));
|
||||
$this->assertSame(0, (int) DB::table('articles')->where('id', $resource)->value('iYearLikeTarget'));
|
||||
$before = DB::table('articles')->orderBy('id')->get()->toJson();
|
||||
$this->artisan('article:backfill-editorial-likes --apply')->assertSuccessful();
|
||||
$this->assertSame($before, DB::table('articles')->orderBy('id')->get()->toJson());
|
||||
}
|
||||
|
||||
public function test_scheduled_command_runs_the_quota_without_resetting_other_settings(): void
|
||||
{
|
||||
$id = $this->article(['i365like' => 365]);
|
||||
$this->travel(365)->days();
|
||||
$this->artisan('article:sync-likes')->assertSuccessful();
|
||||
$this->artisan('article:sync-likes')->assertSuccessful();
|
||||
$this->assertSame(201, (int) DB::table('articles')->where('id', $id)->value('iLikeCount'));
|
||||
$this->travel(1)->days();
|
||||
$this->artisan('article:sync-likes')->assertSuccessful();
|
||||
$this->assertSame(202, (int) DB::table('articles')->where('id', $id)->value('iLikeCount'));
|
||||
$this->assertSame(365, (int) DB::table('articles')->where('id', $id)->value('i365like'));
|
||||
$this->assertSame(200, (int) DB::table('articles')->where('id', $id)->value('iYearLikeAdded'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user