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

+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* 原生后台:已发布文章的改稿审核。
*
* 运营在此查看待审改稿、对比原文章,并通过 admin-post 动作一次性应用或退回。
* 不提供直接编辑改稿内容的入口,避免绕过作者侧校验与版本指纹。
*
* @package Jio
*/
namespace Jio\Admin\Native;
use Jio\Core\SubmissionVersion;
defined('ABSPATH') || exit;
final class SubmissionReview {
public const SLUG = 'jio_submission_reviews';
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('admin_menu', [$this, 'register_menu']);
add_action('admin_notices', [$this, 'render_notice']);
}
public function register_menu(): void {
add_submenu_page(
'edit.php',
__('改稿审核', 'jio'),
__('改稿审核', 'jio'),
'edit_others_posts',
self::SLUG,
[$this, 'render_page']
);
}
public function render_notice(): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || strpos((string) $screen->id, self::SLUG) === false) {
return;
}
$notice = isset($_GET['jio_review_notice']) ? sanitize_key((string) wp_unslash($_GET['jio_review_notice'])) : '';
if ($notice === '') {
return;
}
$message = isset($_GET['jio_review_message']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['jio_review_message']))) : '';
$class = $notice === 'ok' ? 'notice-success' : 'notice-error';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
}
public function render_page(): void {
if (!SubmissionVersion::can_review(get_current_user_id())) {
wp_die(esc_html__('没有审核权限。', 'jio'));
}
$status = isset($_GET['review_status']) ? sanitize_key((string) wp_unslash($_GET['review_status'])) : SubmissionVersion::STATUS_PENDING;
$status = in_array($status, [SubmissionVersion::STATUS_PENDING, SubmissionVersion::STATUS_REJECTED, SubmissionVersion::STATUS_APPROVED], true)
? $status
: SubmissionVersion::STATUS_PENDING;
$versions = get_posts([
'post_type' => SubmissionVersion::POST_TYPE,
'post_status' => ['pending', 'draft', 'private'],
'posts_per_page' => 40,
'orderby' => 'modified',
'order' => 'DESC',
'meta_key' => SubmissionVersion::META_STATUS,
'meta_value' => $status,
]);
$tabs = [
SubmissionVersion::STATUS_PENDING => __('待审核', 'jio'),
SubmissionVersion::STATUS_REJECTED => __('已退回', 'jio'),
SubmissionVersion::STATUS_APPROVED => __('已应用', 'jio'),
];
?>
<div class="wrap">
<h1><?php esc_html_e('改稿审核', 'jio'); ?></h1>
<p class="description">
<?php esc_html_e('作者对已发布文章的修改以独立版本保存;通过后才会替换线上文章。', 'jio'); ?>
</p>
<ul class="subsubsub">
<?php foreach ($tabs as $key => $label) : ?>
<li>
<a href="<?php echo esc_url(add_query_arg(['page' => self::SLUG, 'review_status' => $key], admin_url('edit.php'))); ?>"
class="<?php echo $key === $status ? 'current' : ''; ?>">
<?php echo esc_html($label); ?>
</a>
<?php echo $key !== array_key_last($tabs) ? ' |' : ''; ?>
</li>
<?php endforeach; ?>
</ul>
<table class="widefat striped" style="margin-top:16px;">
<thead>
<tr>
<th><?php esc_html_e('文章', 'jio'); ?></th>
<th><?php esc_html_e('作者', 'jio'); ?></th>
<th><?php esc_html_e('提交时间', 'jio'); ?></th>
<th><?php esc_html_e('来源', 'jio'); ?></th>
<th><?php esc_html_e('操作', 'jio'); ?></th>
</tr>
</thead>
<tbody>
<?php if (!$versions) : ?>
<tr><td colspan="5"><?php esc_html_e('暂无记录。', 'jio'); ?></td></tr>
<?php endif; ?>
<?php foreach ($versions as $version) :
$version_id = (int) $version->ID;
$source_id = SubmissionVersion::source_id($version_id);
$author = get_userdata((int) $version->post_author);
$note = (string) get_post_meta($version_id, SubmissionVersion::META_NOTE, true);
?>
<tr>
<td>
<strong><?php echo esc_html($version->post_title !== '' ? $version->post_title : __('(无标题)', 'jio')); ?></strong>
<?php if ($note !== '') : ?>
<p class="description"><?php echo esc_html(sprintf(__('退回原因:%s', 'jio'), $note)); ?></p>
<?php endif; ?>
<?php if ($source_id > 0) : ?>
<p class="description">
<a href="<?php echo esc_url(get_edit_post_link($source_id)); ?>">
<?php esc_html_e('查看线上文章', 'jio'); ?>
</a>
</p>
<?php endif; ?>
</td>
<td>
<?php echo $author ? esc_html($author->display_name) : '—'; ?>
</td>
<td>
<?php
$submitted = (string) get_post_meta($version_id, SubmissionVersion::META_SUBMITTED_AT, true);
echo esc_html($submitted !== '' ? $submitted : $version->post_modified);
?>
</td>
<td>
<?php esc_html_e('作者改稿', 'jio'); ?>
</td>
<td>
<?php if ($status === SubmissionVersion::STATUS_PENDING) : ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
<input type="hidden" name="action" value="jio_submission_approve">
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $version_id); ?>">
<?php wp_nonce_field('jio_review_approve_' . $version_id); ?>
<button type="submit" class="button button-primary"><?php esc_html_e('应用改稿', 'jio'); ?></button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;margin-left:8px;">
<input type="hidden" name="action" value="jio_submission_reject">
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $version_id); ?>">
<?php wp_nonce_field('jio_review_reject_' . $version_id); ?>
<input type="text" name="note" placeholder="<?php esc_attr_e('退回原因(可选)', 'jio'); ?>" class="regular-text" style="width:180px;">
<button type="submit" class="button"><?php esc_html_e('退回', 'jio'); ?></button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
/**
* 知识付费附件的私有存储。
*
* 与 WordPress 媒体库不同,这里的文件不写入公开 uploads 目录,也不生成任何
* 公网可访问的 URL。记录只保存随机 asset_key 与内部 storage_name;真正取回文件
* 必须经过 ProtectedDownload 的授权与短期令牌。
*
* 未配置可靠的私有目录时 fail closed:上传与下载端点都直接拒绝,避免把“看似
* 受保护”的直链交给用户。配置方式(wp-config.php 或主题 filter):
*
* define('JIO_PRIVATE_STORAGE_DIR', '/var/private/jio-assets');
*
* 目录位于 ABSPATH 之外会被视为已验证;若目录在站点根目录内,必须通过
* JIO_PRIVATE_STORAGE_VERIFIED 常量或 jio_private_storage_verified filter
* 显式声明已在 Web 服务器层禁止访问(如 nginx location deny all)。
*
* @package Jio
*/
namespace Jio\Content;
defined('ABSPATH') || exit;
final class PrivateAssets {
/** 私有附件表名(见 DBMigratorPart3::table)。 */
public const ASSET_KEY_LENGTH = 48;
/** 未关联文章的附件保留时长(与前台待认领上传一致)。 */
private const ORPHAN_TTL = DAY_IN_SECONDS;
private const CRON_HOOK = 'jio_private_asset_cleanup';
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(self::CRON_HOOK, [$this, 'cleanup_orphans']);
add_action('init', [$this, 'maybe_schedule'], 21);
}
public function maybe_schedule(): void {
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK);
}
}
/**
* 是否已配置可信的私有存储。
*/
public static function is_available(): bool {
$dir = self::storage_dir();
if ($dir === '' || !is_dir($dir) || !is_writable($dir)) {
return false;
}
return self::is_verified($dir);
}
/**
* 私有文件根目录;未配置时返回空串。
*/
public static function storage_dir(): string {
$dir = '';
if (defined('JIO_PRIVATE_STORAGE_DIR')) {
$dir = (string) constant('JIO_PRIVATE_STORAGE_DIR');
}
$dir = (string) apply_filters('jio_private_storage_dir', $dir);
$dir = rtrim(trim($dir), '/\\');
if ($dir === '') {
return '';
}
return wp_normalize_path($dir);
}
/**
* 目录是否被判定为不可通过 Web 直接访问。
*/
private static function is_verified(string $dir): bool {
$root = wp_normalize_path(rtrim(ABSPATH, '/\\'));
$outside = strpos($dir . '/', $root . '/') !== 0;
$explicit = (defined('JIO_PRIVATE_STORAGE_VERIFIED') && (bool) constant('JIO_PRIVATE_STORAGE_VERIFIED'))
|| (bool) apply_filters('jio_private_storage_verified', false, $dir);
return $outside || $explicit;
}
/**
* 上传并落盘一个私有附件。
*
* @param array<string, mixed> $file $_FILES 风格数组。
* @param int $user_id 上传者。
* @param int $post_id 关联文章(0 = 待认领)。
* @return array<string, mixed>|\WP_Error
*/
public function store_upload(array $file, int $user_id, int $post_id = 0) {
if (!self::is_available()) {
return new \WP_Error(
'jio_private_storage_unavailable',
__('私有附件存储未配置,暂不能上传付费资料。', 'jio'),
['status' => 501]
);
}
if ($user_id <= 0) {
return new \WP_Error('jio_private_login_required', __('请先登录。', 'jio'), ['status' => 401]);
}
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error !== UPLOAD_ERR_OK || empty($file['tmp_name']) || !is_uploaded_file((string) $file['tmp_name'])) {
return new \WP_Error('jio_private_upload_failed', __('请选择要上传的资料文件。', 'jio'), ['status' => 400]);
}
$max_size = \Jio\Core\FrontSubmit::upload_limit_bytes('resource');
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || ($max_size > 0 && $size > $max_size)) {
return new \WP_Error(
'jio_private_upload_too_large',
sprintf(__('文件大小不能超过 %s。', 'jio'), size_format($max_size)),
['status' => 413]
);
}
$name = sanitize_file_name((string) ($file['name'] ?? ''));
$check = wp_check_filetype_and_ext((string) $file['tmp_name'], $name, get_allowed_mime_types());
$mime = (string) ($check['type'] ?? '');
if ($name === '' || $mime === '') {
return new \WP_Error('jio_private_upload_type', __('不支持的文件类型。', 'jio'), ['status' => 400]);
}
if (strpos($mime, 'video/') === 0) {
return new \WP_Error('jio_private_upload_type', __('私有附件不支持视频文件。', 'jio'), ['status' => 400]);
}
$asset_key = self::new_asset_key();
$storage_name = $asset_key . '-' . $name;
$target = self::storage_dir() . '/' . $storage_name;
if (!@move_uploaded_file((string) $file['tmp_name'], $target)) {
return new \WP_Error('jio_private_upload_save', __('资料保存失败,请稍后重试。', 'jio'), ['status' => 500]);
}
@chmod($target, 0600);
$row_id = $this->insert_row([
'asset_key' => $asset_key,
'post_id' => max(0, $post_id),
'owner_id' => $user_id,
'file_name' => $name,
'mime' => $mime,
'size_bytes' => $size,
'storage_name' => $storage_name,
'created_at' => current_time('mysql'),
]);
if ($row_id <= 0) {
@unlink($target);
return new \WP_Error('jio_private_upload_save', __('资料保存失败,请稍后重试。', 'jio'), ['status' => 500]);
}
return [
'success' => true,
'asset_key' => $asset_key,
'asset_id' => $row_id,
'name' => $name,
'mime' => $mime,
'size' => $size,
'size_label' => size_format($size),
];
}
/**
* 按 asset_key 读取一条附件记录。
*
* @return array{id:int,asset_key:string,post_id:int,owner_id:int,file_name:string,mime:string,size_bytes:int,storage_name:string,created_at:string}|null
*/
public static function get(string $asset_key): ?array {
global $wpdb;
$asset_key = self::sanitize_asset_key($asset_key);
if ($asset_key === '') {
return null;
}
$table = \Jio\Core\DBMigrator::table('private_asset');
if ($table === '') {
return null;
}
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE asset_key = %s LIMIT 1", $asset_key), ARRAY_A);
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'asset_key' => (string) ($row['asset_key'] ?? ''),
'post_id' => (int) ($row['post_id'] ?? 0),
'owner_id' => (int) ($row['owner_id'] ?? 0),
'file_name' => (string) ($row['file_name'] ?? ''),
'mime' => (string) ($row['mime'] ?? ''),
'size_bytes' => (int) ($row['size_bytes'] ?? 0),
'storage_name' => (string) ($row['storage_name'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
/**
* 把待认领附件绑定到文章(作者本人)。
*
* 已绑定到其他文章的附件不允许被再次改绑,避免把线上文章的资料挪走。
*/
public static function claim(string $asset_key, int $post_id, int $user_id): bool {
global $wpdb;
$asset = self::get($asset_key);
if ($asset === null || $post_id <= 0 || $asset['owner_id'] !== $user_id) {
return false;
}
if ($asset['post_id'] === $post_id) {
return true;
}
if ($asset['post_id'] !== 0) {
return false;
}
$table = \Jio\Core\DBMigrator::table('private_asset');
$updated = $wpdb->update($table, ['post_id' => $post_id], ['id' => $asset['id'], 'post_id' => 0], ['%d'], ['%d', '%d']);
return (bool) $updated;
}
/**
* 发表后把文章附件表里的 post_id 指向正式文章。
*
* @param list<string> $keys
*/
public static function claim_many(array $keys, int $post_id, int $user_id): void {
foreach (array_unique($keys) as $key) {
if (is_string($key) && $key !== '') {
self::claim($key, $post_id, $user_id);
}
}
}
/**
* 绝对文件路径;越界或记录损坏时返回空串。
*/
public static function file_path(array $asset): string {
$dir = self::storage_dir();
$name = (string) ($asset['storage_name'] ?? '');
if ($dir === '' || $name === '' || strpos($name, '/') !== false || strpos($name, '\\') !== false) {
return '';
}
$path = $dir . '/' . $name;
return is_file($path) ? $path : '';
}
public static function sanitize_asset_key(string $key): string {
$key = strtolower(trim($key));
return preg_match('/^[a-z0-9]{32,48}$/', $key) ? $key : '';
}
private static function new_asset_key(): string {
return bin2hex(random_bytes(24));
}
/**
* @param array<string, mixed> $data
*/
private function insert_row(array $data): int {
global $wpdb;
$table = \Jio\Core\DBMigrator::table('private_asset');
if ($table === '') {
return 0;
}
$ok = $wpdb->insert($table, $data, ['%s', '%d', '%d', '%s', '%s', '%d', '%s', '%s']);
return $ok ? (int) $wpdb->insert_id : 0;
}
/**
* 清理长时间未关联文章的私有附件(文件 + 记录)。
*/
public function cleanup_orphans(): void {
global $wpdb;
$table = \Jio\Core\DBMigrator::table('private_asset');
if ($table === '') {
return;
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = 0 AND created_at < %s ORDER BY id ASC LIMIT 100",
gmdate('Y-m-d H:i:s', time() - self::ORPHAN_TTL)
), ARRAY_A);
foreach (is_array($rows) ? $rows : [] as $row) {
$path = self::file_path([
'storage_name' => (string) ($row['storage_name'] ?? ''),
]);
if ($path !== '') {
@unlink($path);
}
$wpdb->delete($table, ['id' => (int) ($row['id'] ?? 0)], ['%d']);
}
}
}
+367
View File
@@ -0,0 +1,367 @@
<?php
/**
* 知识付费附件的受控下载。
*
* 私有附件不生成公网 URL。已获授权的用户先请求一个短期一次性令牌,再由本类
* 读取文件并流式输出。令牌绑定文章、附件、用户(访客绑定 IP/UA 指纹)、到期
* 时间与可用次数;到期或次数用尽返回 410,需要重新签发。
*
* 授权判定复用既有单一真相 \Jio\Content\Visibility::can_download_resource()
* (订单 / VIP / 会员等级 / 免费配额),本类不另立付费规则。
*
* @package Jio
*/
namespace Jio\Content;
defined('ABSPATH') || exit;
final class ProtectedDownload {
public const REST_NAMESPACE = 'jio/v1';
/** 默认令牌有效期(秒)。 */
public const DEFAULT_TTL = 900;
/** 默认最多下载次数。 */
public const DEFAULT_MAX_USES = 3;
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('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
register_rest_route(self::REST_NAMESPACE, '/protected-download/token', [
'methods' => 'POST',
'callback' => [$this, 'rest_issue_token'],
'permission_callback' => static function (): bool {
return true;
},
'args' => [
'post_id' => ['required' => true, 'sanitize_callback' => 'absint'],
'asset_key' => ['required' => true, 'sanitize_callback' => [PrivateAssets::class, 'sanitize_asset_key']],
],
]);
register_rest_route(self::REST_NAMESPACE, '/protected-download/file/(?P<token>[a-f0-9]{48})', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_stream'],
'permission_callback' => static function (): bool {
return true;
},
'args' => [
'token' => ['sanitize_callback' => 'sanitize_text_field'],
],
]);
}
/**
* 为当前访问者签发下载令牌。
*/
public function rest_issue_token(\WP_REST_Request $request) {
if (!PrivateAssets::is_available()) {
return new \WP_Error(
'jio_private_storage_unavailable',
__('私有附件存储未配置,暂时无法下载。', 'jio'),
['status' => 501]
);
}
$post_id = absint($request->get_param('post_id'));
$asset_key = PrivateAssets::sanitize_asset_key((string) $request->get_param('asset_key'));
$asset = PrivateAssets::get($asset_key);
if ($post_id <= 0 || $asset === null) {
return new \WP_Error('jio_private_asset_missing', __('附件不存在。', 'jio'), ['status' => 404]);
}
if (PrivateAssets::file_path($asset) === '') {
return new \WP_Error('jio_private_asset_missing', __('附件文件已离线。', 'jio'), ['status' => 404]);
}
if (!self::asset_belongs_to_post($asset_key, $post_id)) {
return new \WP_Error('jio_private_asset_mismatch', __('该附件不属于此内容。', 'jio'), ['status' => 403]);
}
$gate = $this->authorize($post_id);
if (is_wp_error($gate)) {
return $gate;
}
$issued = $this->issue($asset, $post_id);
if (is_wp_error($issued)) {
return $issued;
}
return new \WP_REST_Response([
'success' => true,
'url' => $issued['url'],
'expires_at' => $issued['expires_at'],
'max_uses' => $issued['max_uses'],
], 200);
}
/**
* 归还令牌对应的下载地址。
*
* @param array<string, mixed> $asset
*/
public static function url(array $asset, int $post_id): string {
return add_query_arg([
'post_id' => $post_id,
'asset_key' => (string) ($asset['asset_key'] ?? ''),
], rest_url(self::REST_NAMESPACE . '/protected-download/token'));
}
/**
* 校验下载权限(订单 / VIP / 免费配额),复用既有可见性服务。
*
* @return true|\WP_Error
*/
private function authorize(int $post_id) {
$post = get_post($post_id);
if (!$post instanceof \WP_Post || $post->post_status !== 'publish') {
return new \WP_Error('jio_private_not_published', __('内容尚未发布。', 'jio'), ['status' => 404]);
}
$uid = get_current_user_id();
if ($uid <= 0 && !function_exists('jio_allow_guest_content_pay')) {
return new \WP_Error('jio_need_login', __('请先登录后再下载。', 'jio'), [
'status' => 401,
'login_url' => wp_login_url(get_permalink($post)),
]);
}
if (!function_exists('jio_can_download')) {
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 503]);
}
$gate = jio_can_download($post_id);
if (is_wp_error($gate)) {
$data = (array) $gate->get_error_data();
$status = (int) ($data['status'] ?? 403);
if ($status <= 0 || $status > 499) {
$status = 403;
}
return new \WP_Error($gate->get_error_code(), $gate->get_error_message(), ['status' => $status] + $data);
}
return true;
}
private function asset_belongs_to_post(string $asset_key, int $post_id): bool {
$asset = PrivateAssets::get($asset_key);
if ($asset === null) {
return false;
}
if ($asset['post_id'] === $post_id) {
return true;
}
// 待认领附件(post_id = 0)仅作者本人可在编辑/预览时使用。
return $asset['post_id'] === 0
&& $asset['owner_id'] > 0
&& $asset['owner_id'] === get_current_user_id();
}
/**
* @param array<string, mixed> $asset
* @return array{url:string,expires_at:int,max_uses:int}|\WP_Error
*/
private function issue(array $asset, int $post_id) {
global $wpdb;
$table = \Jio\Core\DBMigrator::table('dl_token');
if ($table === '') {
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 503]);
}
$ttl = max(60, (int) apply_filters('jio_protected_download_ttl', self::DEFAULT_TTL, $post_id));
$max_uses = max(1, (int) apply_filters('jio_protected_download_max_uses', self::DEFAULT_MAX_USES, $post_id));
$uid = get_current_user_id();
$token = bin2hex(random_bytes(24));
$expires_at = time() + $ttl;
$ok = $wpdb->insert($table, [
'token' => $token,
'user_id' => max(0, $uid),
'post_id' => $post_id,
'asset_id' => (int) ($asset['id'] ?? 0),
'source_index' => (int) ($asset['source_index'] ?? 0),
'cost' => 0,
'ip_bind' => $uid > 0 ? null : self::request_fingerprint(),
'ua_hash' => $uid > 0 ? null : sha1(self::user_agent()),
'used_count' => 0,
'max_uses' => $max_uses,
'expires_at' => gmdate('Y-m-d H:i:s', $expires_at),
'created_at' => gmdate('Y-m-d H:i:s'),
], ['%s', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s']);
if (!$ok) {
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 500]);
}
return [
'url' => rest_url(self::REST_NAMESPACE . '/protected-download/file/' . $token),
'expires_at' => $expires_at,
'max_uses' => $max_uses,
];
}
/**
* 校验令牌并流式输出文件。
*/
public function rest_stream(\WP_REST_Request $request) {
$token = strtolower((string) $request->get_param('token'));
if (!preg_match('/^[a-f0-9]{48}$/', $token)) {
return new \WP_Error('jio_token_invalid', __('下载链接无效。', 'jio'), ['status' => 400]);
}
global $wpdb;
$table = \Jio\Core\DBMigrator::table('dl_token');
if ($table === '') {
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 503]);
}
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE token = %s LIMIT 1", $token), ARRAY_A);
if (!is_array($row)) {
return new \WP_Error('jio_token_invalid', __('下载链接无效。', 'jio'), ['status' => 404]);
}
$now = time();
if (strtotime((string) ($row['expires_at'] ?? '') . ' UTC') < $now) {
return new \WP_Error('jio_token_expired', __('下载链接已过期,请重新获取。', 'jio'), ['status' => 410]);
}
if ((int) ($row['used_count'] ?? 0) >= (int) ($row['max_uses'] ?? 1)) {
return new \WP_Error('jio_token_exhausted', __('下载次数已用完,请重新获取。', 'jio'), ['status' => 410]);
}
$uid = get_current_user_id();
$bound_uid = (int) ($row['user_id'] ?? 0);
if ($bound_uid > 0 && $uid !== $bound_uid) {
return new \WP_Error('jio_token_forbidden', __('该下载链接不属于当前账号。', 'jio'), ['status' => 403]);
}
if ($bound_uid <= 0) {
$fp = (string) ($row['ip_bind'] ?? '');
$ua = (string) ($row['ua_hash'] ?? '');
if ($fp !== '' && !hash_equals($fp, self::request_fingerprint())) {
return new \WP_Error('jio_token_forbidden', __('下载链接与当前网络不匹配。', 'jio'), ['status' => 403]);
}
if ($ua !== '' && !hash_equals($ua, sha1(self::user_agent()))) {
return new \WP_Error('jio_token_forbidden', __('下载链接与当前设备不匹配。', 'jio'), ['status' => 403]);
}
}
$post_id = (int) ($row['post_id'] ?? 0);
// 到期前权限可能变化(退款 / VIP 到期 / 配额耗尽),每次下载都重新判定。
$gate = $this->authorize($post_id);
if (is_wp_error($gate)) {
return $gate;
}
$asset = PrivateAssets::get((string) ($this->asset_key_for_id((int) ($row['asset_id'] ?? 0))));
if ($asset === null) {
return new \WP_Error('jio_private_asset_missing', __('附件不存在。', 'jio'), ['status' => 404]);
}
$path = PrivateAssets::file_path($asset);
if ($path === '') {
return new \WP_Error('jio_private_asset_missing', __('附件文件已离线。', 'jio'), ['status' => 404]);
}
// 先原子预占一次下载额度,传输中断/失败时再归还,避免并发绕过次数限制。
$reserved = $wpdb->query($wpdb->prepare(
"UPDATE {$table} SET used_count = used_count + 1 WHERE token = %s AND used_count < max_uses AND expires_at > %s",
$token,
gmdate('Y-m-d H:i:s', $now)
));
if (!$reserved) {
return new \WP_Error('jio_token_exhausted', __('下载次数已用完,请重新获取。', 'jio'), ['status' => 410]);
}
$delivered = $this->stream($path, $asset);
if (!$delivered) {
$wpdb->query($wpdb->prepare(
"UPDATE {$table} SET used_count = GREATEST(used_count - 1, 0) WHERE token = %s",
$token
));
return new \WP_Error('jio_download_interrupted', __('下载中断,请重试。', 'jio'), ['status' => 503]);
}
if (function_exists('jio_bump_content_download_count')) {
jio_bump_content_download_count($post_id);
}
// 文件已直接输出到响应流。
exit;
}
private function asset_key_for_id(int $asset_id): string {
global $wpdb;
if ($asset_id <= 0) {
return '';
}
$table = \Jio\Core\DBMigrator::table('private_asset');
if ($table === '') {
return '';
}
$key = $wpdb->get_var($wpdb->prepare("SELECT asset_key FROM {$table} WHERE id = %d LIMIT 1", $asset_id));
return is_string($key) ? $key : '';
}
/**
* 输出文件;仅在完整写入后返回 true。
*
* @param array<string, mixed> $asset
*/
private function stream(string $path, array $asset): bool {
$size = (int) @filesize($path);
if ($size <= 0) {
return false;
}
@set_time_limit(0);
ignore_user_abort(true);
while (ob_get_level() > 0) {
@ob_end_clean();
}
$name = (string) ($asset['file_name'] ?? 'download');
$mime = (string) ($asset['mime'] ?? 'application/octet-stream');
nocache_headers();
header('Content-Type: ' . ($mime !== '' ? $mime : 'application/octet-stream'));
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
header('Content-Length: ' . $size);
header('X-Content-Type-Options: nosniff');
header('X-Robots-Tag: noindex, nofollow');
$handle = @fopen($path, 'rb');
if (!$handle) {
return false;
}
$sent = 0;
while (!feof($handle)) {
if (connection_aborted()) {
fclose($handle);
return false;
}
$chunk = fread($handle, 8192);
if ($chunk === false || $chunk === '') {
break;
}
echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw file bytes
flush();
$sent += strlen($chunk);
}
fclose($handle);
return $sent === $size;
}
private static function request_fingerprint(): string {
$ip = function_exists('jio_get_client_ip') ? (string) jio_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
return $ip !== '' ? hash('sha256', $ip) : '';
}
private static function user_agent(): string {
return substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
}
}
+670
View File
@@ -0,0 +1,670 @@
<?php
/**
* Jio Creator Studio 的 REST 接口。
*
* 只做前台创作体验需要的编排:读取编辑上下文、保存/自动保存、提交审核、
* 预览链接、私有资料上传。所有文章写入仍复用 FrontSubmit 的权限、字段清洗
* 与状态机;已发布文章的改动走 SubmissionVersion,不直接覆盖线上内容。
*
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
final class CreatorRest {
public const NS = 'jio/v1';
public function register_routes(): void {
register_rest_route(self::NS, '/creator/bootstrap', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_bootstrap'],
'permission_callback' => [$this, 'can_use_studio'],
'args' => [
'post_id' => ['default' => 0, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route(self::NS, '/creator/save', [
'methods' => 'POST',
'callback' => [$this, 'rest_save'],
'permission_callback' => [$this, 'can_use_studio'],
]);
register_rest_route(self::NS, '/creator/submit', [
'methods' => 'POST',
'callback' => [$this, 'rest_submit'],
'permission_callback' => [$this, 'can_use_studio'],
]);
register_rest_route(self::NS, '/creator/preview', [
'methods' => 'POST',
'callback' => [$this, 'rest_preview'],
'permission_callback' => [$this, 'can_use_studio'],
]);
register_rest_route(self::NS, '/creator/private-upload', [
'methods' => 'POST',
'callback' => [$this, 'rest_private_upload'],
'permission_callback' => [$this, 'can_upload_private'],
]);
register_rest_route(self::NS, '/creator/private-uploads', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_list_private_uploads'],
'permission_callback' => [$this, 'can_upload_private'],
]);
}
public function can_use_studio($request = null): bool {
$user_id = get_current_user_id();
if ($user_id <= 0) {
return false;
}
$front = FrontSubmit::get_instance();
if (empty($front->settings()['enabled'])) {
return false;
}
$post_id = 0;
if ($request instanceof \WP_REST_Request) {
$post_id = absint($request->get_param('post_id'));
} elseif (isset($_REQUEST['post_id'])) {
$post_id = absint(wp_unslash($_REQUEST['post_id']));
}
if ($post_id > 0) {
return $front->can_edit_post($post_id, $user_id);
}
return $front->can_submit($user_id);
}
public function can_upload_private(): bool {
$user_id = get_current_user_id();
if ($user_id <= 0) {
return false;
}
return FrontSubmit::get_instance()->user_can_cap('new_post_pay_download', $user_id);
}
/**
* 编辑上下文:作者打开创作页时的一切初始数据。
*/
public function rest_bootstrap(\WP_REST_Request $request): \WP_REST_Response {
$post_id = absint($request->get_param('post_id'));
$user_id = get_current_user_id();
return new \WP_REST_Response($this->bootstrap_payload($post_id, $user_id), 200);
}
/**
* @return array<string, mixed>
*/
public function bootstrap_payload(int $post_id, int $user_id): array {
$front = FrontSubmit::get_instance();
$settings = $front->settings();
$source = $post_id > 0 ? get_post($post_id) : null;
$version = null;
$version_id = 0;
if ($source instanceof \WP_Post && SubmissionVersion::requires_version($post_id)) {
$version = SubmissionVersion::get_working($post_id, $user_id);
$version_id = $version instanceof \WP_Post ? (int) $version->ID : 0;
}
$editable = $version instanceof \WP_Post
? $version
: ($source instanceof \WP_Post ? $source : null);
$pay_meta = $editable instanceof \WP_Post && function_exists('jio_get_pay_meta')
? jio_get_pay_meta($editable->ID)
: DomainModel::default_pay_meta();
$snapshot = $version_id > 0 ? SubmissionVersion::snapshot($version_id) : [];
$post_meta = $source instanceof \WP_Post
? (array) get_post_meta($source->ID, DomainModel::POST_META_KEY, true)
: [];
$terms = $this->term_payload($settings, $source, $snapshot);
return [
'sourceId' => $post_id,
'versionId' => $version_id,
'versionRev' => $version_id > 0 ? SubmissionVersion::revision($version_id) : 0,
'status' => $version_id > 0 ? SubmissionVersion::status($version_id) : ($source->post_status ?? ''),
'reviewNote' => $version_id > 0 ? (string) get_post_meta($version_id, SubmissionVersion::META_NOTE, true) : '',
'title' => $editable instanceof \WP_Post ? $editable->post_title : '',
'excerpt' => $editable instanceof \WP_Post ? $editable->post_excerpt : '',
'content' => $editable instanceof \WP_Post ? $editable->post_content : '',
'permalink' => $source instanceof \WP_Post ? get_permalink($source) : '',
'isPublished'=> $source instanceof \WP_Post && $source->post_status === 'publish',
'categories' => $terms['categories'],
'tags' => $terms['tags'],
'custom' => $terms['custom'],
'customDefs' => $terms['customDefs'],
'featured' => $this->creator_featured_payload($source, $post_meta),
'pay' => $this->pay_payload((array) $pay_meta, $snapshot, $settings),
'capabilities' => $this->capability_payload($front, $user_id, $post_id),
'limits' => [
'titleMin' => (int) $settings['title_min'],
'titleMax' => (int) $settings['title_max'],
'titlePlaceholder' => (string) $settings['title_placeholder'],
'contentPlaceholder' => (string) $settings['content_placeholder'],
],
'privateUploads' => $this->private_upload_rows($user_id, $post_id),
'privateStorage' => PrivateAssets::is_available(),
'media' => [
'listUrl' => esc_url_raw(rest_url(self::NS . '/front-submit/media')),
'uploadUrl' => [
'image' => esc_url_raw(rest_url(self::NS . '/front-submit/upload/image')),
],
'maxBytes' => FrontSubmit::upload_limit_bytes('image'),
'batch' => class_exists('\Jio\Attachment\UploadLimits') ? \Jio\Attachment\UploadLimits::image_batch_limit() : 6,
],
'rest' => [
'root' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest'),
],
'urls' => [
'submit' => function_exists('jio_get_new_post_url') ? jio_get_new_post_url() : '',
'newPost' => function_exists('jio_get_new_post_url') ? jio_get_new_post_url() : '',
'center' => function_exists('jio_get_user_center_url') ? jio_get_user_center_url('posts') : '',
],
];
}
/**
* 保存或自动保存。已发布文章写入版本记录;新文章走既有 persist()。
*/
public function rest_save(\WP_REST_Request $request) {
$user_id = get_current_user_id();
$post_id = absint($request->get_param('post_id'));
$is_auto = (bool) $request->get_param('autosave');
$payload = [
'title' => (string) $request->get_param('title'),
'content' => (string) $request->get_param('content'),
'excerpt' => (string) $request->get_param('excerpt'),
'categories' => (array) $request->get_param('categories'),
'tags' => (array) $request->get_param('tags'),
'custom' => (array) $request->get_param('custom'),
'featured' => (array) $request->get_param('featured'),
'pay' => (array) $request->get_param('pay'),
];
if ($post_id > 0 && SubmissionVersion::requires_version($post_id)) {
$result = SubmissionVersion::get_instance()->save_working($post_id, $user_id, [
'title' => $payload['title'] !== '' ? $payload['title'] : null,
'content' => $payload['content'],
'excerpt' => $payload['excerpt'],
'base_rev' => absint($request->get_param('rev')),
'snapshot' => $this->snapshot_from_payload($payload),
], false);
if (is_wp_error($result)) {
return $result;
}
$result['autosave'] = $is_auto;
return new \WP_REST_Response($result, 200);
}
$front = FrontSubmit::get_instance();
$data = [
'posts_id' => $post_id,
'post_title' => $payload['title'],
'post_content' => $this->block_content($payload['content']),
'excerpt' => $payload['excerpt'],
'category' => $payload['categories'],
'tags' => implode(',', array_map('strval', $payload['tags'])),
'featured_data'=> $payload['featured'],
];
foreach ($payload['custom'] as $key => $value) {
$data['cf_' . sanitize_key((string) $key)] = $value;
}
if ($payload['pay'] !== []) {
$data['pay_enabled'] = !empty($payload['pay']['enabled']) ? '1' : '';
$data['jio_pay'] = (array) ($payload['pay']['fields'] ?? []);
}
$result = $front->persist($data, 'posts_draft');
if (empty($result['success'])) {
return new \WP_Error('jio_creator_save_failed', (string) ($result['message'] ?? __('保存失败', 'jio')), ['status' => 400]);
}
$result['autosave'] = $is_auto;
$result['versionRev'] = max(1, (int) ($result['rev'] ?? 1));
return new \WP_REST_Response($result, 200);
}
/**
* 提交审核 / 直接发布。
*/
public function rest_submit(\WP_REST_Request $request) {
$user_id = get_current_user_id();
$post_id = absint($request->get_param('post_id'));
$front = FrontSubmit::get_instance();
// 与 admin-ajax 投稿入口保持同一道人机验证闸门(启用 front_submit 场景时)。
if (function_exists('jio_require_captcha')) {
$captcha = jio_require_captcha('front_submit', [
'jio_captcha_proof' => sanitize_text_field((string) $request->get_param('captcha_proof')),
]);
if ($captcha !== true) {
$message = is_wp_error($captcha) ? $captcha->get_error_message() : __('人机验证失败,请重试', 'jio');
return new \WP_Error('jio_captcha_failed', $message, ['status' => 400]);
}
}
$payload = [
'title' => (string) $request->get_param('title'),
'content' => (string) $request->get_param('content'),
'excerpt' => (string) $request->get_param('excerpt'),
'categories' => (array) $request->get_param('categories'),
'tags' => (array) $request->get_param('tags'),
'custom' => (array) $request->get_param('custom'),
'featured' => (array) $request->get_param('featured'),
'pay' => (array) $request->get_param('pay'),
];
if ($post_id > 0 && SubmissionVersion::requires_version($post_id)) {
$invalid = $this->validate_submission($payload, $user_id);
if ($invalid !== '') {
return new \WP_Error('jio_creator_invalid', $invalid, ['status' => 400]);
}
$version = SubmissionVersion::get_instance()->save_working($post_id, $user_id, [
'title' => $payload['title'],
'content' => $payload['content'],
'excerpt' => $payload['excerpt'],
'base_rev' => absint($request->get_param('rev')),
'snapshot' => $this->snapshot_from_payload($payload),
], true);
if (is_wp_error($version)) {
return $version;
}
// 具备免审发布权限且明确选择立即发布时,才直接应用到线上文章。
if ($request->get_param('publish_now') && $front->user_can_cap('new_post_audit_no', $user_id)) {
$applied = SubmissionVersion::get_instance()->publish_working($post_id, $user_id);
if (empty($applied['success'])) {
return new \WP_Error('jio_creator_publish_failed', (string) ($applied['message'] ?? __('发布失败', 'jio')), ['status' => 400]);
}
return new \WP_REST_Response($applied, 200);
}
return new \WP_REST_Response($version, 200);
}
$data = [
'posts_id' => $post_id,
'post_title' => $payload['title'],
'post_content' => $this->block_content($payload['content']),
'excerpt' => $payload['excerpt'],
'category' => $payload['categories'],
'tags' => implode(',', array_map('strval', $payload['tags'])),
'featured_data' => $payload['featured'],
];
foreach ($payload['custom'] as $key => $value) {
$data['cf_' . sanitize_key((string) $key)] = $value;
}
if ($payload['pay'] !== []) {
$data['pay_enabled'] = !empty($payload['pay']['enabled']) ? '1' : '';
$data['jio_pay'] = (array) ($payload['pay']['fields'] ?? []);
}
$result = $front->persist($data, 'posts_save');
if (empty($result['success'])) {
return new \WP_Error('jio_creator_submit_failed', (string) ($result['message'] ?? __('提交失败', 'jio')), ['status' => 400]);
}
$this->claim_private_assets($payload, (int) ($result['post_id'] ?? 0), $user_id);
return new \WP_REST_Response($result, 200);
}
/**
* 预览链接:已发布文章的改稿版本使用一次性签名地址,不公开版本内容。
*/
public function rest_preview(\WP_REST_Request $request) {
$user_id = get_current_user_id();
$post_id = absint($request->get_param('post_id'));
$version_id = absint($request->get_param('version_id'));
if ($version_id > 0) {
$version = get_post($version_id);
if (!$version instanceof \WP_Post || $version->post_type !== SubmissionVersion::POST_TYPE) {
return new \WP_Error('jio_review_preview_missing', __('改稿不存在。', 'jio'), ['status' => 404]);
}
if ((int) $version->post_author !== $user_id && !SubmissionVersion::can_review($user_id)) {
return new \WP_Error('jio_review_preview_forbidden', __('没有预览权限。', 'jio'), ['status' => 403]);
}
$token = PreviewToken::issue($version_id, $user_id);
$source_id = SubmissionVersion::source_id($version_id);
$source = $source_id > 0 ? get_post($source_id) : null;
$base = $source instanceof \WP_Post ? (string) get_permalink($source) : home_url('/');
return new \WP_REST_Response([
'url' => add_query_arg('jio_studio_preview', $token, $base),
], 200);
}
if ($post_id <= 0) {
return new \WP_Error('jio_review_preview_missing', __('尚未保存文章。', 'jio'), ['status' => 400]);
}
$post = get_post($post_id);
if (!$post instanceof \WP_Post) {
return new \WP_Error('jio_review_preview_missing', __('文章不存在。', 'jio'), ['status' => 404]);
}
return new \WP_REST_Response([
'url' => (string) get_preview_post_link($post),
], 200);
}
/**
* 私有资料上传(付费附件)。
*/
public function rest_private_upload(\WP_REST_Request $request) {
$file = $request->get_file_params()['file'] ?? null;
if (!is_array($file)) {
return new \WP_Error('jio_private_upload_failed', __('请选择要上传的资料文件。', 'jio'), ['status' => 400]);
}
$post_id = absint($request->get_param('post_id'));
$result = PrivateAssets::get_instance()->store_upload($file, get_current_user_id(), $post_id);
if (is_wp_error($result)) {
return $result;
}
return new \WP_REST_Response($result, 201);
}
public function rest_list_private_uploads(\WP_REST_Request $request): \WP_REST_Response {
return new \WP_REST_Response([
'items' => $this->private_upload_rows(get_current_user_id(), absint($request->get_param('post_id'))),
], 200);
}
/**
* @return list<array<string,mixed>>
*/
private function private_upload_rows(int $user_id, int $post_id): array {
global $wpdb;
$table = DBMigrator::table('private_asset');
if ($table === '' || $user_id <= 0) {
return [];
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT asset_key, post_id, file_name, mime, size_bytes, created_at FROM {$table}
WHERE owner_id = %d AND (post_id = %d OR post_id = 0) ORDER BY id DESC LIMIT 50",
$user_id,
$post_id
), ARRAY_A);
$items = [];
foreach (is_array($rows) ? $rows : [] as $row) {
$items[] = [
'assetKey' => (string) $row['asset_key'],
'postId' => (int) $row['post_id'],
'fileName' => (string) $row['file_name'],
'mime' => (string) $row['mime'],
'sizeBytes' => (int) $row['size_bytes'],
'sizeLabel' => size_format((int) $row['size_bytes']),
];
}
return $items;
}
/**
* 提交前的必填校验,与 FrontSubmitSave 保持同一口径。
*
* @param array<string, mixed> $payload
*/
private function validate_submission(array $payload, int $user_id): string {
$settings = FrontSubmit::get_instance()->settings();
$title = trim(wp_strip_all_tags((string) ($payload['title'] ?? '')));
$content = trim(wp_strip_all_tags((string) ($payload['content'] ?? '')));
if ($title === '') {
return __('请填写文章标题', 'jio');
}
$length = function_exists('mb_strlen') ? mb_strlen($title) : strlen($title);
if ($length < (int) $settings['title_min']) {
return __('标题太短!', 'jio');
}
if ($length > (int) $settings['title_max']) {
return sprintf(__('标题太长了,不能超过%d个字', 'jio'), (int) $settings['title_max']);
}
if ((function_exists('mb_strlen') ? mb_strlen($content) : strlen($content)) < 10) {
return __('文章内容过少', 'jio');
}
if (empty($payload['categories'])) {
return __('请选择文章分类', 'jio');
}
return '';
}
/**
* 组合提交时的版本快照(分类、标签、封面、付费)。
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function snapshot_from_payload(array $payload): array {
$downloads = [];
foreach ((array) ($payload['pay']['fields']['downloads'] ?? []) as $row) {
if (!is_array($row)) {
continue;
}
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
if ($key !== '') {
$asset = PrivateAssets::get($key);
if ($asset === null || $asset['owner_id'] !== get_current_user_id()) {
continue;
}
$downloads[] = [
'type' => 'protected',
'asset_key' => $key,
'file_name' => $asset['file_name'],
'size_bytes' => $asset['size_bytes'],
'label' => (string) ($row['label'] ?? ''),
];
continue;
}
$url = esc_url_raw((string) ($row['url'] ?? ''));
if ($url !== '') {
$downloads[] = [
'type' => 'direct',
'url' => $url,
'label' => (string) ($row['label'] ?? ''),
'extract_code' => (string) ($row['extract_code'] ?? ''),
];
}
}
return [
'categories' => array_values(array_filter(array_map('absint', (array) $payload['categories']))),
'tags' => array_values(array_filter(array_map('sanitize_text_field', (array) $payload['tags']))),
'custom' => is_array($payload['custom']) ? $payload['custom'] : [],
'featured' => is_array($payload['featured']) ? $payload['featured'] : [],
'pay' => [
'enabled' => !empty($payload['pay']['enabled']),
'fields' => array_merge(
(array) ($payload['pay']['fields'] ?? []),
['downloads' => $downloads]
),
],
];
}
private function claim_private_assets(array $payload, int $post_id, int $user_id): void {
if ($post_id <= 0) {
return;
}
$keys = [];
foreach ((array) ($payload['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, $post_id, $user_id);
}
/**
* 客户端提交的是 JSON 区块,落到 post_content 前统一交给 wp_kses。
*/
private function block_content(string $content): string {
return $content;
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
private function term_payload(array $settings, ?\WP_Post $source, array $snapshot): array {
$cat_args = [
'taxonomy' => 'category',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
];
if (!empty($settings['allowed_cat_ids'])) {
$cat_args['include'] = $settings['allowed_cat_ids'];
} else {
$cat_args['parent'] = 0;
}
$terms = get_terms($cat_args);
$categories = [];
foreach (is_array($terms) ? $terms : [] as $term) {
$categories[] = ['id' => (int) $term->term_id, 'name' => (string) $term->name];
}
$current = $source instanceof \WP_Post && $snapshot === []
? array_map('intval', wp_get_post_categories($source->ID))
: array_map('intval', (array) ($snapshot['categories'] ?? []));
$tags = $source instanceof \WP_Post && $snapshot === []
? wp_get_post_tags($source->ID, ['fields' => 'names'])
: array_map('strval', (array) ($snapshot['tags'] ?? []));
$custom_defs = [];
$custom = [];
if (!empty($settings['custom_filter_s']) && class_exists('\Jio\Settings\Settings')) {
$filter_all = \Jio\Settings\Settings::get_post_filter_settings();
$dims = is_array($filter_all['custom_filter'] ?? null) ? $filter_all['custom_filter'] : [];
foreach ($dims as $dim) {
$key = sanitize_key((string) ($dim['key'] ?? ''));
if ($key === '') {
continue;
}
$vals = [];
foreach ((array) ($dim['vals'] ?? []) as $value) {
$vkey = (string) ($value['key'] ?? '');
if ($vkey === '') {
continue;
}
$vals[] = ['key' => $vkey, 'name' => (string) ($value['name'] ?? $vkey)];
}
$custom_defs[] = ['key' => $key, 'name' => (string) ($dim['name'] ?? $key), 'values' => $vals];
$saved = $snapshot !== [] ? ($snapshot['custom'][$key] ?? []) : ($source instanceof \WP_Post ? get_post_meta($source->ID, '_jio_cf_' . $key, true) : []);
$custom[$key] = is_array($saved) ? array_values(array_map('strval', $saved)) : ($saved !== '' ? [(string) $saved] : []);
}
}
return [
'categories' => ['items' => $categories, 'selected' => $current],
'tags' => ['items' => is_array($tags) ? array_values($tags) : [], 'enabled' => !empty($settings['allow_tags'])],
'custom' => $custom,
'customDefs' => $custom_defs,
];
}
/**
* @param array<string, mixed> $post_meta
* @return array<string, mixed>
*/
private function creator_featured_payload(?\WP_Post $source, array $post_meta): array {
$type = '';
$data = [];
$id = 0;
if ($source instanceof \WP_Post) {
$thumb_id = (int) get_post_thumbnail_id($source->ID);
$url = $thumb_id > 0 ? (string) wp_get_attachment_image_url($thumb_id, 'full') : (string) ($post_meta['thumbnail_url'] ?? '');
if ($url !== '') {
$type = 'image';
$id = $thumb_id;
$data = ['url' => $url, 'id' => $thumb_id];
}
}
$front = FrontSubmit::get_instance();
$user = get_current_user_id();
return [
'type' => $type,
'data' => $data,
'mediaId' => $id,
'canImage' => $front->user_can_cap('new_post_image_cover', $user),
'canSlide' => $front->user_can_cap('new_post_slide_cover', $user),
'canVideo' => $front->user_can_cap('new_post_video_cover', $user),
];
}
/**
* @param array<string, mixed> $pay_meta
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
private function pay_payload(array $pay_meta, array $snapshot, array $settings): array {
$front = FrontSubmit::get_instance();
$user = get_current_user_id();
$pay = $snapshot['pay']['fields'] ?? $pay_meta;
$enabled = $snapshot !== [] ? !empty($snapshot['pay']['enabled']) : (($pay_meta['pay_type'] ?? 'no') !== 'no');
$downloads = [];
foreach ((array) ($pay['downloads'] ?? []) as $row) {
if (!is_array($row)) {
continue;
}
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
$downloads[] = [
'type' => $key !== '' ? 'protected' : (string) ($row['type'] ?? 'direct'),
'assetKey' => $key,
'url' => $key !== '' ? '' : (string) ($row['url'] ?? ''),
'label' => (string) ($row['label'] ?? ''),
'fileName' => (string) ($row['file_name'] ?? ''),
'sizeBytes' => (int) ($row['size_bytes'] ?? 0),
];
}
return [
'enabled' => (bool) $enabled,
'canPay' => $front->user_can_cap('new_post_pay', $user),
'canDownload' => $front->user_can_cap('new_post_pay_download', $user),
'payModo' => (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : 'cash',
'price' => (string) ($pay['price'] ?? '0.00'),
'points' => (int) ($pay['points_price'] ?? 0),
'vip1Price' => (string) ($pay['vip1_price'] ?? '0.00'),
'vip2Price' => (string) ($pay['vip2_price'] ?? '0.00'),
'payLimit' => (int) ($pay['pay_limit'] ?? 0),
'downloads' => $downloads,
'freeAllowed' => !empty($settings['pay_free_s']),
'priceMin' => (int) ($settings['pay_price_min'] ?? 0),
'priceMax' => (int) ($settings['pay_price_max'] ?? 0),
'pointsMin' => (int) ($settings['pay_points_min'] ?? 0),
'pointsMax' => (int) ($settings['pay_points_max'] ?? 0),
'privateStorage' => PrivateAssets::is_available(),
];
}
/**
* @return array<string, bool>
*/
private function capability_payload(FrontSubmit $front, int $user_id, int $post_id): array {
$cap = static function (string $name) use ($front, $user_id, $post_id): bool {
return $front->user_can_cap($name, $user_id, $post_id > 0 ? $post_id : null);
};
return [
'canUploadImage' => $cap('upload_submit_image'),
'canUploadVideo' => $cap('new_post_upload_video'),
'canUploadFile' => $cap('upload_resource_attachment'),
'canEmbed' => $cap('new_post_iframe_video'),
'canHide' => $cap('new_post_hide'),
'canPay' => $cap('new_post_pay'),
'canPayDownload' => $cap('new_post_pay_download'),
'canPublishNow' => $cap('new_post_audit_no'),
'canEdit' => $post_id > 0 ? $front->can_edit_post($post_id, $user_id) : $front->can_submit($user_id),
'isLoggedIn' => $user_id > 0,
];
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Jio Creator Studio 前台创作工作台。
*
* 与默认 WordPress 后台编辑器隔离的独立入口:只承载前台写作体验所需的静态资源、
* 初始上下文与挂载点。写作界面是 Gutenberg 内核 + 自定义 React 工作台。
*
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
final class CreatorStudio {
/** 构建产物目录(相对主题根)。 */
public const BUILD_DIR = 'assets/creator-studio/build';
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('wp_enqueue_scripts', [$this, 'maybe_enqueue'], 20);
}
/** 当前请求是否为投稿模板。 */
public function is_studio_request(): bool {
return is_page_template('templates/pages/page-submit.php');
}
/** 构建产物存在且带依赖清单。 */
public static function is_built(): bool {
$dir = get_template_directory() . '/' . self::BUILD_DIR;
return file_exists($dir . '/index.js') && file_exists($dir . '/index.asset.php');
}
public function maybe_enqueue(): void {
if (!$this->is_studio_request() || !self::is_built()) {
return;
}
if (get_current_user_id() <= 0) {
return;
}
$dir = get_template_directory() . '/' . self::BUILD_DIR;
$uri = get_template_directory_uri() . '/' . self::BUILD_DIR;
$meta = include $dir . '/index.asset.php';
$deps = is_array($meta['dependencies'] ?? null) ? $meta['dependencies'] : [];
$ver = (string) ($meta['version'] ?? AKSRT_VERSION);
// 区块样式是前台编辑体验的一部分;编辑器专属样式只在已注册时追加,
// 避免在没有注册该 handle 的前台请求上产生悬空依赖。
$style_deps = ['jio-public'];
foreach (['wp-block-library', 'wp-block-editor', 'wp-components'] as $handle) {
if (wp_style_is($handle, 'registered')) {
$style_deps[] = $handle;
}
}
wp_enqueue_style(
'jio-creator-studio',
$uri . '/index.css',
$style_deps,
$ver
);
wp_enqueue_script(
'jio-creator-studio',
$uri . '/index.js',
$deps,
$ver,
true
);
// Jio 自有区块:编辑器里已注册的同一份定义,前台工作台复用,
// 避免维护第二套 Jio Blocks。前台没有注册 wp-media-utils,
// 该脚本只依赖 block-editor 的 MediaUpload,不需要它。
$blocks_js = get_template_directory() . '/assets/js/gutenberg-blocks.js';
if (file_exists($blocks_js) && !wp_script_is('jio-gutenberg-blocks', 'enqueued')) {
wp_enqueue_script(
'jio-gutenberg-blocks',
get_template_directory_uri() . '/assets/js/gutenberg-blocks.js',
['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-hooks', 'wp-data'],
(string) filemtime($blocks_js),
true
);
}
// 区块前台样式(jio/carousel 等自定义区块的输出样式)。
$blocks_css = get_template_directory() . '/assets/css/gutenberg-blocks.css';
if (file_exists($blocks_css)) {
wp_enqueue_style(
'jio-gutenberg-blocks-front',
get_template_directory_uri() . '/assets/css/gutenberg-blocks.css',
[],
(string) filemtime($blocks_css)
);
}
wp_add_inline_script(
'jio-creator-studio',
'window.jioCreatorStudio = ' . wp_json_encode($this->bootstrap()) . ';',
'before'
);
}
/**
* 渲染给前端的启动上下文。
*
* @return array<string, mixed>
*/
public function bootstrap(): array {
$post_id = isset($_GET['edit']) ? absint($_GET['edit']) : 0;
$rest = new CreatorRest();
$data = $rest->bootstrap_payload($post_id, get_current_user_id());
return array_merge($data, [
'root' => rest_url(),
'ajax' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('wp_rest'),
'captcha' => [
'enabled' => function_exists('jio_is_captcha_enabled_for') && jio_is_captcha_enabled_for('front_submit'),
],
'creator' => [
'saveUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/save')),
'submitUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/submit')),
'previewUrl'=> esc_url_raw(rest_url(CreatorRest::NS . '/creator/preview')),
'privateUrl'=> esc_url_raw(rest_url(CreatorRest::NS . '/creator/private-upload')),
'bootstrapUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/bootstrap')),
],
'blocks' => [
'mediaUploadUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/upload/image')),
'mediaListUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/media/image')),
],
]);
}
}
+4 -1
View File
@@ -28,7 +28,10 @@ class DBMigrator {
// 催单冷却改由 last_urge_time 判定,不再依赖 transient;催单同时写入 system 类型的 ticket_reply。
// 3.3.0: 主题品牌由 AKSRT 更名为 Jio,表名前缀 aksrt_ → jio_。dbDelta 只建不删,
// 因此版本必须提升,否则既有安装仍读旧表;新安装直接建 jio_* 表。
const SCHEMA_VERSION = '3.3.0';
// 3.4.0: 知识付费附件改为服务端受保护交付。新增 jio_private_asset(私有附件
// 记录,文件存放在独立私有目录而非公开上传目录),jio_dl_token 增加 asset_id
// 以把下载令牌绑定到稳定的私有附件,而不是可变的下载条目序号。
const SCHEMA_VERSION = '3.4.0';
const OPT_KEY = 'jio_db_schema_version';
+21 -1
View File
@@ -139,6 +139,7 @@ trait DBMigratorPart2 {
token CHAR(48) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
post_id BIGINT UNSIGNED NOT NULL,
asset_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
source_index INT NOT NULL DEFAULT 0,
cost INT NOT NULL DEFAULT 0,
ip_bind VARCHAR(64) DEFAULT NULL,
@@ -150,10 +151,29 @@ trait DBMigratorPart2 {
PRIMARY KEY (token),
KEY idx_expires (expires_at),
KEY idx_user (user_id),
KEY idx_post (post_id)
KEY idx_post (post_id),
KEY idx_asset (asset_id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_dl_token);
// ---------- 4. 私有附件(知识付费资料,不经公开上传目录) ----------
$sql_private_asset = "CREATE TABLE {$prefix}jio_private_asset (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
asset_key CHAR(48) NOT NULL,
post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
owner_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
file_name VARCHAR(191) NOT NULL DEFAULT '',
mime VARCHAR(100) NOT NULL DEFAULT '',
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
storage_name VARCHAR(191) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_asset_key (asset_key),
KEY idx_post (post_id),
KEY idx_owner (owner_id)
) ENGINE=InnoDB {$charset_collate};";
dbDelta($sql_private_asset);
// ---------- 5. 用户通知收件箱 ----------
$sql_notification = "CREATE TABLE {$prefix}jio_notification (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+1
View File
@@ -23,6 +23,7 @@ trait DBMigratorPart3 {
'financial_flow_entry' => 'jio_financial_flow_entry',
'signin_log' => 'jio_signin_log',
'dl_token' => 'jio_dl_token',
'private_asset' => 'jio_private_asset',
'notification' => 'jio_notification',
'order' => 'jio_order',
'order_item' => 'jio_order_item',
+21 -6
View File
@@ -243,14 +243,14 @@ class DomainModel {
/**
* @param mixed $value
* @return array<int, array{label:string,url:string,type:string,extract_code:string,copy_key:string,copy_val:string,icon:string,class:string,attachment_id:int}>
* @return array<int, array{label:string,url:string,type:string,asset_key:string,file_name:string,size_bytes:int,extract_code:string,copy_key:string,copy_val:string,icon:string,class:string,attachment_id:int}>
*/
public static function sanitize_downloads($value): array {
if (!is_array($value)) {
return [];
}
$allowed_types = ['direct', 'net_disk', 'external'];
$allowed_types = ['direct', 'net_disk', 'external', 'protected'];
$allowed_classes = self::DOWNLOAD_BUTTON_CLASSES;
$downloads = [];
@@ -260,14 +260,26 @@ class DomainModel {
}
$url = isset($row['url']) ? esc_url_raw((string) $row['url']) : '';
if ($url === '') {
continue;
}
$type = isset($row['type']) && in_array($row['type'], $allowed_types, true)
? (string) $row['type']
: 'direct';
// 受保护附件只保存随机 asset_key;文件本体在私有存储中,绝不暴露路径。
$asset_key = class_exists('\Jio\Content\PrivateAssets')
? \Jio\Content\PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''))
: '';
$is_protected = $type === 'protected' || $asset_key !== '';
if ($is_protected && $asset_key === '') {
continue;
}
if (!$is_protected && $url === '') {
continue;
}
if ($is_protected) {
$type = 'protected';
$url = '';
}
$extract_code = '';
foreach ([
'extract_code',
@@ -299,6 +311,9 @@ class DomainModel {
'label' => isset($row['label']) ? sanitize_text_field((string) $row['label']) : '',
'url' => $url,
'type' => $type,
'asset_key' => $asset_key,
'file_name' => $asset_key !== '' ? sanitize_file_name((string) ($row['file_name'] ?? '')) : '',
'size_bytes' => $asset_key !== '' ? max(0, (int) ($row['size_bytes'] ?? 0)) : 0,
'extract_code' => $extract_code,
'copy_key' => isset($row['copy_key']) ? sanitize_text_field((string) $row['copy_key']) : '',
'copy_val' => isset($row['copy_val']) ? sanitize_text_field((string) $row['copy_val']) : '',
+9
View File
@@ -12,6 +12,15 @@ defined('ABSPATH') || exit;
*/
trait FrontSubmitFeatured {
/**
* Persist a featured payload for a post the user may edit.
*
* @param array<string, mixed> $featured Featured payload (`type` + `data`).
*/
public function apply_creator_featured(int $post_id, array $featured, int $user_id): void {
$this->save_featured_meta($post_id, ['featured_data' => $featured], $user_id);
}
/**
* @param array<string, mixed> $data
*/
+45 -6
View File
@@ -18,26 +18,48 @@ trait FrontSubmitPay {
* @return array{success:bool,message?:string}
*/
private function save_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
$prepared = $this->prepare_pay_meta($post_id, $data, $user_id, $settings);
if (empty($prepared['success'])) {
return $prepared;
}
if (!empty($prepared['write'])) {
$this->write_pay_meta($post_id, $prepared['meta']);
}
return ['success' => true];
}
/**
* Validate the submitted paid-content payload without writing anything.
*
* Returns `write => false` when the request did not carry pay fields, so
* callers can persist the post only after they know the payload is valid.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $settings
* @return array{success:bool,message?:string,write?:bool,meta?:array<string,mixed>}
*/
public function prepare_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
if (!$this->user_can_cap('new_post_pay', $user_id)) {
return ['success' => true];
return ['success' => true, 'write' => false];
}
// Only touch pay meta when the form sent pay fields.
$has_pay_payload = isset($data['jio_pay']) || isset($data['pay_enabled']);
if (!$has_pay_payload) {
return ['success' => true];
return ['success' => true, 'write' => false];
}
$raw = is_array($data['jio_pay'] ?? null) ? $data['jio_pay'] : [];
$enabled = !empty($data['pay_enabled']);
$existing = function_exists('jio_get_pay_meta') ? jio_get_pay_meta($post_id) : DomainModel::default_pay_meta();
$existing = $post_id > 0 && function_exists('jio_get_pay_meta')
? jio_get_pay_meta($post_id)
: DomainModel::default_pay_meta();
$meta = is_array($existing) ? $existing : DomainModel::default_pay_meta();
if (!$enabled) {
$meta['pay_type'] = DomainModel::PAY_TYPE_NO;
$this->write_pay_meta($post_id, $meta);
return ['success' => true];
return ['success' => true, 'write' => true, 'meta' => $meta];
}
$pay_modo = sanitize_key((string) ($raw['pay_modo'] ?? '0'));
@@ -170,7 +192,24 @@ trait FrontSubmitPay {
$pay_limit = (int) ($raw['pay_limit'] ?? 0);
$meta['pay_limit'] = in_array($pay_limit, [0, 1, 2], true) ? $pay_limit : 0;
$this->write_pay_meta($post_id, $meta);
return ['success' => true, 'write' => true, 'meta' => $meta];
}
/**
* Apply an already-prepared pay payload to a post (version approval path).
*
* @param array<string, mixed> $data
* @param array<string, mixed> $settings
* @return array{success:bool,message?:string}
*/
public function apply_pay_payload(int $post_id, array $data, int $user_id, array $settings): array {
$prepared = $this->prepare_pay_meta($post_id, $data, $user_id, $settings);
if (empty($prepared['success'])) {
return $prepared;
}
if (!empty($prepared['write'])) {
$this->write_pay_meta($post_id, $prepared['meta']);
}
return ['success' => true];
}
+12 -10
View File
@@ -138,6 +138,13 @@ trait FrontSubmitSave {
$status = $this->resolve_publish_status($user_id);
}
// Paid fields are validated before anything is written, so an invalid
// pay configuration can no longer leave a half-saved post behind.
$pay_result = $this->prepare_pay_meta((int) $posts_id, $data, $user_id, $settings);
if (empty($pay_result['success'])) {
return $pay_result;
}
$can_iframe = $this->user_can_cap('new_post_iframe_video', $user_id);
if ($can_iframe) {
add_filter('wp_kses_allowed_html', [$this, 'allow_iframe_kses'], 99, 2);
@@ -191,20 +198,15 @@ trait FrontSubmitSave {
$this->apply_custom_filters((int) $in_id, $data, $settings);
$pay_result = $this->save_pay_meta((int) $in_id, $data, $user_id, $settings);
if ($pay_result['success']) {
$this->save_featured_meta((int) $in_id, $data, $user_id);
if (!empty($pay_result['write'])) {
$this->write_pay_meta((int) $in_id, $pay_result['meta']);
}
$this->save_featured_meta((int) $in_id, $data, $user_id);
// The post is already persisted even when pay validation fails.
// The post is already persisted at this point; attachment relations and
// hooks below must still run for a successful save.
$post = get_post((int) $in_id);
$this->sync_post_attachments($post);
if (!$pay_result['success']) {
$pay_result['post_id'] = (int) $in_id;
$pay_result['posts_id'] = (int) $in_id;
return $pay_result;
}
if ($post instanceof \WP_Post) {
do_action($is_edit ? 'jio_new_edit_posts' : 'jio_new_add_posts', $post);
if ($status === 'pending') {
+118
View File
@@ -0,0 +1,118 @@
<?php
/**
* 改稿预览令牌。
*
* 已发布文章处于审核中的改稿不能通过公开永久链接查看。作者/审核者请求预览时
* 得到一个短期、一次性绑定到版本与用户的令牌,在源文章页面上临时替换展示内容;
* 不改变数据库中的线上文章,也不把版本暴露给未授权访问者。
*
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
final class PreviewToken {
private const TTL = 900;
private const PREFIX = 'jio_studio_preview_';
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('template_redirect', [$this, 'maybe_preview'], 0);
add_filter('wp_robots', [$this, 'robots_noindex']);
}
public static function issue(int $version_id, int $user_id): string {
$token = bin2hex(random_bytes(24));
set_transient(self::PREFIX . $token, [
'version_id' => $version_id,
'user_id' => $user_id,
], self::TTL);
return $token;
}
/**
* @return array{version_id:int,user_id:int}|null
*/
private static function resolve(string $token): ?array {
if (!preg_match('/^[a-f0-9]{48}$/', $token)) {
return null;
}
$data = get_transient(self::PREFIX . $token);
if (!is_array($data)) {
return null;
}
return [
'version_id' => (int) ($data['version_id'] ?? 0),
'user_id' => (int) ($data['user_id'] ?? 0),
];
}
public function maybe_preview(): void {
$token = isset($_GET['jio_studio_preview']) ? sanitize_text_field(wp_unslash((string) $_GET['jio_studio_preview'])) : '';
if ($token === '') {
return;
}
$resolved = self::resolve($token);
$user_id = get_current_user_id();
if ($resolved === null || $user_id <= 0
|| ($resolved['user_id'] !== $user_id && !SubmissionVersion::can_review($user_id))
) {
status_header(403);
wp_die(esc_html__('预览链接无效或已过期。', 'jio'), esc_html__('预览不可用', 'jio'), ['response' => 403]);
}
$version = get_post($resolved['version_id']);
if (!$version instanceof \WP_Post || $version->post_type !== SubmissionVersion::POST_TYPE) {
status_header(404);
wp_die(esc_html__('改稿不存在。', 'jio'), esc_html__('预览不可用', 'jio'), ['response' => 404]);
}
$source_id = SubmissionVersion::source_id($resolved['version_id']);
$source = $source_id > 0 ? get_post($source_id) : null;
if (!$source instanceof \WP_Post) {
status_header(404);
wp_die(esc_html__('原文章不存在。', 'jio'), esc_html__('预览不可用', 'jio'), ['response' => 404]);
}
// 仅在本次请求内替换展示内容,不写回源文章。
$preview = clone $source;
$preview->post_title = $version->post_title;
$preview->post_content = $version->post_content;
$preview->post_excerpt = $version->post_excerpt;
global $post, $wp_query;
$post = $preview;
if ($wp_query instanceof \WP_Query && !empty($wp_query->posts)) {
$wp_query->posts[0] = $preview;
$wp_query->post = $preview;
}
setup_postdata($preview);
$GLOBALS['jio_studio_preview_mode'] = true;
nocache_headers();
}
/**
* @param array<string, bool> $robots
* @return array<string, bool>
*/
public function robots_noindex(array $robots): array {
if (!empty($GLOBALS['jio_studio_preview_mode'])) {
$robots['noindex'] = true;
$robots['nofollow'] = true;
}
return $robots;
}
}
+5
View File
@@ -484,6 +484,11 @@ trait ScriptsPart1c {
})(),
'commentIasMax' => class_exists('\Jio\Settings\Settings') ? \Jio\Settings\Settings::get_comment_display_settings()['paging_ajax_ias_max'] : 3,
'restUrl' => esc_url_raw(rest_url('jio/v1')),
// 受保护下载:按钮只携带 asset_key,先换短期令牌再取文件。
'protectedDownload' => [
'tokenUrl' => esc_url_raw(rest_url('jio/v1/protected-download/token')),
'error' => __('下载失败,请稍后重试', 'jio'),
],
// Alias used by modal-auth / follow / report (same value as restUrl).
'apiUrl' => esc_url_raw(rest_url('jio/v1')),
// 举报弹窗数据(report.js):预置原因读取后台“禁封/举报”的 ban_preset_reason
+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);
}
}