Files
wordpress-theme-aksrt/src/Core/SubmissionVersion.php
T

565 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 已发布文章的改稿版本(Jio Creator Studio)。
*
* 作者修改一篇已发布文章时,改动不会直接写入线上文章,而是写入一个私有的
* 投稿版本(私有 post type)。线上文章的内容、永久链接与付费条款在审核通过
* 前保持不变;审核通过后由本类一次性把版本内容应用到源文章。
*
* 新文章仍走既有 FrontSubmit::persist() 的 draft → pending/publish 流程;只有
* 源文章已发布时才进入版本流程,避免线上内容被静默改写。
*
* @package Jio
*/
namespace Jio\Core;
use Jio\Content\PrivateAssets;
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,autosave?:bool} $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',
];
// 自动保存不建 revision:几秒一次地写版本表会把改稿记录撑满,
// 需要回滚的节点由手动保存与提交产生。
$is_auto = !empty($data['autosave']);
if ($is_auto) {
remove_action('post_updated', 'wp_save_post_revision');
}
$updated = wp_update_post($update, true);
if ($is_auto) {
add_action('post_updated', 'wp_save_post_revision');
}
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']))));
if ($cats !== []) {
$cats = SubmitCategories::filter_ids($cats, $settings);
}
wp_set_post_categories($post_id, $cats);
}
if (!empty($settings['allow_topics']) && array_key_exists('topics', $snapshot)) {
$topics = is_array($snapshot['topics']) ? $snapshot['topics'] : [];
wp_set_post_terms($post_id, SubmitTopics::filter_ids($topics, $settings), 'topics');
}
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['topics']) && is_array($snapshot['topics'])) {
$clean['topics'] = array_values(array_unique(array_filter(array_map('absint', $snapshot['topics']))));
}
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);
}
}