Add resumable editorial publishing through SSH jump host

This commit is contained in:
Codex 2026-10-31 14:06:07 +08:00
parent 4fb57e494d
commit 7039990ddc
20 changed files with 1001 additions and 9 deletions

View File

@ -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 静态配图副本 ## 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 文章写作(必读) ## 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/ILLUSTRATION_DELIVERY.md)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。
周期文章按[固定发布封装](docs/maintenance/EDITORIAL_RELEASE.md)执行:本机SSH必须经白色机到正式业务服务器,使用服务器进程内API,不临时开放公网接口。每周三篇、每天15:00检查、错过窗口回补及文章随机展示时间见[周期写作](docs/maintenance/WEEKLY_TUTORIALS.md);这一流程替代周期写作中的手动开关API步骤,不扩大其他实例或全站改版权限。

View File

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\Editorial\ReleaseRunner;
use Illuminate\Console\Command;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
final class EditorialRelease extends Command
{
protected $signature = 'editorial:release';
protected $description = 'Run a resumable editorial API operation from JSON on stdin, on this application server';
public function handle(): int
{
try {
abort_unless(PHP_SAPI === 'cli', 403);
if (! app()->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;
}
}
}

View File

@ -32,6 +32,7 @@ public function source(int $article)
return response()->json(['article_id' => $a->id, 'title' => $a->sTitle, 'summary' => $a->sTitleSub, return response()->json(['article_id' => $a->id, 'title' => $a->sTitle, 'summary' => $a->sTitleSub,
'slug' => $a->sSlug, 'body_html' => $a->sContent, 'category_slug' => $category->sSlug, 'slug' => $a->sSlug, 'body_html' => $a->sContent, 'category_slug' => $category->sSlug,
'tag_ids' => DB::table('article_tag')->where('iArticleId', $a->id)->pluck('iTagId'), '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, 'cover' => $a->sThumb, 'show_cover' => (bool) $a->bPageThumb,
'like_growth' => ['seven_day_remaining' => (int) $a->i7like, 'yearly' => (int) $a->i365like, '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_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 = new Articles;
$a->forceFill(['id' => 0, 'sTitle' => $item['title'], 'sTitleSub' => $item['summary'], 'sSlug' => $item['slug'], 'sContent' => $html, $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(); $category = Categories::where('sSlug', $item['category_slug'])->with('oParent')->firstOrFail();
$a->setRelation('oCategory', $category); $a->setRelation('oCategory', $category);
$a->setRelation('oTags', collect()); $a->setRelation('oTags', collect());

View File

@ -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'); 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'); $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) abort_unless(is_array($credential)
&& ($credential['environment'] ?? null) === app()->environment() && ($credential['environment'] ?? null) === app()->environment()
&& ($credential['project'] ?? null) === 'fna-v3' && ($credential['project'] ?? null) === 'fna-v3'

View File

@ -38,7 +38,7 @@ protected function sCreatedAtDiff(): Attribute
{ {
return Attribute::get(function () { return Attribute::get(function () {
// 1. 别直接用 $this->created_at,防止它还没被实例化成 Carbon 对象 // 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(); $oNow = \Carbon\Carbon::now();
// 2. 计算小时差(强制转整数) // 2. 计算小时差(强制转整数)

View File

@ -0,0 +1,66 @@
<?php
namespace App\Services\Editorial;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
/** Calls the existing HTTP routes on the application server, without opening its public API. */
final class ProcessApi
{
private array $credential;
public function __construct()
{
abort_unless(PHP_SAPI === 'cli', 403);
$this->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);
}
}
}

View File

@ -23,6 +23,7 @@ public function draft(array $input): array
'articles.*.tag_ids' => 'sometimes|array|max:20', 'articles.*.tag_ids.*' => 'integer|min:1', 'articles.*.tag_ids' => 'sometimes|array|max:20', 'articles.*.tag_ids.*' => 'integer|min:1',
'articles.*.cover' => 'sometimes|nullable|string|max:255', 'articles.*.cover' => 'sometimes|nullable|string|max:255',
'articles.*.show_cover' => 'sometimes|boolean', 'articles.*.show_cover' => 'sometimes|boolean',
'articles.*.display_time_randomize' => 'sometimes|boolean',
'articles.*.adopt_article_id' => 'sometimes|integer|min:1', 'articles.*.adopt_article_id' => 'sometimes|integer|min:1',
'articles.*.like_settings' => 'sometimes|array:seven_day,yearly,one_year', 'articles.*.like_settings' => 'sometimes|array:seven_day,yearly,one_year',
'articles.*.like_settings.seven_day' => 'sometimes|integer|min:0|max:1000000', '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'); abort_if($doc->article_id && ! $article, 409, 'Managed article was removed');
if ($article) { if ($article) {
abort_if(array_key_exists('like_settings', $row), 422, 'Like settings are only accepted for new articles'); 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'); abort_unless($article->sSlug === $row['slug'], 409, 'Stable article URL cannot change');
$sourceCategory = DB::table('categories')->where('id', $article->iCategoryId)->value('sSlug'); $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'); 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, 'base_article_hash' => $article ? $this->articleHash($article) : null,
]; ];
if (! $article) { 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'] = [ $item['like_settings'] = [
'seven_day' => $row['like_settings']['seven_day'] ?? random_int(5, 35), 'seven_day' => $row['like_settings']['seven_day'] ?? random_int(5, 35),
'yearly' => $row['like_settings']['yearly'] ?? 50, '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, 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'], '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)]; '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'], 'i7like' => $likes['seven_day'], 'i365like' => $likes['yearly'],
'iYearLikeTarget' => $likes['one_year'], 'iYearLikeAdded' => 0, 'iYearLikeTarget' => $likes['one_year'], 'iYearLikeAdded' => 0,
'sYearLikeStartedAt' => $likes['one_year'] > 0 ? now() : null, '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); $oldTags = $this->tags($articleId);
DB::table('article_tag')->where('iArticleId', $articleId)->delete(); DB::table('article_tag')->where('iArticleId', $articleId)->delete();

View File

@ -0,0 +1,204 @@
<?php
namespace App\Services\Editorial;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
final class ReleaseRunner
{
public function __construct(private ProcessApi $api) {}
public function run(array $input): array
{
$op = $input['operation'] ?? '';
abort_unless(in_array($op, ['catalog', 'source', 'prepare', 'review', 'publish', 'status']), 422, 'Unknown operation');
if ($op === 'catalog') {
return $this->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);
}
}
}

View File

@ -0,0 +1,18 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('articles', fn (Blueprint $table) => $table->timestamp('sDisplayPublishedAt')->nullable());
}
public function down(): void
{
Schema::table('articles', fn (Blueprint $table) => $table->dropColumn('sDisplayPublishedAt'));
}
};

View File

@ -4,6 +4,8 @@ # AI 写作与文章关联索引
发布已采用本站[独立文章 API](../maintenance/EDITORIAL_API.md),不经过ATOM。AI保留项目正文源,通过API保存冻结草稿、取得签名预览、发布确认的hash并核对回执;本索引仍由写作AI同任务维护,API不会自动改写它。 发布已采用本站[独立文章 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)。不要在这些文档中再复制一份持续更新的文章关系表。 本文是写作流程、文章用途及文章关系的唯一维护入口。文章正文保存在各自源文件,数据库是页面实际读取的内容;栏目实现、渲染与备份方法见[教程维护](../maintenance/TUTORIALS.md),SEO项目状态见[SEO清单](../maintenance/SEO_PLAN.md)。不要在这些文档中再复制一份持续更新的文章关系表。
## 1 写作前先确定读者要完成的任务 ## 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 新文章的点赞参数(2026-10-29)
后续AI通过本站独立文章API**新建文章**时使用以下默认设置,由API在保存草稿时为每篇独立取值并冻结;相同请求重试、查询预览及发布不重新抽取。重写、接管、回退已有文章不重置原参数、点赞数或已完成进度,不自动给现有文章批量补额度。 后续AI通过本站独立文章API**新建文章**时使用以下默认设置,由API在保存草稿时为每篇独立取值并冻结;相同请求重试、查询预览及发布不重新抽取。重写、接管、回退已有文章不重置原参数、点赞数或已完成进度,不自动给现有文章批量补额度。

View File

@ -1,5 +1,7 @@
# FNA_V3 独立文章 API # 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`,生产地址尚未指定。 状态: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`,生产地址尚未指定。
## 职责与数据 ## 职责与数据

View File

@ -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)。

View File

@ -0,0 +1,5 @@
# 文章发布封装实施记录(2026-10-31)
范围:经白色机的固定SSH传输、服务器进程内API调用、可恢复发布台账、发布回读、三篇/周且每日15:00检查的本机写作计划,以及新文独立展示时间。保留已有数据、图池、广告、并行改动和现行调度。不在正式站发布演练文章,不恢复test。
当前阶段:本地实现及隔离验证进行中,尚未部署。最终提交、备份和服务器验收结果在本任务完成时追加。

View File

@ -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替代。

View File

@ -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_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_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([ DB::table('categories')->insert([
['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'], ['id' => 1, 'sSlug' => 'freenode', 'sName' => '资源'], ['id' => 3, 'sSlug' => 'windows', 'sName' => 'Windows'],
['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'], ['id' => 10, 'sSlug' => 'advanced', 'sName' => '进阶教程'],

View File

@ -0,0 +1,265 @@
<?php
namespace Tests\Feature;
use App\Services\Editorial\ProcessApi;
use App\Services\Editorial\ReleaseRunner;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Http;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
class EditorialReleaseTest extends \Tests\TestCase
{
private string $releaseRoot;
protected function setUp(): void
{
parent::setUp();
config(['database.default' => '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' => '<h2 id="check">检查</h2><p>正文测试。</p>',
]]];
}
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'] .= '<img src="file:diagram" alt="示意图">';
$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();
}
}

76
tools/editorial/release.py Executable file
View File

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

21
tools/editorial/ssh_askpass.py Executable file
View File

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

72
tools/editorial/test_weekly.py Executable file
View File

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

114
tools/editorial/weekly.py Executable file
View File

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