refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
<?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 {
|
||||
if (function_exists('aksrt_get_client_ip')) {
|
||||
return sanitize_text_field((string) aksrt_get_client_ip());
|
||||
}
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
$ip = (string) $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
$ip = trim($parts[0]);
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$ip = (string) $_SERVER['HTTP_X_REAL_IP'];
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = (string) $_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,571 @@
|
||||
<?php
|
||||
/**
|
||||
* IP 归属地查询(腾讯 / 高德 / 太平洋 / 轮询)。
|
||||
*
|
||||
* @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 / IPv6
|
||||
* @param bool $force 管理端测试:跳过启用开关与缓存;仍拒绝私有 IP
|
||||
* @return array{country:string,province:string,city:string,raw:array,reason?:string,provider?:string,http_code?:int,detail?:string}
|
||||
*/
|
||||
public function lookup(string $ip, bool $force = false): array {
|
||||
$config = $this->get_config();
|
||||
$ip = trim($ip);
|
||||
|
||||
if (!$force && empty($config['ip_geo_enabled'])) {
|
||||
return $this->empty_result('disabled');
|
||||
}
|
||||
|
||||
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $this->empty_result('invalid_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));
|
||||
// Negative cache: avoid hammering providers on repeated failures (admin force skips).
|
||||
$neg_key = 'aksrt_ip_geo_neg_' . md5($ip);
|
||||
$neg_ttl = 600;
|
||||
|
||||
if (!$force) {
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false && is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
$neg = get_transient($neg_key);
|
||||
if ($neg !== false && is_array($neg)) {
|
||||
return $neg;
|
||||
}
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? 'null'));
|
||||
$result = $this->query_provider($provider, $ip, $config);
|
||||
|
||||
if ($this->has_location($result)) {
|
||||
// Strip bulky raw payloads before caching.
|
||||
$store = $result;
|
||||
unset($store['raw']);
|
||||
if (!$force) {
|
||||
set_transient($cache_key, $store, $ttl);
|
||||
delete_transient($neg_key);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (empty($result['reason'])) {
|
||||
$result['reason'] = 'provider_empty';
|
||||
}
|
||||
|
||||
// Cache miss with reason so front-end doesn't retry every page view.
|
||||
if (!$force) {
|
||||
$store = $result;
|
||||
unset($store['raw']);
|
||||
set_transient($neg_key, $store, $neg_ttl);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? '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 ($enabled && $provider === 'amap') {
|
||||
if (empty($config['ip_addr_sdk_amap_appkey'])) {
|
||||
$missing[] = 'ip_addr_sdk_amap_appkey';
|
||||
}
|
||||
}
|
||||
// polling / null (free): no hard credential requirements.
|
||||
|
||||
// Free + polling are always "configured" once enabled; paid need keys.
|
||||
// verified flips after a successful admin test (or any write of ip_geo_verified).
|
||||
$configured = $enabled && empty($missing);
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'provider' => $provider,
|
||||
'configured' => $configured,
|
||||
'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', 'user_home', 'user_center', 'post']),
|
||||
'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');
|
||||
}
|
||||
|
||||
$country = $this->string_field($geo['country'] ?? '');
|
||||
$province = $this->string_field($geo['province'] ?? '');
|
||||
$city = $this->string_field($geo['city'] ?? '');
|
||||
|
||||
if ($province !== '' && $city !== '' && $province === $city) {
|
||||
$city = '';
|
||||
}
|
||||
|
||||
switch ($format) {
|
||||
case 'province':
|
||||
if ($province !== '') {
|
||||
return $province;
|
||||
}
|
||||
return $city !== '' ? $city : $country;
|
||||
case 'country_province':
|
||||
$joined = trim($country . ' ' . $province);
|
||||
return $joined !== '' ? $joined : $city;
|
||||
case 'province_city':
|
||||
default:
|
||||
$parts = array_filter([$province, $city]);
|
||||
return $parts ? implode(' ', $parts) : $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':
|
||||
$amap = $this->query_amap($ip, $config);
|
||||
if ($this->has_location($amap)) {
|
||||
return $amap;
|
||||
}
|
||||
// Credential / platform mismatch: free path so front still works.
|
||||
if ($this->should_fallback_free($amap)) {
|
||||
$free = $this->query_pconline($ip);
|
||||
if ($this->has_location($free)) {
|
||||
$free['detail'] = 'amap_failed_fallback_free:' . (string) ($amap['detail'] ?? $amap['reason'] ?? '');
|
||||
return $free;
|
||||
}
|
||||
}
|
||||
return $amap;
|
||||
case 'polling':
|
||||
$last = $this->empty_result('provider_empty');
|
||||
foreach (['qq', 'amap', 'null'] as $fallback) {
|
||||
if ($fallback === 'qq' && empty($config['ip_addr_sdk_qq_appkey'])) {
|
||||
continue;
|
||||
}
|
||||
if ($fallback === 'amap' && empty($config['ip_addr_sdk_amap_appkey'])) {
|
||||
continue;
|
||||
}
|
||||
// Avoid re-entering amap→free→… recursion: call leaf methods.
|
||||
if ($fallback === 'amap') {
|
||||
$result = $this->query_amap($ip, $config);
|
||||
} elseif ($fallback === 'qq') {
|
||||
$result = $this->query_qq($ip, $config);
|
||||
} else {
|
||||
$result = $this->query_pconline($ip);
|
||||
}
|
||||
if ($this->has_location($result)) {
|
||||
return $result;
|
||||
}
|
||||
$last = $result;
|
||||
}
|
||||
// Last resort free path.
|
||||
$free = $this->query_pconline($ip);
|
||||
return $this->has_location($free) ? $free : $last;
|
||||
case 'null':
|
||||
default:
|
||||
// Admin label「太平洋公共接口」: pconline, with ip-api if blocked.
|
||||
return $this->query_pconline($ip);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a paid-provider failure should fall back to free IP lookup.
|
||||
*/
|
||||
private function should_fallback_free(array $result): bool {
|
||||
$reason = (string) ($result['reason'] ?? '');
|
||||
if (in_array($reason, ['missing_key', 'http_error'], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($reason !== 'provider_error') {
|
||||
return false;
|
||||
}
|
||||
$detail = strtoupper((string) ($result['detail'] ?? ''));
|
||||
foreach (['USERKEY_PLAT_NOMATCH', 'INVALID_USER_KEY', 'INVALID_USER_SCODE', 'DAILY_QUERY_OVER_LIMIT', 'ACCESS_TOO_FREQUENT', 'CUQPS_HAS_EXCEEDED_THE_LIMIT'] as $code) {
|
||||
if (str_contains($detail, $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function query_qq(string $ip, array $config): array {
|
||||
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
|
||||
$secretkey = trim((string) ($config['ip_addr_sdk_qq_secretkey'] ?? ''));
|
||||
if ($appkey === '') {
|
||||
return $this->empty_result('missing_key', ['provider' => 'qq', 'detail' => 'missing appkey']);
|
||||
}
|
||||
|
||||
// Path used for Tencent signature (zibll parity).
|
||||
$path = '/ws/location/v1/ip';
|
||||
$query = 'ip=' . rawurlencode($ip) . '&key=' . rawurlencode($appkey);
|
||||
$url = 'https://apis.map.qq.com' . $path . '?' . $query;
|
||||
if ($secretkey !== '') {
|
||||
$url .= '&sig=' . md5($path . '?' . $query . $secretkey);
|
||||
}
|
||||
|
||||
$http = $this->http_get($url, 8);
|
||||
if (!$http['ok']) {
|
||||
return $this->empty_result('http_error', [
|
||||
'provider' => 'qq',
|
||||
'http_code' => $http['code'],
|
||||
'detail' => $http['error'],
|
||||
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $http['json'];
|
||||
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) {
|
||||
$msg = is_array($data) ? (string) ($data['message'] ?? 'status_error') : 'invalid_json';
|
||||
return $this->empty_result('provider_error', [
|
||||
'provider' => 'qq',
|
||||
'http_code' => $http['code'],
|
||||
'detail' => $msg,
|
||||
'raw' => is_array($data) ? $data : [],
|
||||
]);
|
||||
}
|
||||
|
||||
$info = is_array($data['result']['ad_info'] ?? null) ? $data['result']['ad_info'] : [];
|
||||
return [
|
||||
'country' => $this->string_field($info['nation'] ?? '中国'),
|
||||
'province' => $this->string_field($info['province'] ?? ''),
|
||||
'city' => $this->string_field($info['city'] ?? ''),
|
||||
'raw' => $data,
|
||||
'provider' => 'qq',
|
||||
];
|
||||
}
|
||||
|
||||
private function query_amap(string $ip, array $config): array {
|
||||
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
|
||||
$secretkey = trim((string) ($config['ip_addr_sdk_amap_secretkey'] ?? ''));
|
||||
if ($appkey === '') {
|
||||
return $this->empty_result('missing_key', [
|
||||
'provider' => 'amap',
|
||||
'detail' => 'missing appkey',
|
||||
]);
|
||||
}
|
||||
|
||||
// Amap IP API is IPv4-only; free path for IPv6.
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
$free = $this->query_ip_api($ip);
|
||||
if ($this->has_location($free)) {
|
||||
$free['detail'] = 'amap_ipv6_fallback_ip_api';
|
||||
return $free;
|
||||
}
|
||||
return $this->empty_result('provider_empty', [
|
||||
'provider' => 'amap',
|
||||
'detail' => 'amap_ipv4_only',
|
||||
]);
|
||||
}
|
||||
|
||||
// Amap sign: md5(sorted query without sig + secret) — zibll uses ip=&key= order.
|
||||
$base_query = 'ip=' . $ip . '&key=' . $appkey;
|
||||
$url = 'https://restapi.amap.com/v3/ip?' . $base_query;
|
||||
if ($secretkey !== '') {
|
||||
$url .= '&sig=' . md5($base_query . $secretkey);
|
||||
}
|
||||
|
||||
$http = $this->http_get($url, 8);
|
||||
if (!$http['ok']) {
|
||||
return $this->empty_result('http_error', [
|
||||
'provider' => 'amap',
|
||||
'http_code' => $http['code'],
|
||||
'detail' => $http['error'],
|
||||
'raw' => $http['body_raw'] !== '' ? ['body' => $http['body_raw']] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $http['json'];
|
||||
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') {
|
||||
$info = is_array($data) ? (string) ($data['info'] ?? 'status_error') : 'invalid_json';
|
||||
// Common: INVALID_USER_KEY / DAILY_QUERY_OVER_LIMIT / USERKEY_PLAT_NOMATCH
|
||||
return $this->empty_result('provider_error', [
|
||||
'provider' => 'amap',
|
||||
'http_code' => $http['code'],
|
||||
'detail' => $info,
|
||||
'raw' => is_array($data) ? $data : [],
|
||||
]);
|
||||
}
|
||||
|
||||
// Amap returns empty arrays for province/city on some IPs / overseas.
|
||||
$province = $this->string_field($data['province'] ?? '');
|
||||
$city = $this->string_field($data['city'] ?? '');
|
||||
$country = '中国';
|
||||
if ($province === '' && $city === '') {
|
||||
return $this->empty_result('provider_empty', [
|
||||
'provider' => 'amap',
|
||||
'detail' => 'empty province/city',
|
||||
'raw' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'country' => $country,
|
||||
'province' => $province,
|
||||
'city' => $city,
|
||||
'raw' => $data,
|
||||
'provider' => 'amap',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 太平洋网络 whois(GB2312 正文)。
|
||||
*/
|
||||
private function query_pconline(string $ip): array {
|
||||
$url = 'http://whois.pconline.com.cn/ipJson.jsp?json=true&ip=' . rawurlencode($ip);
|
||||
$http = $this->http_get($url, 8, [
|
||||
'Accept' => '*/*',
|
||||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer' => 'https://www.pconline.com.cn/',
|
||||
]);
|
||||
|
||||
if (!$http['ok']) {
|
||||
// Fallback: ip-api only when pconline network fails (not dual storage — runtime failover).
|
||||
$fallback = $this->query_ip_api($ip);
|
||||
if ($this->has_location($fallback)) {
|
||||
$fallback['provider'] = 'ip-api';
|
||||
$fallback['detail'] = 'pconline_failed_fallback_ip_api';
|
||||
return $fallback;
|
||||
}
|
||||
return $this->empty_result('http_error', [
|
||||
'provider' => 'pconline',
|
||||
'http_code' => $http['code'],
|
||||
'detail' => $http['error'] !== '' ? $http['error'] : 'pconline blocked or unreachable',
|
||||
'raw' => $http['body_raw'] !== '' ? ['body' => mb_substr($http['body_raw'], 0, 500)] : [],
|
||||
]);
|
||||
}
|
||||
|
||||
$body = $http['body_raw'];
|
||||
// pconline often serves GBK/GB2312.
|
||||
if (function_exists('mb_convert_encoding')) {
|
||||
$converted = @mb_convert_encoding($body, 'UTF-8', 'GBK,GB2312,UTF-8');
|
||||
if (is_string($converted) && $converted !== '') {
|
||||
$body = $converted;
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
if (is_array($json)) {
|
||||
$province = $this->string_field($json['pro'] ?? $json['province'] ?? '');
|
||||
$city = $this->string_field($json['city'] ?? '');
|
||||
$region = $this->string_field($json['region'] ?? '');
|
||||
$addr = $this->string_field($json['addr'] ?? '');
|
||||
$country = '';
|
||||
if ($addr !== '' && preg_match('/^(\S+)/u', $addr, $m)) {
|
||||
$country = $m[1];
|
||||
}
|
||||
if ($province !== '' || $city !== '') {
|
||||
return [
|
||||
'country' => $country !== '' ? $country : '中国',
|
||||
'province' => $province,
|
||||
'city' => $city !== '' ? $city : $region,
|
||||
'raw' => $json,
|
||||
'provider' => 'pconline',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Regex fallback (zibll).
|
||||
preg_match('/"pro":"(.*?)"/u', $body, $pro);
|
||||
preg_match('/"city":"(.*?)"/u', $body, $city);
|
||||
preg_match('/"region":"(.*?)"/u', $body, $region);
|
||||
preg_match('/"addr":"(.*?)(\s)(.*?)"/u', $body, $addr);
|
||||
|
||||
$province = $this->string_field($pro[1] ?? '');
|
||||
$city_s = $this->string_field($city[1] ?? '');
|
||||
$country = $this->string_field($addr[1] ?? '');
|
||||
|
||||
if ($province === '' && $city_s === '') {
|
||||
// HTTP OK but unusable body (block page / CAPTCHA / empty) → free path.
|
||||
$fallback = $this->query_ip_api($ip);
|
||||
if ($this->has_location($fallback)) {
|
||||
$fallback['provider'] = 'ip-api';
|
||||
$fallback['detail'] = 'pconline_parse_failed_fallback_ip_api';
|
||||
return $fallback;
|
||||
}
|
||||
return $this->empty_result('provider_empty', [
|
||||
'provider' => 'pconline',
|
||||
'detail' => 'parse_failed',
|
||||
'raw' => ['body' => mb_substr($body, 0, 500)],
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'country' => $country !== '' ? $country : '中国',
|
||||
'province' => $province,
|
||||
'city' => $city_s !== '' ? $city_s : $this->string_field($region[1] ?? ''),
|
||||
'raw' => ['body' => $body],
|
||||
'provider' => 'pconline',
|
||||
];
|
||||
}
|
||||
|
||||
private function query_ip_api(string $ip): array {
|
||||
$url = 'http://ip-api.com/json/' . rawurlencode($ip) . '?lang=zh-CN&fields=status,message,country,regionName,city,query';
|
||||
$http = $this->http_get($url, 6);
|
||||
if (!$http['ok']) {
|
||||
return $this->empty_result('http_error', [
|
||||
'provider' => 'ip-api',
|
||||
'http_code' => $http['code'],
|
||||
'detail' => $http['error'],
|
||||
]);
|
||||
}
|
||||
$data = $http['json'];
|
||||
if (!is_array($data) || ($data['status'] ?? '') !== 'success') {
|
||||
return $this->empty_result('provider_error', [
|
||||
'provider' => 'ip-api',
|
||||
'detail' => is_array($data) ? (string) ($data['message'] ?? 'fail') : 'invalid_json',
|
||||
'raw' => is_array($data) ? $data : [],
|
||||
]);
|
||||
}
|
||||
return [
|
||||
'country' => $this->string_field($data['country'] ?? ''),
|
||||
'province' => $this->string_field($data['regionName'] ?? ''),
|
||||
'city' => $this->string_field($data['city'] ?? ''),
|
||||
'raw' => $data,
|
||||
'provider' => 'ip-api',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $extra_headers
|
||||
* @return array{ok:bool,code:int,error:string,json:?array,body_raw:string}
|
||||
*/
|
||||
private function http_get(string $url, int $timeout = 8, array $extra_headers = []): array {
|
||||
$headers = array_merge([
|
||||
'Accept' => 'application/json,text/plain,*/*',
|
||||
'User-Agent' => 'AKSRT-IPGeo/3.4 (+WordPress)',
|
||||
], $extra_headers);
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => $timeout,
|
||||
'redirection' => 3,
|
||||
'sslverify' => true,
|
||||
'headers' => $headers,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'code' => 0,
|
||||
'error' => $response->get_error_message(),
|
||||
'json' => null,
|
||||
'body_raw' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$code = (int) wp_remote_retrieve_response_code($response);
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
if ($code < 200 || $code >= 300) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'code' => $code,
|
||||
'error' => 'HTTP ' . $code,
|
||||
'json' => null,
|
||||
'body_raw' => $body,
|
||||
];
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'code' => $code,
|
||||
'error' => '',
|
||||
'json' => is_array($json) ? $json : null,
|
||||
'body_raw' => $body,
|
||||
];
|
||||
}
|
||||
|
||||
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 has_location(array $result): bool {
|
||||
return $this->string_field($result['province'] ?? '') !== ''
|
||||
|| $this->string_field($result['city'] ?? '') !== ''
|
||||
|| $this->string_field($result['country'] ?? '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Amap may return [] for empty province/city.
|
||||
*
|
||||
* @param mixed $value Raw field.
|
||||
*/
|
||||
private function string_field($value): string {
|
||||
if (is_array($value)) {
|
||||
if ($value === []) {
|
||||
return '';
|
||||
}
|
||||
$value = reset($value);
|
||||
}
|
||||
if (!is_scalar($value) && $value !== null) {
|
||||
return '';
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $extra Extra fields.
|
||||
* @return array{country:string,province:string,city:string,raw:array,reason?:string}
|
||||
*/
|
||||
private function empty_result(string $reason = '', array $extra = []): array {
|
||||
$out = [
|
||||
'country' => '',
|
||||
'province' => '',
|
||||
'city' => '',
|
||||
'raw' => is_array($extra['raw'] ?? null) ? $extra['raw'] : [],
|
||||
];
|
||||
if ($reason !== '') {
|
||||
$out['reason'] = $reason;
|
||||
}
|
||||
foreach (['provider', 'http_code', 'detail'] as $k) {
|
||||
if (array_key_exists($k, $extra)) {
|
||||
$out[$k] = $extra[$k];
|
||||
}
|
||||
}
|
||||
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