Skip to content

コンテンツスケジューリング — ライフサイクル状態を持つ時刻ベース公開

publish_at カラム、ステートマシン(draft → scheduled → published → archived)、および期日になった記事を公開するために cron ジョブが呼び出す公開トリガーエンドポイントを使って、コンテンツを将来の日時に公開するようスケジュールします。

参照実装: FT172 pubschedulelog in hideyukiMORI/NENE2-examples


ステータスライフサイクル

draft ──┬──► scheduled ──► published ──► archived
        │                               ▲
        └───────────────────────────────┘
        (また: scheduled → draft(unschedule 経由))
現在許可される遷移
draftscheduledpublishedarchived
scheduledpublisheddraftarchived
publishedarchived
archived(なし)

スキーマ

sql
CREATE TABLE articles (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    author_id    INTEGER NOT NULL,
    title        TEXT    NOT NULL,
    body         TEXT    NOT NULL,
    status       TEXT    NOT NULL DEFAULT 'draft',
    -- 'draft' | 'scheduled' | 'published' | 'archived'
    publish_at   TEXT,    -- ISO 8601; スケジュール時に設定; それ以外は NULL
    published_at TEXT,    -- 実際に公開された時に設定
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

エンドポイント

メソッドパス認証説明
POST/articlesX-User-Idドラフトを作成する
GET/articlesオプション一覧表示(?status=published はパブリック; 他のステータスは認証 + 自分の記事のみ)
GET/articles/{id}オプション記事を取得する(published = パブリック、draft/scheduled = オーナーのみ)
PUT/articles/{id}X-User-Idtitle/body を更新する(draft または scheduled のみ)
POST/articles/{id}/scheduleX-User-Idpublish_at を設定 → scheduled に移行
POST/articles/{id}/unscheduleX-User-Idスケジュールをキャンセル → draft に戻す
POST/articles/{id}/publishX-User-Id即座に公開する
POST/articles/{id}/archiveX-User-Idアーカイブする
POST/articles/publish-dueX-Admin-Key期日になった全 scheduled 記事を一括公開する

コアパターン

遷移ガード付きステータス Enum

php
enum ArticleStatus: string {
    case Draft     = 'draft';
    case Scheduled = 'scheduled';
    case Published = 'published';
    case Archived  = 'archived';

    public function canTransitionTo(self $next): bool {
        return match ($this) {
            self::Draft     => in_array($next, [self::Scheduled, self::Published, self::Archived], true),
            self::Scheduled => in_array($next, [self::Published, self::Draft, self::Archived], true),
            self::Published => $next === self::Archived,
            self::Archived  => false,
        };
    }
}

スケジュール: 未来の日時のみバリデーション

php
$ts = strtotime($publishAt);
if ($ts === false || $ts === -1) {
    throw new ArticleScheduleException('publish_at is not a valid datetime.');
}
if ($ts <= strtotime($now)) {
    throw new ArticleScheduleException('publish_at must be in the future.');
}

公開デュートリガー(cron セーフ、冪等)

php
public function publishDue(string $now): array
{
    $rows = $this->db->fetchAll(
        "SELECT id FROM articles WHERE status = ? AND publish_at <= ? ORDER BY publish_at",
        [ArticleStatus::Scheduled->value, $now],
    );

    $published = [];
    foreach ($rows as $row) {
        $id = (int) $row['id'];
        $this->db->execute(
            'UPDATE articles SET status = ?, published_at = ?, publish_at = NULL, updated_at = ? WHERE id = ?',
            [ArticleStatus::Published->value, $now, $now, $id],
        );
        $published[] = $id;
    }

    return $published;  // list<int>
}

cron ジョブから毎分呼び出します。冪等: publish_at は公開時に NULL にクリアされるため、すぐに再実行しても新たな期日の記事は見つかりません。

IDOR 防止

ドラフトとスケジュール済みの記事はオーナーのみです — 存在を漏洩しないよう 403 ではなく 404 を返します:

php
if ($article->authorId !== $actorId) {
    throw new ArticleNotFoundException($id);  // 404、403 ではない
}

管理キー — タイミングセーフな比較

php
if ($apiKey === '' || !hash_equals($expected, $apiKey)) {
    return $this->responseFactory->create(['error' => 'unauthorized'], 401);
}

シークレットの比較には !== を使わず — タイミング攻撃を防ぐために hash_equals() を使用します。


セキュリティノート

リスク軽減策
過去の publish_at インジェクションstrtotime($publishAt) <= strtotime($now) → 422
クロスユーザーの状態変更全遷移前にオーナーシップチェック; 404(403 ではない)
ボディ経由の著者 ID インジェクションauthorIdX-User-Id ヘッダーからのみ取得
ボディ経由のステータスインジェクションPUT ボディの status フィールドは無視; 遷移は専用アクションエンドポイント経由
管理キーへのタイミング攻撃!== の代わりに hash_equals() を使用
未公開記事の列挙パブリック一覧は常に status = published でフィルター; 非公開は認証 + 自分の記事のみ
公開後の編集PUT は非 draft/scheduled 記事を 422 で拒否
二重アーカイブ遷移ガードが無効な遷移に 409 を返す

Cron インテグレーション

bash
# /etc/cron.d/publish-due
* * * * * www-data curl -s -X POST https://api.example.com/articles/publish-due \
  -H "X-Admin-Key: $ADMIN_KEY"

大量ワークロードの場合は、ジョブキュー(job-queue.md 参照)に移行し、キューワーカーから publishDue() を呼び出してください。


関連ハウツー

MIT ライセンスの下で公開されています。