Files
wordpress-theme-aksrt/src/Admin/UserController.php
T
kate522 18e579a8c6 fix(admin): restore WeChat template message REST callbacks
/wechat/status 与 /wechat/settings 自引入起只注册了路由、从未实现回调方法,后台「微信模板消息」页调用即 500。

按既定设计补齐:状态读取委托 WeChatNotify::get_status()(其既定用途即 REST 与 AJAX 复用),写入委托 Settings::update_wechat_settings() 并由 Integration 收敛键白名单;前端空密钥下发掩码 *** 时保留已存密钥,避免把密钥写成字面量。

新增 PHP harness 驱动真实 UserController + Integration + WeChatNotify 验证状态形状、掩码保留、缺省沿用、未知键丢弃与输入消毒。
2026-10-13 21:00:35 +08:00

777 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}