/wechat/status 与 /wechat/settings 自引入起只注册了路由、从未实现回调方法,后台「微信模板消息」页调用即 500。 按既定设计补齐:状态读取委托 WeChatNotify::get_status()(其既定用途即 REST 与 AJAX 复用),写入委托 Settings::update_wechat_settings() 并由 Integration 收敛键白名单;前端空密钥下发掩码 *** 时保留已存密钥,避免把密钥写成字面量。 新增 PHP harness 驱动真实 UserController + Integration + WeChatNotify 验证状态形状、掩码保留、缺省沿用、未知键丢弃与输入消毒。
777 lines
36 KiB
PHP
777 lines
36 KiB
PHP
<?php
|
||
/**
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Admin;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class UserController extends ControllerBase {
|
||
use UserControllerReportOperations;
|
||
use UserControllerAuthPhoneOperations;
|
||
use UserControllerRestrictionOperations;
|
||
use UserControllerFollowOperations;
|
||
use UserControllerProfileMediaOperations;
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) self::$instance = new self();
|
||
return self::$instance;
|
||
}
|
||
|
||
public function register_routes(): void {
|
||
// Auth settings (login/registration)
|
||
register_rest_route(self::API_NAMESPACE, '/settings/auth', [
|
||
'methods' => ['GET', 'POST'],
|
||
'callback' => [$this, 'rest_handle_auth_settings'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// User profile media uploads (logged-in users).
|
||
register_rest_route(self::API_NAMESPACE, '/user/background', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_upload_user_background'],
|
||
'permission_callback' => function () { return is_user_logged_in(); },
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/user/avatar', [
|
||
[
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_upload_user_avatar'],
|
||
'permission_callback' => function () { return is_user_logged_in(); },
|
||
],
|
||
[
|
||
'methods' => 'DELETE',
|
||
'callback' => [$this, 'rest_delete_user_avatar'],
|
||
'permission_callback' => function () { return is_user_logged_in(); },
|
||
],
|
||
]);
|
||
|
||
// Comment inline image upload (logged-in users with the comment upload setting enabled).
|
||
register_rest_route(self::API_NAMESPACE, '/comment/upload-image', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_upload_comment_image'],
|
||
'permission_callback' => [$this, 'check_comment_image_upload_permission'],
|
||
]);
|
||
|
||
// Report evidence image upload (logged-in users; the report image setting gates the entry).
|
||
// Reuses the comment upload handler — the permission boundary differs only.
|
||
register_rest_route(self::API_NAMESPACE, '/report/upload-image', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_upload_comment_image'],
|
||
'permission_callback' => static function (): bool {
|
||
if (!is_user_logged_in() || !class_exists('\AKSRT\Settings\UserRuntime')) {
|
||
return false;
|
||
}
|
||
$report = \AKSRT\Settings\UserRuntime::report_settings();
|
||
return !empty($report['enabled']) && !empty($report['img_enabled']);
|
||
},
|
||
]);
|
||
|
||
// Notification settings
|
||
register_rest_route(self::API_NAMESPACE, '/settings/notifications', [
|
||
'methods' => ['GET', 'POST'],
|
||
'callback' => [$this, 'rest_handle_notification_settings'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// Keep message-center aliases on this controller so existing admin pages
|
||
// remain compatible while notifications runtime lives in Message controller.
|
||
register_rest_route(self::API_NAMESPACE, '/notifications/overview', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'rest_get_notification_overview'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/notifications/mark-read', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_mark_notification_read'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/notifications/(?P<id>\d+)', [
|
||
'methods' => 'DELETE',
|
||
'callback' => [$this, 'rest_delete_notification_item'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/notifications/cleanup', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_run_notification_cleanup'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
register_rest_route(self::API_NAMESPACE, '/assets/settings', [
|
||
'methods' => ['GET', 'POST'],
|
||
'callback' => [$this, 'rest_handle_credit_settings'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
register_rest_route(self::API_NAMESPACE, '/assets/me', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'rest_get_credits_me'],
|
||
'permission_callback' => [$this, 'check_asset_self_permission'],
|
||
]);
|
||
|
||
register_rest_route(self::API_NAMESPACE, '/assets/accounts', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'rest_list_credit_users'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
register_rest_route(self::API_NAMESPACE, '/assets/accounts/(?P<id>\d+)/adjust', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'rest_adjust_user_credits'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
'args' => [
|
||
'id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
|
||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
|
||
'delta' => [
|
||
'required' => true,
|
||
'oneOf' => [
|
||
['type' => 'integer'],
|
||
['type' => 'string', 'pattern' => '^-?(?:0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
|
||
],
|
||
],
|
||
'remark' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
|
||
],
|
||
]);
|
||
|
||
register_rest_route(self::API_NAMESPACE, '/assets/transactions', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'rest_get_credits_log'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
'args' => [
|
||
'user_id' => ['type' => 'integer', 'minimum' => 1, 'required' => true],
|
||
'asset' => ['type' => 'string', 'enum' => ['points', 'balance'], 'required' => true],
|
||
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
|
||
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 50, 'default' => 10],
|
||
'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||
'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
|
||
],
|
||
]);
|
||
|
||
// Report ticket list (admin)
|
||
register_rest_route(self::API_NAMESPACE, '/users/reports', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_report_list'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/users/reports/(?P<id>\d+)', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_report_detail'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/users/reports/(?P<id>\d+)/process', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'process_report'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// Report submit (logged-in users, front-end)
|
||
register_rest_route(self::API_NAMESPACE, '/report/submit', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'submit_report'],
|
||
'permission_callback' => function () { return is_user_logged_in(); },
|
||
]);
|
||
|
||
// Follow toggle (logged-in users)
|
||
register_rest_route(self::API_NAMESPACE, '/follow/toggle', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'follow_toggle'],
|
||
'permission_callback' => function () { return is_user_logged_in(); },
|
||
]);
|
||
|
||
// Follow status check (logged-in users)
|
||
register_rest_route(self::API_NAMESPACE, '/follow/status', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'follow_status'],
|
||
'permission_callback' => function () { return is_user_logged_in(); },
|
||
]);
|
||
|
||
// Follow counts (public)
|
||
register_rest_route(self::API_NAMESPACE, '/follow/counts', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'follow_counts'],
|
||
'permission_callback' => '__return_true',
|
||
]);
|
||
|
||
// Shared public authentication contract for modal and /sign/.
|
||
register_rest_route(self::API_NAMESPACE, '/auth/code', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'auth_code'],
|
||
'permission_callback' => '__return_true',
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/user/phone-code', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'user_phone_code'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/user/phone-match', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'user_phone_match'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/user/phone-verify', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'user_verify_phone'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/user/phone', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'user_bind_phone'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/auth/login', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'auth_login'],
|
||
'permission_callback' => '__return_true',
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/auth/register', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'auth_register'],
|
||
'permission_callback' => '__return_true',
|
||
]);
|
||
register_rest_route(self::API_NAMESPACE, '/auth/reset-password', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'auth_reset_password'],
|
||
'permission_callback' => '__return_true',
|
||
]);
|
||
|
||
register_rest_route(self::API_NAMESPACE, '/sms/test', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'test_sms_send'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// WeChat notification status
|
||
register_rest_route(self::API_NAMESPACE, '/wechat/status', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_wechat_status'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// WeChat settings update
|
||
register_rest_route(self::API_NAMESPACE, '/wechat/settings', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'update_wechat_settings'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// ── User Restrictions / Ban ──
|
||
|
||
// List restrictions for a user (admin)
|
||
register_rest_route(self::API_NAMESPACE, '/users/(?P<user_id>\d+)/restrictions', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_user_restrictions'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// Add restriction (admin)
|
||
register_rest_route(self::API_NAMESPACE, '/users/(?P<user_id>\d+)/restrict', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'add_user_restriction'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// Remove restriction (admin)
|
||
register_rest_route(self::API_NAMESPACE, '/users/restrictions/(?P<id>\d+)', [
|
||
'methods' => 'DELETE',
|
||
'callback' => [$this, 'remove_user_restriction'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// Restriction stats (admin)
|
||
register_rest_route(self::API_NAMESPACE, '/users/restrictions/stats', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_restriction_stats'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// 前台禁封弹窗(1:1 复刻参考主题 set_user_ban_modal / zib_get_set_user_ban_modal)。
|
||
// modal 内容每次拉取(restriction 状态可能已变),不缓存。
|
||
register_rest_route(self::API_NAMESPACE, '/user-ban/modal', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_user_ban_modal'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// 前台禁封 / 解封提交(对齐参考主题 wp_ajax_set_user_ban)。
|
||
register_rest_route(self::API_NAMESPACE, '/user-ban/set', [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'set_user_ban'],
|
||
'permission_callback' => [$this, 'check_permission'],
|
||
]);
|
||
|
||
// 前台查看禁封状态弹窗(1:1 复刻参考主题 zib_get_ban_info_modal;
|
||
// 本人或管理员可见,权限在 helper 内判定)。
|
||
register_rest_route(self::API_NAMESPACE, '/user-ban/info-modal', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_user_ban_info_modal'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
|
||
// 前台禁封申诉弹窗(1:1 复刻参考主题 zib_get_user_ban_appeal_modal;仅本人)。
|
||
register_rest_route(self::API_NAMESPACE, '/user-ban/appeal-modal', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_user_ban_appeal_modal'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
|
||
// 前台禁封记录弹窗(1:1 复刻参考主题 zib_get_ban_log_modal;仅管理员)。
|
||
register_rest_route(self::API_NAMESPACE, '/user-ban/log-modal', [
|
||
'methods' => 'GET',
|
||
'callback' => [$this, 'get_user_ban_log_modal'],
|
||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||
]);
|
||
}
|
||
|
||
|
||
/**
|
||
* GET /wechat/status — 微信模板消息配置状态。
|
||
*
|
||
* 状态形状与 WeChatNotify::get_status() 对齐(该方法的既定用途即 REST 与 AJAX 复用)。
|
||
*/
|
||
public function get_wechat_status(\WP_REST_Request $request): \WP_REST_Response {
|
||
if (!class_exists('\AKSRT\Payment\WeChatNotify')) {
|
||
return $this->error(__('微信通知组件未加载', 'aksrt'), 500);
|
||
}
|
||
|
||
return $this->success(['data' => \AKSRT\Payment\WeChatNotify::get_instance()->get_status()]);
|
||
}
|
||
|
||
/**
|
||
* POST /wechat/settings — 更新微信模板消息配置。
|
||
*
|
||
* 前端在密钥留空且已配置时发送掩码 ***,此时保留已存密钥;
|
||
* 未提供的字段沿用当前值。键白名单由 Integration::update_wechat_settings() 收敛。
|
||
*/
|
||
public function update_wechat_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||
$params = $this->get_json_params($request);
|
||
$current = \AKSRT\Settings\Settings::get_wechat_settings();
|
||
|
||
$enabled = array_key_exists('enabled', $params) ? !empty($params['enabled']) : !empty($current['enabled']);
|
||
$app_id = array_key_exists('app_id', $params)
|
||
? sanitize_text_field((string) $params['app_id'])
|
||
: (string) ($current['app_id'] ?? '');
|
||
$template_id = array_key_exists('template_id', $params)
|
||
? sanitize_text_field((string) $params['template_id'])
|
||
: (string) ($current['template_id'] ?? '');
|
||
|
||
// 掩码 *** 表示「不修改密钥」;仅在显式传入其他值时才覆盖。
|
||
if (array_key_exists('app_secret', $params) && (string) $params['app_secret'] !== '***') {
|
||
$app_secret = sanitize_text_field((string) $params['app_secret']);
|
||
} else {
|
||
$app_secret = (string) ($current['app_secret'] ?? '');
|
||
}
|
||
|
||
\AKSRT\Settings\Settings::update_wechat_settings([
|
||
'enabled' => $enabled,
|
||
'app_id' => $app_id,
|
||
'app_secret' => $app_secret,
|
||
'template_id' => $template_id,
|
||
]);
|
||
|
||
return $this->success(['message' => __('微信通知配置已保存', 'aksrt')]);
|
||
}
|
||
|
||
public function rest_handle_credit_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||
if ($request->get_method() === 'GET') {
|
||
$config = \AKSRT\Settings\CreditsRuntime::get_credit_settings();
|
||
return $this->success(['data' => $config]);
|
||
}
|
||
|
||
$params = $this->get_json_params($request);
|
||
$current = get_option('aksrt_credit_settings', []);
|
||
if (!is_array($current)) {
|
||
$current = [];
|
||
}
|
||
$merged = array_replace_recursive($current, $params);
|
||
update_option('aksrt_credit_settings', $merged);
|
||
|
||
return $this->success(['message' => __('积分配置已保存', 'aksrt')]);
|
||
}
|
||
|
||
public function rest_handle_auth_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||
$provider = SchemaProvider::get_instance();
|
||
$allowed = $provider->get_allowed_fields('users', 'auth');
|
||
|
||
if ($request->get_method() === 'GET') {
|
||
$data = \AKSRT\Settings\Settings::get_all();
|
||
return $this->success(['data' => array_intersect_key($data, $allowed)]);
|
||
}
|
||
|
||
$params = $this->get_json_params($request);
|
||
$settings = \AKSRT\Settings\Settings::get_all();
|
||
$sanitized = $this->sanitize_fields($params, $allowed);
|
||
$settings = array_merge($settings, $sanitized);
|
||
|
||
update_option('aksrt_settings', $settings);
|
||
\AKSRT\Settings\Settings::flush_settings_cache();
|
||
|
||
return $this->success(['message' => __('注册登录配置已保存', 'aksrt')]);
|
||
}
|
||
|
||
public function rest_handle_notification_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||
$provider = SchemaProvider::get_instance();
|
||
$allowed = $provider->get_allowed_fields('users', 'notifications');
|
||
|
||
if ($request->get_method() === 'GET') {
|
||
$data = \AKSRT\Settings\Settings::get_all();
|
||
return $this->success(['data' => array_intersect_key($data, $allowed)]);
|
||
}
|
||
|
||
$params = $this->get_json_params($request);
|
||
$settings = \AKSRT\Settings\Settings::get_all();
|
||
$settings = array_merge($settings, $this->sanitize_fields($params, $allowed));
|
||
update_option('aksrt_settings', $settings);
|
||
\AKSRT\Settings\Settings::flush_settings_cache();
|
||
|
||
return $this->success(['message' => __('消息通知配置已保存', 'aksrt')]);
|
||
}
|
||
|
||
public function rest_get_notification_overview(\WP_REST_Request $request): \WP_REST_Response {
|
||
$page = max(1, (int) $request->get_param('page'));
|
||
$per_page = min(100, max(1, (int) $request->get_param('per_page')));
|
||
$type = sanitize_key((string) $request->get_param('type'));
|
||
$is_read = $request->get_param('is_read');
|
||
|
||
$items = [];
|
||
$unread = 0;
|
||
if (class_exists('\AKSRT\Messages\Message')) {
|
||
$args = ['limit' => $per_page, 'offset' => ($page - 1) * $per_page];
|
||
if ($type !== '') {
|
||
$args['type'] = $type;
|
||
}
|
||
$items = \AKSRT\Messages\Message::get_instance()->get_notifications(get_current_user_id(), $args);
|
||
if ($is_read !== null && $is_read !== '') {
|
||
$want_read = (string) $is_read === '1';
|
||
$items = array_values(array_filter($items, static function ($item) use ($want_read) {
|
||
return !empty($item['is_read']) === $want_read;
|
||
}));
|
||
}
|
||
$unread = \AKSRT\Messages\Message::get_instance()->get_unread_count(get_current_user_id());
|
||
}
|
||
|
||
$total = count($items);
|
||
$types = [];
|
||
$today = 0;
|
||
$system = 0;
|
||
foreach ($items as $item) {
|
||
$itemType = sanitize_key((string) ($item['type'] ?? 'system'));
|
||
$types[$itemType] = ($types[$itemType] ?? 0) + 1;
|
||
if ($itemType === 'system' || $itemType === 'notice') {
|
||
$system++;
|
||
}
|
||
$created = (string) ($item['created_at'] ?? '');
|
||
if ($created !== '' && str_starts_with($created, gmdate('Y-m-d'))) {
|
||
$today++;
|
||
}
|
||
}
|
||
|
||
return $this->success(['data' => [
|
||
'recent' => $items,
|
||
'types' => array_map(static fn($key, $count) => ['type' => $key, 'count' => $count], array_keys($types), array_values($types)),
|
||
'summary' => [
|
||
'total' => $total,
|
||
'unread' => $unread,
|
||
'today' => $today,
|
||
'system' => $system,
|
||
],
|
||
'pagination' => [
|
||
'page' => $page,
|
||
'per_page' => $per_page,
|
||
'total_items' => $total,
|
||
'total_pages' => 1,
|
||
],
|
||
]]);
|
||
}
|
||
|
||
public function rest_mark_notification_read(\WP_REST_Request $request): \WP_REST_Response {
|
||
if (!class_exists('\AKSRT\Messages\Message')) {
|
||
return $this->success(['message' => __('消息系统未启用', 'aksrt')]);
|
||
}
|
||
|
||
$params = $this->get_json_params($request);
|
||
$msg = \AKSRT\Messages\Message::get_instance();
|
||
if (!empty($params['mark_all'])) {
|
||
$msg->mark_all_read(get_current_user_id());
|
||
} else {
|
||
$id = sanitize_text_field((string) ($params['id'] ?? ''));
|
||
if ($id !== '') {
|
||
$msg->mark_read(get_current_user_id(), $id);
|
||
}
|
||
}
|
||
|
||
return $this->success(['message' => __('已更新消息状态', 'aksrt')]);
|
||
}
|
||
|
||
public function rest_delete_notification_item(\WP_REST_Request $request): \WP_REST_Response {
|
||
$user_id = get_current_user_id();
|
||
if ($user_id <= 0) {
|
||
return $this->error(__('请先登录', 'aksrt'), 401);
|
||
}
|
||
$params = $this->get_json_params($request);
|
||
$id = sanitize_text_field((string) ($params['id'] ?? $request->get_param('id') ?? ''));
|
||
if ($id === '') {
|
||
return $this->error(__('参数错误', 'aksrt'), 400);
|
||
}
|
||
if (function_exists('aksrt_delete_user_notification')) {
|
||
aksrt_delete_user_notification($user_id, $id);
|
||
} elseif (class_exists('\AKSRT\Messages\Message')) {
|
||
\AKSRT\Messages\Message::get_instance()->mark_read($user_id, $id);
|
||
}
|
||
return $this->success([
|
||
'message' => __('消息已删除', 'aksrt'),
|
||
'unread' => function_exists('aksrt_get_user_unread_notification_count')
|
||
? aksrt_get_user_unread_notification_count($user_id)
|
||
: 0,
|
||
]);
|
||
}
|
||
|
||
public function rest_run_notification_cleanup(\WP_REST_Request $request): \WP_REST_Response {
|
||
return $this->success(['message' => __('消息清理任务已受理', 'aksrt')]);
|
||
}
|
||
|
||
public function check_comment_image_upload_permission(?\WP_REST_Request $request = null) {
|
||
if (!is_user_logged_in()) {
|
||
return new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
|
||
}
|
||
if ($request !== null) {
|
||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||
}
|
||
}
|
||
$settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||
$image_enabled = !isset($settings['comment_img']) || !empty($settings['comment_img']);
|
||
if (!$image_enabled || empty($settings['comment_upload_img'])) {
|
||
return new \WP_Error('aksrt_comment_upload_disabled', __('评论图片上传功能未开启', 'aksrt'), ['status' => 403]);
|
||
}
|
||
$user_id = get_current_user_id();
|
||
if (!function_exists('aksrt_user_can') || !aksrt_user_can('upload_comment_image', $user_id)) {
|
||
return new \WP_Error('aksrt_comment_upload_forbidden', __('当前账号没有上传评论图片的权限', 'aksrt'), ['status' => 403]);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public function rest_upload_comment_image(\WP_REST_Request $request): \WP_REST_Response {
|
||
$user_id = get_current_user_id();
|
||
if (empty($_FILES['image'])) {
|
||
return $this->error(__('请选择要上传的图片', 'aksrt'), 400);
|
||
}
|
||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||
|
||
$file_key = 'image';
|
||
$file = $_FILES[$file_key];
|
||
$upload_error = isset($file['error']) ? (int) $file['error'] : UPLOAD_ERR_NO_FILE;
|
||
if ($upload_error !== UPLOAD_ERR_OK) {
|
||
return $this->error(__('图片文件上传失败,请重新选择', 'aksrt'), 400);
|
||
}
|
||
|
||
$max_size = class_exists('\AKSRT\Attachment\UploadLimits')
|
||
? \AKSRT\Attachment\UploadLimits::limit_bytes('image', $user_id)
|
||
: (int) wp_max_upload_size();
|
||
$actual_size = !empty($file['tmp_name']) && is_readable($file['tmp_name']) ? (int) filesize($file['tmp_name']) : 0;
|
||
if ($actual_size <= 0 || $actual_size > $max_size) {
|
||
return $this->error(sprintf(__('图片大小不能超过 %s。', 'aksrt'), size_format($max_size)), 413);
|
||
}
|
||
|
||
$mimes = [
|
||
'jpg|jpeg|jpe' => 'image/jpeg',
|
||
'png' => 'image/png',
|
||
'gif' => 'image/gif',
|
||
'webp' => 'image/webp',
|
||
];
|
||
$checked = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $mimes);
|
||
$image_size = wp_getimagesize($file['tmp_name']);
|
||
if (empty($checked['type']) || empty($checked['ext']) || !wp_match_mime_types('image', $checked['type']) || !$image_size || ((int) $image_size[0] * (int) $image_size[1]) > 40000000) {
|
||
return $this->error(__('仅支持有效的 JPG、PNG、GIF 或 WebP 图片', 'aksrt'), 415);
|
||
}
|
||
if (class_exists('\AKSRT\Extensions\ContentAudit')) {
|
||
$audit = \AKSRT\Extensions\ContentAudit::get_instance()->audit_image((string) $file['tmp_name']);
|
||
if (empty($audit['passed'])) {
|
||
return $this->error(__('图片未通过内容审核', 'aksrt'), 422);
|
||
}
|
||
}
|
||
|
||
$attachment_id = media_handle_upload($file_key, 0, [], [
|
||
'test_form' => false,
|
||
'mimes' => $mimes,
|
||
]);
|
||
if (is_wp_error($attachment_id)) {
|
||
return $this->error($attachment_id->get_error_message(), 400);
|
||
}
|
||
wp_update_post(['ID' => (int) $attachment_id, 'post_author' => $user_id]);
|
||
if (!wp_attachment_is_image((int) $attachment_id)) {
|
||
wp_delete_attachment((int) $attachment_id, true);
|
||
return $this->error(__('上传文件不是有效图片', 'aksrt'), 415);
|
||
}
|
||
$url = wp_get_attachment_url((int) $attachment_id);
|
||
if (!$url) {
|
||
wp_delete_attachment((int) $attachment_id, true);
|
||
return $this->error(__('上传失败', 'aksrt'), 500);
|
||
}
|
||
return $this->success([
|
||
'message' => __('图片上传成功', 'aksrt'),
|
||
'data' => [
|
||
'url' => $url,
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function rest_get_credits_me(\WP_REST_Request $request): \WP_REST_Response {
|
||
$user_id = get_current_user_id();
|
||
return $this->success(['data' => [
|
||
'user_id' => $user_id,
|
||
'points' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
|
||
'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
|
||
]]);
|
||
}
|
||
|
||
public function check_asset_self_permission(\WP_REST_Request $request) {
|
||
if (!is_user_logged_in()) {
|
||
return new \WP_Error('aksrt_auth_required', __('请先登录', 'aksrt'), ['status' => 401]);
|
||
}
|
||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public function rest_list_credit_users(\WP_REST_Request $request): \WP_REST_Response {
|
||
global $wpdb;
|
||
$page = max(1, (int) $request->get_param('page'));
|
||
$per_page = min(100, max(1, (int) $request->get_param('per_page')));
|
||
$search = trim(sanitize_text_field((string) $request->get_param('search')));
|
||
$where = [];
|
||
$params = [];
|
||
if ($search !== '') {
|
||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||
$where[] = '(u.user_login LIKE %s OR u.user_email LIKE %s OR u.display_name LIKE %s' . (ctype_digit($search) ? ' OR u.ID = %d' : '') . ')';
|
||
array_push($params, $like, $like, $like);
|
||
if (ctype_digit($search)) $params[] = (int) $search;
|
||
}
|
||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||
$total_sql = "SELECT COUNT(*) FROM {$wpdb->users} u {$where_sql}";
|
||
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
|
||
$sql = "SELECT u.ID AS id, u.user_login AS login, u.user_email AS email, u.display_name, u.user_registered AS registered,
|
||
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'balance' ORDER BY m.umeta_id ASC LIMIT 1) AS balance_value,
|
||
(SELECT m.meta_value FROM {$wpdb->usermeta} m WHERE m.user_id = u.ID AND m.meta_key = 'points' ORDER BY m.umeta_id ASC LIMIT 1) AS points_value
|
||
FROM {$wpdb->users} u {$where_sql} ORDER BY u.user_registered DESC, u.ID DESC LIMIT %d OFFSET %d";
|
||
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, ($page - 1) * $per_page])), ARRAY_A);
|
||
$items = [];
|
||
foreach ((array) $rows as $row) {
|
||
try { $balance = \AKSRT\Ledger\AssetAmount::balance((string) ($row['balance_value'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { $balance = '0.00'; }
|
||
$items[] = [
|
||
'id' => (int) $row['id'], 'login' => (string) $row['login'], 'email' => (string) $row['email'],
|
||
'display_name' => (string) $row['display_name'], 'registered' => (string) $row['registered'],
|
||
'balance' => $balance, 'points' => max(0, (int) ($row['points_value'] ?? 0)),
|
||
];
|
||
}
|
||
$data = ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||
return $this->success(['data' => $data]);
|
||
}
|
||
|
||
public function rest_adjust_user_credits(\WP_REST_Request $request): \WP_REST_Response {
|
||
$user_id = (int) $request->get_param('id');
|
||
$params = $this->get_json_params($request);
|
||
$asset = sanitize_key((string) ($params['asset'] ?? ''));
|
||
$raw_delta = $params['delta'] ?? null;
|
||
$remark = sanitize_text_field((string) ($params['remark'] ?? ''));
|
||
if ($user_id <= 0 || !in_array($asset, ['points', 'balance'], true) || !get_userdata($user_id)) {
|
||
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
|
||
}
|
||
if (!current_user_can('edit_user', $user_id)) {
|
||
return $this->error(__('无权调整该用户资产', 'aksrt'), 403);
|
||
}
|
||
$negative = is_string($raw_delta) && str_starts_with($raw_delta, '-');
|
||
$unsigned = $negative ? substr($raw_delta, 1) : $raw_delta;
|
||
try {
|
||
if ($asset === 'points') {
|
||
if (!is_int($raw_delta) || $raw_delta === PHP_INT_MIN) throw new \InvalidArgumentException();
|
||
$minor = abs($raw_delta);
|
||
$negative = $raw_delta < 0;
|
||
} else {
|
||
if (!is_string($unsigned)) throw new \InvalidArgumentException();
|
||
$minor = \AKSRT\Ledger\AssetAmount::balance($unsigned)->minor();
|
||
}
|
||
} catch (\InvalidArgumentException $e) {
|
||
return $this->error(__('资产变动金额格式无效', 'aksrt'), 400);
|
||
}
|
||
if ($minor <= 0) return $this->error(__('资产变动金额必须非零', 'aksrt'), 400);
|
||
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'admin-adjustment', get_current_user_id());
|
||
if (is_wp_error($operation_key)) return new \WP_REST_Response(['success' => false, 'message' => $operation_key->get_error_message()], 400);
|
||
$service = \AKSRT\Ledger\LedgerService::get_instance();
|
||
$result = $negative
|
||
? $service->debit($user_id, $asset, $minor, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $remark, get_current_user_id())
|
||
: $service->credit($user_id, $asset, $minor, $operation_key, 'admin_adjustment', ['type' => 'user', 'id' => $user_id], $remark, get_current_user_id());
|
||
|
||
if (is_wp_error($result)) {
|
||
$status = (int) (($result->get_error_data()['status'] ?? 400));
|
||
return $this->error($result->get_error_message(), $status);
|
||
}
|
||
|
||
return $this->success(['data' => [
|
||
'user_id' => $user_id,
|
||
'asset' => $asset,
|
||
'balance' => $asset === 'balance'
|
||
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, $asset))->decimal()
|
||
: $result->balance($user_id, $asset),
|
||
'replayed' => $result->replayed,
|
||
], 'message' => __('资产调整已保存', 'aksrt')]);
|
||
}
|
||
|
||
public function rest_get_credits_log(\WP_REST_Request $request): \WP_REST_Response {
|
||
$user_id = max(0, (int) $request->get_param('user_id'));
|
||
$asset = sanitize_key((string) $request->get_param('asset'));
|
||
if ($user_id <= 0 || !get_userdata($user_id) || !in_array($asset, ['points', 'balance'], true)) {
|
||
return $this->error(__('用户或资产类型无效', 'aksrt'), 400);
|
||
}
|
||
$meta_key = $asset === 'points' ? 'points_record' : 'balance_record';
|
||
$records = get_user_meta($user_id, $meta_key, true);
|
||
$records = is_array($records) ? array_slice($records, 0, 50) : [];
|
||
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
|
||
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
|
||
$records = array_values(array_filter($records, static function ($record) use ($date_from, $date_to): bool {
|
||
if (!is_array($record)) return false;
|
||
$day = substr((string) ($record['time'] ?? ''), 0, 10);
|
||
return ($date_from === '' || $day >= $date_from) && ($date_to === '' || $day <= $date_to);
|
||
}));
|
||
$page = max(1, (int) $request->get_param('page'));
|
||
$per_page = min(50, max(1, (int) $request->get_param('per_page')));
|
||
$items = [];
|
||
foreach (array_slice($records, ($page - 1) * $per_page, $per_page) as $index => $record) {
|
||
$value = $asset === 'points' ? (int) ($record['value'] ?? 0) : $this->asset_value_to_minor((string) ($record['value'] ?? '0'));
|
||
$after = $asset === 'points' ? (int) ($record['points'] ?? 0) : $this->asset_value_to_minor((string) ($record['balance'] ?? '0'));
|
||
$items[] = [
|
||
'id' => (($page - 1) * $per_page) + $index + 1,
|
||
'operation_key' => (string) ($record['operation_key'] ?? ''),
|
||
'reason' => (string) ($record['type'] ?? ''),
|
||
'ref_type' => !empty($record['order_num']) ? 'order' : '',
|
||
'ref_key' => (string) ($record['order_num'] ?? ''),
|
||
'actor_user_id' => 0,
|
||
'assets' => [$asset],
|
||
'created_at' => (string) ($record['time'] ?? ''),
|
||
'remark' => (string) ($record['desc'] ?? ''),
|
||
'entries' => [[
|
||
'user_id' => $user_id, 'asset' => $asset, 'entry_type' => $value < 0 ? 'debit' : 'credit',
|
||
'delta' => $value, 'balance_after' => $after, 'remark' => (string) ($record['desc'] ?? ''),
|
||
]],
|
||
];
|
||
}
|
||
$data = ['items' => $items, 'total' => count($records), 'page' => $page, 'per_page' => $per_page];
|
||
return $this->success(['data' => $data]);
|
||
}
|
||
|
||
private function asset_value_to_minor(string $value): int {
|
||
$value = trim($value);
|
||
$negative = str_starts_with($value, '-');
|
||
if ($negative) $value = substr($value, 1);
|
||
try { $minor = \AKSRT\Ledger\AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; }
|
||
return $negative ? -$minor : $minor;
|
||
}
|
||
|
||
}
|