Files
wordpress-theme-aksrt/src/Admin/UserController.php
T
kate522 835e0f983d feat(users): enforce real bans from report processing and fix appeal lifting
- apply_report_action now writes aksrt_user_restriction via Restriction::restrict
  and lifts via new lift_user_restriction(), replacing the display-only
  aksrt_user_ban_state meta dual-track
- ban type restrictions destroy the target user's sessions on apply
- appeal approval lifts active ban/suspend/mute restrictions instead of
  updating a nonexistent status column
- UsersBan.vue posts to /users/{id}/restrict with valid types (warn/mute/
  suspend/ban), scope and expires_at
- add comment report entry to the comment actions dropdown
- add duplicate pending report guard on submit
- remove dead ReportCenter ajax handlers, option migration, REST alias
  routes and write-only restriction meta
- fix report admin mail link to the real aksrt_user_ban page
- add report-ban contract tests
2026-09-19 21:38:56 +08:00

694 lines
31 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
class UserController extends ControllerBase {
use UserControllerReportOperations;
use UserControllerVerifyIntegrations;
use UserControllerIdentitySettings;
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'],
]);
// 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' => [
'transaction_id' => ['type' => 'integer', 'minimum' => 1],
'operation_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
'user_id' => ['type' => 'integer', 'minimum' => 1],
'actor_user_id' => ['type' => 'integer', 'minimum' => 1],
'asset' => ['type' => 'string', 'enum' => ['points', 'balance']],
'reason' => ['type' => 'string', 'maxLength' => 64, 'sanitize_callback' => 'sanitize_key'],
'ref_type' => ['type' => 'string', 'maxLength' => 32, 'sanitize_callback' => 'sanitize_key'],
'ref_key' => ['type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_text_field'],
'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
'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', [
'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',
]);
// Identity verification provider settings (admin only)
register_rest_route(self::API_NAMESPACE, '/identity/settings', [
[
'methods' => 'GET',
'callback' => [$this, 'get_identity_settings'],
'permission_callback' => [$this, 'check_permission'],
],
[
'methods' => 'POST',
'callback' => [$this, 'update_identity_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
register_rest_route(self::API_NAMESPACE, '/identity/settings/test', [
'methods' => 'POST',
'callback' => [$this, 'test_identity_settings'],
'permission_callback' => [$this, 'check_permission'],
]);
// SMS provider status
register_rest_route(self::API_NAMESPACE, '/sms/status', [
'methods' => 'GET',
'callback' => [$this, 'get_sms_status'],
'permission_callback' => [$this, 'check_permission'],
]);
// SMS test send (admin only)
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'],
]);
}
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);
}
$settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$max_size_mb = max(1, min(10, (int) ($settings['comment_image_max_size'] ?? 2)));
$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_mb * MB_IN_BYTES) {
return $this->error(sprintf(__('图片大小必须在 1 字节至 %dMB 之间', 'aksrt'), $max_size_mb), 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 {
$args = [
'search' => (string) $request->get_param('search'),
'page' => (int) $request->get_param('page'),
'per_page' => (int) $request->get_param('per_page'),
];
$asset = sanitize_key((string) $request->get_param('asset'));
$asset = in_array($asset, ['points', 'balance'], true) ? $asset : 'points';
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->list_accounts($asset, $args);
foreach ($data['items'] as &$item) {
$item['asset'] = $asset;
$item['balance'] = $asset === 'balance'
? \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, (int) $item['balance_minor'])->decimal()
: (int) $item['balance_minor'];
unset($item['balance_minor']);
}
unset($item);
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),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
], 'message' => __('资产调整已保存', 'aksrt')]);
}
public function rest_get_credits_log(\WP_REST_Request $request): \WP_REST_Response {
$args = [
'transaction_id' => (int) $request->get_param('transaction_id'),
'operation_key' => (string) $request->get_param('operation_key'),
'user_id' => (int) $request->get_param('user_id'),
'actor_user_id' => (int) $request->get_param('actor_user_id'),
'reason' => (string) $request->get_param('reason'),
'ref_type' => (string) $request->get_param('ref_type'),
'ref_key' => (string) $request->get_param('ref_key'),
'page' => (int) $request->get_param('page'),
'per_page' => (int) $request->get_param('per_page'),
'date_from' => (string) $request->get_param('date_from'),
'date_to' => (string) $request->get_param('date_to'),
];
$args['asset'] = sanitize_key((string) $request->get_param('asset'));
$data = \AKSRT\Ledger\LedgerQuery::get_instance()->history($args);
foreach ($data['items'] as &$item) {
$assets = array_values(array_unique(array_map('strval', (array) ($item['assets'] ?? []))));
$single_asset = count($assets) === 1 ? $assets[0] : '';
if ($single_asset !== '') {
$item['delta'] = $this->asset_minor_for_response($single_asset, (int) ($item['delta_minor'] ?? 0), true);
$item['fee'] = $this->asset_minor_for_response($single_asset, (int) ($item['fee_minor'] ?? 0));
$item['balance_after'] = $this->asset_minor_for_response($single_asset, (int) ($item['balance_after_minor'] ?? 0));
} else {
$item['delta'] = null;
$item['fee'] = null;
$item['balance_after'] = null;
}
unset($item['delta_minor'], $item['fee_minor'], $item['balance_after_minor']);
foreach ($item['entries'] as &$entry) {
$entry_asset = (string) ($entry['asset'] ?? '');
$entry['delta'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['delta_minor'] ?? 0), true);
$entry['balance_after'] = $this->asset_minor_for_response($entry_asset, (int) ($entry['balance_after_minor'] ?? 0));
unset($entry['delta_minor'], $entry['balance_after_minor']);
}
unset($entry);
}
unset($item);
return $this->success(['data' => $data]);
}
private function asset_minor_for_response(string $asset, int $minor, bool $signed = false) {
if ($asset === \AKSRT\Ledger\AssetType::POINTS->value) {
return $minor;
}
if ($asset !== \AKSRT\Ledger\AssetType::BALANCE->value) {
return null;
}
$negative = $minor < 0;
$value = \AKSRT\Ledger\AssetAmount::decimal_from_minor(abs($minor));
if ($negative) {
return '-' . $value;
}
return $signed && $minor > 0 ? '+' . $value : $value;
}
}