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 $robots * @return array */ 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 */ 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,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 $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 $snapshot * @return array */ 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 $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); } }