diff --git a/AGENTS.md b/AGENTS.md index 580db8d0..f5f9eef3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,37 @@ -# FNA_V3维护入口 +# fnb 项目维护入口 + +项目工作先读 GemDocs AI版项目总览、维护协议及 `/Users/hellcat/Documents/GemDocs/AI版/10-项目/fnb.md`。线上事务优先经白色机 SSH;网页与订阅 HTTP 访问须符合 GemDocs 的反代规则。 + +订阅检测已独立为 SubCheck,入口见 [SubCheck](/Users/hellcat/Documents/GemCodes/subcheck/AGENTS.md);cfn来源适配与调查见 [CFN.md](/Users/hellcat/Documents/GemCodes/subcheck/docs/CFN.md)。fnb不再保存检测实现。代码、凭据、业务数据及并行修改按当前任务授权处理。 + +## FNA_V3 本地副本维护 + +本目录为独立 FNA_V3 副本,身份与环境以 GemDocs AI版 `10-项目/fna_v3.md` 为准;上述 FN_B 入口保留来源背景,不代表授权修改 FN_B。文章广告实现、配置与验收见 [广告维护说明](docs/maintenance/ADVERTISING.md)。 + +SEO 的只读审计、优化顺序与逐项验收统一维护在 [SEO 优化建议与执行清单](docs/maintenance/SEO_PLAN.md),2026-10-22 建立。清单中的建议不代表已实施,后续按用户指定项目推进并更新本文件所链接的唯一正文。 + +## CFN系绘图接手 + +2026-10-23用户明确将此前由张天承担的绘图工作交给“画师【CFN系】”(personnel-position:cd029d9db8454ec68172b221ff35e6f6)。FNA_V3主程继续开发工作,后续绘图由该画师岗位承接,避免重复制作。本记录仅转交绘图范围,不改变开发或上线权限;未向原聊天发送消息。 + +风格已从原聊天最近作品及本地成图核实:现代主义剪纸拼贴、哑光色块、轻微丝网印刷颗粒、成熟动物比例、非对称构图和大块留白。参考文件在本项目output/imagegen/:freenode-dog-dalmatian-20261023.png、freenode-dog-dachshund-20261023.png、freenode-dogs-dance-20261023.png;同名txt保存完整提示词。延续视觉语言,每张更换主题或构图,不照搬布局。 + +接手首张试画及提示词:/Users/hellcat/Documents/Codex/2026-10-23/cfn/outputs/cfn-tuxedo-cat-20261023.png 和同名txt。用户已认可风格并继续扩充;2026-10-26按授权与后续猫狗作品一起归档到Resource V2,并接入本地FNA_V3,未发布到线上。归档和栏目配图的唯一维护入口见[本地插画资源与配图](docs/maintenance/ILLUSTRATIONS.md)。 + +教程栏目现为“入门教程”(/client)和“综合教程”(/advanced,原进阶教程)。栏目改名、首篇重写、DNS进阶文章及内容回退见[教程栏目维护](docs/maintenance/TUTORIALS.md)。原七篇旧教程均已处理:迁移文归进阶,其余按Android、iOS、Mac和Linux重写;另有Windows纯入门与DNS进阶文章。正文、图解和关联已本地验收,本地内容不代表线上发布。 ## FNA 静态配图副本 -2026-10-30起Resource保留原稿、图池和固定分配,FNA保存按内容去重的公开展示副本,由本站Nginx直接提供。同步、创建时校验、撤回、缓存与部署验收唯一正文见[静态配图副本](docs/maintenance/LOCAL_ILLUSTRATIONS.md)。用户访问图片不调用Resource、不执行PHP修复。 +2026-10-30起Resource保留原稿、图池和固定分配,FNA保存按内容去重的公开展示副本,由本站Nginx直接提供。同步、创建时校验、撤回、缓存与部署验收唯一正文见[静态配图副本](docs/maintenance/LOCAL_ILLUSTRATIONS.md)。用户访问图片不调用Resource、不执行PHP修复。2026-10-31明确源端删除/停用不影响FNA独立副本或已有文章关联,修复与恢复原图记录见[独立副本保留修复](docs/maintenance/INDEPENDENT_ILLUSTRATIONS_FIX_2026-10-31.md)。 ## AI 文章写作(必读) -文章写作和发布先读docs/content/WRITING.md及docs/maintenance/EDITORIAL_API.md。项目身份和访问边界先读GemDocs AI版项目总览、维护协议及10-项目/fna_v3.md。 +后续为 FNA_V3 选题、写作、重写、调整分类或撤下文章时,必须先读[AI写作与文章关联索引](docs/content/WRITING.md)。它是文章用途、稳定地址和正文关系的唯一登记入口。公开文章禁止出现“核对范围”“未实测”等内部核验/验收说明,具体禁止项与发布前检查按该写作规范执行;相关事实只留项目维护记录。写作时复用相关的下载、入门、进阶和排错内容,自然设置内链,并检查相关旧文是否需要补入口;完成后同步正文源、运行数据库和该索引,验收真实目标与锚点。未创建的文章不能编造链接,本地生效不能写成已上线;不自动开展全站重写或发布。 + +## 独立文章发布 API + +用户已明确文章直接发布到 FNA_V3,不经过 ATOM。实现、调用契约、凭据边界及验收见[独立文章 API](docs/maintenance/EDITORIAL_API.md)。后续写稿使用本站草稿→签名预览→按hash发布→回执核对流程;相关旧文内链修改可同批提交。不要再用临时SQL脚本发布普通编辑文章。旧文首次接管必须显式指定原文章ID并保留URL;发布结果未知先查原请求UUID。代码已于2026-10-29随正式切换部署,生产临时编辑密钥在教程迁移后删除、接口默认关闭;后续按明确写作/发布任务临时启用并在完成后关闭,不扩大线上访问或发布范围。正式部署、备份、Git SHA及验收统一见[发布记录](docs/maintenance/RELEASE_PREFLIGHT_2026-10-28.md#正式切换2026-10-29)。 插画完成后按[自动交付](docs/maintenance/ILLUSTRATION_DELIVERY.md)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。 + +周期文章按[固定发布封装](docs/maintenance/EDITORIAL_RELEASE.md)执行:本机SSH必须经白色机到正式业务服务器,使用服务器进程内API,不临时开放公网接口。每周三篇、每天15:00检查、错过窗口回补及文章随机展示时间见[周期写作](docs/maintenance/WEEKLY_TUTORIALS.md);这一流程替代周期写作中的手动开关API步骤,不扩大其他实例或全站改版权限。 diff --git a/app/Console/Commands/EditorialRelease.php b/app/Console/Commands/EditorialRelease.php new file mode 100644 index 00000000..ca26f05c --- /dev/null +++ b/app/Console/Commands/EditorialRelease.php @@ -0,0 +1,40 @@ +environment('testing')) { + abort_unless(app()->environment('production') + && realpath(base_path()) === '/www/wwwroot/clashfreenode-new.com' + && rtrim(config('editorial.base_url'), '/') === 'https://clashfreenode.com' + && function_exists('posix_geteuid') && posix_getpwuid(posix_geteuid())['name'] === 'nginx', 403, 'Server binding mismatch'); + } + $raw = stream_get_contents(STDIN, 48 * 1024 * 1024 + 1); + abort_unless(strlen($raw) <= 48 * 1024 * 1024, 413); + $input = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + abort_unless(is_array($input), 422); + $result = app(ReleaseRunner::class)->run($input); + $this->line(json_encode(['ok' => true, 'data' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + + return ($result['phase'] ?? '') === 'published_pending' ? 2 : 0; + } catch (\Throwable $e) { + $status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500; + $this->line(json_encode(['ok' => false, 'status' => $status, 'error' => 'Release operation failed; use the same release_id to inspect or resume. No credential details are exposed.'])); + + return 1; + } + } +} diff --git a/app/Http/Controllers/Api/Editorial/EditorialController.php b/app/Http/Controllers/Api/Editorial/EditorialController.php index 189b0e37..91fab551 100644 --- a/app/Http/Controllers/Api/Editorial/EditorialController.php +++ b/app/Http/Controllers/Api/Editorial/EditorialController.php @@ -32,6 +32,7 @@ public function source(int $article) return response()->json(['article_id' => $a->id, 'title' => $a->sTitle, 'summary' => $a->sTitleSub, 'slug' => $a->sSlug, 'body_html' => $a->sContent, 'category_slug' => $category->sSlug, 'tag_ids' => DB::table('article_tag')->where('iArticleId', $a->id)->pluck('iTagId'), + 'created_at' => $a->created_at, 'display_published_at' => $a->sDisplayPublishedAt, '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, @@ -140,7 +141,7 @@ public function preview(string $draft, string $key, Publisher $p) } $a = new Articles; $a->forceFill(['id' => 0, 'sTitle' => $item['title'], 'sTitleSub' => $item['summary'], 'sSlug' => $item['slug'], 'sContent' => $html, - 'created_at' => now(), 'iLikeCount' => 0, 'bPageThumb' => $item['show_cover'] ?? false, 'sThumb' => $cover]); + 'created_at' => now(), 'sDisplayPublishedAt' => now()->subSeconds($item['display_offset_seconds'] ?? 0), 'iLikeCount' => 0, 'bPageThumb' => $item['show_cover'] ?? false, 'sThumb' => $cover]); $category = Categories::where('sSlug', $item['category_slug'])->with('oParent')->firstOrFail(); $a->setRelation('oCategory', $category); $a->setRelation('oTags', collect()); diff --git a/app/Http/Middleware/EditorialAuthentication.php b/app/Http/Middleware/EditorialAuthentication.php index c88c3b28..1ae75c57 100644 --- a/app/Http/Middleware/EditorialAuthentication.php +++ b/app/Http/Middleware/EditorialAuthentication.php @@ -15,7 +15,9 @@ public function handle(Request $request, Closure $next) abort_unless(($db['database'] ?? '') === 'fna_v3' && in_array($db['host'] ?? '', ['localhost', '127.0.0.1']) && empty($db['url']), 503, 'Local editorial database binding mismatch'); } $path = config('editorial.credential_file'); - $credential = is_file($path) ? json_decode(file_get_contents($path), true) : null; + $process = PHP_SAPI === 'cli' ? config('editorial.process_credential') : null; + $credential = $process ?? (is_file($path) ? json_decode(file_get_contents($path), true) : null); + abort_if($process && (! is_int($process['expires_at'] ?? null) || $process['expires_at'] <= time()), 401, 'Process session expired'); abort_unless(is_array($credential) && ($credential['environment'] ?? null) === app()->environment() && ($credential['project'] ?? null) === 'fna-v3' diff --git a/app/Models/Articles.php b/app/Models/Articles.php index 1c940349..dd9486a4 100755 --- a/app/Models/Articles.php +++ b/app/Models/Articles.php @@ -38,7 +38,7 @@ protected function sCreatedAtDiff(): Attribute { return Attribute::get(function () { // 1. 别直接用 $this->created_at,防止它还没被实例化成 Carbon 对象 - $oDate = \Carbon\Carbon::parse($this->created_at); + $oDate = \Carbon\Carbon::parse($this->attributes['sDisplayPublishedAt'] ?? $this->created_at); $oNow = \Carbon\Carbon::now(); // 2. 计算小时差(强制转整数) diff --git a/app/Services/Editorial/ProcessApi.php b/app/Services/Editorial/ProcessApi.php new file mode 100644 index 00000000..f4ae4cc8 --- /dev/null +++ b/app/Services/Editorial/ProcessApi.php @@ -0,0 +1,66 @@ +credential = ['schema' => 'gemdocs.credential.v1', 'project' => 'fna-v3', + 'environment' => app()->environment(), 'base_url' => config('editorial.base_url'), + 'expires_at' => time() + 600, 'fields' => ['api_key' => 'fna_ed_'.bin2hex(random_bytes(32))]]; + } + + public function call(string $method, string $path, array $data = [], ?UploadedFile $image = null): array + { + abort_unless(in_array($method, ['GET', 'POST']) && preg_match('~\A[a-zA-Z0-9_/-]+\z~', $path), 422); + $response = $this->dispatch($method, '/api/editorial/v1/'.$path, $data, $image); + $body = json_decode($response->getContent(), true); + abort_unless($response->isSuccessful(), $response->getStatusCode(), 'Editorial API rejected operation; inspect state before retry'); + abort_unless(is_array($body), 502, 'Unexpected API response'); + + return $body; + } + + public function preview(string $url): string + { + $base = rtrim(config('editorial.base_url'), '/'); + abort_unless(str_starts_with($url, $base.'/api/editorial/v1/preview/'), 422, 'Preview origin mismatch'); + $response = $this->dispatch('GET', substr($url, strlen($base))); + abort_unless($response->isSuccessful() && str_contains($response->headers->get('Content-Type', ''), 'text/html') + && str_contains($response->headers->get('X-Robots-Tag', ''), 'noindex'), 502, 'Preview render failed'); + + return $response->getContent(); + } + + private function dispatch(string $method, string $path, array $data = [], ?UploadedFile $image = null) + { + abort_unless(time() < $this->credential['expires_at'], 401, 'Process session expired'); + $before = ['editorial.enabled' => config('editorial.enabled'), + 'editorial.process_credential' => config('editorial.process_credential')]; + $originalRequest = app('request'); + config(['editorial.enabled' => true, 'editorial.process_credential' => $this->credential]); + try { + $server = ['HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer '.$this->credential['fields']['api_key'], + 'CONTENT_TYPE' => 'application/json', 'REMOTE_ADDR' => '127.0.0.1']; + $request = Request::create(rtrim(config('editorial.base_url'), '/').$path, $method, [], [], + $image ? ['image' => $image] : [], $server, $image ? null : json_encode($data)); + $kernel = app(Kernel::class); + $response = $kernel->handle($request); + $kernel->terminate($request, $response); + + return $response; + } finally { + config($before); + app()->instance('request', $originalRequest); + } + } +} diff --git a/app/Services/Editorial/Publisher.php b/app/Services/Editorial/Publisher.php index 272cb57f..9d869907 100644 --- a/app/Services/Editorial/Publisher.php +++ b/app/Services/Editorial/Publisher.php @@ -23,6 +23,7 @@ public function draft(array $input): array 'articles.*.tag_ids' => 'sometimes|array|max:20', 'articles.*.tag_ids.*' => 'integer|min:1', 'articles.*.cover' => 'sometimes|nullable|string|max:255', 'articles.*.show_cover' => 'sometimes|boolean', + 'articles.*.display_time_randomize' => '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', @@ -64,6 +65,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_if(array_key_exists('display_time_randomize', $row), 422, 'Display time 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'); @@ -84,7 +86,8 @@ public function draft(array $input): array 'base_article_hash' => $article ? $this->articleHash($article) : null, ]; if (! $article) { - // Freeze the random defaults once per draft, so a retry/publication cannot redraw them. + // Freeze display offset and like defaults once; retries cannot redraw them. + $item['display_offset_seconds'] = ($row['display_time_randomize'] ?? true) ? random_int(0, 21600) : 0; $item['like_settings'] = [ 'seven_day' => $row['like_settings']['seven_day'] ?? random_int(5, 35), 'yearly' => $row['like_settings']['yearly'] ?? 50, @@ -114,7 +117,7 @@ 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', 'like_settings' => $i['like_settings'] ?? null, + 'path' => '/a/'.$i['slug'].'.html', 'display_offset_seconds' => $i['display_offset_seconds'] ?? 0, '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)]; } @@ -189,7 +192,8 @@ public function publish(string $id, array $input): array '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()]); + 'iIsTop' => 0, 'created_at' => now(), + 'sDisplayPublishedAt' => now()->subSeconds($item['display_offset_seconds'] ?? 0)]); } $oldTags = $this->tags($articleId); DB::table('article_tag')->where('iArticleId', $articleId)->delete(); diff --git a/app/Services/Editorial/ReleaseRunner.php b/app/Services/Editorial/ReleaseRunner.php new file mode 100644 index 00000000..3110dab8 --- /dev/null +++ b/app/Services/Editorial/ReleaseRunner.php @@ -0,0 +1,204 @@ +api->call('GET', 'catalog'); + } + if ($op === 'source') { + abort_unless(is_int($input['article_id'] ?? null) && $input['article_id'] > 0, 422); + + return $this->api->call('GET', 'source-articles/'.$input['article_id']); + } + $id = $input['release_id'] ?? ''; + abort_unless(is_string($id) && Str::isUuid($id), 422, 'release_id must be a UUID'); + $root = config('editorial.release_directory', storage_path('app/private/editorial-releases')); + if (! is_dir($root)) { + mkdir($root, 0700, true); + } + $lock = fopen($root.'/runner.lock', 'c'); + abort_unless($lock && flock($lock, LOCK_EX | LOCK_NB), 409, 'Another release operation is running'); + try { + $file = $root.'/'.$id.'.json'; + $state = is_file($file) ? json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR) : null; + if ($op === 'prepare') { + $bundle = ['articles' => $input['articles'] ?? [], 'assets' => $input['assets'] ?? []]; + $bundleHash = app(Publisher::class)->hash($bundle); + abort_if($state && $state['bundle_hash'] !== $bundleHash, 409, 'Release ID reused with changed bundle'); + if (! $state) { + $state = ['release_id' => $id, 'bundle_hash' => $bundleHash, 'draft_id' => $id, + 'publication_request_id' => (string) Str::uuid(), 'phase' => 'preparing']; + $this->save($file, $state); // Persist IDs before any API mutation. + } + $articles = $this->assets($bundle); + $draft = $this->api->call('POST', 'drafts', ['request_id' => $id, 'articles' => $articles]); + $state['draft'] = $draft; + if ($state['phase'] === 'preparing') { + $state['phase'] = 'prepared'; + } + $this->save($file, $state); + + return $state; + } + abort_unless($state, 404, 'Release not found; retry prepare using the original bundle'); + if ($op === 'status') { + return $this->receipt($file, $state); + } + $draft = $this->api->call('GET', 'drafts/'.$state['draft_id']); + if ($op === 'review') { + $previews = []; + foreach ($draft['articles'] as $article) { + $previews[$article['content_key']] = $this->api->preview($article['preview_url']); + } + $state['review'] = ['hash' => $draft['hash'], 'digest' => app(Publisher::class)->hash($previews), 'rendered_at' => gmdate('c')]; + $state['phase'] = $draft['status'] === 'published' ? 'published' : 'reviewed'; + $this->save($file, $state); + + return $state + ['previews' => $previews]; + } + abort_unless(isset($state['review']) + && hash_equals($state['review']['hash'], $input['expected_hash'] ?? '') + && hash_equals($state['review']['digest'], $input['review_digest'] ?? '') + && hash_equals($draft['hash'], $state['review']['hash']), 409, 'Review the frozen draft before publishing'); + // Query first even after a lost response or interrupted local state update. + $state = $this->receipt($file, $state); + if (! isset($state['receipt'])) { + $state['phase'] = 'publishing'; + $this->save($file, $state); + $state['receipt'] = $this->api->call('POST', 'drafts/'.$state['draft_id'].'/publish', + ['request_id' => $state['publication_request_id'], 'expected_hash' => $state['review']['hash']]); + $this->save($file, $state); + } + $receiptId = $state['receipt']['publication_id']; + $state['receipt'] = $this->api->call('POST', 'publications/'.$receiptId.'/synchronize'); + + return $this->verify($file, $state); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function receipt(string $file, array $state): array + { + try { + $state['receipt'] = $this->api->call('GET', 'publications/'.$state['publication_request_id']); + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { + if ($e->getStatusCode() !== 404) { + throw $e; + } + // Another publisher may have completed this same draft with its own UUID. + $draft = $this->api->call('GET', 'drafts/'.$state['draft_id']); + if ($draft['publication_id']) { + $state['receipt'] = $this->api->call('GET', 'publications/'.$draft['publication_id']); + } else { + return $state; + } + } + + return $this->verify($file, $state); + } + + private function verify(string $file, array $state): array + { + $timestamp = \Illuminate\Support\Facades\DB::table('editorial_publications')->where('id', $state['receipt']['publication_id'])->value('created_at'); + $state['published_at'] = \Carbon\CarbonImmutable::parse($timestamp, config('app.timezone'))->toIso8601String(); + $readback = []; + foreach ($state['receipt']['articles'] as $article) { + $source = $this->api->call('GET', 'source-articles/'.$article['article_id']); + $version = $this->api->call('GET', 'articles/'.$article['content_key'].'/versions/'.$article['revision'])['content']; + $imagesValid = true; + $resolve = function (string $html) use (&$imagesValid) { + return preg_replace_callback('/asset:([a-f0-9]{64})/', function ($m) use (&$imagesValid) { + $extension = \Illuminate\Support\Facades\DB::table('editorial_assets')->where('sha256', $m[1])->value('extension'); + $url = '/images/editorial/'.$m[1].'.'.$extension; + $path = public_path(ltrim($url, '/')); + $imagesValid = $imagesValid && is_file($path) && hash_file('sha256', $path) === $m[1]; + + return $url; + }, $html); + }; + $expectedHtml = $resolve($version['html']); + $expectedCover = $version['cover'] ? $resolve($version['cover']) : $version['cover']; + $matches = $source['body_html'] === $expectedHtml && $source['title'] === $version['title'] + && $source['summary'] === $version['summary'] && $source['slug'] === $version['slug'] + && $source['category_slug'] === $version['category_slug'] && $source['cover'] === $expectedCover + && $source['show_cover'] === $version['show_cover'] && $imagesValid; + $readback[] = ['content_key' => $article['content_key'], 'article_id' => $article['article_id'], + 'revision_matches' => (int) $source['management']['published_revision'] === (int) $article['revision'], + 'content_matches' => $matches, + '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'); + $state['phase'] = $done && collect($readback)->every(fn ($r) => $r['revision_matches'] && $r['content_matches']) ? 'complete' : 'published_pending'; + $this->save($file, $state); + + return $state; + } + + private function assets(array $bundle): array + { + abort_unless(is_array($bundle['assets']) && count($bundle['assets']) <= 10, 422); + $replacements = []; + foreach ($bundle['assets'] as $asset) { + abort_unless(preg_match('/\A[a-z0-9_-]{1,80}\z/', $asset['name'] ?? '') && ! isset($replacements['file:'.$asset['name']]), 422, 'Invalid or duplicate asset name'); + $bytes = base64_decode($asset['base64'] ?? '', true); + abort_unless($bytes !== false && strlen($bytes) <= 8 * 1024 * 1024, 422, 'Invalid asset'); + $temp = tempnam(sys_get_temp_dir(), 'editorial-upload-'); + try { + file_put_contents($temp, $bytes); + chmod($temp, 0600); + $result = $this->api->call('POST', 'assets', [], new UploadedFile($temp, $asset['name'], null, null, true)); + $replacements['file:'.$asset['name']] = $result['reference']; + } finally { + @unlink($temp); + } + } + $articles = $bundle['articles']; + abort_unless(is_array($articles) && count($articles) >= 1 && count($articles) <= 10, 422); + foreach ($articles as &$article) { + foreach (['body', 'cover'] as $field) { + if (isset($article[$field])) { + $article[$field] = preg_replace_callback('/file:([a-z0-9_-]+)/', function ($m) use ($replacements) { + abort_unless(isset($replacements[$m[0]]), 422, 'Missing bundled asset'); + + return $replacements[$m[0]]; + }, $article[$field]); + } + } + } + + return $articles; + } + + private function save(string $file, array $state): void + { + $temp = $file.'.'.bin2hex(random_bytes(8)); + try { + $handle = fopen($temp, 'x'); + chmod($temp, 0600); + $bytes = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + if (fwrite($handle, $bytes) !== strlen($bytes) || ! fflush($handle) || ! fsync($handle)) { + throw new \RuntimeException('Release state write failed'); + } + fclose($handle); + if (! rename($temp, $file)) { + throw new \RuntimeException('Release state replacement failed'); + } + } finally { + @unlink($temp); + } + } +} diff --git a/database/migrations/2026_10_31_150000_add_article_display_timestamp.php b/database/migrations/2026_10_31_150000_add_article_display_timestamp.php new file mode 100644 index 00000000..455804f9 --- /dev/null +++ b/database/migrations/2026_10_31_150000_add_article_display_timestamp.php @@ -0,0 +1,18 @@ + $table->timestamp('sDisplayPublishedAt')->nullable()); + } + + public function down(): void + { + Schema::table('articles', fn (Blueprint $table) => $table->dropColumn('sDisplayPublishedAt')); + } +}; diff --git a/docs/content/WRITING.md b/docs/content/WRITING.md index a59dbe03..c80cbe88 100644 --- a/docs/content/WRITING.md +++ b/docs/content/WRITING.md @@ -4,6 +4,8 @@ # AI 写作与文章关联索引 发布已采用本站[独立文章 API](../maintenance/EDITORIAL_API.md),不经过ATOM。AI保留项目正文源,通过API保存冻结草稿、取得签名预览、发布确认的hash并核对回执;本索引仍由写作AI同任务维护,API不会自动改写它。 +2026-10-31用户另行授权综合教程每周随机三篇的周期写作与发布,Codex任务已建立,自2026-11-02当周开始。随机计划、额度、幂等台账与执行状态唯一见[周期写作](../maintenance/WEEKLY_TUTORIALS.md);这一授权不扩大为全站重写或其他实例发布。 + 本文是写作流程、文章用途及文章关系的唯一维护入口。文章正文保存在各自源文件,数据库是页面实际读取的内容;栏目实现、渲染与备份方法见[教程维护](../maintenance/TUTORIALS.md),SEO项目状态见[SEO清单](../maintenance/SEO_PLAN.md)。不要在这些文档中再复制一份持续更新的文章关系表。 ## 1 写作前先确定读者要完成的任务 @@ -25,6 +27,10 @@ ## 1 写作前先确定读者要完成的任务 自然、准确地表达;必要时用真实操作图、流程图、配置示例。软件版本、下载入口与兼容要求使用当次核对的官方来源;来源核验、页面检查和设备实测分别记录,未做实测不写“实测”。 +### 周期文章的展示时间(2026-10-31) + +用户要求每天北京时间15:00检查周期计划,新文章展示日期从实际发布时间随机向前偏移0~21600秒(含端点)。API在新文草稿中冻结`display_offset_seconds`,首次发布计算独立的`sDisplayPublishedAt`;列表和详情沿原有日期显示样式使用该字段,未设置时使用真实创建时间。实际`created_at`、发布回执及点赞起点不改;重试、旧文编辑和回退不重抽、不改旧文展示时间。`display_time_randomize`只允许新建时设置,默认true,显式false为零偏移。不要把随机时间或内部执行过程写进公开正文。 + ### AI 新文章的点赞参数(2026-10-29) 后续AI通过本站独立文章API**新建文章**时使用以下默认设置,由API在保存草稿时为每篇独立取值并冻结;相同请求重试、查询预览及发布不重新抽取。重写、接管、回退已有文章不重置原参数、点赞数或已完成进度,不自动给现有文章批量补额度。 diff --git a/docs/maintenance/EDITORIAL_API.md b/docs/maintenance/EDITORIAL_API.md index 6d296c2c..d2418acd 100644 --- a/docs/maintenance/EDITORIAL_API.md +++ b/docs/maintenance/EDITORIAL_API.md @@ -1,5 +1,7 @@ # FNA_V3 独立文章 API +2026-10-31新增[经白色机的固定发布封装](EDITORIAL_RELEASE.md):周期写作使用本机SSH经白色机到业务服务器,由`editorial:release`进程调用本页原API路由。公网编辑API保持关闭,进程权限不落盘;无需逐次修改生产.env或启用公网密钥。以下HTTP契约继续有效,服务器命令复用同一套验证、事务和回执。 + 状态:2026-10-27本地实施并联调;2026-10-29已随独立test预览提交、推送和部署,10篇教程真实API迁移通过后,test编辑接口已关闭且临时密钥移除。正式站现已切换,正式10篇教程亦通过同一API迁移;生产临时密钥用后删除,编辑接口默认关闭,后续明确写作/发布任务再按绑定环境启用并完成后关闭。test执行和回执沿[发布记录](RELEASE_PREFLIGHT_2026-10-28.md#当前门槛先test预览确认后再切正式2026-10-29),日常本地调用不受影响。用户明确采用本站独立接收 API,不经过 ATOM;不新增 ATOM 依赖。入口为 `http://fna-v3.loc/api/editorial/v1`,生产地址尚未指定。 ## 职责与数据 diff --git a/docs/maintenance/EDITORIAL_RELEASE.md b/docs/maintenance/EDITORIAL_RELEASE.md new file mode 100644 index 00000000..b5fcbbba --- /dev/null +++ b/docs/maintenance/EDITORIAL_RELEASE.md @@ -0,0 +1,43 @@ +# 经白色机的固定文章发布流程 + +## 设计 + +维护脚本:`tools/editorial/release.py`(本机传输)、`tools/editorial/ssh_askpass.py`(只向OpenSSH认证组件提供登记密码)、`editorial:release`(业务服务器命令)。现有文章API负责资产、草稿、版本、链接校验、原子发布、回执和搜索缓存同步,契约见[EDITORIAL_API.md](EDITORIAL_API.md)。本封装不另写文章SQL,不经ATOM。 + +固定路线:本机SSH→VPS-001白色机→VPS-003 `/www/wwwroot/clashfreenode-new.com`。SSH强制ProxyCommand、既有主机密钥核验,禁用配置文件和已有连接复用,不接受自定义目标/命令,也没有直连回退。白色机仅转发SSH,不保存稿件、凭据或运行定时器。 + +远端固定以nginx运行`php artisan editorial:release`,命令校验production环境、真实项目路径和base_url。JSON由stdin传递。服务器内通过Laravel HTTP Kernel调用原有API路由,经过原认证、校验和控制器;无需本机或公网HTTP。运行身份使用现有服务器权限,不改SSH策略或管理员凭据。 + +进程内临时API认证值只保存在内存,寿命10分钟,每次内部请求后恢复原配置,结束/断开杀进程即消失;不写.env或公用credential.json,不重载配置、不启用公网编辑入口。签名预览也在同一命令内渲染,公网API关闭时这些预览URL不可从外部打开。旧API的手动操作契约仍保留,周期发布改用本封装。 + +## 调用 + +本机在项目目录执行,输出文件包含私有预览,权限600,不提交Git: + +```sh +python3 tools/editorial/release.py catalog --input /私有目录/empty.json --output /私有目录/catalog.json +python3 tools/editorial/release.py source --input /私有目录/source.json --output /私有目录/source-result.json +python3 tools/editorial/release.py prepare --input /私有目录/bundle.json --output /私有目录/prepared.json +python3 tools/editorial/release.py review --input /私有目录/id.json --output /私有目录/review.json +python3 tools/editorial/release.py publish --input /私有目录/publish.json --output /私有目录/published.json +python3 tools/editorial/release.py status --input /私有目录/id.json --output /私有目录/status.json +``` + +- empty.json为`{}`;source.json为`{"article_id":真实整数ID}`。catalog/source只读,不创建release。 +- bundle.json含`release_id`(UUID)、`articles`(原API字段)及可选`assets`。每个asset为`{"name":"diagram","base64":"实际PNG/JPEG/WebP/GIF字节的base64"}`,正文/封面用`file:diagram`占位;程序上传后替换为asset引用。单图≤8MiB、最多10图、总输入≤48MiB;不接收SVG,既有本站SVG可以按真实路径引用。新画的技术SVG可在本地渲染为PNG后通过资产API上传,无需为了配图部署程序。 +- 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`来自服务器真实发布事务时间。 + +本机工具只输出结果文件路径及状态,不回显SSH认证、正文或预览。SSH断开、超时或非JSON输出都保存未知/失败状态,不自动重放写入;用原release_id查status。不存在草稿时沿原bundle恢复prepare。 + +## 状态与恢复 + +服务器台账`storage/app/private/editorial-releases/UUID.json`,权限600;原子替换并fsync,文件锁防并发,系统会在进程退出时释放锁。保存IDs先于写入API。台账记录prepared/reviewed/publishing/complete或published_pending;唯一发表事实仍以API事务回执为准。服务器台账损坏/丢失时须从原请求UUID和API回执恢复,不能另起ID重发。 + +回读校验包括真实版本、标题、摘要、slug、栏目、封面、正文哈希及内容寻址图文件SHA;点赞参数随结果返回供确认。文章完整HTTP页面及外部链接状态按任务另外验证,不能把内部Kernel渲染说成外部网络/浏览器验收。没有真实新稿时不向正式站发布演练文章。 + +上线前仅执行`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)。 diff --git a/docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md b/docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md new file mode 100644 index 00000000..4e428a4b --- /dev/null +++ b/docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md @@ -0,0 +1,5 @@ +# 文章发布封装实施记录(2026-10-31) + +范围:经白色机的固定SSH传输、服务器进程内API调用、可恢复发布台账、发布回读、三篇/周且每日15:00检查的本机写作计划,以及新文独立展示时间。保留已有数据、图池、广告、并行改动和现行调度。不在正式站发布演练文章,不恢复test。 + +当前阶段:本地实现及隔离验证进行中,尚未部署。最终提交、备份和服务器验收结果在本任务完成时追加。 diff --git a/docs/maintenance/WEEKLY_TUTORIALS.md b/docs/maintenance/WEEKLY_TUTORIALS.md new file mode 100644 index 00000000..5c90a199 --- /dev/null +++ b/docs/maintenance/WEEKLY_TUTORIALS.md @@ -0,0 +1,26 @@ +# 综合教程周期写作 + +2026-10-31用户最终规则:每周三篇,每天北京时间15:00检查;文章展示时间随机往前0~6小时,精确到秒。原每周两篇、早晚两个窗口的规则已替换。Codex heartbeat `fna`,名称“FNA 综合教程|每周三篇”,ACTIVE,绑定本聊天,自2026-11-02当周开始。调度已修改,首轮实际写作尚未执行。 + +## 执行入口 + +项目根运行 `python3 tools/editorial/weekly.py due`。未到期、当日已发表或本周额度满则安静退出。台账唯一位置为`storage/app/private/weekly-tutorials/state.json`,Git忽略;不保存凭据。电脑/Codex需开启,不保证关机仍执行或开机立即补跑。 + +每个自然周(Asia/Shanghai,周一至周日),在周一/二、周三/四、周五/六/日三组里各随机选一天,计划时间均为15:00,三篇间隔至少24小时。首次计划持久化,同一周不反复抽取。首周旧两篇计划尚未开始,已按最新规则重建三篇;具体日期以台账为准。 + +- `due`返回`write`后,先生成稳定release UUID,用`weekly.py claim --slot SLOT --release-id UUID`锁定任务,再写稿。每次最多一篇,同一天不连发。 +- `resume`表示已有未决release;先用[发布封装](EDITORIAL_RELEASE.md)的status查服务器,再沿原bundle、ID、草稿继续,不能创建另一篇。 +- `synchronize`表示正文已经发布、后处理待完成;沿原publish请求重试同步,不重发正文。 +- 发布完成或正文已提交但后处理pending时,用`weekly.py record --slot SLOT --receipt 私有发布结果.json`回写。只接受匹配release_id和真实published回执;用服务器事务时间计数,不用展示日期或本机收到结果的时间。 +- 漏掉的本周时段在下次15:00检查时补一篇;未开始的往周欠额不累计。跨周未决操作先查原回执,真实成功记入实际发表周,避免重复或计错额度。 +- 台账丢失、损坏或锁冲突时停止,先恢复/核对,不按空周补发。仅人工明确放弃无已发表结果的任务后可标记abandoned。人工另写文章只有明确登记替代时才占周期槽位。 + +## 内容与发布边界 + +写作规范、稳定文章与关联表唯一来源为[WRITING.md](../content/WRITING.md)。每篇先查正式目录与源稿,避免重复,官方一手资料核实事实,图解与缩略图沿现有风格。自然补下载/入门/专题内链,必要时只改直接相关旧文。公开内容不出现内部核验、未实测等说明,不编造设备实测。 + +新文点赞参数与展示偏移在API草稿中冻结;真实创建时间、回执和点赞起点仍真实记录。正文源、图片、渲染稿、关联表同任务维护。 + +生产固定经本机SSH→白色机VPS-001→正式业务服务器VPS-003。使用[固定发布命令](EDITORIAL_RELEASE.md),公网编辑API保持关闭,不在白色机部署写作服务。普通文章和图片通过内容API,源码/记录另做Git留档;程序/公共资源变更才单独Git部署,保留并行改动,不扩大成全站改版,不重建test。 + +用户授权周期内正常选题、写作、相关链接和正式发布,不逐篇再次确认。资料、权限或工具不足时保留草稿并报告真实缺口。只有有意义的完成、失败或需用户操作时通知,成功给标题和真实URL,不发每日检查流水。调度变更必须通过automation_update,不能手改调度文件或加系统cron替代。 diff --git a/tests/Feature/EditorialApiTest.php b/tests/Feature/EditorialApiTest.php index 9243b452..8e6d506d 100644 --- a/tests/Feature/EditorialApiTest.php +++ b/tests/Feature/EditorialApiTest.php @@ -75,6 +75,7 @@ protected function setUp(): void }); (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(); + (require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.php'))->up(); DB::table('categories')->insert([ ['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'], ['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'], diff --git a/tests/Feature/EditorialReleaseTest.php b/tests/Feature/EditorialReleaseTest.php new file mode 100644 index 00000000..3d8a89d6 --- /dev/null +++ b/tests/Feature/EditorialReleaseTest.php @@ -0,0 +1,265 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:', + 'editorial.enabled' => false, 'editorial.base_url' => 'http://localhost', + 'scout.driver' => 'collection', 'responsecache.enabled' => false, 'cache.default' => 'array']); + DB::purge('sqlite'); + Http::preventStrayRequests(); + Schema::create('categories', function (Blueprint $t) { + $t->id(); + $t->string('sSlug'); + $t->string('sName'); + $t->integer('iParentId')->default(0); + $t->integer('iStatus')->default(1); + $t->integer('iSort')->default(0); + $t->integer('iShowChildren')->default(0); + $t->string('sHtmlClass')->nullable(); + $t->text('aOpt')->nullable(); + }); + Schema::create('articles', function (Blueprint $t) { + $t->id(); + $t->integer('iCategoryId'); + $t->integer('iUserId')->default(1); + $t->string('sSlug')->unique(); + $t->string('sTitle'); + $t->string('sTitleSub')->nullable(); + $t->text('sContent'); + $t->string('sThumb')->nullable(); + $t->integer('iStatus')->default(1); + $t->boolean('bPageThumb')->default(false); + foreach (['iViews', 'iLikeCount', 'i7like', 'i365like', 'iIsTop'] as $field) { + $t->integer($field)->default(0); + } + $t->timestamp('sLastLikedAt')->nullable(); + $t->timestamps(); + }); + Schema::create('tags', function (Blueprint $t) { + $t->id(); + $t->string('sSlug'); + $t->string('sName'); + $t->integer('iArticleCount')->default(0); + }); + Schema::create('article_tag', function (Blueprint $t) { + $t->integer('iArticleId'); + $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(); + (require database_path('migrations/2026_10_31_150000_add_article_display_timestamp.php'))->up(); + DB::table('categories')->insert([ + ['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'], + ['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'], + ]); + DB::table('tags')->insert(['id' => 1, 'sSlug' => 'clash', 'sName' => 'Clash']); + $this->releaseRoot = sys_get_temp_dir().'/fna-release-test-'.Str::uuid(); + config(['editorial.enabled' => false, 'editorial.release_directory' => $this->releaseRoot]); + } + + protected function tearDown(): void + { + foreach (glob($this->releaseRoot.'/*') ?: [] as $file) { + unlink($file); + } + @rmdir($this->releaseRoot); + parent::tearDown(); + } + + private function bundle(): array + { + return ['operation' => 'prepare', 'release_id' => (string) Str::uuid(), 'articles' => [[ + 'content_key' => 'release-example', 'expected_revision' => 0, 'slug' => 'release-example', + 'title' => '发布演练', 'summary' => '演练摘要', 'category_slug' => 'advanced', + 'body' => '

