refactor(autoload): migrate theme classes to Composer PSR-4
Move 288 types from global AKSRT_* + inc/classes manual requires into
namespace AKSRT\{Domain} under src/AKSRT/, wire composer.json psr-4,
and slim bootstrap to helpers/modules/hooks only. Split multi-class
widget/gateway files, update admin contract audit paths, and document
the layout in ADR/README.
This commit is contained in:
1 parent
8f349b40fb
commit
aaf458af1c
404 files changed
+13602
-7705
No files matched your search
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class APIAudit {
|
||||
|
||||
private static $instance = null;
|
||||
private static $table_name = '';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
global $wpdb;
|
||||
self::$table_name = $wpdb->prefix . 'aksrt_api_audit';
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_hooks']);
|
||||
add_action('aksrt_db_migrate', [$this, 'maybe_create_table']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if audit logging is enabled
|
||||
*/
|
||||
private function is_enabled(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) return false;
|
||||
return (bool) \AKSRT\Settings\Settings::get_value('api_audit_enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST pre-dispatch hook to log requests
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
if (!$this->is_enabled()) return;
|
||||
|
||||
// Log after response to capture status code
|
||||
add_filter('rest_post_dispatch', [$this, 'log_request'], 10, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a REST API request
|
||||
*/
|
||||
public function log_request($result, $server, $request) {
|
||||
$route = $request->get_route();
|
||||
$method = $request->get_method();
|
||||
|
||||
// Only log write operations (POST, PUT, DELETE, PATCH)
|
||||
if (!in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Skip non-aksrt routes
|
||||
if (strpos($route, '/aksrt/') === false) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Skip health checks and non-sensitive routes
|
||||
$skip_patterns = ['/health', '/settings/export', '/settings/import'];
|
||||
foreach ($skip_patterns as $pattern) {
|
||||
if (strpos($route, $pattern) !== false) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$ip = $this->get_client_ip();
|
||||
$status = $result instanceof \WP_REST_Response ? $result->get_status() : 200;
|
||||
|
||||
$this->insert_log([
|
||||
'user_id' => $user_id,
|
||||
'method' => $method,
|
||||
'route' => $route,
|
||||
'ip' => $ip,
|
||||
'status' => $status,
|
||||
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually log an event
|
||||
*/
|
||||
public static function log(string $action, array $context = [], int $user_id = 0): void {
|
||||
$instance = self::get_instance();
|
||||
if (!$instance->is_enabled()) return;
|
||||
|
||||
$instance->insert_log([
|
||||
'user_id' => $user_id ?: get_current_user_id(),
|
||||
'method' => 'ACTION',
|
||||
'route' => $action,
|
||||
'ip' => $instance->get_client_ip(),
|
||||
'status' => 200,
|
||||
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
'context' => wp_json_encode($context, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a log entry
|
||||
*/
|
||||
private function insert_log(array $data): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->insert(self::$table_name, [
|
||||
'user_id' => $data['user_id'] ?? 0,
|
||||
'method' => $data['method'] ?? '',
|
||||
'route' => $data['route'] ?? '',
|
||||
'ip' => $data['ip'] ?? '',
|
||||
'status' => $data['status'] ?? 0,
|
||||
'user_agent' => $data['user_agent'] ?? '',
|
||||
'context' => $data['context'] ?? '',
|
||||
'created_at' => current_time('mysql', true),
|
||||
], ['%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP
|
||||
*/
|
||||
private function get_client_ip(): string {
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
$ip = trim($parts[0]);
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$ip = $_SERVER['HTTP_X_REAL_IP'];
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
}
|
||||
return sanitize_text_field($ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create audit table if not exists
|
||||
*/
|
||||
public function maybe_create_table(): void {
|
||||
global $wpdb;
|
||||
$table = self::$table_name;
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
method VARCHAR(10) NOT NULL DEFAULT '',
|
||||
route VARCHAR(255) NOT NULL DEFAULT '',
|
||||
ip VARCHAR(45) NOT NULL DEFAULT '',
|
||||
status SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
user_agent VARCHAR(255) NOT NULL DEFAULT '',
|
||||
context TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_route (route),
|
||||
KEY idx_created_at (created_at)
|
||||
) {$charset};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit logs with pagination
|
||||
*/
|
||||
public function query_logs(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = [
|
||||
'per_page' => 20,
|
||||
'page' => 1,
|
||||
'user_id' => 0,
|
||||
'route' => '',
|
||||
'method' => '',
|
||||
'from' => '',
|
||||
'to' => '',
|
||||
];
|
||||
$args = wp_parse_args($args, $defaults);
|
||||
|
||||
$where = ['1=1'];
|
||||
$values = [];
|
||||
|
||||
if ($args['user_id'] > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$values[] = $args['user_id'];
|
||||
}
|
||||
if (!empty($args['route'])) {
|
||||
$where[] = 'route LIKE %s';
|
||||
$values[] = '%' . $wpdb->esc_like($args['route']) . '%';
|
||||
}
|
||||
if (!empty($args['method'])) {
|
||||
$where[] = 'method = %s';
|
||||
$values[] = $args['method'];
|
||||
}
|
||||
if (!empty($args['from'])) {
|
||||
$where[] = 'created_at >= %s';
|
||||
$values[] = $args['from'];
|
||||
}
|
||||
if (!empty($args['to'])) {
|
||||
$where[] = 'created_at <= %s';
|
||||
$values[] = $args['to'];
|
||||
}
|
||||
|
||||
$where_sql = implode(' AND ', $where);
|
||||
$offset = ($args['page'] - 1) * $args['per_page'];
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM " . self::$table_name . " WHERE {$where_sql}";
|
||||
$total = (int) ($values
|
||||
? $wpdb->get_var($wpdb->prepare($count_sql, $values))
|
||||
: $wpdb->get_var($count_sql));
|
||||
|
||||
$values[] = $args['per_page'];
|
||||
$values[] = $offset;
|
||||
|
||||
$logs = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM " . self::$table_name . " WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
|
||||
$values
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'per_page'=> $args['per_page'],
|
||||
'page' => $args['page'],
|
||||
'logs' => $logs ?: [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge old logs
|
||||
*/
|
||||
public function purge_old(int $days = 90): int {
|
||||
global $wpdb;
|
||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
||||
return (int) $wpdb->query(
|
||||
$wpdb->prepare("DELETE FROM " . self::$table_name . " WHERE created_at < %s", $cutoff)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ContentAudit {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核文本内容
|
||||
*
|
||||
* @param string $text 待审文本
|
||||
* @param bool $force 管理端测试用:跳过启用开关与失败放行策略,直接打服务商
|
||||
* @return array{passed: bool, reason: string, labels: array, raw: array}
|
||||
*/
|
||||
public function audit_text(string $text, bool $force = false): array {
|
||||
$config = $this->get_config();
|
||||
|
||||
if (
|
||||
!$force
|
||||
&& (empty($config['content_audit_enabled']) || empty($config['content_audit_text_enabled']))
|
||||
) {
|
||||
return $this->ok('disabled');
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu'));
|
||||
$result = $this->call_text_api($provider, $text, $config);
|
||||
|
||||
// 管理端强制测试不写生产日志,避免污染审计流水
|
||||
if (!$force && !empty($config['content_audit_log_enabled'])) {
|
||||
$this->log_audit('text', $text, $result);
|
||||
}
|
||||
|
||||
// 管理端强制测试保留服务商原始结论,不套用 on_failure=allow
|
||||
if (
|
||||
!$force
|
||||
&& !$result['passed']
|
||||
&& ($config['content_audit_on_failure'] ?? 'allow') === 'allow'
|
||||
) {
|
||||
$result['passed'] = true;
|
||||
$result['reason'] .= ' (failure_policy: allow)';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核图片
|
||||
*
|
||||
* @param string $image_path 本地文件路径或 URL
|
||||
* @return array{passed: bool, reason: string, labels: array, raw: array}
|
||||
*/
|
||||
public function audit_image(string $image_path): array {
|
||||
$config = $this->get_config();
|
||||
|
||||
if (empty($config['content_audit_enabled']) || empty($config['content_audit_image_enabled'])) {
|
||||
return $this->ok('disabled');
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu'));
|
||||
$result = $this->call_image_api($provider, $image_path, $config);
|
||||
|
||||
if (!empty($config['content_audit_log_enabled'])) {
|
||||
$this->log_audit('image', $image_path, $result);
|
||||
}
|
||||
|
||||
if (!$result['passed'] && ($config['content_audit_on_failure'] ?? 'allow') === 'allow') {
|
||||
$result['passed'] = true;
|
||||
$result['reason'] .= ' (failure_policy: allow)';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务运行时状态
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$enabled = !empty($config['content_audit_enabled']);
|
||||
$provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu'));
|
||||
|
||||
$missing = [];
|
||||
if ($enabled && $provider === 'baidu') {
|
||||
if (empty($config['content_audit_api_key'])) $missing[] = 'content_audit_api_key';
|
||||
if (empty($config['content_audit_api_secret'])) $missing[] = 'content_audit_api_secret';
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'provider' => $provider,
|
||||
'configured' => $enabled && ($provider === 'null' || empty($missing)),
|
||||
'available' => $enabled,
|
||||
'verified' => !empty($config['content_audit_verified']),
|
||||
'transport' => 'http_api',
|
||||
'missing_fields' => $missing,
|
||||
'text_enabled' => !empty($config['content_audit_text_enabled']),
|
||||
'image_enabled' => !empty($config['content_audit_image_enabled']),
|
||||
'on_failure' => (string) ($config['content_audit_on_failure'] ?? 'allow'),
|
||||
'log_enabled' => !empty($config['content_audit_log_enabled']),
|
||||
];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Provider dispatch
|
||||
* ============================================================ */
|
||||
|
||||
private function call_text_api(string $provider, string $text, array $config): array {
|
||||
switch ($provider) {
|
||||
case 'baidu':
|
||||
return $this->baidu_text_censor($text, $config);
|
||||
default:
|
||||
return $this->local_pattern_check($text, $provider);
|
||||
}
|
||||
}
|
||||
|
||||
private function call_image_api(string $provider, string $image_path, array $config): array {
|
||||
switch ($provider) {
|
||||
case 'baidu':
|
||||
return $this->baidu_image_censor($image_path, $config);
|
||||
default:
|
||||
return $this->ok('no_image_provider');
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 百度内容审核 Provider
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* 百度文本审核
|
||||
*/
|
||||
private function baidu_text_censor(string $text, array $config): array {
|
||||
$api_key = trim((string) ($config['content_audit_api_key'] ?? ''));
|
||||
$api_secret = trim((string) ($config['content_audit_api_secret'] ?? ''));
|
||||
if ($api_key === '' || $api_secret === '') {
|
||||
return ['passed' => false, 'reason' => 'config_missing', 'labels' => [], 'raw' => []];
|
||||
}
|
||||
|
||||
$token = $this->baidu_get_access_token($api_key, $api_secret, $config);
|
||||
if ($token === '') {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => []];
|
||||
}
|
||||
|
||||
$censor_url = trim((string) ($config['content_audit_baidu_text_url'] ?? ''));
|
||||
if ($censor_url === '') {
|
||||
$censor_url = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined';
|
||||
}
|
||||
|
||||
$request_url = add_query_arg(['access_token' => $token], $censor_url);
|
||||
$response = wp_remote_post($request_url, [
|
||||
'timeout' => 15,
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'body' => http_build_query(['text' => $text]),
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['error' => $response->get_error_message()]];
|
||||
}
|
||||
|
||||
$status_code = (int) wp_remote_retrieve_response_code($response);
|
||||
if ($status_code !== 200) {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['status' => $status_code]];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($payload) || isset($payload['error_code'])) {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => $payload ?: []];
|
||||
}
|
||||
|
||||
return $this->parse_baidu_result($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 百度图片审核
|
||||
*/
|
||||
private function baidu_image_censor(string $image_path, array $config): array {
|
||||
$api_key = trim((string) ($config['content_audit_api_key'] ?? ''));
|
||||
$api_secret = trim((string) ($config['content_audit_api_secret'] ?? ''));
|
||||
if ($api_key === '' || $api_secret === '') {
|
||||
return ['passed' => false, 'reason' => 'config_missing', 'labels' => [], 'raw' => []];
|
||||
}
|
||||
|
||||
$token = $this->baidu_get_access_token($api_key, $api_secret, $config);
|
||||
if ($token === '') {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => []];
|
||||
}
|
||||
|
||||
// 支持本地路径和 URL
|
||||
if (filter_var($image_path, FILTER_VALIDATE_URL)) {
|
||||
$image_data = @file_get_contents($image_path);
|
||||
} else {
|
||||
$image_data = @file_get_contents($image_path);
|
||||
}
|
||||
if ($image_data === false) {
|
||||
return ['passed' => false, 'reason' => 'file_error', 'labels' => [], 'raw' => []];
|
||||
}
|
||||
|
||||
$censor_url = trim((string) ($config['content_audit_baidu_img_url'] ?? ''));
|
||||
if ($censor_url === '') {
|
||||
$censor_url = 'https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined';
|
||||
}
|
||||
|
||||
$request_url = add_query_arg(['access_token' => $token], $censor_url);
|
||||
$response = wp_remote_post($request_url, [
|
||||
'timeout' => 30,
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'body' => http_build_query(['image' => base64_encode($image_data)]),
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['error' => $response->get_error_message()]];
|
||||
}
|
||||
|
||||
$status_code = (int) wp_remote_retrieve_response_code($response);
|
||||
if ($status_code !== 200) {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['status' => $status_code]];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($payload) || isset($payload['error_code'])) {
|
||||
return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => $payload ?: []];
|
||||
}
|
||||
|
||||
return $this->parse_baidu_result($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 百度 access_token 获取(带缓存)
|
||||
*/
|
||||
private function baidu_get_access_token(string $api_key, string $api_secret, array $config): string {
|
||||
$cache_key = 'aksrt_baidu_token_' . md5($api_key);
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached) return (string) $cached;
|
||||
|
||||
$token_url = trim((string) ($config['content_audit_baidu_token_url'] ?? ''));
|
||||
if ($token_url === '') {
|
||||
$token_url = 'https://aip.baidubce.com/oauth/2.0/token';
|
||||
}
|
||||
|
||||
$request_url = add_query_arg([
|
||||
'grant_type' => 'client_credentials',
|
||||
'client_id' => $api_key,
|
||||
'client_secret' => $api_secret,
|
||||
], $token_url);
|
||||
|
||||
$response = wp_remote_get($request_url, ['timeout' => 10]);
|
||||
if (is_wp_error($response)) return '';
|
||||
|
||||
$status_code = (int) wp_remote_retrieve_response_code($response);
|
||||
if ($status_code !== 200) return '';
|
||||
|
||||
$payload = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($payload) || isset($payload['error'])) return '';
|
||||
|
||||
$token = (string) ($payload['access_token'] ?? '');
|
||||
$expires = max(3600, (int) ($payload['expires_in'] ?? 3600) - 300);
|
||||
if ($token !== '') {
|
||||
set_transient($cache_key, $token, $expires);
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析百度审核结果 → 统一格式
|
||||
*/
|
||||
private function parse_baidu_result(array $payload): array {
|
||||
$conclusion_type = isset($payload['conclusionType']) ? (int) $payload['conclusionType'] : 1;
|
||||
$conclusion = (string) ($payload['conclusion'] ?? '');
|
||||
$top_category = $this->baidu_extract_top_category($payload);
|
||||
|
||||
// conclusionType: 1=合规, 2=不合规, 3=疑似, 4=审核失败
|
||||
if ($conclusion_type === 1) {
|
||||
return [
|
||||
'passed' => true,
|
||||
'reason' => '',
|
||||
'labels' => ['compliant'],
|
||||
'raw' => $payload,
|
||||
];
|
||||
}
|
||||
|
||||
$score_map = [2 => 95, 3 => 70, 4 => 60];
|
||||
$reason_map = [2 => 'non_compliant', 3 => 'suspected', 4 => 'review_failed'];
|
||||
|
||||
return [
|
||||
'passed' => false,
|
||||
'reason' => $reason_map[$conclusion_type] ?? 'unknown',
|
||||
'labels' => [$top_category ?: ('baidu_type_' . $conclusion_type)],
|
||||
'raw' => $payload,
|
||||
];
|
||||
}
|
||||
|
||||
private function baidu_extract_top_category(array $payload): string {
|
||||
if (!isset($payload['data']) || !is_array($payload['data']) || empty($payload['data'][0])) {
|
||||
return '';
|
||||
}
|
||||
$first = $payload['data'][0];
|
||||
if (!is_array($first)) return '';
|
||||
if (!empty($first['msg'])) return (string) $first['msg'];
|
||||
if (!empty($first['type'])) return 'type_' . (string) $first['type'];
|
||||
if (!empty($first['subType'])) return 'sub_' . (string) $first['subType'];
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 本地基础过滤(无 API 时的 fallback)
|
||||
* ============================================================ */
|
||||
|
||||
private function local_pattern_check(string $text, string $provider): array {
|
||||
$forbidden_patterns = ['/赌博/i', '/色情/i', '/枪支/i', '/毒品/i'];
|
||||
foreach ($forbidden_patterns as $pattern) {
|
||||
if (preg_match($pattern, $text, $m)) {
|
||||
return [
|
||||
'passed' => false,
|
||||
'reason' => sprintf('本地敏感词匹配: %s', $m[0]),
|
||||
'labels' => ['forbidden'],
|
||||
'raw' => ['provider' => $provider, 'method' => 'local_pattern'],
|
||||
];
|
||||
}
|
||||
}
|
||||
return $this->ok('passthrough');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Helpers
|
||||
* ============================================================ */
|
||||
|
||||
private function ok(string $reason = ''): array {
|
||||
return ['passed' => true, 'reason' => $reason, 'labels' => [], 'raw' => []];
|
||||
}
|
||||
|
||||
private function get_config(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
return \AKSRT\Settings\Settings::get_content_audit_settings();
|
||||
}
|
||||
return ['content_audit_enabled' => false];
|
||||
}
|
||||
|
||||
private function log_audit(string $type, string $content, array $result): void {
|
||||
$log = get_option('aksrt_content_audit_log', []);
|
||||
$log[] = [
|
||||
'type' => $type,
|
||||
'content' => mb_substr($content, 0, 100),
|
||||
'passed' => $result['passed'],
|
||||
'reason' => $result['reason'] ?? '',
|
||||
'time' => current_time('mysql'),
|
||||
];
|
||||
if (count($log) > 500) {
|
||||
$log = array_slice($log, -500);
|
||||
}
|
||||
update_option('aksrt_content_audit_log', $log);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class IPGeo {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 IP 归属地
|
||||
*
|
||||
* @param string $ip IPv4 or IPv6
|
||||
* @param bool $force 管理端测试用:跳过「启用」开关与缓存,仍拒绝私有 IP
|
||||
* @return array{country: string, province: string, city: string, raw: array, reason?: string}
|
||||
*/
|
||||
public function lookup(string $ip, bool $force = false): array {
|
||||
$config = $this->get_config();
|
||||
|
||||
if (!$force && empty($config['ip_geo_enabled'])) {
|
||||
return $this->empty_result('disabled');
|
||||
}
|
||||
|
||||
// 本地/私有 IP 跳过查询
|
||||
if ($this->is_private_ip($ip)) {
|
||||
return $this->empty_result('private_ip');
|
||||
}
|
||||
|
||||
$cache_key = 'aksrt_ip_geo_' . md5($ip);
|
||||
$ttl = max(3600, (int) ($config['ip_geo_cache_ttl'] ?? 86400));
|
||||
if (!$force) {
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false && is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? ($config['ip_geo_provider'] ?? 'null')));
|
||||
$result = $this->query_provider($provider, $ip, $config);
|
||||
|
||||
if (!empty($result['province']) || !empty($result['city']) || !empty($result['country'])) {
|
||||
if (!$force) {
|
||||
set_transient($cache_key, $result, $ttl);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (empty($result['reason'])) {
|
||||
$result['reason'] = 'provider_empty';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务运行时状态
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? ($config['ip_geo_provider'] ?? 'null')));
|
||||
$enabled = !empty($config['ip_geo_enabled']);
|
||||
|
||||
$missing = [];
|
||||
if ($enabled && $provider === 'qq') {
|
||||
if (empty($config['ip_addr_sdk_qq_appkey'])) $missing[] = 'ip_addr_sdk_qq_appkey';
|
||||
if (empty($config['ip_addr_sdk_qq_secretkey'])) $missing[] = 'ip_addr_sdk_qq_secretkey';
|
||||
}
|
||||
if ($enabled && $provider === 'amap') {
|
||||
if (empty($config['ip_addr_sdk_amap_appkey'])) $missing[] = 'ip_addr_sdk_amap_appkey';
|
||||
if (empty($config['ip_addr_sdk_amap_secretkey'])) $missing[] = 'ip_addr_sdk_amap_secretkey';
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'provider' => $provider,
|
||||
'configured' => $enabled && empty($missing),
|
||||
'available' => $enabled,
|
||||
'verified' => !empty($config['ip_geo_verified']),
|
||||
'transport' => 'http_api',
|
||||
'missing_fields' => $missing,
|
||||
'cache_ttl' => (int) ($config['ip_geo_cache_ttl'] ?? 86400),
|
||||
'display' => (array) ($config['ip_geo_display'] ?? ['comment']),
|
||||
'format' => (string) ($config['ip_geo_format'] ?? 'province_city'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化归属地显示文本
|
||||
*/
|
||||
public function format(array $geo, string $format = ''): string {
|
||||
if ($format === '') {
|
||||
$config = $this->get_config();
|
||||
$format = (string) ($config['ip_geo_format'] ?? 'province_city');
|
||||
}
|
||||
|
||||
switch ($format) {
|
||||
case 'province':
|
||||
return $geo['province'] ?? '';
|
||||
case 'country_province':
|
||||
return trim(($geo['country'] ?? '') . ' ' . ($geo['province'] ?? ''));
|
||||
case 'province_city':
|
||||
default:
|
||||
$parts = array_filter([$geo['province'] ?? '', $geo['city'] ?? '']);
|
||||
return implode(' ', $parts) ?: ($geo['country'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
private function get_config(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
return \AKSRT\Settings\Settings::get_ip_geo_settings();
|
||||
}
|
||||
return ['ip_geo_enabled' => false];
|
||||
}
|
||||
|
||||
private function query_provider(string $provider, string $ip, array $config): array {
|
||||
switch ($provider) {
|
||||
case 'qq':
|
||||
return $this->query_qq($ip, $config);
|
||||
case 'amap':
|
||||
return $this->query_amap($ip, $config);
|
||||
case 'polling':
|
||||
foreach (['qq', 'amap', 'null'] as $fallback) {
|
||||
$result = $this->query_provider($fallback, $ip, $config);
|
||||
if (!empty($result['province']) || !empty($result['city']) || !empty($result['country'])) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
return $this->empty_result();
|
||||
case 'null':
|
||||
default:
|
||||
return $this->query_ip_api($ip);
|
||||
}
|
||||
}
|
||||
|
||||
private function query_qq(string $ip, array $config): array {
|
||||
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
|
||||
if ($appkey === '') {
|
||||
return $this->empty_result();
|
||||
}
|
||||
|
||||
$url = add_query_arg([
|
||||
'ip' => $ip,
|
||||
'key' => $appkey,
|
||||
], 'https://apis.map.qq.com/ws/location/v1/ip');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 8,
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
if (is_wp_error($response)) return $this->empty_result();
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) return $this->empty_result();
|
||||
|
||||
$info = $data['result']['ad_info'] ?? [];
|
||||
return [
|
||||
'country' => '中国',
|
||||
'province' => (string) ($info['province'] ?? ''),
|
||||
'city' => (string) ($info['city'] ?? ''),
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
private function query_amap(string $ip, array $config): array {
|
||||
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
|
||||
if ($appkey === '') {
|
||||
return $this->empty_result();
|
||||
}
|
||||
|
||||
$url = add_query_arg([
|
||||
'ip' => $ip,
|
||||
'key' => $appkey,
|
||||
], 'https://restapi.amap.com/v3/ip');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 8,
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
if (is_wp_error($response)) return $this->empty_result();
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') return $this->empty_result();
|
||||
|
||||
return [
|
||||
'country' => '中国',
|
||||
'province' => (string) ($data['province'] ?? ''),
|
||||
'city' => (string) ($data['city'] ?? ''),
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
private function query_ip_api(string $ip): array {
|
||||
$response = wp_remote_get("http://ip-api.com/json/{$ip}?lang=zh-CN", [
|
||||
'timeout' => 5,
|
||||
]);
|
||||
if (is_wp_error($response)) return $this->empty_result();
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($data) || ($data['status'] ?? '') !== 'success') return $this->empty_result();
|
||||
|
||||
return [
|
||||
'country' => (string) ($data['country'] ?? ''),
|
||||
'province' => (string) ($data['regionName'] ?? ''),
|
||||
'city' => (string) ($data['city'] ?? ''),
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
private function is_private_ip(string $ip): bool {
|
||||
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
||||
}
|
||||
|
||||
private function empty_result(string $reason = ''): array {
|
||||
$out = ['country' => '', 'province' => '', 'city' => '', 'raw' => []];
|
||||
if ($reason !== '') {
|
||||
$out['reason'] = $reason;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class MeiliSearch {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!$this->is_configured()) return;
|
||||
|
||||
add_filter('posts_search', [$this, 'override_search'], 10, 2);
|
||||
add_action('aksrt_meilisearch_sync', [$this, 'sync_all_posts']);
|
||||
add_action('save_post', [$this, 'on_save_post'], 10, 3);
|
||||
add_action('delete_post', [$this, 'on_delete_post']);
|
||||
|
||||
// Register cron schedule
|
||||
add_filter('cron_schedules', function ($schedules) {
|
||||
$schedules['aksrt_meilisearch_hourly'] = [
|
||||
'interval' => HOUR_IN_SECONDS,
|
||||
'display' => __('每小时', 'aksrt'),
|
||||
];
|
||||
return $schedules;
|
||||
});
|
||||
|
||||
if (!wp_next_scheduled('aksrt_meilisearch_sync')) {
|
||||
wp_schedule_event(time(), 'aksrt_meilisearch_hourly', 'aksrt_meilisearch_sync');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MeiliSearch is configured
|
||||
*/
|
||||
public function is_configured(): bool {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) return false;
|
||||
$host = \AKSRT\Settings\Settings::get_value('meilisearch_host', '');
|
||||
$key = \AKSRT\Settings\Settings::get_value('meilisearch_api_key', '');
|
||||
return !empty($host) && !empty($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which post types are indexed/searched. Shares the single
|
||||
* data-selection contract with front search and search push.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function indexable_post_types(): array {
|
||||
if (function_exists('aksrt_searchable_post_types')) {
|
||||
return aksrt_searchable_post_types();
|
||||
}
|
||||
return ['post'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MeiliSearch config
|
||||
*/
|
||||
private function get_config(): array {
|
||||
return [
|
||||
'host' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_host', ''),
|
||||
'api_key' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_api_key', ''),
|
||||
'index' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_index', 'aksrt_posts'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to MeiliSearch
|
||||
*/
|
||||
private function request(string $method, string $path, array $body = []): ?array {
|
||||
$config = $this->get_config();
|
||||
$url = rtrim($config['host'], '/') . $path;
|
||||
|
||||
$args = [
|
||||
'method' => $method,
|
||||
'timeout' => 10,
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $config['api_key'],
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($body)) {
|
||||
$args['body'] = wp_json_encode($body);
|
||||
}
|
||||
|
||||
$response = wp_remote_request($url, $args);
|
||||
if (is_wp_error($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code($response);
|
||||
if ($code < 200 || $code >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search posts via MeiliSearch
|
||||
*/
|
||||
public function search(string $query, array $args = []): array {
|
||||
$config = $this->get_config();
|
||||
$index = $config['index'];
|
||||
|
||||
$body = [
|
||||
'q' => $query,
|
||||
'limit' => $args['posts_per_page'] ?? get_option('posts_per_page', 10),
|
||||
'offset' => (($args['paged'] ?? 1) - 1) * ($args['posts_per_page'] ?? get_option('posts_per_page', 10)),
|
||||
'attributesToRetrieve' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail'],
|
||||
];
|
||||
|
||||
// Filter by post type
|
||||
if (!empty($args['post_type'])) {
|
||||
$body['filter'] = 'post_type = ' . $args['post_type'];
|
||||
}
|
||||
|
||||
$result = $this->request('POST', "/indexes/{$index}/search", $body);
|
||||
if (!$result) return [];
|
||||
|
||||
return [
|
||||
'hits' => $result['hits'] ?? [],
|
||||
'total' => $result['estimatedTotalHits'] ?? 0,
|
||||
'query_time' => $result['processingTimeMs'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Override WordPress search with MeiliSearch
|
||||
*/
|
||||
public function override_search(string $search, \WP_Query $query): string {
|
||||
if (!$query->is_search() || !$query->is_main_query()) {
|
||||
return $search;
|
||||
}
|
||||
|
||||
$q = $query->get('s');
|
||||
if (empty($q)) return $search;
|
||||
|
||||
$results = $this->search($q, [
|
||||
'posts_per_page' => $query->get('posts_per_page'),
|
||||
'paged' => $query->get('paged') ?: 1,
|
||||
'post_type' => $query->get('post_type') ?: 'post',
|
||||
]);
|
||||
|
||||
if (empty($results['hits'])) {
|
||||
// No results — force empty
|
||||
return 'AND 1=0';
|
||||
}
|
||||
|
||||
$ids = array_map(function ($hit) {
|
||||
return (int) ($hit['id'] ?? 0);
|
||||
}, $results['hits']);
|
||||
|
||||
$ids = array_filter($ids);
|
||||
if (empty($ids)) return 'AND 1=0';
|
||||
|
||||
global $wpdb;
|
||||
return 'AND ' . $wpdb->posts . '.ID IN (' . implode(',', $ids) . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a single post
|
||||
*/
|
||||
public function index_post(int $post_id): bool {
|
||||
$post = get_post($post_id);
|
||||
if (!$post || $post->post_status !== 'publish') return false;
|
||||
|
||||
$config = $this->get_config();
|
||||
$index = $config['index'];
|
||||
|
||||
$doc = [
|
||||
'id' => $post_id,
|
||||
'title' => $post->post_title,
|
||||
'excerpt' => wp_strip_all_tags(get_the_excerpt($post)),
|
||||
'content' => wp_strip_all_tags($post->post_content),
|
||||
'url' => get_permalink($post_id),
|
||||
'date' => $post->post_date,
|
||||
'post_type' => $post->post_type,
|
||||
'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '',
|
||||
];
|
||||
|
||||
$result = $this->request('PUT', "/indexes/{$index}/documents", [$doc]);
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post from index
|
||||
*/
|
||||
public function delete_post(int $post_id): bool {
|
||||
$config = $this->get_config();
|
||||
$index = $config['index'];
|
||||
$result = $this->request('DELETE', "/indexes/{$index}/documents/{$post_id}");
|
||||
return $result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all published posts
|
||||
*/
|
||||
public function sync_all_posts(): void {
|
||||
$config = $this->get_config();
|
||||
$index = $config['index'];
|
||||
|
||||
// Create index if not exists
|
||||
$this->request('POST', '/indexes', ['uid' => $index, 'primaryKey' => 'id']);
|
||||
|
||||
// Configure filterable attributes
|
||||
$this->request('PUT', "/indexes/{$index}/settings", [
|
||||
'filterableAttributes' => ['post_type'],
|
||||
'searchableAttributes' => ['title', 'excerpt', 'content'],
|
||||
'displayedAttributes' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail', 'post_type'],
|
||||
]);
|
||||
|
||||
$posts = get_posts([
|
||||
'post_type' => $this->indexable_post_types(),
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => -1,
|
||||
'fields' => 'ids',
|
||||
]);
|
||||
|
||||
$batch = [];
|
||||
foreach ($posts as $post_id) {
|
||||
$post = get_post($post_id);
|
||||
if (!$post) continue;
|
||||
|
||||
$batch[] = [
|
||||
'id' => (int) $post_id,
|
||||
'title' => $post->post_title,
|
||||
'excerpt' => wp_strip_all_tags(get_the_excerpt($post)),
|
||||
'content' => wp_strip_all_tags($post->post_content),
|
||||
'url' => get_permalink($post_id),
|
||||
'date' => $post->post_date,
|
||||
'post_type' => $post->post_type,
|
||||
'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '',
|
||||
];
|
||||
|
||||
// Batch 100 at a time
|
||||
if (count($batch) >= 100) {
|
||||
$this->request('PUT', "/indexes/{$index}/documents", $batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($batch)) {
|
||||
$this->request('PUT', "/indexes/{$index}/documents", $batch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: re-index on post save
|
||||
*/
|
||||
public function on_save_post(int $post_id, $post, bool $update): void {
|
||||
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return;
|
||||
if ($post->post_status !== 'publish') return;
|
||||
if (!in_array($post->post_type, $this->indexable_post_types(), true)) return;
|
||||
|
||||
// Debounce: schedule async
|
||||
wp_schedule_single_event(time() + 5, 'aksrt_meilisearch_index_single', [$post_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: remove from index on post delete
|
||||
*/
|
||||
public function on_delete_post(int $post_id): void {
|
||||
$this->delete_post($post_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SearchPush {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定运行时钩子:发布/更新主动推送 + 前台自动推送 JS。
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action('transition_post_status', [$this, 'on_transition_post_status'], 10, 3);
|
||||
add_action('wp_footer', [$this, 'output_auto_push_script'], 99);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章发布/更新时主动推送链接到百度。
|
||||
*
|
||||
* @param string $new_status 新状态。
|
||||
* @param string $old_status 旧状态。
|
||||
* @param \WP_Post $post 文章对象。
|
||||
*/
|
||||
public function on_transition_post_status(string $new_status, string $old_status, $post): void {
|
||||
if (!($post instanceof \WP_Post)) {
|
||||
return;
|
||||
}
|
||||
if ($new_status !== 'publish') {
|
||||
return;
|
||||
}
|
||||
if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
|
||||
return;
|
||||
}
|
||||
// 仅推送公开可索引的内容类型。
|
||||
$allowed = function_exists('aksrt_searchable_post_types')
|
||||
? aksrt_searchable_post_types()
|
||||
: ['post'];
|
||||
if (!in_array($post->post_type, $allowed, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
if (empty($config['baidu_push_enabled'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$is_new = $old_status !== 'publish';
|
||||
if ($is_new && empty($config['baidu_push_on_publish'])) {
|
||||
return;
|
||||
}
|
||||
if (!$is_new && empty($config['baidu_push_on_update'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->push_url((string) get_permalink($post));
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台输出百度自动推送 JS(由 wp_footer 调用)。
|
||||
*/
|
||||
public function output_auto_push_script(): void {
|
||||
if (is_admin()) {
|
||||
return;
|
||||
}
|
||||
$script = $this->get_auto_push_script();
|
||||
if ($script !== '') {
|
||||
echo $script; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — 固定常量脚本,无用户输入
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送单条 URL 到百度
|
||||
*
|
||||
* @param string $url 待推送的完整 URL
|
||||
* @return array{success: bool, message: string, result: array}
|
||||
*/
|
||||
public function push_url(string $url): array {
|
||||
$config = $this->get_config();
|
||||
|
||||
if (empty($config['baidu_push_enabled'])) {
|
||||
return ['success' => false, 'message' => __('百度推送未启用', 'aksrt'), 'result' => []];
|
||||
}
|
||||
|
||||
$token = (string) ($config['baidu_push_token'] ?? '');
|
||||
if ($token === '') {
|
||||
return ['success' => false, 'message' => __('推送 Token 未配置', 'aksrt'), 'result' => []];
|
||||
}
|
||||
|
||||
$api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}";
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'timeout' => 10,
|
||||
'headers' => ['Content-Type' => 'text/plain'],
|
||||
'body' => $url,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->log('error', $url, $response->get_error_message());
|
||||
return ['success' => false, 'message' => $response->get_error_message(), 'result' => []];
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$status = wp_remote_retrieve_response_code($response);
|
||||
|
||||
if ($status === 200 && isset($body['success'])) {
|
||||
$this->log('success', $url, wp_json_encode($body));
|
||||
return ['success' => true, 'message' => __('推送成功', 'aksrt'), 'result' => $body];
|
||||
}
|
||||
|
||||
$err_msg = $body['message'] ?? ($body['error'] ?? sprintf('HTTP %d', $status));
|
||||
$this->log('error', $url, $err_msg);
|
||||
return ['success' => false, 'message' => $err_msg, 'result' => $body ?? []];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量推送 URL
|
||||
*
|
||||
* @param string[] $urls
|
||||
* @return array{success: int, failed: int, results: array}
|
||||
*/
|
||||
public function push_batch(array $urls): array {
|
||||
$config = $this->get_config();
|
||||
$batch_size = max(1, min(500, (int) ($config['baidu_push_batch_size'] ?? 50)));
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$results = [];
|
||||
|
||||
$chunks = array_chunk($urls, $batch_size);
|
||||
foreach ($chunks as $chunk) {
|
||||
$token = (string) ($config['baidu_push_token'] ?? '');
|
||||
$api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}";
|
||||
$body_str = implode("\n", $chunk);
|
||||
|
||||
$response = wp_remote_post($api_url, [
|
||||
'timeout' => 15,
|
||||
'headers' => ['Content-Type' => 'text/plain'],
|
||||
'body' => $body_str,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$failed += count($chunk);
|
||||
$results[] = ['error' => $response->get_error_message()];
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (isset($body['success'])) {
|
||||
$success += (int) ($body['success'] ?? 0);
|
||||
$failed += (int) ($body['not_same_site'] ?? 0) + (int) ($body['not_valid'] ?? 0);
|
||||
} else {
|
||||
$failed += count($chunk);
|
||||
}
|
||||
$results[] = $body ?? [];
|
||||
}
|
||||
|
||||
return ['success' => $success, 'failed' => $failed, 'results' => $results];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取自动推送 JS 代码
|
||||
*/
|
||||
public function get_auto_push_script(): string {
|
||||
$config = $this->get_config();
|
||||
if (empty($config['baidu_push_enabled']) || empty($config['baidu_push_auto'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$site_url = home_url('/');
|
||||
return <<<SCRIPT
|
||||
<script>
|
||||
(function(){
|
||||
var bp = document.createElement('script');
|
||||
var curProtocol = window.location.protocol.split(':')[0];
|
||||
if (curProtocol === 'https') {
|
||||
bp.src = 'https://zz.bdstatic.com/linksubmit/push.js';
|
||||
} else {
|
||||
bp.src = 'http://push.zhanzhang.baidu.com/push.js';
|
||||
}
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(bp, s);
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务运行时状态
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$enabled = !empty($config['baidu_push_enabled']);
|
||||
$token = (string) ($config['baidu_push_token'] ?? '');
|
||||
|
||||
$missing = [];
|
||||
if ($enabled && $token === '') {
|
||||
$missing[] = 'baidu_push_token';
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'provider' => 'baidu',
|
||||
'configured' => $enabled && $token !== '',
|
||||
'available' => $enabled && $token !== '',
|
||||
'verified' => !empty($config['baidu_push_verified']),
|
||||
'transport' => 'http_api',
|
||||
'missing_fields' => $missing,
|
||||
'auto_push' => !empty($config['baidu_push_auto']),
|
||||
'on_publish' => !empty($config['baidu_push_on_publish']),
|
||||
'on_update' => !empty($config['baidu_push_on_update']),
|
||||
'batch_size' => max(1, (int) ($config['baidu_push_batch_size'] ?? 50)),
|
||||
];
|
||||
}
|
||||
|
||||
private function get_config(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
return \AKSRT\Settings\Settings::get_search_push_settings();
|
||||
}
|
||||
return ['baidu_push_enabled' => false];
|
||||
}
|
||||
|
||||
private function log(string $level, string $url, string $message): void {
|
||||
if (!defined('WP_DEBUG') || !WP_DEBUG) return;
|
||||
error_log(sprintf('[AKSRT Search Push] [%s] URL: %s — %s', $level, $url, $message));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user