refactor(classes): organize inc/classes into domain folders

Move ~94 root class shells and root *-traits into domain directories
(core, auth, user, credits, content, payment/gateways, forum, shop,
sms, ai, messages, extensions, widgets, settings, admin). Update
bootstrap require paths, co-locate traits with owners, fix forum/user
relative requires, drop duplicate shop controller requires, and wire
home-modules load. Path-only; class names and init order unchanged.
This commit is contained in:
kate522 committed 2026-07-17 11:11:57 +08:00
1 parent 2a8fe68048
commit ce1c13b947
115 files changed
+171 -224

No files matched your search

@@ -0,0 +1,251 @@
<?php
/**
* AKSRT API Audit Logger
*
* Logs sensitive REST API operations for security auditing.
* Does NOT log passwords, secrets, or private keys.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_API_Audit {
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')) return false;
return (bool) AKSRT_Settings::get_value('api_audit_enabled', false);
}
/**
* Register REST pre-dispatch hook to log requests
*/
public function register_hooks(): void {
if (!$this->is_enabled()) return;
// Log after response to capture status code
add_filter('rest_post_dispatch', [$this, 'log_request'], 10, 3);
}
/**
* Log a REST API request
*/
public function log_request($result, $server, $request) {
$route = $request->get_route();
$method = $request->get_method();
// Only log write operations (POST, PUT, DELETE, PATCH)
if (!in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) {
return $result;
}
// Skip non-aksrt routes
if (strpos($route, '/aksrt/') === false) {
return $result;
}
// Skip health checks and non-sensitive routes
$skip_patterns = ['/health', '/settings/export', '/settings/import'];
foreach ($skip_patterns as $pattern) {
if (strpos($route, $pattern) !== false) {
return $result;
}
}
$user_id = get_current_user_id();
$ip = $this->get_client_ip();
$status = $result instanceof \WP_REST_Response ? $result->get_status() : 200;
$this->insert_log([
'user_id' => $user_id,
'method' => $method,
'route' => $route,
'ip' => $ip,
'status' => $status,
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
return $result;
}
/**
* Manually log an event
*/
public static function log(string $action, array $context = [], int $user_id = 0): void {
$instance = self::get_instance();
if (!$instance->is_enabled()) return;
$instance->insert_log([
'user_id' => $user_id ?: get_current_user_id(),
'method' => 'ACTION',
'route' => $action,
'ip' => $instance->get_client_ip(),
'status' => 200,
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
'context' => wp_json_encode($context, JSON_UNESCAPED_UNICODE),
]);
}
/**
* Insert a log entry
*/
private function insert_log(array $data): void {
global $wpdb;
$wpdb->insert(self::$table_name, [
'user_id' => $data['user_id'] ?? 0,
'method' => $data['method'] ?? '',
'route' => $data['route'] ?? '',
'ip' => $data['ip'] ?? '',
'status' => $data['status'] ?? 0,
'user_agent' => $data['user_agent'] ?? '',
'context' => $data['context'] ?? '',
'created_at' => current_time('mysql', true),
], ['%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']);
}
/**
* Get client IP
*/
private function get_client_ip(): string {
$ip = '';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim($parts[0]);
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
$ip = $_SERVER['HTTP_X_REAL_IP'];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
return sanitize_text_field($ip);
}
/**
* Create audit table if not exists
*/
public function maybe_create_table(): void {
global $wpdb;
$table = self::$table_name;
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
method VARCHAR(10) NOT NULL DEFAULT '',
route VARCHAR(255) NOT NULL DEFAULT '',
ip VARCHAR(45) NOT NULL DEFAULT '',
status SMALLINT UNSIGNED NOT NULL DEFAULT 0,
user_agent VARCHAR(255) NOT NULL DEFAULT '',
context TEXT,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_user_id (user_id),
KEY idx_route (route),
KEY idx_created_at (created_at)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* Query audit logs with pagination
*/
public function query_logs(array $args = []): array {
global $wpdb;
$defaults = [
'per_page' => 20,
'page' => 1,
'user_id' => 0,
'route' => '',
'method' => '',
'from' => '',
'to' => '',
];
$args = wp_parse_args($args, $defaults);
$where = ['1=1'];
$values = [];
if ($args['user_id'] > 0) {
$where[] = 'user_id = %d';
$values[] = $args['user_id'];
}
if (!empty($args['route'])) {
$where[] = 'route LIKE %s';
$values[] = '%' . $wpdb->esc_like($args['route']) . '%';
}
if (!empty($args['method'])) {
$where[] = 'method = %s';
$values[] = $args['method'];
}
if (!empty($args['from'])) {
$where[] = 'created_at >= %s';
$values[] = $args['from'];
}
if (!empty($args['to'])) {
$where[] = 'created_at <= %s';
$values[] = $args['to'];
}
$where_sql = implode(' AND ', $where);
$offset = ($args['page'] - 1) * $args['per_page'];
$count_sql = "SELECT COUNT(*) FROM " . self::$table_name . " WHERE {$where_sql}";
$total = (int) ($values
? $wpdb->get_var($wpdb->prepare($count_sql, $values))
: $wpdb->get_var($count_sql));
$values[] = $args['per_page'];
$values[] = $offset;
$logs = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM " . self::$table_name . " WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$values
),
ARRAY_A
);
return [
'total' => $total,
'per_page'=> $args['per_page'],
'page' => $args['page'],
'logs' => $logs ?: [],
];
}
/**
* Purge old logs
*/
public function purge_old(int $days = 90): int {
global $wpdb;
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
return (int) $wpdb->query(
$wpdb->prepare("DELETE FROM " . self::$table_name . " WHERE created_at < %s", $cutoff)
);
}
}
@@ -0,0 +1,368 @@
<?php
/**
* AKSRT Content Audit Service
*
* 第三方内容审核统一入口。支持多 provider(百度内容审核、阿里云等)。
* 文本审核 + 图片审核 + 日志 + 失败策略。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Content_Audit {
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')) {
return AKSRT_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,231 @@
<?php
/**
* AKSRT IP Geolocation Service
*
* 提供 IP 归属地查询能力。支持多 provider、transient 缓存和统一状态输出。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_IP_Geo {
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')) {
return AKSRT_Settings::get_ip_geo_settings();
}
return ['ip_geo_enabled' => false];
}
private function query_provider(string $provider, string $ip, array $config): array {
switch ($provider) {
case 'qq':
return $this->query_qq($ip, $config);
case 'amap':
return $this->query_amap($ip, $config);
case 'polling':
foreach (['qq', 'amap', 'null'] as $fallback) {
$result = $this->query_provider($fallback, $ip, $config);
if (!empty($result['province']) || !empty($result['city']) || !empty($result['country'])) {
return $result;
}
}
return $this->empty_result();
case 'null':
default:
return $this->query_ip_api($ip);
}
}
private function query_qq(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result();
}
$url = add_query_arg([
'ip' => $ip,
'key' => $appkey,
], 'https://apis.map.qq.com/ws/location/v1/ip');
$response = wp_remote_get($url, [
'timeout' => 8,
'headers' => ['Accept' => 'application/json'],
]);
if (is_wp_error($response)) return $this->empty_result();
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) return $this->empty_result();
$info = $data['result']['ad_info'] ?? [];
return [
'country' => '中国',
'province' => (string) ($info['province'] ?? ''),
'city' => (string) ($info['city'] ?? ''),
'raw' => $data,
];
}
private function query_amap(string $ip, array $config): array {
$appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? ''));
if ($appkey === '') {
return $this->empty_result();
}
$url = add_query_arg([
'ip' => $ip,
'key' => $appkey,
], 'https://restapi.amap.com/v3/ip');
$response = wp_remote_get($url, [
'timeout' => 8,
'headers' => ['Accept' => 'application/json'],
]);
if (is_wp_error($response)) return $this->empty_result();
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') return $this->empty_result();
return [
'country' => '中国',
'province' => (string) ($data['province'] ?? ''),
'city' => (string) ($data['city'] ?? ''),
'raw' => $data,
];
}
private function query_ip_api(string $ip): array {
$response = wp_remote_get("http://ip-api.com/json/{$ip}?lang=zh-CN", [
'timeout' => 5,
]);
if (is_wp_error($response)) return $this->empty_result();
$data = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($data) || ($data['status'] ?? '') !== 'success') return $this->empty_result();
return [
'country' => (string) ($data['country'] ?? ''),
'province' => (string) ($data['regionName'] ?? ''),
'city' => (string) ($data['city'] ?? ''),
'raw' => $data,
];
}
private function is_private_ip(string $ip): bool {
return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
private function empty_result(string $reason = ''): array {
$out = ['country' => '', 'province' => '', 'city' => '', 'raw' => []];
if ($reason !== '') {
$out['reason'] = $reason;
}
return $out;
}
}
@@ -0,0 +1,284 @@
<?php
/**
* AKSRT MeiliSearch Integration
*
* Optional high-performance full-text search engine integration.
* Falls back to WordPress native search when not configured.
* Indexes posts via WP-Cron.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_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')) return false;
$host = AKSRT_Settings::get_value('meilisearch_host', '');
$key = AKSRT_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::get_value('meilisearch_host', ''),
'api_key' => (string) AKSRT_Settings::get_value('meilisearch_api_key', ''),
'index' => (string) AKSRT_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,237 @@
<?php
/**
* AKSRT Search Push Service
*
* 提供搜索引擎主动推送能力。当前支持百度推送。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Search_Push {
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')) {
return AKSRT_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));
}
}