Add resumable editorial publishing through SSH jump host
This commit is contained in:
parent
4fb57e494d
commit
7039990ddc
32
AGENTS.md
32
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步骤,不扩大其他实例或全站改版权限。
|
||||
|
||||
40
app/Console/Commands/EditorialRelease.php
Normal file
40
app/Console/Commands/EditorialRelease.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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. 计算小时差(强制转整数)
|
||||
|
||||
66
app/Services/Editorial/ProcessApi.php
Normal file
66
app/Services/Editorial/ProcessApi.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
204
app/Services/Editorial/ReleaseRunner.php
Normal file
204
app/Services/Editorial/ReleaseRunner.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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'));
|
||||
}
|
||||
};
|
||||
@ -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在保存草稿时为每篇独立取值并冻结;相同请求重试、查询预览及发布不重新抽取。重写、接管、回退已有文章不重置原参数、点赞数或已完成进度,不自动给现有文章批量补额度。
|
||||
|
||||
@ -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`,生产地址尚未指定。
|
||||
|
||||
## 职责与数据
|
||||
|
||||
43
docs/maintenance/EDITORIAL_RELEASE.md
Normal file
43
docs/maintenance/EDITORIAL_RELEASE.md
Normal 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)。
|
||||
5
docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md
Normal file
5
docs/maintenance/EDITORIAL_RELEASE_2026-10-31.md
Normal file
@ -0,0 +1,5 @@
|
||||
# 文章发布封装实施记录(2026-10-31)
|
||||
|
||||
范围:经白色机的固定SSH传输、服务器进程内API调用、可恢复发布台账、发布回读、三篇/周且每日15:00检查的本机写作计划,以及新文独立展示时间。保留已有数据、图池、广告、并行改动和现行调度。不在正式站发布演练文章,不恢复test。
|
||||
|
||||
当前阶段:本地实现及隔离验证进行中,尚未部署。最终提交、备份和服务器验收结果在本任务完成时追加。
|
||||
26
docs/maintenance/WEEKLY_TUTORIALS.md
Normal file
26
docs/maintenance/WEEKLY_TUTORIALS.md
Normal 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替代。
|
||||
@ -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' => '进阶教程'],
|
||||
|
||||
265
tests/Feature/EditorialReleaseTest.php
Normal file
265
tests/Feature/EditorialReleaseTest.php
Normal 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
76
tools/editorial/release.py
Executable 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
21
tools/editorial/ssh_askpass.py
Executable 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
72
tools/editorial/test_weekly.py
Executable 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
114
tools/editorial/weekly.py
Executable 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)
|
||||
Loading…
Reference in New Issue
Block a user