diff --git a/app/Console/Commands/RetryEditorialIndexNow.php b/app/Console/Commands/RetryEditorialIndexNow.php new file mode 100644 index 00000000..2128a369 --- /dev/null +++ b/app/Console/Commands/RetryEditorialIndexNow.php @@ -0,0 +1,40 @@ +environment(['production', 'testing']) || ! config('editorial.indexnow_enabled', app()->environment('production'))) { + $this->line('IndexNow retry disabled in this environment.'); + + return self::SUCCESS; + } + $ids = DB::table('editorial_publications')->where('effects->indexnow->status', 'pending') + ->where(function ($q) { + $q->whereNull('effects->indexnow->next_attempt_at') + ->orWhere('effects->indexnow->next_attempt_at', '<=', now()->timestamp); + })->orderBy('updated_at')->limit(10)->pluck('id'); + $pending = 0; + foreach ($ids as $id) { + try { + $receipt = app(PublicationEffects::class)->synchronize($id); + $pending += ($receipt['effects']['indexnow']['status'] ?? '') === 'pending' ? 1 : 0; + } catch (\Throwable) { + $pending++; + } + } + $this->line(json_encode(['processed' => count($ids), 'pending' => $pending])); + + return $pending ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Services/Editorial/IndexNowNotification.php b/app/Services/Editorial/IndexNowNotification.php new file mode 100644 index 00000000..9bbb3382 --- /dev/null +++ b/app/Services/Editorial/IndexNowNotification.php @@ -0,0 +1,97 @@ +enabled()) { + return ['status' => 'skipped', 'reason' => 'disabled-environment']; + } + if (! collect($articles)->contains(fn ($a) => $a['content_changed'] ?? false)) { + return ['status' => 'skipped', 'reason' => 'no-content-change']; + } + + return ['status' => 'pending', 'attempts' => 0]; + } + + public function synchronize(array $state, array $published): array + { + if (($state['status'] ?? '') !== 'pending' || ! $this->enabled() + || ($state['next_attempt_at'] ?? 0) > now()->timestamp) { + return $state; + } + + $base = rtrim((string) config('editorial.base_url'), '/'); + $host = parse_url($base, PHP_URL_HOST); + // No per-article target, redirect, or credentials in URLs. Never probe public pages from local. + if (! $host || ! preg_match('~\Ahttps://[a-z0-9.-]+\z~i', $base) + || $base !== rtrim((string) config('app.url'), '/') + || $host !== config('app.site_code')) { + return $this->pending($state, 'site-binding-mismatch'); + } + $key = config('key.indexnow'); + if (! is_string($key) || ! preg_match('/\A[a-zA-Z0-9-]{8,128}\z/', $key)) { + return $this->pending($state, 'key-missing-or-invalid'); + } + + $urls = []; + foreach ($published as $item) { + if (! ($item['content_changed'] ?? false)) { + continue; + } + $article = Articles::with('oCategory')->find($item['article_id']); + if (! $article || (int) $article->iStatus !== 1 || ! $article->oCategory + || (int) $article->oCategory->iStatus !== 1 || $article->created_at->isFuture() + || $article->bIsSeoExpired()) { + continue; + } + $url = $base.'/a/'.$article->sSlug.'.html'; + if ($url === $item['url'] && preg_match('/\A[a-z0-9_-]+\z/', $article->sSlug)) { + $urls[] = $url; + } + } + $urls = array_values(array_unique($urls)); + if (! $urls) { + return array_replace($state, ['status' => 'skipped', 'reason' => 'no-current-indexable-url']); + } + + try { + $response = Http::connectTimeout(5)->timeout(15)->withoutRedirecting()->acceptJson() + ->post('https://api.indexnow.org/indexnow', [ + 'host' => $host, 'key' => $key, 'keyLocation' => $base.'/'.$key.'.txt', 'urlList' => $urls, + ]); + $code = $response->status(); + if (in_array($code, [200, 202], true)) { + return ['status' => 'done', 'attempts' => ($state['attempts'] ?? 0) + 1, + 'http_status' => $code, 'urls' => $urls, 'submitted_at' => now()->toIso8601String(), + 'result' => $code === 202 ? 'received-key-validation-pending' : 'received']; + } + + return $this->pending($state, 'http-error', $code); + } catch (\Throwable) { + // Response bodies and exception messages may contain the key. Persist only safe diagnostics. + return $this->pending($state, 'transport-error'); + } + } + + private function enabled(): bool + { + return app()->environment(['production', 'testing']) + && (bool) config('editorial.indexnow_enabled', app()->environment('production')); + } + + private function pending(array $state, string $reason, ?int $code = null): array + { + $attempts = ($state['attempts'] ?? 0) + 1; + + return ['status' => 'pending', 'attempts' => $attempts, 'reason' => $reason, 'http_status' => $code, + 'last_attempt_at' => now()->toIso8601String(), + 'next_attempt_at' => now()->timestamp + min(86400, 300 * 2 ** min(9, $attempts - 1))]; + } +} diff --git a/app/Services/Editorial/PublicationEffects.php b/app/Services/Editorial/PublicationEffects.php index 802ae1cb..30bc560e 100644 --- a/app/Services/Editorial/PublicationEffects.php +++ b/app/Services/Editorial/PublicationEffects.php @@ -36,6 +36,12 @@ public function synchronize(string $id): array } // Sitemap is generated from current DB rows, no external submission is performed. $effects['sitemap'] = ['status' => ($effects['cache']['status'] ?? '') === 'done' ? 'done' : 'pending', 'method' => 'database-driven']; + // Legacy receipts were not queued for notification; never backfill them implicitly. + $notification = $effects['indexnow'] ?? ['status' => 'skipped', 'reason' => 'legacy-publication']; + if (($effects['cache']['status'] ?? '') === 'done') { + $notification = app(IndexNowNotification::class)->synchronize($notification, $receipt['articles']); + } + $effects['indexnow'] = $notification; DB::table('editorial_publications')->where('id', $id)->update(['effects' => json_encode($effects), 'updated_at' => now()]); }); diff --git a/app/Services/Editorial/Publisher.php b/app/Services/Editorial/Publisher.php index 9d869907..61c1edf7 100644 --- a/app/Services/Editorial/Publisher.php +++ b/app/Services/Editorial/Publisher.php @@ -206,10 +206,13 @@ public function publish(string $id, array $input): array DB::table('editorial_documents')->where('content_key', $item['content_key'])->update(['article_id' => $articleId, 'published_revision' => $item['revision'], 'updated_at' => now()]); DB::table('editorial_versions')->insert(['content_key' => $item['content_key'], 'revision' => $item['revision'], 'snapshot' => json_encode($item, JSON_UNESCAPED_UNICODE), 'created_at' => now()]); $published[] = ['content_key' => $item['content_key'], 'article_id' => $articleId, 'revision' => $item['revision'], + 'content_changed' => $item['base_article_hash'] !== $this->articleHash(DB::table('articles')->where('id', $articleId)->first()), 'url' => rtrim(config('editorial.base_url'), '/').'/a/'.$item['slug'].'.html']; } DB::table('editorial_publications')->insert(['id' => $data['request_id'], 'draft_id' => $id, 'payload_hash' => $opHash, - 'receipt' => json_encode(['publication_id' => $data['request_id'], 'status' => 'published', 'articles' => $published], JSON_UNESCAPED_UNICODE), 'created_at' => now(), 'updated_at' => now()]); + 'receipt' => json_encode(['publication_id' => $data['request_id'], 'status' => 'published', 'articles' => $published], JSON_UNESCAPED_UNICODE), + 'effects' => json_encode(['indexnow' => app(IndexNowNotification::class)->initial($published)]), + 'created_at' => now(), 'updated_at' => now()]); DB::table('editorial_drafts')->where('id', $id)->update(['status' => 'published', 'publication_id' => $data['request_id'], 'updated_at' => now()]); return $data['request_id']; diff --git a/app/Services/Editorial/ReleaseRunner.php b/app/Services/Editorial/ReleaseRunner.php index 3110dab8..bb60cec3 100644 --- a/app/Services/Editorial/ReleaseRunner.php +++ b/app/Services/Editorial/ReleaseRunner.php @@ -141,7 +141,8 @@ private function verify(string $file, array $state): array 'body_sha256' => hash('sha256', $source['body_html']), 'like_growth' => $source['like_growth']]; } $state['readback'] = $readback; - $done = collect($state['receipt']['effects'])->every(fn ($e) => ($e['status'] ?? '') === 'done'); + $done = collect($state['receipt']['effects'])->every(fn ($e, $name) => ($e['status'] ?? '') === 'done' + || ($name === 'indexnow' && ($e['status'] ?? '') === 'skipped')); $state['phase'] = $done && collect($readback)->every(fn ($r) => $r['revision_matches'] && $r['content_matches']) ? 'complete' : 'published_pending'; $this->save($file, $state); diff --git a/config/editorial.php b/config/editorial.php index c5d7bb64..24ea970f 100644 --- a/config/editorial.php +++ b/config/editorial.php @@ -7,4 +7,5 @@ 'preview_minutes' => 30, 'max_batch' => 10, 'search_index' => env('EDITORIAL_SEARCH_INDEX', env('APP_ENV') === 'local' ? 'fna_v3_articles' : null), + 'indexnow_enabled' => env('EDITORIAL_INDEXNOW_ENABLED', env('APP_ENV') === 'production'), ]; diff --git a/docs/maintenance/EDITORIAL_API.md b/docs/maintenance/EDITORIAL_API.md index d2418acd..04bd24d5 100644 --- a/docs/maintenance/EDITORIAL_API.md +++ b/docs/maintenance/EDITORIAL_API.md @@ -10,7 +10,7 @@ ## 职责与数据 项目正文源保留可编辑稿;`editorial_drafts`保存收到并渲染后的冻结版本,发布读取该快照,不临时重新读取文件。`articles`为公开显示数据,草稿不写入它。`editorial_documents`按稳定`content_key`绑定本站文章;`editorial_versions`保留已发布版本和首次接管旧文的基线版本0;`editorial_publications`保存独立发布回执及后续处理状态。图片元信息在`editorial_assets`。 -新API不调用旧接收器,不发送旧Telegram通知、IndexNow提交或ATOM请求。旧资源接收接口继续沿用原流程,不在本次改造范围;普通编辑API拒绝免费节点与小火箭ID两个自动资源栏目。旧接口/人工直接编辑同一文章会造成草稿基线变化,新API发布时返回409,不能静默覆盖并行修改。 +新API不调用旧接收器,不发送旧Telegram通知或ATOM请求。2026-10-31起编辑文章新增/实质修改后具有独立IndexNow回执与失败重试,见[教程SEO通知](EDITORIAL_INDEXNOW.md);本地和测试不真实外发。旧资源接收接口继续沿用原流程,不在本次改造范围;普通编辑API拒绝免费节点与小火箭ID两个自动资源栏目。旧接口/人工直接编辑同一文章会造成草稿基线变化,新API发布时返回409,不能静默覆盖并行修改。 ## 凭据与环境 @@ -116,9 +116,9 @@ ### 3 发布确认过的版本 - `status: published`:本次内容已写入本站公开文章表;这是所调用环境的状态,本地成功不代表生产成功。 - `articles`:各文章的`content_key`、`article_id`、`revision`和URL。 -- `effects.cache/search/sitemap`:各后续处理的独立结果。搜索暂时失败不撤销已提交正文,返回`pending`,不能重发正文来弥补搜索失败。 +- `effects.cache/search/sitemap/indexnow`:各后续处理的独立结果。搜索暂时失败不撤销已提交正文,返回`pending`,不能重发正文来弥补搜索失败。 -后续处理为刷新本站响应缓存与热门数据缓存、同步本站搜索,以及使用数据库驱动的sitemap。缓存未刷新时sitemap也标记pending。不调用搜索引擎主动提交。 +后续处理为刷新本站响应缓存与热门数据缓存、同步本站搜索,以及使用数据库驱动的sitemap。缓存未刷新时sitemap也标记pending。IndexNow以独立`effects.indexnow`记录,不与sitemap结果混用,见[教程SEO通知](EDITORIAL_INDEXNOW.md)。 ### 4 查询、重试与回退 diff --git a/docs/maintenance/EDITORIAL_INDEXNOW.md b/docs/maintenance/EDITORIAL_INDEXNOW.md new file mode 100644 index 00000000..2c672963 --- /dev/null +++ b/docs/maintenance/EDITORIAL_INDEXNOW.md @@ -0,0 +1,12 @@ +# 编辑文章 IndexNow + +用户2026-10-31要求接入教程发布后的自动推送。新增或实际内容改变的编辑文章在发布事务写入IndexNow待处理状态,缓存刷新完成后提交当前有效URL。目标为IndexNow官方API,复用本站既有key及根目录验证文件,不生成/轮换密钥,不改旧资源接收器。 + +- `config/editorial.php` 的 `indexnow_enabled`:默认仅production开启,可用`EDITORIAL_INDEXNOW_ENABLED=false`关闭。local不会发出通知,测试使用Http fake。 +- `PublicationEffects`保存`effects.indexnow`,包括状态、次数、HTTP状态和下一次重试时间;不保存key、响应正文或异常消息。只有200/202记done;202表示收到并待验证key。提交被接收不等于搜索引擎已经抓取或收录,见[官方协议](https://www.indexnow.org/documentation)。 +- `editorial:indexnow-retry`挂接原Laravel调度,每5分钟检查一次,单次最多10个到期回执,防重叠;退避从5分钟递增,最大24小时。定时任务运行在FNA业务服务器,以原nginx身份执行,白色机仅SSH跳板。 +- 已done的通知不重复调用;同一发布UUID重放不重发文章。网络结果未知或提交后进程崩溃可能重复通知同一URL,按至少一次投递处理,不能承诺第三方只收到一次。每次重试都重新确认文章仍公开、栏目启用且可索引。 +- 内容未变、已撤下、不再允许索引时跳过;旧版本中没有IndexNow状态的历史发布回执不自动补推。暂时禁用不会丢弃已pending状态。 +- 仅推送同一站点的规范文章地址;app.url、editorial.base_url、app.site_code必须一致,不跟随重定向,不从文章输入指定外部推送目标。 + +状态:本地专项测试及回归已通过,正在准备生产部署;最终提交和服务器实测证据将在本次任务收尾更新。普通文章发布继续通过[白色机发布封装](EDITORIAL_RELEASE.md),公网编辑API保持关闭。 diff --git a/docs/maintenance/EDITORIAL_RELEASE.md b/docs/maintenance/EDITORIAL_RELEASE.md index b5fcbbba..85c661d9 100644 --- a/docs/maintenance/EDITORIAL_RELEASE.md +++ b/docs/maintenance/EDITORIAL_RELEASE.md @@ -28,7 +28,7 @@ ## 调用 - id.json仅`{"release_id":"原UUID"}`。服务器先保存稳定草稿ID/发布UUID,再调用API;相同bundle重复prepare不新建版本或重新抽取参数,相同release_id改正文拒绝409。改稿应显式创建新release并使用当前版本。 - review.json的`data.previews`含原模板渲染的HTML,`data.review`含hash和digest。先阅读及检查冻结版本;程序渲染通过不等于人类/浏览器视觉验收。正式HTML内含线上资源链接,禁止直接在本机浏览器打开加载;视觉检查在本地对应稿件完成。必须线上页面验收时仍按项目访问批准规则到Windows,不以取预览为由越界。 - publish.json为`{"release_id":"原UUID","expected_hash":"review中hash","review_digest":"review中digest"}`,不得盲填或以另一份稿件替换。程序先查回执;已发表则只重试后处理,草稿版本冲突返回失败。 -- status会从API重新查询并核对公开正文,不只读本地台账。`phase=complete`要求内容/版本/上传图哈希和缓存/搜索/sitemap状态通过;`published_pending`代表正文已提交但后续仍待处理,退出码2,不能据此另发正文。返回`published_at`来自服务器真实发布事务时间。 +- status会从API重新查询并核对公开正文,不只读本地台账。`phase=complete`要求内容/版本/上传图哈希和缓存/搜索/sitemap/IndexNow状态通过(无需通知的IndexNow skipped视为完成),且IndexNow通知为done或明确skipped;`published_pending`代表正文已提交但后续仍待处理,退出码2,不能据此另发正文。返回`published_at`来自服务器真实发布事务时间。 本机工具只输出结果文件路径及状态,不回显SSH认证、正文或预览。SSH断开、超时或非JSON输出都保存未知/失败状态,不自动重放写入;用原release_id查status。不存在草稿时沿原bundle恢复prepare。 @@ -41,3 +41,7 @@ ## 状态与恢复 上线前仅执行`2026_10_31_150000_add_article_display_timestamp.php`迁移(新增可空`sDisplayPublishedAt`);旧文显示回退原时间。新文草稿冻结0~21600秒偏移,首次发布计算展示时间;保留created_at、实际回执、点赞起点,旧文改稿不改展示日期。回滚程序保留新增可空列和历史发布记录,不删业务数据。 验证与当前部署状态见[2026-10-31发布记录](EDITORIAL_RELEASE_2026-10-31.md)。周期调度和额度唯一见[WEEKLY_TUTORIALS.md](WEEKLY_TUTORIALS.md)。 + +IndexNow的结果、环境开关及服务器延时重试见[教程SEO通知](EDITORIAL_INDEXNOW.md)。通知pending只重试后处理,不再次发表正文。 + +2026-10-31编辑发布已接入[IndexNow通知与重试](EDITORIAL_INDEXNOW.md),复用发布回执effects;通知pending不表示正文未发布,禁止为通知重发正文。 diff --git a/routes/console.php b/routes/console.php index c46eefdc..16bfddee 100755 --- a/routes/console.php +++ b/routes/console.php @@ -19,6 +19,8 @@ Schedule::command('article:sync-likes')->everyFiveMinutes(); +Schedule::command('editorial:indexnow-retry')->everyFiveMinutes()->environments(['production'])->withoutOverlapping(10); + //Artisan::command('db:reCreate {sTableName?}', function ($sTableName = null) { // // 1. 没传参数?直接亮红牌! diff --git a/tests/Feature/EditorialApiTest.php b/tests/Feature/EditorialApiTest.php index 8e6d506d..55de7d3f 100644 --- a/tests/Feature/EditorialApiTest.php +++ b/tests/Feature/EditorialApiTest.php @@ -389,4 +389,92 @@ public function test_raw_script_markup_is_escaped_and_publish_request_id_cannot_ $this->publish($next, $request)->assertConflict(); $this->assertSame(1, DB::table('articles')->count()); } -} + private function enableEditorialIndexNow(): void + { + config(['editorial.indexnow_enabled' => true, 'editorial.base_url' => 'https://example.org', + 'app.url' => 'https://example.org', 'app.site_code' => 'example.org', + 'key.indexnow' => 'test-key-12345678']); + // API authentication in these tests is bound to the same origin. + file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'testing', + 'base_url' => 'https://example.org', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]])); + } + + public function test_editorial_indexnow_notifies_new_and_changed_content_once_but_not_noop(): void + { + $this->enableEditorialIndexNow(); + Http::fake(['api.indexnow.org/*' => Http::response('', 200)]); + $item = $this->item(); + $item['category_slug'] = 'advanced'; + $draft = $this->draft([$item]); + $id = (string) Str::uuid(); + $first = $this->publish($draft, $id)->assertOk()->assertJsonPath('effects.indexnow.status', 'done'); + $this->assertTrue($first->json('articles.0.content_changed')); + $this->publish($draft, $id)->assertOk(); + $this->postJson('/api/editorial/v1/publications/'.$id.'/synchronize')->assertOk(); + Http::assertSentCount(1); + Http::assertSent(fn ($r) => $r->url() === 'https://api.indexnow.org/indexnow' + && $r['host'] === 'example.org' && $r['urlList'] === ['https://example.org/a/example.html']); + $noop = $this->draft([$this->item('example', 1)]); + // The category change in this revision is itself meaningful. + $this->publish($noop)->assertOk()->assertJsonPath('effects.indexnow.status', 'done'); + $same = $this->draft([$this->item('example', 2)]); + $this->publish($same)->assertOk()->assertJsonPath('effects.indexnow.reason', 'no-content-change'); + Http::assertSentCount(2); + $changed = $this->item('example', 3); + $changed['body'] .= '
新的说明。
'; + $this->publish($this->draft([$changed]))->assertOk()->assertJsonPath('effects.indexnow.status', 'done'); + Http::assertSentCount(3); + } + + public function test_editorial_indexnow_failure_retries_with_backoff_without_republishing(): void + { + $this->enableEditorialIndexNow(); + Http::fake(['api.indexnow.org/*' => Http::sequence()->push('do not persist response secrets', 429)->push('', 202)]); + $draft = $this->draft([$this->item()]); + $result = $this->publish($draft)->assertOk()->assertJsonPath('status', 'published') + ->assertJsonPath('effects.indexnow.status', 'pending')->assertJsonPath('effects.indexnow.http_status', 429); + $this->assertStringNotContainsString('do not persist', json_encode($result->json())); + $this->artisan('editorial:indexnow-retry')->assertExitCode(0); + Http::assertSentCount(1); + $this->travel(6)->minutes(); + $this->artisan('editorial:indexnow-retry')->assertExitCode(0); + $this->getJson('/api/editorial/v1/publications/'.$result->json('publication_id'))->assertOk() + ->assertJsonPath('effects.indexnow.status', 'done')->assertJsonPath('effects.indexnow.http_status', 202) + ->assertJsonPath('effects.indexnow.attempts', 2); + $this->assertSame(1, DB::table('articles')->count()); + $this->assertSame(1, DB::table('editorial_versions')->count()); + $this->assertSame(1, DB::table('editorial_publications')->count()); + $this->artisan('editorial:indexnow-retry')->assertExitCode(0); + Http::assertSentCount(2); + } + + public function test_editorial_indexnow_does_not_send_disabled_or_mismatched_hosts_or_legacy_receipts(): void + { + Http::fake(); + $draft = $this->draft([$this->item()]); + $result = $this->publish($draft)->assertOk()->assertJsonPath('effects.indexnow.status', 'skipped'); + $this->enableEditorialIndexNow(); + $legacy = $result->json('publication_id'); + DB::table('editorial_publications')->where('id', $legacy)->update(['effects' => null]); + $this->postJson('/api/editorial/v1/publications/'.$legacy.'/synchronize')->assertOk() + ->assertJsonPath('effects.indexnow.reason', 'legacy-publication'); + config(['app.site_code' => 'wrong.example']); + $new = $this->draft([$this->item('another')]); + $this->publish($new)->assertOk()->assertJsonPath('effects.indexnow.reason', 'site-binding-mismatch'); + Http::assertNothingSent(); + } + + public function test_editorial_indexnow_retries_skip_withdrawn_or_expired_articles(): void + { + $this->enableEditorialIndexNow(); + Http::fake(['api.indexnow.org/*' => Http::response('', 503)]); + $result = $this->publish($this->draft([$this->item()]))->assertOk(); + DB::table('articles')->update(['iStatus' => 0]); + $this->travel(6)->minutes(); + $this->artisan('editorial:indexnow-retry')->assertExitCode(0); + $this->getJson('/api/editorial/v1/publications/'.$result->json('publication_id'))->assertOk() + ->assertJsonPath('effects.indexnow.reason', 'no-current-indexable-url'); + Http::assertSentCount(1); + } + +} \ No newline at end of file