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:
kate522 committed 2026-07-17 13:29:22 +08:00
1 parent 8f349b40fb
commit aaf458af1c
404 files changed
+13602 -7705

No files matched your search

+248
View File
@@ -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)
);
}
}
+365
View File
@@ -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);
}
}
+229
View File
@@ -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;
}
}
+280
View File
@@ -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);
}
}
+235
View File
@@ -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));
}
}