guard(),其中统一做了 * check_ajax_referer() 与 current_user_can( 'manage_options' ),两者都不通过 * 就直接结束请求。取参数的活儿拆在 request_*() 辅助方法里,PHPCS 不跨方法 * 追踪调用链,才会在那些方法上误判成「读了 $_POST 却没校验 nonce」—— * 逐处的 phpcs:ignore 注释指的都是这一件事。 */ class APGT_Ajax { /** nonce action 名。 */ const NONCE = 'apgt_admin'; /** 单条原文/译文的最大长度(字符)。超长的本来也不会送去翻译。 */ const MAX_TEXT_LENGTH = 60000; /** 一次提交的原文/译文 JSON 的最大字节数。整页文本撑死也就几百 KB。 */ const MAX_JSON_BYTES = 8388608; /** * 注册 AJAX 钩子。 * * @return void */ public function init() { add_action( 'wp_ajax_apgt_load_page', array( $this, 'load_page' ) ); add_action( 'wp_ajax_apgt_translate_page', array( $this, 'translate_page' ) ); add_action( 'wp_ajax_apgt_save_page', array( $this, 'save_page' ) ); add_action( 'wp_ajax_apgt_page_status', array( $this, 'page_status' ) ); add_action( 'wp_ajax_apgt_pending_pages', array( $this, 'pending_pages' ) ); add_action( 'wp_ajax_apgt_translate_one', array( $this, 'translate_one' ) ); add_action( 'wp_ajax_apgt_job_status', array( $this, 'job_status' ) ); add_action( 'wp_ajax_apgt_worker_tick', array( $this, 'worker_tick' ) ); add_action( 'wp_ajax_apgt_validate_regex', array( $this, 'validate_regex' ) ); add_action( 'wp_ajax_apgt_license_activate', array( $this, 'license_activate' ) ); add_action( 'wp_ajax_apgt_license_status', array( $this, 'license_status' ) ); add_action( 'wp_ajax_apgt_license_deactivate', array( $this, 'license_deactivate' ) ); add_action( 'wp_ajax_apgt_data_save', array( $this, 'data_save' ) ); add_action( 'wp_ajax_apgt_data_delete', array( $this, 'data_delete' ) ); add_action( 'wp_ajax_apgt_data_delete_search', array( $this, 'data_delete_search' ) ); } /** * 译文数据页:保存单条译文(改动会标记为 manual)。 * * @return void */ public function data_save() { $this->guard(); $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 $row = $id ? APGT_Store::get_by_id( $id ) : null; if ( ! $row ) { wp_send_json_error( array( 'message' => __( 'Record not found; it may already have been deleted', 'aipogeo-context-translate' ) ) ); } // 译文是自由文本,不能过标签过滤器,理由见 apgt_sanitize_translatable_text()。 // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce 见 guard(),清理即本行调用。 $text = isset( $_POST['text_trans'] ) ? apgt_sanitize_translatable_text( wp_unslash( $_POST['text_trans'] ), self::MAX_TEXT_LENGTH ) : ''; if ( ! APGT_Store::update_trans( $id, $text ) ) { wp_send_json_error( array( 'message' => __( 'Save failed', 'aipogeo-context-translate' ) ) ); } $this->invalidate_progress( array( $row ) ); wp_send_json_success( array( 'message' => __( 'Saved', 'aipogeo-context-translate' ), 'engine' => 'manual', ) ); } /** * 译文数据页:删除单条记录。 * * @return void */ public function data_delete() { $this->guard(); $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 $row = $id ? APGT_Store::get_by_id( $id ) : null; if ( ! $row ) { wp_send_json_error( array( 'message' => __( 'Record not found; it may already have been deleted', 'aipogeo-context-translate' ) ) ); } if ( ! APGT_Store::delete( $id ) ) { wp_send_json_error( array( 'message' => __( 'Delete failed', 'aipogeo-context-translate' ) ) ); } $this->invalidate_progress( array( $row ) ); wp_send_json_success( array( 'message' => __( 'Deleted', 'aipogeo-context-translate' ) ) ); } /** * 译文数据页:删除当前搜索结果。 * * @return void */ public function data_delete_search() { $this->guard(); $args = $this->request_data_filters(); $result = APGT_Store::delete_by_query( $args ); if ( ! $result['ok'] ) { wp_send_json_error( array( 'message' => __( 'Set at least one filter first, otherwise the entire table would be wiped', 'aipogeo-context-translate' ) ) ); } $this->invalidate_progress( $result['pages'] ); wp_send_json_success( array( 'deleted' => $result['deleted'], /* translators: %d: number of records deleted */ 'message' => sprintf( __( 'Deleted %d translation records', 'aipogeo-context-translate' ), $result['deleted'] ), ) ); } /** * 从请求里取译文数据页的筛选条件。 * * @return array */ private function request_data_filters() { $keys = array( 'search_source', 'search_trans', 'search_url', 'language_from', 'language_to', 'engine', 'type', ); $args = array(); foreach ( $keys as $key ) { if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 $args[ $key ] = sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 } } $args['only_empty'] = ! empty( $_POST['only_empty'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 return $args; } /** * 译文变动后作废相关页面的翻译进度。 * * 进度表存的是「这一页多少条已译」,删了译文却不清进度,页面会一直 * 显示已完成、批量翻译也不会再碰它。清掉后下次打开或翻译时重新统计。 * * @param array $rows 含 source_url 与 language_to 的记录数组。 * @return void */ private function invalidate_progress( array $rows ) { $seen = array(); foreach ( $rows as $row ) { $path = isset( $row['source_url'] ) ? (string) $row['source_url'] : ''; $to = isset( $row['language_to'] ) ? (string) $row['language_to'] : ''; if ( '' === $path || '' === $to ) { continue; } $key = $path . '|' . $to; if ( isset( $seen[ $key ] ) ) { continue; } $seen[ $key ] = true; APGT_Progress::delete( $path, $to ); } } /** * 校验一批正则表达式能否被 PCRE 编译。 * * 用户在替换规则里写错正则(漏分隔符、括号不配对等),preg_replace 会直接 * 失败,那条规则就成了哑弹——译文照常入库但替换没生效,且很难发现。 * * @return void */ public function validate_regex() { $this->guard(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce 见 guard(),每项在下方循环里逐条清理。 $raw = isset( $_POST['patterns'] ) && is_array( $_POST['patterns'] ) ? wp_unslash( $_POST['patterns'] ) : array(); $results = array(); foreach ( $raw as $key => $pattern ) { if ( ! is_scalar( $pattern ) ) { continue; } $results[ sanitize_key( $key ) ] = APGT_Admin::is_valid_regex( apgt_sanitize_translatable_text( $pattern, APGT_Admin::MAX_RULE_LENGTH ) ); } wp_send_json_success( array( 'results' => $results ) ); } /** * 激活订阅:把本站登记到密钥对应的订阅上。 * * 密钥由前端提交(用户刚粘贴进来,可能还没保存设置),这里先落库再激活, * 免得用户"点了激活但没点保存"导致下次打开又变回未激活。 */ public function license_activate() { $this->guard(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 见 guard() $key = isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 见 guard() $url = isset( $_POST['service_url'] ) ? esc_url_raw( wp_unslash( $_POST['service_url'] ) ) : ''; $ai = APGT_Settings::get_ai(); if ( '' !== $key ) { $ai['license_key'] = $key; } if ( '' !== $url ) { $ai['service_url'] = untrailingslashit( $url ); } $ai['provider'] = 'service'; APGT_Settings::save_ai( $ai ); $res = APGT_Service::activate(); if ( ! $res['ok'] ) { wp_send_json_error( array( 'message' => APGT_Service::explain( $res ) ) ); } wp_send_json_success( array( 'message' => __( 'This site is now activated', 'aipogeo-context-translate' ), 'info' => $res['data'], ) ); } /** 查询订阅状态(强制刷新,跳过本地缓存) */ public function license_status() { $this->guard(); $res = APGT_Service::status( true ); if ( ! $res['ok'] ) { wp_send_json_error( array( 'message' => APGT_Service::explain( $res ) ) ); } wp_send_json_success( array( 'info' => $res['data'] ) ); } /** 解绑本站(不会删除订阅本身,额度与到期日不受影响) */ public function license_deactivate() { $this->guard(); $res = APGT_Service::deactivate(); if ( ! $res['ok'] ) { wp_send_json_error( array( 'message' => APGT_Service::explain( $res ) ) ); } wp_send_json_success( array( 'message' => __( 'This site has been released', 'aipogeo-context-translate' ), 'info' => $res['data'], ) ); } /** * 校验权限与 nonce,不通过直接结束请求。 * * @return void */ private function guard() { // 翻译一个页面要等 AI 几十秒,期间 PHP 会一直占着 session 文件锁, // 同一浏览器的其它请求全得排队。插件本身不用 session,尽早写回释放。 if ( function_exists( 'session_status' ) && PHP_SESSION_ACTIVE === session_status() ) { session_write_close(); } // 先验 nonce 再看权限:请求不是本站表单发来的,权限再高也不该往下走。 check_ajax_referer( self::NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'aipogeo-context-translate' ) ), 403 ); } } /** * 抢当前页面的翻译锁,抢不到就带着占用者信息返回。 * * 锁按「页面 + 目标语言」区分,别人翻别的页面互不妨碍;只有同一个 * 页面翻同一种语言才互斥,避免重复烧 AI 额度。 * * @param string $path 站内路径。 * @param string $to 目标语言。 * @return array [ 'key'=>string, 'owner'=>string ],供 finish_task() 释放。 */ private function start_task( $path, $to ) { $key = APGT_Lock::key( $path, $to ); $owner = APGT_Lock::make_owner(); if ( ! APGT_Lock::acquire( $key, $owner ) ) { wp_send_json_error( array( 'locked' => true, 'holder' => APGT_Lock::holder_name( $key ), 'message' => sprintf( /* translators: %s: name of the lock holder */ __( 'This page is already being translated (%s). Please wait for it to finish; a stalled job unlocks automatically once the request timeout passes.', 'aipogeo-context-translate' ), APGT_Lock::holder_name( $key ) ), ) ); } // 单页翻译可能要几分钟,且中途断开也要跑完。 APGT_Translator::prepare_long_task(); // 每完成一次 AI 请求给锁续期,长任务不会被当成僵尸抢走。 APGT_Lock::keep_alive( $key, $owner ); return array( 'key' => $key, 'owner' => $owner, ); } /** * 释放翻译锁。 * * @param array $lock start_task() 返回的锁信息。 * @return void */ private function finish_task( array $lock ) { APGT_Lock::release( $lock['key'], $lock['owner'] ); } /** * 把当前请求转入后台执行。 * * 翻译要等 AI 几十秒到几分钟,CDN 不允许连接长时间没有字节往来, * 所以先把「任务已开始 + 任务号」应答出去并断开连接,本进程继续 * 跑翻译,前端拿任务号轮询 job_status()。 * * @return string 任务号;返回空串表示保持同步模式(连接挂到翻译完, * 行为同旧版)。两种情况会同步:环境不支持断开连接 * (非 PHP-FPM),或请求没带 async 标记——说明前端是 * 浏览器缓存里的旧版脚本,不认识任务号,只能同步等结果。 */ private function begin_job() { if ( empty( $_POST['async'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 return ''; } $job = APGT_Jobs::create(); if ( ! APGT_Jobs::detach( array( 'job' => $job ) ) ) { APGT_Jobs::delete( $job ); return ''; } // 每完成一次 AI 请求刷新任务活跃时间,供轮询端判断死活。 APGT_Jobs::heartbeat( $job ); return $job; } /** * 执行一段翻译动作,无论成败都把锁还回去。 * * AI 请求链路上抛出的任何异常(HTTP 客户端、JSON 解析、内存不足……) * 都在这里兜住:先释放锁再报错,不然这个页面要等到锁超时才能重新 * 发起翻译。 * * @param array $lock start_task() 返回的锁信息。 * @param string $job begin_job() 返回的任务号,同步模式为空串。 * @param callable $fn 实际的翻译动作。 * @return mixed $fn 的返回值。 */ private function run_task( array $lock, $job, callable $fn ) { try { $result = $fn(); } catch ( \Throwable $e ) { $this->finish_task( $lock ); $this->task_output( $job, false, array( 'message' => sprintf( /* translators: %s: error message */ __( 'Translation aborted unexpectedly: %s', 'aipogeo-context-translate' ), $e->getMessage() ), ) ); } $this->finish_task( $lock ); return $result; } /** * 翻译任务的统一出口:后台模式写任务记录,同步模式直接 JSON 应答。 * * 连接已断开时绝不能再调 wp_send_json_*()(会往已关闭的响应里写头)。 * 本方法不返回。 * * @param string $job 任务号,空串表示同步模式。 * @param bool $ok 任务成败。 * @param array $data 结果数据。 * @return void */ private function task_output( $job, $ok, array $data ) { if ( '' !== $job ) { APGT_Jobs::finish( $job, $ok ? 'done' : 'failed', $data ); exit; } if ( $ok ) { wp_send_json_success( $data ); } wp_send_json_error( $data ); } /** * 轮询后台翻译任务:进行中报心跳,结束后一次性取走结果。 * * @return void */ public function job_status() { $this->guard(); $id = isset( $_POST['job'] ) ? APGT_Jobs::sanitize_id( sanitize_text_field( wp_unslash( $_POST['job'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 $job = '' !== $id ? APGT_Jobs::get( $id ) : null; if ( ! $job ) { wp_send_json_error( array( 'message' => __( 'The translation job no longer exists; it may have expired. Reload the page to see the current state.', 'aipogeo-context-translate' ) ) ); } if ( 'running' === $job['status'] ) { // 进程被强杀时没人来写 failed,靠活跃时间兜底; // 判定标准与页面锁一致:超过一次请求超时没动静就当它死了。 if ( ( time() - (int) $job['updated'] ) > APGT_Lock::ttl() ) { APGT_Jobs::delete( $id ); wp_send_json_error( array( 'message' => __( 'The translation job stopped responding and was abandoned. Please try again.', 'aipogeo-context-translate' ) ) ); } wp_send_json_success( array( 'status' => 'running', 'ticks' => (int) $job['ticks'], 'elapsed' => time() - (int) $job['started'], ) ); } // done / failed:结果交给这次轮询,记录即刻删除。 APGT_Jobs::delete( $id ); $result = isset( $job['result'] ) && is_array( $job['result'] ) ? $job['result'] : array(); if ( 'failed' === $job['status'] ) { wp_send_json_error( $result ); } wp_send_json_success( array_merge( array( 'status' => 'done' ), $result ) ); } /** * 取并校验请求中的目标语言。 * * @return string */ private function request_language() { $to = isset( $_POST['language_to'] ) ? sanitize_text_field( wp_unslash( $_POST['language_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 if ( '' === $to || ! array_key_exists( $to, APGT_Settings::target_languages() ) ) { wp_send_json_error( array( 'message' => __( 'The target language is invalid or not configured', 'aipogeo-context-translate' ) ) ); } return $to; } /** * 取并校验请求中的页面路径。 * * 前端传的可能是路径,也可能是旧书签里的完整地址,统一折算成站内路径; * 只接受以单个 / 开头的路径,杜绝被当成任意地址抓取器。 * * @return string 站内路径。 */ private function request_path() { $raw = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 $path = APGT_Pages::to_relative( $raw ); if ( ! APGT_Pages::is_site_path( $path ) ) { wp_send_json_error( array( 'message' => __( 'Invalid page URL', 'aipogeo-context-translate' ) ) ); } return $path; } /** * 按「序号 => 原文」重算并写入该页面的翻译进度。 * * done 按原文 md5 现查,别的页面顺带译好的共享文本也会被算进来。 * * @param string $path 站内路径。 * @param string $to 目标语言。 * @param string $from 源语言。 * @param array $source 序号 => 原文。 * @return void */ private function refresh_progress( $path, $to, $from, array $source ) { $md5s = array(); foreach ( $source as $text ) { $text = trim( (string) $text ); if ( '' !== $text ) { $md5s[ md5( $text ) ] = true; } } $map = APGT_Store::get_trans_map( array_keys( $md5s ), $from, $to ); APGT_Progress::save( $path, $to, count( $md5s ), count( $map ) ); } /** * 取源语言,未配置则报错。 * * @return string */ private function source_language() { $from = APGT_Settings::default_language(); if ( '' === $from ) { wp_send_json_error( array( 'message' => __( 'Choose the site default language under Translation Settings first', 'aipogeo-context-translate' ) ) ); } return $from; } /** * 解析前端回传的原文 JSON(序号 => 原文)。 * * @param string $field 字段名。 * @return array */ private function request_json( $field ) { // 请求体是一段 JSON,先解结构再逐条清理内容——json_decode() 本身只是把 // 字符串变成数组,不做任何过滤,解出来的每个值仍然是未经检查的用户输入。 // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce 见 guard(),值逐条清理见下方循环。 $raw = isset( $_POST[ $field ] ) ? wp_unslash( $_POST[ $field ] ) : ''; if ( ! is_string( $raw ) || '' === trim( $raw ) ) { return array(); } if ( strlen( $raw ) > self::MAX_JSON_BYTES ) { /* translators: %s: field name */ wp_send_json_error( array( 'message' => sprintf( __( '%s is too large to process', 'aipogeo-context-translate' ), $field ) ) ); } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { /* translators: %s: field name */ wp_send_json_error( array( 'message' => sprintf( __( '%s is not valid JSON', 'aipogeo-context-translate' ), $field ) ) ); } $out = array(); foreach ( $data as $seq => $text ) { // 键是编辑器里的行号,只可能是正整数;别的形状说明请求不是本插件发的。 if ( ! is_scalar( $text ) || ! ctype_digit( (string) $seq ) ) { continue; } $out[ (string) $seq ] = apgt_sanitize_translatable_text( $text, self::MAX_TEXT_LENGTH ); } return $out; } /** * 加载一个页面在指定语言下的原文与译文。 * * @return void */ public function load_page() { $this->guard(); $path = $this->request_path(); $to = $this->request_language(); $from = $this->source_language(); $fetch = APGT_Translator::fetch_html( APGT_Pages::to_absolute( $path ) ); if ( ! $fetch['ok'] ) { // 抓不到页面时把请求现场(最终地址、解析到的 IP、响应头、页面开头)一并回传, // 管理员在浏览器控制台就能看到,不用再去翻服务器日志猜是哪一层给的 404。 wp_send_json_error( array( 'message' => $fetch['message'] . APGT_Translator::debug_summary( $fetch['debug'] ), 'debug' => $fetch['debug'], ) ); } $texts = APGT_Html::extract( $fetch['html'], APGT_Translator::translate_urls_enabled(), $node_types ); if ( empty( $texts ) ) { APGT_Progress::save( $path, $to, 0, 0 ); wp_send_json_success( array( 'source' => new stdClass(), 'trans' => new stdClass(), 'total' => 0, 'done' => 0, 'message' => __( 'No translatable text was extracted from this page', 'aipogeo-context-translate' ), ) ); } $map = APGT_Store::get_trans_map( array_keys( $texts ), $from, $to ); $source = array(); $trans = array(); $types = array(); $seq = 0; $done = 0; foreach ( $texts as $md5 => $text ) { ++$seq; $source[ (string) $seq ] = $text; $trans[ (string) $seq ] = isset( $map[ $md5 ] ) ? $map[ $md5 ] : ''; // 序号 => 类型,随保存/翻译一起回传,入库时才知道该标 text 还是 uri。 $types[ (string) $seq ] = isset( $node_types[ $md5 ] ) ? $node_types[ $md5 ] : 'text'; if ( isset( $map[ $md5 ] ) ) { ++$done; } } // 打开页面顺带把进度校准一次:别的页面译好的共享文本也会算进来。 APGT_Progress::save( $path, $to, count( $source ), $done ); wp_send_json_success( array( 'source' => $source, 'trans' => $trans, 'types' => $types, 'total' => count( $source ), 'done' => $done, ) ); } /** * 取某条原文该标什么 type。 * * 优先用前端回传的类型(来自加载时的 DOM 解析);没有就沿用库里已有记录的, * 都没有才落到 text。 * * @param string $seq 序号。 * @param array $types 前端回传的 序号 => 类型。 * @param string $text 原文。 * @param string $from 源语言。 * @param string $to 目标语言。 * @return string 'text' 或 'uri' */ private function resolve_type( $seq, array $types, $text, $from, $to ) { if ( isset( $types[ $seq ] ) && 'uri' === $types[ $seq ] ) { return 'uri'; } if ( isset( $types[ $seq ] ) && 'text' === $types[ $seq ] ) { return 'text'; } $row = APGT_Store::get( $text, $from, $to ); return ( $row && 'uri' === $row['type'] ) ? 'uri' : 'text'; } /** * 调用 AI 翻译当前页面的原文 JSON,结果入库并回填。 * * @return void */ public function translate_page() { $this->guard(); $path = $this->request_path(); $to = $this->request_language(); $from = $this->source_language(); $source = $this->request_json( 'source_json' ); if ( empty( $source ) ) { wp_send_json_error( array( 'message' => __( 'No source text to translate', 'aipogeo-context-translate' ) ) ); } $types = $this->request_json( 'types_json' ); $skip_done = ! empty( $_POST['skip_translated'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce 与权限由 guard() 统一校验。 // 序号 => 原文 转成 md5 => 原文,同时记住 md5 => 序号 以便回填。 $pending = array(); $md5_to_seq = array(); $skipped = 0; $existing = array(); foreach ( $source as $seq => $text ) { $text = trim( (string) $text ); if ( '' === $text ) { continue; } $md5 = md5( $text ); $md5_to_seq[ $md5 ] = (string) $seq; if ( mb_strlen( $text, 'UTF-8' ) > APGT_Translator::MAX_TEXT_LENGTH ) { ++$skipped; continue; } if ( $skip_done ) { $row = APGT_Store::get( $text, $from, $to ); if ( $row && '' !== (string) $row['text_trans'] ) { $existing[ (string) $seq ] = $row['text_trans']; ++$skipped; continue; } } $pending[ $md5 ] = $text; } if ( empty( $pending ) ) { wp_send_json_success( array( 'trans' => $existing, 'saved' => 0, 'skipped' => $skipped, 'failed' => 0, 'errors' => array(), 'message' => __( 'Nothing left to translate (all items already had translations and were skipped)', 'aipogeo-context-translate' ), ) ); } $rules = APGT_Translator::text_replace_rules( $to ); $lock = $this->start_task( $path, $to ); // 从这里开始连接已断开(后台模式),出口只能走 task_output()。 $job = $this->begin_job(); $result = $this->run_task( $lock, $job, function () use ( $pending, $from, $to, $rules ) { return APGT_Translator::translate_texts( $pending, $from, $to, $rules ); } ); // 入库并按序号回填。 $out = $existing; $saved = 0; foreach ( $result['trans'] as $md5 => $text_trans ) { if ( ! isset( $pending[ $md5 ] ) || '' === trim( $text_trans ) ) { continue; } $seq = isset( $md5_to_seq[ $md5 ] ) ? $md5_to_seq[ $md5 ] : ''; $ok = APGT_Store::save( array( 'text_source' => $pending[ $md5 ], 'text_trans' => $text_trans, 'language_from' => $from, 'language_to' => $to, 'source_url' => $path, 'engine' => 'ai', 'type' => $this->resolve_type( $seq, $types, $pending[ $md5 ], $from, $to ), ) ); if ( $ok ) { ++$saved; } if ( isset( $md5_to_seq[ $md5 ] ) ) { $out[ $md5_to_seq[ $md5 ] ] = $text_trans; } } $failed = count( $pending ) - $saved; $this->refresh_progress( $path, $to, $from, $source ); $this->task_output( $job, true, array( 'trans' => $out, 'saved' => $saved, 'skipped' => $skipped, 'failed' => $failed, 'errors' => $result['errors'], 'message' => sprintf( /* translators: 1: succeeded, 2: skipped, 3: failed */ __( 'AI translation finished: %1$d succeeded, %2$d skipped, %3$d failed', 'aipogeo-context-translate' ), $saved, $skipped, $failed ), ) ); } /** * 保存人工校对后的译文 JSON。 * * @return void */ public function save_page() { $this->guard(); $path = $this->request_path(); $to = $this->request_language(); $from = $this->source_language(); $source = $this->request_json( 'source_json' ); $trans = $this->request_json( 'trans_json' ); $types = $this->request_json( 'types_json' ); if ( empty( $source ) ) { wp_send_json_error( array( 'message' => __( 'The source data is empty, nothing to save', 'aipogeo-context-translate' ) ) ); } $saved = 0; $deleted = 0; $failed = 0; foreach ( $source as $seq => $text_source ) { $text_source = trim( (string) $text_source ); if ( '' === $text_source ) { continue; } $text_trans = isset( $trans[ $seq ] ) ? trim( (string) $trans[ $seq ] ) : ''; // 译文被清空:删掉记录,让该条回落到显示原文。 if ( '' === $text_trans ) { $row = APGT_Store::get( $text_source, $from, $to ); if ( $row ) { APGT_Store::delete( (int) $row['id'] ); ++$deleted; } continue; } $ok = APGT_Store::save( array( 'text_source' => $text_source, 'text_trans' => $text_trans, 'language_from' => $from, 'language_to' => $to, 'source_url' => $path, 'engine' => 'manual', 'type' => $this->resolve_type( (string) $seq, $types, $text_source, $from, $to ), ) ); if ( $ok ) { ++$saved; } else { ++$failed; } } $this->refresh_progress( $path, $to, $from, $source ); wp_send_json_success( array( 'saved' => $saved, 'deleted' => $deleted, 'failed' => $failed, 'message' => sprintf( /* translators: 1: saved, 2: cleared, 3: failed */ __( '%1$d saved, %2$d cleared, %3$d failed', 'aipogeo-context-translate' ), $saved, $deleted, $failed ), ) ); } /** * 取各页面在指定语言下的已译条数(切换语言时刷新下拉标记)。 * * @return void */ public function page_status() { $this->guard(); $to = $this->request_language(); wp_send_json_success( array( 'progress' => APGT_Progress::map( $to ) ) ); } /** * 取指定语言下的待翻译 / 已翻译页面清单(「全部翻译」的任务源)。 * * 除本语言的清单外,还带上各目标语言的待办数(当前语言排最前), * 「全部翻译」据此把整轮任务排满:翻完这门语言接着取下一门的清单。 * * @return void */ public function pending_pages() { $this->guard(); $to = $this->request_language(); $this->source_language(); $split = APGT_Pages::split_by_progress( $to ); wp_send_json_success( array( 'pending' => $split['pending'], 'pending_count' => count( $split['pending'] ), 'done_count' => count( $split['done'] ), 'total' => count( $split['pending'] ) + count( $split['done'] ), 'languages' => APGT_Pages::progress_summary( $to ), ) ); } /** * 整页翻译一个页面(「全部翻译」逐页调用)。 * * 与手动「AI 翻译」不同,这里由服务端自己抓页面、提取文本、跳过已译条目, * 前端只负责推进度,所以不需要回传 JSON。 * * @return void */ public function translate_one() { $this->guard(); $path = $this->request_path(); $to = $this->request_language(); $this->source_language(); $lock = $this->start_task( $path, $to ); // 从这里开始连接已断开(后台模式),出口只能走 task_output()。 $job = $this->begin_job(); $result = $this->run_task( $lock, $job, function () use ( $path, $to ) { return APGT_Translator::translate_page( APGT_Pages::to_absolute( $path ), $to ); } ); $this->task_output( $job, ! empty( $result['ok'] ), array( 'message' => $result['message'], 'saved' => $result['saved'], 'skipped' => $result['skipped'], 'failed' => $result['failed'], 'errors' => $result['errors'], ) ); } /** * 后台 worker 心跳:挑一个待办页面翻译。 * * 由登录后台的管理员浏览器定时触发。页面锁保证同一个页面同一时刻 * 只有一个进程在翻,撞上的心跳会立刻空转返回,不会堆叠。 * * @return void */ public function worker_tick() { $this->guard(); $settings = APGT_Settings::get_settings(); if ( empty( $settings['auto_worker'] ) ) { wp_send_json_success( array( 'state' => 'disabled', 'message' => __( 'Background translation is not enabled', 'aipogeo-context-translate' ), ) ); } if ( '' === APGT_Settings::default_language() || ! APGT_Settings::target_languages() ) { wp_send_json_success( array( 'state' => 'idle', 'message' => __( 'No languages configured yet', 'aipogeo-context-translate' ), ) ); } $task = APGT_Pages::next_task(); if ( ! $task ) { wp_send_json_success( array( 'state' => 'idle', 'message' => __( 'No pending pages', 'aipogeo-context-translate' ), ) ); } // 锁按页面区分,只锁自己要翻的这一页;这页正被别人翻(多半是 // 另一个管理员的手动翻译或另一份 worker 心跳),本次空转,等下 // 一跳时它的进度已更新,next_task 自然会给出下一个页面。 $key = APGT_Lock::key( $task['url'], $task['language_to'] ); $owner = APGT_Lock::make_owner(); if ( ! APGT_Lock::acquire( $key, $owner ) ) { wp_send_json_success( array( 'state' => 'busy', 'task' => $task, 'holder' => APGT_Lock::holder_name( $key ), ) ); } APGT_Translator::prepare_long_task(); APGT_Lock::keep_alive( $key, $owner ); // 心跳不需要回传翻译结果(worker.js 只看 disabled),直接应答 // 「已开始」并断开连接,翻译在本进程里跑完。锁还握着,后续心跳 // 会空转返回 busy,直到这一页翻完、进度更新。 $detached = APGT_Jobs::detach( array( 'state' => 'started', 'task' => $task, ) ); try { $result = APGT_Translator::translate_page( APGT_Pages::to_absolute( $task['url'] ), $task['language_to'] ); } catch ( \Throwable $e ) { APGT_Lock::release( $key, $owner ); if ( $detached ) { exit; } wp_send_json_error( array( 'message' => sprintf( /* translators: %s: error message */ __( 'Translation aborted unexpectedly: %s', 'aipogeo-context-translate' ), $e->getMessage() ), ) ); } APGT_Lock::release( $key, $owner ); if ( $detached ) { exit; } wp_send_json_success( array( 'state' => 'done', 'task' => $task, 'ok' => $result['ok'], 'message' => $result['message'], 'saved' => $result['saved'], 'failed' => $result['failed'], 'errors' => $result['errors'], ) ); } }