feat(creator-studio): 前台 Gutenberg 创作工作台与受保护付费附件

前台投稿页改为沉浸式创作工作台:Gutenberg 负责区块解析与序列化,
React + TypeScript 负责工作台外壳与发布抽屉,Jio 服务端继续作为权限、
审核与付费的唯一权威。

- 新增 assets/creator-studio 独立构建(wp-scripts,锁定 WP 6.4 时代包),
  仅在投稿模板加载,构建缺失或游客访问时回退原有 TinyMCE 表单
- 自动保存(4s 防抖 + rev 冲突 409)、本地快照恢复、发布配置抽屉、
  移动端全屏单列与安全区适配
- 图片插入走现有 front-submit 上传管线,保留能力校验与待认领生命周期
- 已发布文章的改稿写入私有版本,审核通过前线上内容/链接/付费条款不变;
  原生后台「改稿审核」一次性应用或退回,并校验源文章并发指纹
- 私有附件新表 jio_private_asset + jio_dl_token.asset_id,schema 升到 3.4.0;
  文件存放 ABSPATH 之外,未配置私有存储时 fail closed
- 下载令牌绑定文章/附件/用户/IP/UA/次数,每次交付复核 Visibility 的
  订单、VIP 与配额判定;中断传输归还次数
- 付费字段改为写入前校验,非法价格不再留下半保存文章
- 新增 creator-studio 契约测试;更新受影响的 schema/下载/投稿契约

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 14:53:23 +08:00
1 parent 7a8279f8e7
commit fac4a714e0
45 files changed
+28495 -56

No files matched your search

