Add bounded one-year article likes and editorial defaults

This commit is contained in:
Codex 2026-10-29 21:28:49 +08:00
parent cbb847aed8
commit ea39498706
9 changed files with 398 additions and 4 deletions

View 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;
}
}

View File

@ -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} 赞,额度用完后停止。");
}
/**

View File

@ -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()]);
}

View File

@ -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();

View 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);
}
}

View File

@ -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',
]));
}
};

View 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。未发布临时文章、未触发真实文章加赞。

View File

@ -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();

View 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'));
}
}