bool,'code'=>string,'message'=>string,'data'=>array,'http'=>int] */ protected static function request( $path, $body = null, $timeout = 20 ) { $base = self::base_url(); if ( '' === $base ) { return self::err( 'no_service_url', __( 'No service address has been entered yet', 'aipogeo-context-translate' ) ); } $ai = APGT_Settings::get_ai(); $key = trim( (string) ( isset( $ai['license_key'] ) ? $ai['license_key'] : '' ) ); $args = array( 'timeout' => (int) $timeout, 'headers' => array( // 密钥走 Authorization 头;部分主机会吞掉这个头,所以 POST 里再带一份 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', ), ); if ( null === $body ) { $url = $base . $path . ( false === strpos( $path, '?' ) ? '?' : '&' ) . 'license_key=' . rawurlencode( $key ); $res = wp_remote_get( $url, $args ); } else { $body['license_key'] = $key; $args['body'] = $body; $res = wp_remote_post( $base . $path, $args ); } if ( is_wp_error( $res ) ) { // 网络层失败:不要当成"密钥无效",否则用户会被误导去重置密钥 return self::err( 'network', $res->get_error_message() ); } $http = (int) wp_remote_retrieve_response_code( $res ); $json = json_decode( (string) wp_remote_retrieve_body( $res ), true ); if ( ! is_array( $json ) ) { return self::err( 'bad_response', sprintf( /* translators: %d: HTTP status code */ __( 'The service returned something that could not be read (HTTP %d)', 'aipogeo-context-translate' ), $http ), $http ); } $data = isset( $json['data'] ) && is_array( $json['data'] ) ? $json['data'] : array(); $ok = isset( $json['status'] ) && 'yes' === $json['status']; return array( 'ok' => $ok, 'code' => isset( $data['code'] ) ? (string) $data['code'] : ( $ok ? '' : 'failed' ), 'message' => isset( $json['message'] ) ? (string) $json['message'] : '', 'data' => $data, 'http' => $http, ); } protected static function err( $code, $message, $http = 0 ) { return array( 'ok' => false, 'code' => $code, 'message' => $message, 'data' => array(), 'http' => (int) $http ); } /** * 激活:把本站登记到订阅的绑定列表。 * * @return array 同 request() */ public static function activate() { $res = self::request( '/api/v1/subscription/activate', array( 'site_url' => self::site_url() ) ); if ( $res['ok'] ) { self::remember( $res['data'] ); } return $res; } /** 解绑本站 */ public static function deactivate() { $res = self::request( '/api/v1/subscription/deactivate', array( 'site_url' => self::site_url() ) ); delete_transient( self::STATUS_TRANSIENT ); return $res; } /** * 查询订阅状态。 * * @param bool $force 跳过本地缓存 */ public static function status( $force = false ) { if ( ! $force ) { $cached = get_transient( self::STATUS_TRANSIENT ); if ( is_array( $cached ) ) { return array( 'ok' => true, 'code' => '', 'message' => '', 'data' => $cached, 'http' => 200 ); } } $res = self::request( '/api/v1/subscription/status?site_url=' . rawurlencode( self::site_url() ), null ); if ( $res['ok'] ) { self::remember( $res['data'] ); } return $res; } /** * 上报本次翻译消耗的用量(token)。 * * 走 translate() 代理的路径不需要调这里 —— 服务端按上游返回的用量当场扣。 * 留着是给自带 Key、自行统计用量的场景用。 * * 放在翻译成功之后调用:失败的请求不该计费。服务端的扣减是单条 UPDATE, * 余额不足会直接拒绝并返回 quota_exhausted,插件据此停止后续翻译。 * * @param int $tokens * @return array */ public static function consume( $tokens ) { $tokens = (int) $tokens; if ( $tokens < 1 ) { return array( 'ok' => true, 'code' => '', 'message' => '', 'data' => array(), 'http' => 200 ); } $res = self::request( '/api/v1/subscription/consume', array( 'tokens' => $tokens, 'site_url' => self::site_url(), ) ); if ( $res['ok'] ) { self::remember( $res['data'] ); } return $res; } /** * 代调翻译:把渲染好的指令交给服务端,由它转发上游模型并按实际用量扣额度。 * * 订阅模式下 AI 凭据只存在服务端,本站不持有 —— 这正是订阅要解决的事。 * 用量也由服务端按上游返回的 token 数自己算,不再由本站上报,所以走这条路径的 * 翻译不需要另外调 consume()。 * * @param string $prompt 完整指令。 * @param int $max_tokens 0 表示不限制,交给服务端与模型的默认值。 * @return array 同 request();成功时 data 含 content / finish_reason / billed。 */ public static function translate( $prompt, $max_tokens = 0 ) { $prompt = (string) $prompt; if ( '' === trim( $prompt ) ) { return self::err( 'empty_prompt', __( 'There is nothing to translate', 'aipogeo-context-translate' ) ); } $body = array( 'site_url' => self::site_url(), 'prompt' => $prompt, ); if ( (int) $max_tokens > 0 ) { $body['max_tokens'] = (int) $max_tokens; } $res = self::request( '/api/v1/subscription/translate', $body, self::TRANSLATE_TIMEOUT ); // 响应里带着扣减后的额度,顺手刷新快照,省下一次 status 请求 if ( $res['ok'] ) { self::remember( $res['data'] ); } return $res; } /** * 把最近一次订阅信息记到设置里(设置页展示用,避免每次打开都请求服务端)。 */ protected static function remember( array $data ) { set_transient( self::STATUS_TRANSIENT, $data, self::STATUS_TTL ); $ai = APGT_Settings::get_ai(); $ai['license_snapshot'] = $data; $ai['license_checked'] = time(); if ( isset( $data['sites'] ) && is_array( $data['sites'] ) ) { // 服务端归一化后的本站地址(协议+主机),用于设置页显示"已绑定" $ai['license_site'] = isset( $data['site_bound'] ) && $data['site_bound'] ? self::site_url() : ''; } APGT_Settings::save_ai( $ai ); } /** * 额度是否已经不够继续翻译。 * * 只看本地快照,不发请求 —— 用在翻译循环里,避免每页都打一次服务端。 * 真正的判定以 consume() 的返回为准。 * * @return bool */ public static function out_of_quota() { $ai = APGT_Settings::get_ai(); $snap = isset( $ai['license_snapshot'] ) && is_array( $ai['license_snapshot'] ) ? $ai['license_snapshot'] : array(); if ( ! $snap ) { return false; // 还没查过,交给服务端判断 } if ( ! empty( $snap['quota_unlimited'] ) ) { return false; } return isset( $snap['quota_left'] ) && (int) $snap['quota_left'] <= 0; } /* ===================================================================== * 一键连接 * * 用户买完订阅后不必再手工复制密钥:从设置页跳到服务站点选订阅授权, * 服务端把一次性 code 回调回来,插件在后台用 code 换密钥。 * 密钥因此不经过浏览器地址栏,也就不会落进历史记录、日志或 Referer。 * ===================================================================*/ /** 回调落点:插件设置页;state 存在 transient 里,回来时比对 */ const STATE_TRANSIENT = 'apgt_connect_state'; const STATE_TTL = 900; // 15 分钟,够用户登录+选订阅 /** * 生成「去服务站点授权」的跳转地址。 * * @return string */ public static function connect_url() { $state = wp_generate_password( 32, false ); set_transient( self::STATE_TRANSIENT, $state, self::STATE_TTL ); return add_query_arg( array( 'site_url' => rawurlencode( self::site_url() ), 'return_url' => rawurlencode( self::callback_url() ), 'state' => rawurlencode( $state ), ), self::base_url() . '/connect' ); } /** 授权完成后服务端把用户送回这里 */ public static function callback_url() { return admin_url( 'admin.php?page=' . APGT_Admin::PAGE_SLUG . '&tab=ai' ); } /** * 处理服务端回调:拿 code 换密钥并保存。 * * 在 admin_init 里调用。成功后会把 provider 切到 service 并激活本站, * 用户回到设置页时已经是「可以直接翻译」的状态。 * * @return array|null ['ok'=>bool,'message'=>string];没有回调参数时返回 null */ public static function handle_callback() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 用 state 比对,见下 $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; if ( '' === $code ) { return null; } // state 必须与发起时存的一致:挡住别人构造链接把任意 code 塞进来 $expect = get_transient( self::STATE_TRANSIENT ); delete_transient( self::STATE_TRANSIENT ); if ( ! $expect || ! hash_equals( (string) $expect, $state ) ) { return array( 'ok' => false, 'message' => __( 'The connection could not be verified. Start the connection again.', 'aipogeo-context-translate' ) ); } // 换密钥:服务器到服务器,密钥不经过浏览器 $res = self::request( '/api/v1/subscription/exchange', array( 'code' => $code ) ); if ( ! $res['ok'] || empty( $res['data']['license_key'] ) ) { return array( 'ok' => false, 'message' => self::explain( $res ) ); } $data = $res['data']; $ai = APGT_Settings::get_ai(); $ai['provider'] = 'service'; $ai['license_key'] = (string) $data['license_key']; $ai['license_checked'] = time(); unset( $data['license_key'] ); // 快照里不留明文 $ai['license_snapshot'] = $data; $ai['license_site'] = ! empty( $data['site_bound'] ) ? self::site_url() : ''; APGT_Settings::save_ai( $ai ); set_transient( self::STATUS_TRANSIENT, $data, self::STATUS_TTL ); return array( 'ok' => true, 'message' => sprintf( /* translators: %s: subscription product name */ __( 'Connected to "%s". This site can start translating.', 'aipogeo-context-translate' ), isset( $data['product_name'] ) ? $data['product_name'] : '' ), ); } /** * 把服务端返回的错误码翻成给站长看的话。 * * 关键是让人知道"该去做什么":是去续费、去重置密钥,还是等一会儿再试。 */ public static function explain( array $res ) { $map = array( 'missing_key' => __( 'No access key has been entered yet.', 'aipogeo-context-translate' ), 'bad_key' => __( 'That access key is not valid. Check that you copied all of it.', 'aipogeo-context-translate' ), 'key_revoked' => __( 'That access key has been revoked. Generate a new one in your account.', 'aipogeo-context-translate' ), 'subscription_inactive' => __( 'The subscription has expired or was cancelled. Renew it to carry on.', 'aipogeo-context-translate' ), 'quota_exhausted' => __( 'This billing period\'s translation quota is used up. Upgrade the plan or wait for the next period.', 'aipogeo-context-translate' ), 'site_limit' => __( 'The subscription is already linked to as many sites as it allows. Release another site in your account first.', 'aipogeo-context-translate' ), 'site_not_bound' => __( 'This site is not activated yet. Press "Activate this site" once.', 'aipogeo-context-translate' ), 'network' => __( 'The service could not be reached. Check the service address and the server\'s network access.', 'aipogeo-context-translate' ), 'bad_response' => __( 'The service returned an error. Please try again shortly.', 'aipogeo-context-translate' ), 'prompt_too_large' => __( 'That batch was too large for one request. It will be split up and retried.', 'aipogeo-context-translate' ), 'upstream_failed' => __( 'The translation service could not reach its AI provider. Please try again shortly.', 'aipogeo-context-translate' ), 'empty_response' => __( 'The AI returned nothing this time. Please try again shortly.', 'aipogeo-context-translate' ), 'ai_not_configured' => __( 'The translation service has no AI model configured yet. Please contact support.', 'aipogeo-context-translate' ), ); $code = isset( $res['code'] ) ? $res['code'] : ''; if ( isset( $map[ $code ] ) ) { return $map[ $code ]; } return '' !== (string) $res['message'] ? (string) $res['message'] : __( 'That did not work. Please try again shortly.', 'aipogeo-context-translate' ); } /** * 这个失败还值不值得重试。 * * 密钥、订阅、站点绑定、配置这几类问题,拆小批次再发一百次也是同样的结果, * 只会白白拖长任务并反复打服务端;网络抖动和上游故障则相反,值得再试。 * * @param array $res request() 的返回。 * @return bool true = 别再试了。 */ public static function is_fatal( array $res ) { $stop = array( 'missing_key', 'bad_key', 'key_revoked', 'subscription_inactive', 'quota_exhausted', 'site_limit', 'site_not_bound', 'ai_not_configured', 'no_service_url', ); return in_array( isset( $res['code'] ) ? $res['code'] : '', $stop, true ); } }