From ea394987069cc0e7a980c60ebe2dc845c03b5fbe Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 29 Oct 2026 21:28:49 +0800 Subject: [PATCH] Add bounded one-year article likes and editorial defaults --- .../Commands/BackfillEditorialLikes.php | 52 +++++++ app/Console/Commands/SyncArticleLikes.php | 7 +- .../Api/Editorial/EditorialController.php | 3 + app/Services/Editorial/Publisher.php | 24 +++- app/Services/OneYearArticleLikes.php | 59 ++++++++ ...0_29_200000_add_one_year_article_likes.php | 24 ++++ docs/maintenance/ARTICLE_LIKES.md | 41 ++++++ tests/Feature/EditorialApiTest.php | 56 ++++++++ tests/Feature/OneYearArticleLikesTest.php | 136 ++++++++++++++++++ 9 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 app/Console/Commands/BackfillEditorialLikes.php create mode 100644 app/Services/OneYearArticleLikes.php create mode 100644 database/migrations/2026_10_29_200000_add_one_year_article_likes.php create mode 100644 docs/maintenance/ARTICLE_LIKES.md create mode 100644 tests/Feature/OneYearArticleLikesTest.php diff --git a/app/Console/Commands/BackfillEditorialLikes.php b/app/Console/Commands/BackfillEditorialLikes.php new file mode 100644 index 00000000..23600108 --- /dev/null +++ b/app/Console/Commands/BackfillEditorialLikes.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/app/Console/Commands/SyncArticleLikes.php b/app/Console/Commands/SyncArticleLikes.php index 06e313e9..415d3a8d 100755 --- a/app/Console/Commands/SyncArticleLikes.php +++ b/app/Console/Commands/SyncArticleLikes.php @@ -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} 赞,额度用完后停止。"); } /** diff --git a/app/Http/Controllers/Api/Editorial/EditorialController.php b/app/Http/Controllers/Api/Editorial/EditorialController.php index aaf3385c..189b0e37 100644 --- a/app/Http/Controllers/Api/Editorial/EditorialController.php +++ b/app/Http/Controllers/Api/Editorial/EditorialController.php @@ -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()]); } diff --git a/app/Services/Editorial/Publisher.php b/app/Services/Editorial/Publisher.php index 4227e27a..272cb57f 100644 --- a/app/Services/Editorial/Publisher.php +++ b/app/Services/Editorial/Publisher.php @@ -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' => '']); @@ -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(); diff --git a/app/Services/OneYearArticleLikes.php b/app/Services/OneYearArticleLikes.php new file mode 100644 index 00000000..2d7e8872 --- /dev/null +++ b/app/Services/OneYearArticleLikes.php @@ -0,0 +1,59 @@ +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); + } +} diff --git a/database/migrations/2026_10_29_200000_add_one_year_article_likes.php b/database/migrations/2026_10_29_200000_add_one_year_article_likes.php new file mode 100644 index 00000000..7c9a4f01 --- /dev/null +++ b/database/migrations/2026_10_29_200000_add_one_year_article_likes.php @@ -0,0 +1,24 @@ +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', + ])); + } +}; diff --git a/docs/maintenance/ARTICLE_LIKES.md b/docs/maintenance/ARTICLE_LIKES.md new file mode 100644 index 00000000..f1f6a070 --- /dev/null +++ b/docs/maintenance/ARTICLE_LIKES.md @@ -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。未发布临时文章、未触发真实文章加赞。 diff --git a/tests/Feature/EditorialApiTest.php b/tests/Feature/EditorialApiTest.php index 3e0fee6c..9243b452 100644 --- a/tests/Feature/EditorialApiTest.php +++ b/tests/Feature/EditorialApiTest.php @@ -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(); diff --git a/tests/Feature/OneYearArticleLikesTest.php b/tests/Feature/OneYearArticleLikesTest.php new file mode 100644 index 00000000..7032fd97 --- /dev/null +++ b/tests/Feature/OneYearArticleLikesTest.php @@ -0,0 +1,136 @@ + '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')); + } +}