+547
View File
@@ -0,0 +1,547 @@
<?php
/**
* 已发布文章的改稿版本(Jio Creator Studio)。
*
* 作者修改一篇已发布文章时,改动不会直接写入线上文章,而是写入一个私有的
* 投稿版本(私有 post type)。线上文章的内容、永久链接与付费条款在审核通过
* 前保持不变;审核通过后由本类一次性把版本内容应用到源文章。
*
* 新文章仍走既有 FrontSubmit::persist() 的 draft → pending/publish 流程;只有
* 源文章已发布时才进入版本流程,避免线上内容被静默改写。
*
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
final class SubmissionVersion {
public const POST_TYPE = 'jio_submission';
/** 版本记录上的 meta。 */
public const META_SOURCE = '_jio_source_post';
public const META_SOURCE_REV = '_jio_source_rev';
public const META_REV = '_jio_version_rev';
public const META_STATUS = '_jio_review_status';
public const META_NOTE = '_jio_review_note';
public const META_SNAPSHOT = '_jio_snapshot';
public const META_SUBMITTED_AT = '_jio_submitted_at';
public const STATUS_WORKING = 'working';
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('init', [$this, 'register_post_type'], 2);
add_action('admin_post_jio_submission_approve', [$this, 'admin_post_approve']);
add_action('admin_post_jio_submission_reject', [$this, 'admin_post_reject']);
add_filter('wp_robots', [$this, 'robots_noindex']);
}
public function admin_post_approve(): void {
$this->handle_review_action('approve');
}
public function admin_post_reject(): void {
$this->handle_review_action('reject');
}
private function handle_review_action(string $action): void {
if (!SubmissionVersion::can_review(get_current_user_id())) {
wp_die(esc_html__('没有审核权限。', 'jio'), '', ['response' => 403]);
}
$version_id = absint($_POST['version_id'] ?? 0);
check_admin_referer('jio_review_' . $action . '_' . $version_id);
$result = $action === 'approve'
? $this->approve($version_id, get_current_user_id(), !empty($_POST['force']))
: $this->reject($version_id, get_current_user_id(), (string) ($_POST['note'] ?? ''));
$ok = !is_wp_error($result) && !empty($result['success']);
$msg = is_wp_error($result) ? $result->get_error_message() : (string) ($result['message'] ?? '');
$redirect = add_query_arg(
['jio_review_notice' => $ok ? 'ok' : 'error', 'jio_review_message' => rawurlencode($msg)],
wp_get_referer() ?: admin_url('edit.php?post_type=' . self::POST_TYPE)
);
wp_safe_redirect($redirect);
exit;
}
public function register_post_type(): void {
register_post_type(self::POST_TYPE, [
'labels' => [
'name' => __('投稿版本', 'jio'),
'singular_name' => __('投稿版本', 'jio'),
],
'public' => false,
'publicly_queryable' => false,
'exclude_from_search' => true,
'show_ui' => false,
'show_in_rest' => false,
'rewrite' => false,
'query_var' => false,
'capability_type' => 'post',
'map_meta_cap' => true,
'supports' => ['title', 'editor', 'revisions', 'author'],
'can_export' => false,
]);
}
/**
* @param array<string, bool> $robots
* @return array<string, bool>
*/
public function robots_noindex(array $robots): array {
if (is_singular(self::POST_TYPE)) {
$robots['noindex'] = true;
$robots['nofollow'] = true;
}
return $robots;
}
/**
* 该源文章是否需要走版本流程(仅已发布文章)。
*/
public static function requires_version(int $source_id): bool {
$post = get_post($source_id);
return $post instanceof \WP_Post
&& $post->post_type === 'post'
&& $post->post_status === 'publish';
}
/**
* 作者针对某篇源文章当前可见的工作版本(工作 / 待审 / 被退回)。
*/
public static function get_working(int $source_id, int $user_id): ?\WP_Post {
if ($source_id <= 0 || $user_id <= 0) {
return null;
}
$posts = get_posts([
'post_type' => self::POST_TYPE,
'post_status' => ['draft', 'pending', 'publish', 'private'],
'author' => $user_id,
'posts_per_page' => 1,
'orderby' => 'modified',
'order' => 'DESC',
'no_found_rows' => true,
'meta_key' => self::META_SOURCE,
'meta_value' => (string) $source_id,
]);
foreach ($posts as $post) {
if (!in_array(self::status((int) $post->ID), [self::STATUS_APPROVED], true)) {
return $post;
}
}
return null;
}
public static function status(int $version_id): string {
$status = (string) get_post_meta($version_id, self::META_STATUS, true);
return in_array($status, [self::STATUS_WORKING, self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED], true)
? $status
: self::STATUS_WORKING;
}
/**
* @return array<string, mixed>
*/
public static function snapshot(int $version_id): array {
$raw = get_post_meta($version_id, self::META_SNAPSHOT, true);
return is_array($raw) ? $raw : [];
}
public static function revision(int $version_id): int {
return max(0, (int) get_post_meta($version_id, self::META_REV, true));
}
public static function source_id(int $version_id): int {
return max(0, (int) get_post_meta($version_id, self::META_SOURCE, true));
}
/**
* 源文章的版本指纹:改动后用于检测审核期间的并发修改。
*/
public static function source_revision(int $source_id): string {
$post = get_post($source_id);
if (!$post instanceof \WP_Post) {
return '';
}
return sha1(implode('|', [
$post->post_modified_gmt,
(string) strlen((string) $post->post_content),
md5((string) $post->post_content),
md5((string) $post->post_title),
]));
}
/**
* 创建或更新作者的工作版本。
*
* @param array{title?:string,content?:string,excerpt?:string,snapshot?:array<string,mixed>,base_rev?:int} $data
* @return array{success:bool,message?:string,version_id?:int,rev?:int,conflict?:bool}|\WP_Error
*/
public function save_working(int $source_id, int $user_id, array $data, bool $submit = false) {
if (!self::requires_version($source_id)) {
return new \WP_Error('jio_version_not_required', __('该文章不需要版本流程。', 'jio'), ['status' => 400]);
}
$source = get_post($source_id);
if (!$source instanceof \WP_Post || (int) $source->post_author !== $user_id) {
return new \WP_Error('jio_version_forbidden', __('没有编辑此文章的权限。', 'jio'), ['status' => 403]);
}
$version = self::get_working($source_id, $user_id);
$base_rev = max(0, (int) ($data['base_rev'] ?? 0));
if ($version instanceof \WP_Post) {
$current_rev = self::revision((int) $version->ID);
if ($base_rev !== 0 && $base_rev !== $current_rev) {
return new \WP_Error(
'jio_version_conflict',
__('该文章已在其他页面更新,请刷新后再保存。', 'jio'),
['status' => 409, 'current_rev' => $current_rev, 'version_id' => (int) $version->ID]
);
}
$version_id = (int) $version->ID;
} else {
$version_id = wp_insert_post([
'post_type' => self::POST_TYPE,
'post_status' => 'draft',
'post_author' => $user_id,
'post_title' => (string) $source->post_title,
'post_content' => (string) $source->post_content,
'post_excerpt' => (string) $source->post_excerpt,
'meta_input' => [
self::META_SOURCE => $source_id,
self::META_SOURCE_REV => self::source_revision($source_id),
self::META_REV => 0,
self::META_STATUS => self::STATUS_WORKING,
],
], true);
if (is_wp_error($version_id) || !$version_id) {
return new \WP_Error('jio_version_failed', __('无法创建改稿版本,请稍后再试。', 'jio'), ['status' => 500]);
}
}
$title = isset($data['title']) ? sanitize_text_field((string) $data['title']) : (string) $source->post_title;
$excerpt = isset($data['excerpt']) ? sanitize_textarea_field((string) $data['excerpt']) : (string) $source->post_excerpt;
$content = isset($data['content']) ? (string) $data['content'] : (string) $source->post_content;
$can_iframe = $this->user_can_cap('new_post_iframe_video', $user_id);
if ($can_iframe) {
add_filter('wp_kses_allowed_html', [FrontSubmit::get_instance(), 'allow_iframe_kses'], 99, 2);
}
$content = wp_kses_post($content);
if ($can_iframe) {
remove_filter('wp_kses_allowed_html', [FrontSubmit::get_instance(), 'allow_iframe_kses'], 99);
}
$next_rev = $base_rev > 0 ? $base_rev + 1 : ($version instanceof \WP_Post ? self::revision($version_id) + 1 : 1);
// 已经在审核队列中的改稿:作者继续编辑(含自动保存)时保持 pending,
// 否则一次自动保存会把待审内容悄悄移出审核队列。
$current_status = $version instanceof \WP_Post ? self::status($version_id) : self::STATUS_WORKING;
$stays_pending = !$submit && $current_status === self::STATUS_PENDING;
$next_status = $submit || $stays_pending ? self::STATUS_PENDING : self::STATUS_WORKING;
$update = [
'ID' => $version_id,
'post_title' => $title,
'post_content' => $content,
'post_excerpt' => $excerpt,
'post_status' => $next_status === self::STATUS_PENDING ? 'pending' : 'draft',
];
$updated = wp_update_post($update, true);
if (is_wp_error($updated)) {
return new \WP_Error('jio_version_failed', $updated->get_error_message(), ['status' => 500]);
}
if (isset($data['snapshot']) && is_array($data['snapshot'])) {
$snapshot = $this->sanitize_snapshot($data['snapshot']);
update_post_meta($version_id, self::META_SNAPSHOT, $snapshot);
// 私有资料在上传后即绑定到源文章:审核通过后可直接交付,也不会被
// 未关联附件的定时清理误删。
$this->claim_snapshot_assets($snapshot, $source_id, $user_id);
}
update_post_meta($version_id, self::META_REV, $next_rev);
update_post_meta($version_id, self::META_STATUS, $next_status);
if ($submit) {
update_post_meta($version_id, self::META_SUBMITTED_AT, current_time('mysql'));
update_post_meta($version_id, self::META_SOURCE_REV, self::source_revision($source_id));
delete_post_meta($version_id, self::META_NOTE);
}
return [
'success' => true,
'version_id' => $version_id,
'rev' => $next_rev,
'status' => $next_status,
'message' => $submit
? __('改稿已提交审核,线上文章保持不变。', 'jio')
: ($stays_pending ? __('待审改稿已更新', 'jio') : __('改稿已保存', 'jio')),
];
}
/**
* 审核通过:把版本快照一次性应用到源文章。
*
* @return array{success:bool,message?:string,post_id?:int}|\WP_Error
*/
public function approve(int $version_id, int $reviewer_id, bool $force = false) {
$version = get_post($version_id);
if (!$version instanceof \WP_Post || $version->post_type !== self::POST_TYPE) {
return new \WP_Error('jio_version_missing', __('改稿版本不存在。', 'jio'), ['status' => 404]);
}
if (!self::can_review($reviewer_id)) {
return new \WP_Error('jio_review_forbidden', __('没有审核权限。', 'jio'), ['status' => 403]);
}
$source_id = self::source_id($version_id);
$source = $source_id > 0 ? get_post($source_id) : null;
if (!$source instanceof \WP_Post || $source->post_type !== 'post') {
return new \WP_Error('jio_version_source_missing', __('原文章不存在。', 'jio'), ['status' => 404]);
}
$snapshot_rev = (string) get_post_meta($version_id, self::META_SOURCE_REV, true);
if (!$force && $snapshot_rev !== '' && $snapshot_rev !== self::source_revision($source_id)) {
return new \WP_Error(
'jio_version_source_changed',
__('原文章在审核期间已被修改,请先确认后再应用。', 'jio'),
['status' => 409]
);
}
$snapshot = self::snapshot($version_id);
$update = [
'ID' => $source_id,
'post_title' => (string) $version->post_title,
'post_content' => (string) $version->post_content,
'post_excerpt' => (string) $version->post_excerpt,
];
$result = wp_update_post($update, true);
if (is_wp_error($result)) {
return new \WP_Error('jio_version_apply_failed', $result->get_error_message(), ['status' => 500]);
}
$this->apply_snapshot($source_id, $snapshot, (int) $source->post_author);
wp_update_post([
'ID' => $version_id,
'post_status' => 'private',
]);
update_post_meta($version_id, self::META_STATUS, self::STATUS_APPROVED);
update_post_meta($version_id, self::META_REV, self::revision($version_id) + 1);
do_action('jio_submission_version_approved', $source_id, $version_id, $reviewer_id);
return [
'success' => true,
'post_id' => $source_id,
'message' => __('改稿已应用到线上文章。', 'jio'),
];
}
/**
* 作者直接应用自己的改稿(具备免审发布权限时)。
*/
public function publish_working(int $source_id, int $user_id): array {
$version = self::get_working($source_id, $user_id);
if (!$version instanceof \WP_Post) {
return ['success' => false, 'message' => __('没有待应用的改稿。', 'jio')];
}
$source = get_post($source_id);
if (!$source instanceof \WP_Post || (int) $source->post_author !== $user_id) {
return ['success' => false, 'message' => __('没有编辑此文章的权限。', 'jio')];
}
$result = wp_update_post([
'ID' => $source_id,
'post_title' => (string) $version->post_title,
'post_content' => (string) $version->post_content,
'post_excerpt' => (string) $version->post_excerpt,
], true);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message()];
}
$this->apply_snapshot($source_id, self::snapshot((int) $version->ID), $user_id);
wp_update_post(['ID' => (int) $version->ID, 'post_status' => 'private']);
update_post_meta((int) $version->ID, self::META_STATUS, self::STATUS_APPROVED);
update_post_meta((int) $version->ID, self::META_REV, self::revision((int) $version->ID) + 1);
return [
'success' => true,
'post_id' => $source_id,
'message' => __('改稿已发布。', 'jio'),
'url' => get_permalink($source_id),
];
}
/**
* 退回改稿。
*/
public function reject(int $version_id, int $reviewer_id, string $note = ''): array {
if (!self::can_review($reviewer_id)) {
return ['success' => false, 'message' => __('没有审核权限。', 'jio')];
}
$version = get_post($version_id);
if (!$version instanceof \WP_Post || $version->post_type !== self::POST_TYPE) {
return ['success' => false, 'message' => __('改稿版本不存在。', 'jio')];
}
wp_update_post(['ID' => $version_id, 'post_status' => 'draft']);
update_post_meta($version_id, self::META_STATUS, self::STATUS_REJECTED);
update_post_meta($version_id, self::META_NOTE, sanitize_textarea_field($note));
update_post_meta($version_id, self::META_REV, self::revision($version_id) + 1);
do_action('jio_submission_version_rejected', $version_id, $reviewer_id, $note);
return ['success' => true, 'message' => __('已退回改稿,线上文章未变动。', 'jio')];
}
/**
* 写入版本快照中的分类、标签、自定义筛选、封面与付费设置。
*
* @param array<string, mixed> $snapshot
*/
private function apply_snapshot(int $post_id, array $snapshot, int $author_id): void {
if ($snapshot === []) {
return;
}
$settings = FrontSubmit::get_instance()->settings();
if (isset($snapshot['categories']) && is_array($snapshot['categories'])) {
$cats = array_values(array_unique(array_filter(array_map('absint', $snapshot['categories']))));
$allowed = is_array($settings['allowed_cat_ids'] ?? null) ? array_map('intval', $settings['allowed_cat_ids']) : [];
if ($allowed !== [] && $cats !== []) {
$cats = array_values(array_intersect($cats, $allowed));
}
wp_set_post_categories($post_id, $cats);
}
if (array_key_exists('tags', $snapshot)) {
$tags = is_array($snapshot['tags']) ? $snapshot['tags'] : [];
wp_set_post_tags($post_id, array_values(array_filter(array_map('sanitize_text_field', $tags))));
}
if (isset($snapshot['custom']) && is_array($snapshot['custom'])) {
foreach ($snapshot['custom'] as $key => $value) {
$meta_key = '_jio_cf_' . sanitize_key((string) $key);
if (is_array($value)) {
$vals = array_values(array_filter(array_map('sanitize_key', $value)));
$vals ? update_post_meta($post_id, $meta_key, $vals) : delete_post_meta($post_id, $meta_key);
} else {
$val = sanitize_key((string) $value);
$val !== '' ? update_post_meta($post_id, $meta_key, $val) : delete_post_meta($post_id, $meta_key);
}
}
}
$front = FrontSubmit::get_instance();
if (isset($snapshot['featured']) && is_array($snapshot['featured'])) {
$front->apply_creator_featured($post_id, $snapshot['featured'], $author_id);
}
if (isset($snapshot['pay']) && is_array($snapshot['pay'])) {
$pay_data = [
'pay_enabled' => !empty($snapshot['pay']['enabled']) ? '1' : '',
'jio_pay' => is_array($snapshot['pay']['fields'] ?? null) ? $snapshot['pay']['fields'] : [],
];
$front->apply_pay_payload($post_id, $pay_data, $author_id, $settings);
}
}
/**
* @param array<string, mixed> $snapshot
* @return array<string, mixed>
*/
private function sanitize_snapshot(array $snapshot): array {
$clean = [];
if (isset($snapshot['categories']) && is_array($snapshot['categories'])) {
$clean['categories'] = array_values(array_unique(array_filter(array_map('absint', $snapshot['categories']))));
}
if (isset($snapshot['tags'])) {
$tags = is_array($snapshot['tags']) ? $snapshot['tags'] : preg_split('/[,,\n]+/u', (string) $snapshot['tags']);
$clean['tags'] = array_values(array_filter(array_map('sanitize_text_field', (array) $tags)));
}
if (isset($snapshot['custom']) && is_array($snapshot['custom'])) {
$custom = [];
foreach ($snapshot['custom'] as $key => $value) {
$key = sanitize_key((string) $key);
if ($key === '') {
continue;
}
$custom[$key] = is_array($value)
? array_values(array_filter(array_map('sanitize_key', $value)))
: sanitize_key((string) $value);
}
$clean['custom'] = $custom;
}
if (isset($snapshot['featured']) && is_array($snapshot['featured'])) {
$clean['featured'] = $snapshot['featured'];
}
if (isset($snapshot['pay']) && is_array($snapshot['pay'])) {
$clean['pay'] = [
'enabled' => !empty($snapshot['pay']['enabled']),
'fields' => is_array($snapshot['pay']['fields'] ?? null) ? $snapshot['pay']['fields'] : [],
];
}
return $clean;
}
/**
* 把版本快照里引用的私有资料绑定到源文章。
*
* @param array<string, mixed> $snapshot
*/
private function claim_snapshot_assets(array $snapshot, int $source_id, int $user_id): void {
$keys = [];
foreach ((array) ($snapshot['pay']['fields']['downloads'] ?? []) as $row) {
if (is_array($row)) {
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
if ($key !== '') {
$keys[] = $key;
}
}
}
PrivateAssets::claim_many($keys, $source_id, $user_id);
}
/**
* 是否具备审核权限。
*/
public static function can_review(int $user_id = 0): bool {
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
if ($user_id <= 0) {
return false;
}
return user_can($user_id, 'manage_options') || user_can($user_id, 'edit_others_posts');
}
public static function status_label(string $status): string {
switch ($status) {
case self::STATUS_PENDING:
return __('待审核', 'jio');
case self::STATUS_APPROVED:
return __('已应用', 'jio');
case self::STATUS_REJECTED:
return __('已退回', 'jio');
default:
return __('草稿', 'jio');
}
}
private function user_can_cap(string $cap, int $user_id): bool {
return FrontSubmit::get_instance()->user_can_cap($cap, $user_id);
}
}