From d00b590773672f153cc94875a69ec2f81bdd2fe2 Mon Sep 17 00:00:00 2001 From: fermatmind <17551046983@163.com> Date: Wed, 1 Jul 2026 22:42:56 +0800 Subject: [PATCH] MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1: add PDF snapshot render-source cache guard --- .../API/V0_3/AttemptReadController.php | 3 + .../Report/Pdf/ReportPdfDocumentService.php | 17 ++++ backend/config/gotenberg.php | 4 + .../V0_3/AttemptPublicReportPdfParityTest.php | 51 +++++++++++- docs/codex/pr-train-state.json | 83 +++++++++++++++++++ docs/codex/pr-train.yaml | 39 +++++++++ 6 files changed, 194 insertions(+), 3 deletions(-) diff --git a/backend/app/Http/Controllers/API/V0_3/AttemptReadController.php b/backend/app/Http/Controllers/API/V0_3/AttemptReadController.php index 4ee0dfd72..0d03d6725 100644 --- a/backend/app/Http/Controllers/API/V0_3/AttemptReadController.php +++ b/backend/app/Http/Controllers/API/V0_3/AttemptReadController.php @@ -3106,6 +3106,7 @@ public function resultPagePdf(Request $request, string $id): Response|JsonRespon 'X-Pdf-Surface' => (string) ($generated['surface'] ?? ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE), 'X-Pdf-Surface-Version' => (string) ($generated['surface_version'] ?? ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION), 'X-Pdf-Render-Version' => (string) ($generated['render_version'] ?? ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION), + 'X-Pdf-Print-Asset-Hash' => (string) ($generated['print_asset_hash'] ?? ReportPdfDocumentService::mbtiResultPageSnapshotPrintAssetHash()), 'X-Pdf-Artifact-Cache' => ((bool) ($generated['cached'] ?? false)) ? 'HIT' : 'MISS', 'X-Legacy-Mpdf-Fallback' => 'false', 'X-Gotenberg-Trace' => (string) ($generated['trace_id'] ?? ''), @@ -3125,6 +3126,7 @@ private function resultPagePdfFailureHeaders(Request $request, string $traceId): 'X-Pdf-Surface' => ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE, 'X-Pdf-Surface-Version' => ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION, 'X-Pdf-Render-Version' => ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION, + 'X-Pdf-Print-Asset-Hash' => ReportPdfDocumentService::mbtiResultPageSnapshotPrintAssetHash(), 'X-Gotenberg-Trace' => $traceId, 'X-Legacy-Mpdf-Fallback' => 'false', 'X-Pdf-Error-Stage' => 'gotenberg.convert_url', @@ -3149,6 +3151,7 @@ private function resultPagePdfCorsHeaders(Request $request): array 'X-Pdf-Surface', 'X-Pdf-Surface-Version', 'X-Pdf-Render-Version', + 'X-Pdf-Print-Asset-Hash', 'X-Legacy-Mpdf-Fallback', 'X-Gotenberg-Trace', 'X-Pdf-Error-Stage', diff --git a/backend/app/Services/Report/Pdf/ReportPdfDocumentService.php b/backend/app/Services/Report/Pdf/ReportPdfDocumentService.php index 233f19a26..1aa6979a4 100644 --- a/backend/app/Services/Report/Pdf/ReportPdfDocumentService.php +++ b/backend/app/Services/Report/Pdf/ReportPdfDocumentService.php @@ -24,6 +24,8 @@ final class ReportPdfDocumentService public const MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION = 'mbti.snapshot.print_layout.v1'; + public const MBTI_RESULT_PAGE_SNAPSHOT_PRINT_ASSET_HASH = 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614'; + public const RESULT_PAGE_EXPORT_ENGINE = 'gotenberg_chromium'; private const MBTI_LEGACY_RESULT_PAGE_EXPORT_SURFACE_VERSION = 'mbti.result_page_export.v2'; @@ -405,6 +407,7 @@ public function getOrGenerateMbtiResultPageExport(Attempt $attempt, array $gate, 'surface' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE, 'surface_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION, 'render_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION, + 'print_asset_hash' => self::mbtiResultPageSnapshotPrintAssetHash(), 'trace_id' => $traceId, ]; } @@ -423,6 +426,7 @@ public function getOrGenerateMbtiResultPageExport(Attempt $attempt, array $gate, 'surface' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE, 'surface_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION, 'render_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION, + 'print_asset_hash' => self::mbtiResultPageSnapshotPrintAssetHash(), 'trace_id' => $traceId, ]; } @@ -442,6 +446,7 @@ private function resolveResultPageExportManifestHash(Attempt $attempt, array $ga $baseHash, self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION, self::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION, + $this->sanitizeManifestSegment(self::mbtiResultPageSnapshotPrintAssetHash()), self::RESULT_PAGE_EXPORT_ENGINE, preg_replace('/[^a-z0-9_.-]+/i', '_', $locale) ?: 'locale', $entitlement, @@ -449,6 +454,18 @@ private function resolveResultPageExportManifestHash(Attempt $attempt, array $ga ]); } + public static function mbtiResultPageSnapshotPrintAssetHash(): string + { + $configured = trim((string) config('gotenberg.result_print_asset_hash', '')); + + return $configured !== '' ? $configured : self::MBTI_RESULT_PAGE_SNAPSHOT_PRINT_ASSET_HASH; + } + + private function sanitizeManifestSegment(string $value): string + { + return preg_replace('/[^a-z0-9_.-]+/i', '_', trim($value)) ?: 'unset'; + } + private function baseContentManifestHash(Attempt $attempt, ?Result $result = null): string { $summary = is_array($attempt->answers_summary_json ?? null) ? $attempt->answers_summary_json : []; diff --git a/backend/config/gotenberg.php b/backend/config/gotenberg.php index e34c19e5a..0276cdc18 100644 --- a/backend/config/gotenberg.php +++ b/backend/config/gotenberg.php @@ -9,6 +9,10 @@ '/{locale}/result/{attempt_id}/print' ), 'result_print_token_secret' => env('GOTENBERG_RESULT_PRINT_TOKEN_SECRET', ''), + 'result_print_asset_hash' => env( + 'GOTENBERG_RESULT_PRINT_ASSET_HASH', + 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614' + ), 'timeout_seconds' => (int) env('GOTENBERG_TIMEOUT_SECONDS', 60), 'connect_timeout_seconds' => (int) env('GOTENBERG_CONNECT_TIMEOUT_SECONDS', 5), 'allow_single_label_hosts' => (bool) env('GOTENBERG_ALLOW_SINGLE_LABEL_HOSTS', true), diff --git a/backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php b/backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php index 702d36af0..a2160c8d1 100644 --- a/backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php +++ b/backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php @@ -310,6 +310,7 @@ public function test_public_mbti_result_page_pdf_uses_strict_gotenberg_surface() $pdf->assertHeader('X-Pdf-Surface', 'mbti_result_page_snapshot'); $pdf->assertHeader('X-Pdf-Surface-Version', 'mbti.result_page_snapshot.v4'); $pdf->assertHeader('X-Pdf-Render-Version', 'mbti.snapshot.print_layout.v1'); + $pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614'); $pdf->assertHeader('X-Pdf-Artifact-Cache', 'MISS'); $pdf->assertHeader('X-Legacy-Mpdf-Fallback', 'false'); $this->assertNotSame('', (string) $pdf->headers->get('X-Gotenberg-Trace')); @@ -353,7 +354,7 @@ public function test_public_mbti_result_page_pdf_uses_strict_gotenberg_surface() $this->assertArrayNotHasKey('failOnConsoleExceptions', $payload); Storage::disk('local')->assertExists( - "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-gotenberg_chromium-zh-locked-free/report_free.pdf" + "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf" ); } @@ -528,6 +529,7 @@ public function test_public_mbti_result_page_pdf_does_not_fallback_to_mpdf_when_ $pdf->assertHeader('X-Pdf-Surface', 'mbti_result_page_snapshot'); $pdf->assertHeader('X-Pdf-Surface-Version', 'mbti.result_page_snapshot.v4'); $pdf->assertHeader('X-Pdf-Render-Version', 'mbti.snapshot.print_layout.v1'); + $pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614'); $pdf->assertHeader('X-Legacy-Mpdf-Fallback', 'false'); $pdf->assertHeader('X-Pdf-Error-Stage', 'gotenberg.convert_url'); $this->assertStringContainsString('X-Gotenberg-Trace', (string) $pdf->headers->get('Access-Control-Expose-Headers')); @@ -541,7 +543,7 @@ public function test_public_mbti_result_page_pdf_does_not_fallback_to_mpdf_when_ $pdf->assertJsonPath('request_id', 'pdf-export-request-1'); $this->assertSame($pdf->headers->get('X-Gotenberg-Trace'), $pdf->json('trace')); Storage::disk('local')->assertMissing( - "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-gotenberg_chromium-zh-locked-free/report_free.pdf" + "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf" ); } @@ -618,7 +620,7 @@ public function test_public_mbti_result_page_pdf_cache_hit_preserves_engine_head $this->createResult($attemptId); Storage::disk('local')->put( - "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-gotenberg_chromium-zh-locked-free/report_free.pdf", + "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf", '%PDF-1.4 cached chromium export' ); @@ -634,9 +636,52 @@ public function test_public_mbti_result_page_pdf_cache_hit_preserves_engine_head $pdf->assertHeader('X-Pdf-Surface', 'mbti_result_page_snapshot'); $pdf->assertHeader('X-Pdf-Surface-Version', 'mbti.result_page_snapshot.v4'); $pdf->assertHeader('X-Pdf-Render-Version', 'mbti.snapshot.print_layout.v1'); + $pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614'); $pdf->assertHeader('X-Pdf-Artifact-Cache', 'HIT'); $pdf->assertHeader('X-Legacy-Mpdf-Fallback', 'false'); $this->assertStringContainsString('cached chromium export', (string) $pdf->getContent()); Http::assertNothingSent(); } + + public function test_public_mbti_result_page_pdf_print_asset_hash_change_misses_old_artifact(): void + { + $this->seedScales(); + config()->set('fap.features.report_snapshot_strict_v2', false); + config()->set('gotenberg.enabled', true); + config()->set('gotenberg.base_url', 'http://gotenberg:3000'); + config()->set('gotenberg.result_print_base_url', 'http://frontend:3000'); + config()->set('gotenberg.result_print_asset_hash', 'sha256:1111111111111111111111111111111111111111111111111111111111111111'); + Storage::fake('local'); + + $attemptId = (string) Str::uuid(); + $anonId = 'anon_mbti_result_page_pdf_asset_hash'; + $token = $this->issueAnonToken($anonId); + $this->createAttempt($attemptId, 'MBTI', $anonId); + $this->createResult($attemptId); + + Storage::disk('local')->put( + "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf", + '%PDF-1.4 cached old print asset hash export' + ); + + Http::fake([ + 'gotenberg:3000/forms/chromium/convert/url' => Http::response('%PDF-1.4 new print asset hash export', 200, [ + 'Content-Type' => 'application/pdf', + ]), + ]); + + $pdf = $this->withHeaders([ + 'X-Anon-Id' => $anonId, + 'Authorization' => 'Bearer '.$token, + ])->get("/api/v0.3/attempts/{$attemptId}/result-page.pdf"); + + $pdf->assertStatus(200); + $pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:1111111111111111111111111111111111111111111111111111111111111111'); + $pdf->assertHeader('X-Pdf-Artifact-Cache', 'MISS'); + $this->assertStringContainsString('new print asset hash export', (string) $pdf->getContent()); + $this->assertStringNotContainsString('cached old print asset hash export', (string) $pdf->getContent()); + Storage::disk('local')->assertExists( + "artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_1111111111111111111111111111111111111111111111111111111111111111-gotenberg_chromium-zh-locked-free/report_free.pdf" + ); + } } diff --git a/docs/codex/pr-train-state.json b/docs/codex/pr-train-state.json index 52683435f..b249cf37f 100644 --- a/docs/codex/pr-train-state.json +++ b/docs/codex/pr-train-state.json @@ -66323,5 +66323,88 @@ "summary": "Re-audited all zh-CN/en desktop clone locked/visible content; initialized local en owner rows via existing public personality baseline importer; zh-CN and en desktop clone dry-runs both pass." } ] + }, + "MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1": { + "repo": "fap-api", + "base": "main", + "branch": "codex/mbti-pdf-snapshot-sync-guard-h1", + "title": "MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1: add PDF snapshot render-source cache guard", + "train_scope": "mbti_pdf_snapshot_sync_guard_h1_companion", + "depends_on": [], + "status": "pr_open", + "commit_sha": null, + "pr_url": "https://github.com/fermatmind/fap-api/pull/2579", + "merged_at": null, + "remote_branch_deleted": false, + "local_cleanup_executed": false, + "failure_reason": null, + "checks": { + "authorization": { + "status": "pass", + "evidence": "User provided /goal execution request with explicit H1 backend companion scope, validation, merge, and cleanup rules." + }, + "worktree_isolation": { + "status": "pass", + "evidence": "Created branch codex/mbti-pdf-snapshot-sync-guard-h1 in isolated /private/tmp fap-api worktree from origin/main because the main branch is owned by another local worktree." + }, + "phpunit_result_page_pdf": { + "status": "pass", + "command": "cd backend && php artisan test --filter=AttemptPublicReportPdfParityTest", + "evidence": "11 tests completed with exit code 0; warnings are existing deprecation/file_get_contents notices." + }, + "pint_dirty": { + "status": "pass", + "command": "cd backend && ./vendor/bin/pint --dirty", + "evidence": "4 changed PHP files formatted." + }, + "manifest_parse": { + "status": "pass", + "command": "ruby -e \"require 'yaml'; YAML.load_file('docs/codex/pr-train.yaml')\"", + "evidence": "YAML parsed successfully." + }, + "state_parse": { + "status": "pass", + "command": "python3 -m json.tool docs/codex/pr-train-state.json >/dev/null", + "evidence": "JSON parsed successfully." + }, + "diff_check": { + "status": "pass", + "command": "git diff --check", + "evidence": "No whitespace errors." + }, + "autoload_isolation": { + "status": "pass", + "evidence": "Temporary backend worktree uses copied vendor plus composer dump-autoload so tests load current branch sources rather than the original checkout." + } + }, + "scope_validation": { + "allowed_files": [ + "backend/config/gotenberg.php", + "backend/app/Services/Report/Pdf/ReportPdfDocumentService.php", + "backend/app/Http/Controllers/API/V0_3/AttemptReadController.php", + "backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php", + "docs/codex/pr-train.yaml", + "docs/codex/pr-train-state.json", + "generated/pr-train-sidecar-issues/**" + ], + "outside_scope_files": [] + }, + "events": [ + { + "at": "2026-07-01T14:30:04.421Z", + "status": "in_progress", + "notes": "Initialized H1 backend companion. Scope is print asset hash header/cache-key guard only; no deploy, server mutation, content import, Gotenberg route/timeout, visual layout, CMS/DB, sitemap/llms/SEO runtime changes." + }, + { + "at": "2026-07-01T14:41:15.290294Z", + "status": "local_checks_passed", + "notes": "fap-api companion H1 local checks passed; no deploy, DB/CMS write, or production import performed." + }, + { + "at": "2026-07-01T14:44:12.039300Z", + "status": "pr_open", + "notes": "Opened fap-api companion PR #2579 for H1." + } + ] } } diff --git a/docs/codex/pr-train.yaml b/docs/codex/pr-train.yaml index a0d3f5f1c..4039606c9 100644 --- a/docs/codex/pr-train.yaml +++ b/docs/codex/pr-train.yaml @@ -30680,3 +30680,42 @@ prs: publish_allowed: false deploy_allowed: false content_authority: backend + + - id: MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1 + repo: fap-api + depends_on: [] + branch: codex/mbti-pdf-snapshot-sync-guard-h1 + title: "MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1: add PDF snapshot render-source cache guard" + train_scope: mbti_pdf_snapshot_sync_guard_h1_companion + status: in_progress + scope: + - Add `X-Pdf-Print-Asset-Hash` observability for MBTI result-page snapshot PDF responses. + - Include the print asset hash in the result-page PDF artifact key so print-impact frontend asset changes can miss old artifacts when the hash is updated. + - Keep `MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION` as the manual fallback escape hatch. + - Add focused cache tests proving changed print asset hash does not hit the old artifact. + allowed_paths: + - backend/config/gotenberg.php + - backend/app/Services/Report/Pdf/ReportPdfDocumentService.php + - backend/app/Http/Controllers/API/V0_3/AttemptReadController.php + - backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php + - docs/codex/pr-train.yaml + - docs/codex/pr-train-state.json + - generated/pr-train-sidecar-issues/** + do_not: + - Modify fap-web in this repository. + - Deploy, reload, restart, clear cache, write DB/CMS/search/indexing/queue, mutate server env, or run production import. + - Change Gotenberg route, timeout, PDF rendering chain, content injection, visual layout, content assets, mPDF behavior, sitemap, llms, canonical, noindex, JSON-LD, or SEO runtime. + validation: + - cd backend && php artisan test --filter=AttemptPublicReportPdfParityTest + - cd backend && ./vendor/bin/pint --dirty + - ruby -e "require 'yaml'; YAML.load_file('docs/codex/pr-train.yaml')" + - python3 -m json.tool docs/codex/pr-train-state.json >/dev/null + - git diff --check + merge_policy: { github_checks_required: true, squash: true, auto_merge: true } + production_write_execution: false + database_mutation: false + cms_mutation: false + api_runtime_change: true + publish_allowed: false + deploy_allowed: false + content_authority: backend