检查

正文测试。

', + ]]]; + } + + public function test_release_retry_after_lost_response_preserves_ids_and_single_publication(): void + { + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $prepared = $runner->run($bundle); + $repeat = $runner->run($bundle); + $this->assertSame($prepared['publication_request_id'], $repeat['publication_request_id']); + $this->assertSame(1, DB::table('editorial_documents')->value('revision')); + $this->assertFalse(config('editorial.enabled')); + $this->assertNull(config('editorial.process_credential')); + $review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]); + $this->assertStringContainsString('正文测试', $review['previews']['release-example']); + $publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]; + // Server committed, but the caller lost its response before saving the local receipt. + app(ProcessApi::class)->call('POST', 'drafts/'.$prepared['draft_id'].'/publish', [ + 'request_id' => $prepared['publication_request_id'], 'expected_hash' => $review['review']['hash']]); + $done = $runner->run($publish); + $this->assertSame('complete', $done['phase']); + $again = $runner->run($publish); + $this->assertSame($done['receipt'], $again['receipt']); + $this->assertSame(1, DB::table('articles')->count()); + $this->assertSame(1, DB::table('editorial_publications')->count()); + $this->assertFalse(config('editorial.enabled')); + $this->assertNull(config('editorial.process_credential')); + $this->getJson('/api/editorial/v1/catalog')->assertStatus(503); + } + + public function test_release_changed_input_and_unreviewed_publish_fail_closed(): void + { + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $runner->run($bundle); + try { + $runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id']]); + $this->fail('Unreviewed publication accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } + $bundle['articles'][0]['body'] .= '变动'; + try { + $runner->run($bundle); + $this->fail('Changed bundle accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } + $this->assertSame(0, DB::table('articles')->count()); + $this->assertFalse(config('editorial.enabled')); + } + + public function test_release_lock_and_failure_restore_process_permission(): void + { + mkdir($this->releaseRoot, 0700, true); + $lock = fopen($this->releaseRoot.'/runner.lock', 'c'); + flock($lock, LOCK_EX); + try { + app(ReleaseRunner::class)->run($this->bundle()); + $this->fail('Concurrent release accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + $bundle = $this->bundle(); + $bundle['articles'][0]['category_slug'] = 'unknown'; + try { + app(ReleaseRunner::class)->run($bundle); + $this->fail('Unknown category accepted'); + } catch (HttpException $e) { + $this->assertSame(422, $e->getStatusCode()); + } + $this->assertFalse(config('editorial.enabled')); + $this->assertNull(config('editorial.process_credential')); + $this->assertSame(0, DB::table('articles')->count()); + } + + public function test_release_expired_process_credential_is_rejected(): void + { + config(['editorial.enabled' => true, 'editorial.process_credential' => ['expires_at' => time() - 1]]); + $this->getJson('/api/editorial/v1/catalog')->assertStatus(401); + } + + public function test_release_asset_batch_and_pending_effects_recover_without_republishing(): void + { + $bundle = $this->bundle(); + $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXZkAAAAASUVORK5CYII='); + $sha = hash('sha256', $bytes); + $bundle['assets'] = [['name' => 'diagram', 'base64' => base64_encode($bytes)]]; + $bundle['articles'][0]['body'] .= '示意图'; + $runner = app(ReleaseRunner::class); + try { + $runner->run($bundle); + $review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]); + $publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]; + config(['scout.driver' => null]); + $pending = $runner->run($publish); + $this->assertSame('published_pending', $pending['phase']); + $this->assertStringContainsString('/images/editorial/'.$sha.'.png', DB::table('articles')->value('sContent')); + $this->assertSame($sha, hash_file('sha256', public_path('images/editorial/'.$sha.'.png'))); + config(['scout.driver' => 'collection']); + $done = $runner->run($publish); + $this->assertSame('complete', $done['phase']); + $this->assertTrue($done['readback'][0]['content_matches']); + $this->assertNotEmpty($done['published_at']); + $this->assertSame(1, DB::table('editorial_publications')->count()); + // An external edit cannot be reported as verified merely because revision metadata matches. + DB::table('articles')->update(['sTitle' => '并行修改']); + $check = $runner->run(['operation' => 'status', 'release_id' => $bundle['release_id']]); + $this->assertSame('published_pending', $check['phase']); + } finally { + @unlink(app(\App\Services\Editorial\ContentRenderer::class)->privateImage($sha)); + @unlink(public_path('images/editorial/'.$sha.'.png')); + } + } + + public function test_release_superseded_draft_does_not_publish(): void + { + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $runner->run($bundle); + $review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]); + $other = $bundle; + $other['release_id'] = (string) Str::uuid(); + $other['articles'][0]['expected_revision'] = 1; + $other['articles'][0]['body'] = '其他编辑的新版本'; + $runner->run($other); + try { + $runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]); + $this->fail('Superseded draft accepted'); + } catch (HttpException $e) { + $this->assertSame(409, $e->getStatusCode()); + } + $this->assertSame(0, DB::table('articles')->count()); + $this->assertFalse(config('editorial.enabled')); + } + + public function test_release_display_time_is_frozen_and_real_publish_time_is_preserved(): void + { + $this->travelTo(\Carbon\Carbon::parse('2026-11-02 15:37:42')); + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $prepared = $runner->run($bundle); + $offset = $prepared['draft']['articles'][0]['display_offset_seconds']; + $this->assertGreaterThanOrEqual(0, $offset); + $this->assertLessThanOrEqual(21600, $offset); + $this->assertSame($offset, $runner->run($bundle)['draft']['articles'][0]['display_offset_seconds']); + $review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]); + $runner->run(['operation' => 'publish', 'release_id' => $bundle['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]); + $article = DB::table('articles')->first(); + $this->assertSame('2026-11-02 15:37:42', $article->created_at); + $this->assertSame($article->created_at, $article->sYearLikeStartedAt); + $this->assertSame(now()->subSeconds($offset)->toDateTimeString(), $article->sDisplayPublishedAt); + $model = new \App\Models\Articles; + $model->forceFill(['created_at' => now(), 'sDisplayPublishedAt' => now()->subHours(4)->toDateTimeString()]); + $this->assertSame('4小时前', $model->sCreatedAtDiff); + $edit = $this->bundle(); + $edit['articles'][0]['expected_revision'] = 1; + $edit['articles'][0]['body'] = '更新正文'; + $runner->run($edit); + $review = $runner->run(['operation' => 'review', 'release_id' => $edit['release_id']]); + $runner->run(['operation' => 'publish', 'release_id' => $edit['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]); + $this->assertSame($article->sDisplayPublishedAt, DB::table('articles')->value('sDisplayPublishedAt')); + $this->travelBack(); + } +} diff --git a/tools/editorial/release.py b/tools/editorial/release.py new file mode 100755 index 00000000..3a6762f3 --- /dev/null +++ b/tools/editorial/release.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Fixed local SSH -> VPS-001 -> VPS-003 transport. No local HTTP or direct fallback.""" +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile + +OPERATIONS = ('catalog', 'source', 'prepare', 'review', 'publish', 'status') +REMOTE_COMMAND = 'cd /www/wwwroot/clashfreenode-new.com && exec runuser -u nginx -- php artisan editorial:release' + + +def ssh_command(): + options = ['-F', '/dev/null', '-T', '-o', 'StrictHostKeyChecking=yes', + '-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', + '-o', 'NumberOfPasswordPrompts=1', '-o', 'ConnectTimeout=15', + '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', + '-o', 'ControlMaster=no', '-o', 'ControlPath=none', + '-o', 'ClearAllForwardings=yes', '-o', 'PermitLocalCommand=no'] + jump = shlex.join(['ssh', *options, '-W', '45.153.247.44:22', 'root@104.168.30.40']) + return ['ssh', *options, '-o', 'ProxyCommand=' + jump, 'root@45.153.247.44', REMOTE_COMMAND] + + +def atomic_json(path, data): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix=path.name + '.', dir=path.parent) + try: + with os.fdopen(fd, 'w') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('operation', choices=OPERATIONS) + parser.add_argument('--input', type=Path, help='JSON request, without operation; stdin if omitted') + parser.add_argument('--output', type=Path, required=True, help='Private JSON result; review includes HTML, never load its online assets locally') + args = parser.parse_args() + raw = args.input.read_bytes() if args.input else sys.stdin.buffer.read(48 * 1024 * 1024 + 1) + if len(raw) > 48 * 1024 * 1024: + parser.error('Input exceeds 48 MiB') + payload = json.loads(raw or b'{}') + if not isinstance(payload, dict) or ('operation' in payload and payload['operation'] != args.operation): + parser.error('Invalid operation payload') + payload['operation'] = args.operation + helper = Path(__file__).with_name('ssh_askpass.py').resolve() + env = os.environ.copy() + env.update(SSH_ASKPASS=str(helper), SSH_ASKPASS_REQUIRE='force', DISPLAY=':0') + try: + result = subprocess.run(ssh_command(), input=json.dumps(payload, ensure_ascii=False).encode(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=660) + try: + reply = json.loads(result.stdout) + except (ValueError, UnicodeError): + reply = {'ok': False, 'error': 'SSH/server response unavailable. Query status with the original release_id; no direct fallback.', + 'transport_exit': result.returncode} + atomic_json(args.output, reply) + print(json.dumps({'ok': reply.get('ok', False), 'phase': reply.get('data', {}).get('phase'), + 'result_file': str(args.output.resolve()), 'transport_exit': result.returncode})) + return result.returncode if result.returncode in (0, 1, 2) else 1 + except subprocess.TimeoutExpired: + atomic_json(args.output, {'ok': False, 'error': 'Transport timed out; publication may have completed. Query original release_id.'}) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/editorial/ssh_askpass.py b/tools/editorial/ssh_askpass.py new file mode 100755 index 00000000..fa06e782 --- /dev/null +++ b/tools/editorial/ssh_askpass.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Only OpenSSH may consume this helper's output; never run it interactively.""" +import os +import re +import sys +from pathlib import Path + +if __name__ == '__main__': + prompt = sys.argv[1] if len(sys.argv) == 2 else '' + match = re.fullmatch(r"root@(104\.168\.30\.40|45\.153\.247\.44)'s password: ?", prompt) + if not match or os.environ.get('SSH_ASKPASS_REQUIRE') != 'force': + raise SystemExit(2) + root = Path('/Users/hellcat/Documents/GemDocs/AI版') + sys.path.insert(0, str(root / 'tools')) + import credentials + resource = {'104.168.30.40': 'VPS-001', '45.153.247.44': 'VPS-003'}[match[1]] + try: + password = credentials.get_field(root, resource, 'password') + os.write(1, password.encode()) + except Exception: + raise SystemExit(2) diff --git a/tools/editorial/test_weekly.py b/tools/editorial/test_weekly.py new file mode 100755 index 00000000..7ab175f8 --- /dev/null +++ b/tools/editorial/test_weekly.py @@ -0,0 +1,72 @@ +import unittest +from datetime import datetime, timedelta +import copy +from weekly import due, new_week, record, ZONE +from release import ssh_command, REMOTE_COMMAND + + +class WeeklyTests(unittest.TestCase): + def state(self): + return {'starts_on': '2026-11-02', 'weeks': {'2026-11-02': {'slots': [ + {'slot_id': str(i), 'status': 'planned', 'planned_at': f'2026-11-0{day}T15:00:00+08:00', + 'release_id': None, 'published_at': None} for i, day in enumerate([2, 4, 6]) + ]}}} + + def test_random_plans_have_three_distinct_days_at_fifteen(self): + for _ in range(200): + slots = new_week('2026-11-02')['slots'] + times = [datetime.fromisoformat(s['planned_at']) for s in slots] + self.assertEqual(len({t.date() for t in times}), 3) + self.assertTrue(all(t.hour == 15 and t.minute == t.second == 0 for t in times)) + self.assertTrue(all(b-a >= timedelta(hours=24) for a, b in zip(times, times[1:]))) + + def test_missed_slot_catches_up_without_two_same_day(self): + state = self.state() + now = datetime(2026, 11, 4, 15, tzinfo=ZONE) + self.assertEqual(due(state, now)['slot']['slot_id'], '0') + slot = state['weeks']['2026-11-02']['slots'][0] + slot.update(status='published', published_at=now.isoformat()) + self.assertEqual(due(state, now)['reason'], 'already_published_today') + self.assertEqual(due(state, now+timedelta(days=1))['slot']['slot_id'], '1') + + def test_retry_pending_is_not_a_new_article(self): + state = self.state() + slot = state['weeks']['2026-11-02']['slots'][0] + slot.update(release_id='original', status='publishing') + self.assertEqual(due(state, datetime(2026, 11, 9, 15, tzinfo=ZONE))['action'], 'resume') + response = {'ok': True, 'data': {'release_id': 'original', 'phase': 'published_pending', + 'published_at': '2026-11-08T16:11:10+08:00', + 'receipt': {'status': 'published', 'publication_id': 'receipt', 'articles': [{'url': '/a/example.html'}]}}} + record(state, '0', response, datetime(2026, 11, 9, 15, tzinfo=ZONE)) + self.assertEqual(slot['published_at'], '2026-11-08T16:11:10+08:00') + self.assertEqual(due(state, datetime(2026, 11, 9, 15, tzinfo=ZONE))['action'], 'synchronize') + response['ok'] = False + before = copy.deepcopy(state) + with self.assertRaises(ValueError): + record(state, '0', response, datetime(2026, 11, 9, 15, tzinfo=ZONE)) + self.assertEqual(before, state) + + def test_next_week_does_not_publish_unstarted_backlog(self): + state = self.state() + now = datetime(2026, 11, 9, 15, tzinfo=ZONE) + result = due(state, now) + if result['action'] == 'write': + self.assertTrue(result['slot']['slot_id'].startswith('2026-11-09')) + for i, slot in enumerate(state['weeks']['2026-11-02']['slots']): + slot.update(status='published', published_at=f'2026-11-0{2+i}T16:00:00+08:00') + self.assertEqual(due(state, datetime(2026, 11, 6, 15, tzinfo=ZONE))['reason'], 'weekly_quota_met') + + def test_transport_always_forces_white_host_and_fixed_remote_command(self): + command = ssh_command() + proxy = next(a for a in command if a.startswith('ProxyCommand=')) + self.assertIn('root@104.168.30.40', proxy) + self.assertIn('-W 45.153.247.44:22', proxy) + self.assertIn('StrictHostKeyChecking=yes', proxy) + self.assertIn('ControlPath=none', proxy) + self.assertEqual(command[-2], 'root@45.153.247.44') + self.assertEqual(command[-1], REMOTE_COMMAND) + self.assertEqual(command[1:3], ['-F', '/dev/null']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/editorial/weekly.py b/tools/editorial/weekly.py new file mode 100755 index 00000000..86db9f05 --- /dev/null +++ b/tools/editorial/weekly.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Weekly writing quota and missed-window recovery; never publishes by itself.""" +import argparse +from datetime import datetime, timedelta +import fcntl +import json +from pathlib import Path +import secrets +import sys +from zoneinfo import ZoneInfo +from release import atomic_json + +ZONE = ZoneInfo('Asia/Shanghai') +STATE = Path(__file__).resolve().parents[2] / 'storage/app/private/weekly-tutorials/state.json' + + +def week_start(now): + return (now - timedelta(days=now.weekday())).date().isoformat() + + +def new_week(key): + monday = datetime.fromisoformat(key).replace(tzinfo=ZONE) + while True: + times = [monday + timedelta(days=secrets.choice(days), hours=15) + for days in ([0, 1], [2, 3], [4, 5, 6])] + if all(b - a >= timedelta(hours=24) for a, b in zip(times, times[1:])): + return {'slots': [{'slot_id': f'{key}-{i + 1}', 'planned_at': t.isoformat(), 'status': 'planned', + 'release_id': None, 'published_at': None, 'urls': []} for i, t in enumerate(times)]} + + +def published_slots(state): + return [s for w in state['weeks'].values() for s in w['slots'] if s.get('published_at')] + + +def due(state, now): + if now.date().isoformat() < state['starts_on']: + return {'action': 'wait', 'reason': 'not_started'} + key = week_start(now) + state['weeks'].setdefault(key, new_week(key)) + published = published_slots(state) + for slot in published: + if slot['status'] == 'published_pending': + return {'action': 'synchronize', 'slot': slot} + if any(datetime.fromisoformat(s['published_at']).astimezone(ZONE).date() == now.date() for s in published): + return {'action': 'wait', 'reason': 'already_published_today'} + # Uncertain old work must be reconciled before creating a new release or applying the quota. + for week in sorted(state['weeks']): + for slot in state['weeks'][week]['slots']: + if slot.get('release_id') and not slot.get('published_at') and slot['status'] != 'abandoned': + return {'action': 'resume', 'slot': slot} + count = sum(week_start(datetime.fromisoformat(s['published_at']).astimezone(ZONE)) == key for s in published) + if count >= 3: + return {'action': 'wait', 'reason': 'weekly_quota_met'} + for slot in state['weeks'][key]['slots']: + if slot['status'] == 'planned' and datetime.fromisoformat(slot['planned_at']) <= now: + return {'action': 'write', 'slot': slot} + return {'action': 'wait', 'reason': 'not_due'} + + +def record(state, slot_id, response, now): + if response.get('ok') is not True: + raise ValueError('No successful server response') + data = response['data'] + receipt = data.get('receipt', {}) + if receipt.get('status') != 'published' or data.get('phase') not in ('complete', 'published_pending'): + raise ValueError('No committed publication receipt') + slot = next(s for w in state['weeks'].values() for s in w['slots'] if s['slot_id'] == slot_id) + if slot.get('release_id') != data['release_id']: + raise ValueError('Release does not match claimed slot') + # Record actual server transaction timestamp, not the time a delayed response was received. + published_at = data.get('published_at') + if not published_at: + raise ValueError('Missing server publication timestamp') + instant = datetime.fromisoformat(published_at).astimezone(ZONE) + if instant > now + timedelta(minutes=5): + raise ValueError('Publication timestamp is in the future') + slot.update(status='published' if data['phase'] == 'complete' else 'published_pending', + published_at=instant.isoformat(), publication_id=receipt['publication_id'], + urls=[a['url'] for a in receipt['articles']], receipt_phase=data['phase']) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('operation', choices=['due', 'claim', 'record']) + parser.add_argument('--slot') + parser.add_argument('--release-id') + parser.add_argument('--receipt', type=Path) + args = parser.parse_args() + now = datetime.now(ZONE) + STATE.parent.mkdir(parents=True, exist_ok=True) + with open(STATE.with_suffix('.lock'), 'a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + # Missing/corrupt state must be reconciled with server receipts, not reset to an empty quota. + state = json.loads(STATE.read_text()) + result = due(state, now) + if args.operation == 'claim': + import uuid + release_id = str(uuid.UUID(args.release_id)) + if result['action'] != 'write' or result['slot']['slot_id'] != args.slot: + raise ValueError('Requested slot is not due') + result['slot'].update(status='drafting', release_id=release_id) + elif args.operation == 'record': + record(state, args.slot, json.loads(args.receipt.read_text()), now) + result = {'action': 'recorded', 'slot_id': args.slot} + atomic_json(STATE, state) + print(json.dumps(result, ensure_ascii=False)) + + +if __name__ == '__main__': + try: + main() + except Exception as error: + print(json.dumps({'action': 'blocked', 'reason': str(error)})) + sys.exit(1)