refactor: flatten src/AKSRT → src (drop theme-identifier dir)

Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
2026-08-02 22:06:06 +08:00
parent 56f1a8175f
commit 8c5b1555c9
285 changed files with 26 additions and 3600 deletions
+178
View File
@@ -0,0 +1,178 @@
<?php
/**
* Identity verification facade for existing presentation call sites.
*
* @package AKSRT
*/
namespace AKSRT\User;
use AKSRT\Identity\ApplicationRepository;
use AKSRT\Identity\EvidenceService;
use AKSRT\Identity\EventRepository;
use AKSRT\Identity\TransitionService;
defined('ABSPATH') || exit;
class AuthApply {
private static ?self $instance = null;
public static function get_instance(): self {
return self::$instance ??= new self();
}
public function init(): void {
if (is_admin()) {
add_filter('manage_users_columns', [$this, 'add_admin_user_column']);
add_filter('manage_users_custom_column', [$this, 'render_admin_user_column'], 10, 3);
add_action('show_user_profile', [$this, 'render_admin_profile_status']);
add_action('edit_user_profile', [$this, 'render_admin_profile_status']);
}
}
/** @return array<string,mixed> */
public static function settings(): array {
$identity = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_user_identity_settings() : [];
return is_array($identity['verify'] ?? null) ? $identity['verify'] : [];
}
/** @return array<string,mixed> */
public static function type_settings(string $type): array {
$types = self::settings()['types'] ?? [];
$type = sanitize_key($type);
return is_array($types[$type] ?? null) ? $types[$type] : [];
}
public static function application_count(int $user_id, string $type): int {
return (new ApplicationRepository())->count_user_type($user_id, sanitize_key($type));
}
public static function get_status(int $user_id): string {
$repo = new ApplicationRepository();
$application = $repo->verified_for_user($user_id) ?: $repo->latest_for_user($user_id);
return $application ? TransitionService::public_status((string) $application['status']) : '';
}
public static function is_verified(int $user_id): bool {
return (new ApplicationRepository())->verified_for_user($user_id) !== null;
}
/** @return array<string,mixed> */
public static function get_auth_info(int $user_id): array {
$application = (new ApplicationRepository())->verified_for_user($user_id);
return $application ? [
'name' => (string) $application['public_name'],
'desc' => (string) $application['public_desc'],
'type' => (string) $application['type'],
'time' => (string) ($application['completed_at'] ?: $application['updated_at']),
] : [];
}
/** @return array<string,mixed> */
public static function get_apply_payload(int $user_id): array {
$application = (new ApplicationRepository())->latest_for_user($user_id);
if (!$application) {
return [];
}
return [
'application_uuid' => (string) $application['application_uuid'],
'user_id' => (int) $application['user_id'],
'name' => (string) $application['public_name'],
'real_name' => (string) $application['person_name_masked'],
'desc' => (string) $application['public_desc'],
'msg' => (string) ($application['manual_reason'] ?? ''),
'type' => (string) $application['type'],
'time' => (string) $application['created_at'],
'fee' => (float) $application['fee_amount'],
'_application' => $application,
];
}
/** @return array<int,array{id:int,url:string,thumbnail_url:string}> */
public static function get_evidence_items(array $application, int $owner_id = 0, bool $admin_context = false): array {
$record = is_array($application['_application'] ?? null) ? $application['_application'] : null;
if (!$record && !empty($application['application_uuid'])) {
$record = (new ApplicationRepository())->find_by_uuid((string) $application['application_uuid']);
}
return $record ? (new EvidenceService())->items($record, $admin_context) : [];
}
/** @return array<string,mixed> */
public static function application_view(array $application, int $owner_id = 0, bool $admin_context = false): array {
return [
'name' => (string) ($application['name'] ?? ''),
'real_name' => (string) ($application['real_name'] ?? ''),
'desc' => (string) ($application['desc'] ?? ''),
'msg' => (string) ($application['msg'] ?? ''),
'type' => sanitize_key((string) ($application['type'] ?? '')),
'time' => (string) ($application['time'] ?? ''),
'evidence' => self::get_evidence_items($application, $owner_id, $admin_context),
];
}
/** @return array<int,array<string,mixed>> */
public static function get_review_history(int $user_id): array {
$application = (new ApplicationRepository())->latest_for_user($user_id);
if (!$application) {
return [];
}
$history = [];
foreach ((new EventRepository())->for_application((int) $application['id']) as $event) {
$history[] = [
'status' => TransitionService::public_status((string) ($event['to_status'] ?? '')),
'reason' => (string) ($event['result_code'] ?? ''),
'reviewer' => (int) ($event['actor_user_id'] ?? 0),
'time' => (string) ($event['created_at'] ?? ''),
];
}
return $history;
}
public static function review(int $user_id, string $status, string $note, int $reviewer_id) {
$repo = new ApplicationRepository();
$application = $repo->latest_for_user($user_id);
if (!$application) {
return new \WP_Error('aksrt_verify_missing', __('认证申请不存在', 'aksrt'));
}
$status = sanitize_key($status);
$note = sanitize_textarea_field($note);
if (!in_array($status, ['verified', 'rejected', 'cancelled'], true)) {
return new \WP_Error('aksrt_verify_status', __('审核状态无效', 'aksrt'));
}
if (in_array($status, ['rejected', 'cancelled'], true) && $note === '') {
return new \WP_Error('aksrt_verify_note', __('请填写处理原因', 'aksrt'));
}
$result = (new TransitionService($repo))->transition($application, $status, [
'review_note' => $note,
'reviewer_id' => $reviewer_id,
], ['source' => 'admin', 'event_type' => 'manual_review', 'actor_user_id' => $reviewer_id]);
return is_wp_error($result) ? $result : ['status' => $status, 'reviewed_at' => (string) $result['completed_at']];
}
public function add_admin_user_column(array $columns): array {
$columns['aksrt_identity_status'] = __('身份认证', 'aksrt');
return $columns;
}
public function render_admin_user_column(string $output, string $column_name, int $user_id): string {
if ($column_name !== 'aksrt_identity_status') {
return $output;
}
$status = self::get_status($user_id);
$labels = ['pending' => __('进行中', 'aksrt'), 'verified' => __('已认证', 'aksrt'), 'rejected' => __('未通过', 'aksrt'), 'cancelled' => __('已取消', 'aksrt')];
return $status === '' ? esc_html__('未认证', 'aksrt') : esc_html($labels[$status] ?? $status);
}
public function render_admin_profile_status(\WP_User $user): void {
$status = self::get_status((int) $user->ID);
$info = self::get_auth_info((int) $user->ID);
?>
<h2><?php esc_html_e('身份认证(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr><th><?php esc_html_e('认证状态', 'aksrt'); ?></th><td><?php echo esc_html($status ?: __('未认证', 'aksrt')); ?></td></tr>
<?php if (!empty($info['name'])) : ?><tr><th><?php esc_html_e('认证身份', 'aksrt'); ?></th><td><?php echo esc_html((string) $info['name']); ?></td></tr><?php endif; ?>
</table>
<?php
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* Auto-grant medals from catalog thresholds (zibll zib_auto_add_user_medal lite).
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class BadgeAutoAward {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
// Run after login / current user bootstrap.
add_action('wp_login', [$this, 'on_login'], 20, 2);
add_action('wp', [$this, 'maybe_for_current_user'], 30);
// Cheap hooks for common counters.
add_action('publish_post', [$this, 'on_content_change'], 20, 1);
add_action('comment_post', [$this, 'on_content_change'], 20, 1);
add_action('aksrt_user_followed', [$this, 'on_follow'], 20, 2);
add_action('aksrt_checkin_done', [$this, 'on_checkin'], 20, 1);
}
public function on_login($user_login, $user): void {
if ($user instanceof \WP_User) {
$this->evaluate((int) $user->ID);
}
}
public function maybe_for_current_user(): void {
if (!is_user_logged_in() || is_admin()) {
return;
}
// Throttle: once per 10 minutes per user.
$uid = get_current_user_id();
$key = 'aksrt_badge_auto_' . $uid;
if (get_transient($key)) {
return;
}
set_transient($key, 1, 10 * MINUTE_IN_SECONDS);
$this->evaluate($uid);
}
public function on_content_change($arg = null): void {
$uid = get_current_user_id();
if ($uid > 0) {
$this->evaluate($uid);
}
}
public function on_follow(int $follower_id, int $following_id): void {
if ($following_id > 0) {
$this->evaluate($following_id);
}
if ($follower_id > 0) {
$this->evaluate($follower_id);
}
}
public function on_checkin(int $user_id): void {
if ($user_id > 0) {
$this->evaluate($user_id);
}
}
public function evaluate(int $user_id): void {
if ($user_id <= 0 || !class_exists('\AKSRT\User\Badges') || !Badges::enabled()) {
return;
}
Badges::auto_evaluate($user_id);
}
}
+740
View File
@@ -0,0 +1,740 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Badges {
public const META_OWNED = 'aksrt_user_badges';
public const META_WEAR = 'aksrt_wear_badge';
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 {
add_action('rest_api_init', [$this, 'register_routes']);
add_action('aksrt_badge_granted', [$this, 'on_badge_granted_notify'], 10, 3);
// HTML modal for remote-modal (zibll user_medal_info_modal).
add_action('wp_ajax_aksrt_user_badge_info_modal', [$this, 'ajax_info_modal']);
add_action('wp_ajax_nopriv_aksrt_user_badge_info_modal', [$this, 'ajax_info_modal']);
add_action('wp_ajax_aksrt_single_badge_info_modal', [$this, 'ajax_single_info_modal']);
add_action('wp_ajax_nopriv_aksrt_single_badge_info_modal', [$this, 'ajax_single_info_modal']);
}
/**
* Notify user when a badge is granted (zibll user_add_medal message).
*/
public function on_badge_granted_notify(int $user_id, string $slug, int $actor = 0): void {
if ($user_id <= 0 || $slug === '') {
return;
}
$map = self::get_dictionary_map();
if (!isset($map[$slug])) {
return;
}
$label = (string) ($map[$slug]['label'] ?? $slug);
$title = sprintf(
/* translators: %s badge name */
__('恭喜您!获得新的徽章[%s]', 'aksrt'),
$label
);
$content = sprintf(
/* translators: %s badge name */
__('恭喜您获得徽章「%s」', 'aksrt'),
$label
);
if (class_exists('\AKSRT\Messages\Message')) {
\AKSRT\Messages\Message::get_instance()->send($user_id, 'medal', $content, [
'title' => $title,
'target_url' => function_exists('aksrt_get_user_center_url')
? aksrt_get_user_center_url()
: home_url('/'),
'slug' => $slug,
]);
}
}
/**
* AJAX: full badge list modal HTML.
*/
public function ajax_info_modal(): void {
$user_id = max(0, (int) ($_REQUEST['id'] ?? $_REQUEST['user_id'] ?? 0)); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ($user_id <= 0) {
$user_id = get_current_user_id();
}
if ($user_id <= 0 || !get_userdata($user_id)) {
wp_send_json_error(['message' => __('用户不存在', 'aksrt')], 404);
}
$html = self::render_info_modal_html($user_id);
wp_send_json_success(['html' => $html]);
}
/**
* AJAX: single badge detail modal.
*/
public function ajax_single_info_modal(): void {
$slug = sanitize_key((string) ($_REQUEST['name'] ?? $_REQUEST['slug'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$user_id = max(0, (int) ($_REQUEST['user_id'] ?? $_REQUEST['id'] ?? 0)); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ($slug === '') {
wp_send_json_error(['message' => __('徽章不存在', 'aksrt')], 404);
}
$html = self::render_single_modal_html($slug, $user_id);
wp_send_json_success(['html' => $html]);
}
/**
* zibll zib_get_user_medal_info_modal lite.
*/
public static function render_info_modal_html(int $user_id): string {
$user = get_userdata($user_id);
if (!$user) {
return '<div class="aksrt-badge-modal"><p>' . esc_html__('用户不存在', 'aksrt') . '</p></div>';
}
$current = get_current_user_id();
$is_self = $current > 0 && $current === $user_id;
$display = $is_self ? __('我', 'aksrt') : $user->display_name;
$avatar = function_exists('aksrt_get_avatar_url')
? aksrt_get_avatar_url($user_id, ['size' => 64])
: get_avatar_url($user_id, ['size' => 64]);
$data = self::list_for_user($user_id, $is_self || self::can_manually_set($current));
$items = is_array($data['items'] ?? null) ? $data['items'] : [];
$wear = (string) ($data['wear'] ?? '');
$html = '<div class="aksrt-badge-modal">';
$html .= '<div class="aksrt-badge-modal__head">';
$html .= '<img class="aksrt-badge-modal__avatar" src="' . esc_url($avatar) . '" alt="" width="40" height="40" />';
$html .= '<div class="aksrt-badge-modal__title">' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display));
if ($wear !== '') {
$map = self::get_dictionary_map();
if (isset($map[$wear])) {
$html .= ' <span class="aksrt-badge-modal__wear-tag">' . esc_html(sprintf(__('佩戴:%s', 'aksrt'), (string) $map[$wear]['label'])) . '</span>';
}
} else {
$html .= ' <span class="badg badg-sm c-red">' . esc_html__('暂无徽章', 'aksrt') . '</span>';
}
$html .= '</div>';
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '</div>';
$html .= '<div class="aksrt-badge-modal__list mini-scrollbar">';
if (!$items) {
$html .= '<div class="aksrt-badge-modal__empty">' . esc_html__('暂无徽章', 'aksrt') . '</div>';
} else {
foreach ($items as $item) {
$slug = (string) ($item['slug'] ?? '');
$label = (string) ($item['label'] ?? $slug);
$icon = (string) ($item['icon_url'] ?? '');
$is_wear = !empty($item['wear']);
$html .= '<div class="aksrt-badge-modal__item' . ($is_wear ? ' is-wear' : '') . '" data-slug="' . esc_attr($slug) . '">';
if ($icon !== '') {
$html .= '<img class="medal-icon img-icon" src="' . esc_url($icon) . '" alt="" width="40" height="40" loading="lazy" />';
} else {
$html .= '<span class="aksrt-badge-modal__icon-fa"><i class="fas fa-award" aria-hidden="true"></i></span>';
}
$html .= '<div class="aksrt-badge-modal__meta"><div class="aksrt-badge-modal__name">' . esc_html($label) . '</div>';
if ($is_wear) {
$html .= '<div class="em09 c-blue">' . esc_html__('当前佩戴', 'aksrt') . '</div>';
}
$html .= '</div>';
if ($is_self && !$is_wear) {
$html .= '<button type="button" class="but jb-blue but-sm" data-aksrt-badge-wear="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</button>';
}
$html .= '</div>';
}
}
$html .= '</div></div>';
return $html;
}
/**
* Single badge card modal.
*/
public static function render_single_modal_html(string $slug, int $user_id = 0): string {
$map = self::get_dictionary_map();
$slug = sanitize_key($slug);
if ($slug === '' || !isset($map[$slug])) {
return '<div class="aksrt-badge-modal"><p>' . esc_html__('徽章不存在', 'aksrt') . '</p></div>';
}
$item = $map[$slug];
$current = get_current_user_id();
$is_self = $user_id > 0 && $current === $user_id;
$owned = $user_id > 0 ? self::get_owned($user_id) : [];
$has = in_array($slug, $owned, true);
$wear = $user_id > 0 ? self::get_wear($user_id) : '';
$is_wear = $has && $wear === $slug;
$html = '<div class="aksrt-badge-modal aksrt-badge-modal--single">';
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '<div class="aksrt-badge-modal__card">';
if (!empty($item['icon_url'])) {
$html .= '<img class="medal-icon img-icon" src="' . esc_url((string) $item['icon_url']) . '" alt="" width="72" height="72" />';
} else {
$html .= '<span class="aksrt-badge-modal__icon-fa lg"><i class="fas fa-award" aria-hidden="true"></i></span>';
}
$html .= '<div class="aksrt-badge-modal__name">' . esc_html((string) $item['label']) . '</div>';
if ($is_self) {
$html .= $has
? '<div class="c-blue em09 mt6">' . esc_html__('已获得', 'aksrt') . '</div>'
: '<div class="c-yellow em09 mt6">' . esc_html__('暂未获得', 'aksrt') . '</div>';
}
$html .= '</div>';
if ($is_self && $has && !$is_wear) {
$html .= '<div class="aksrt-badge-modal__actions"><button type="button" class="but jb-blue btn-block" data-aksrt-badge-wear="' . esc_attr($slug) . '">' . esc_html__('佩戴此徽章', 'aksrt') . '</button></div>';
} elseif ($is_self && $is_wear) {
$html .= '<div class="aksrt-badge-modal__actions"><span class="badg btn-block c-blue">' . esc_html__('当前佩戴此徽章', 'aksrt') . '</span></div>';
}
$html .= '</div>';
return $html;
}
/**
* Remote-modal trigger for a user's badge list (zibll zib_get_user_medal_info_link).
*/
public static function get_info_link(int $user_id, string $class = '', ?string $text = null): string {
if ($user_id <= 0 || !self::enabled()) {
return '';
}
if ($text === null) {
$text = __('徽章明细', 'aksrt');
}
if (!function_exists('aksrt_get_remote_modal_link')) {
return '';
}
$url = add_query_arg(
[
'action' => 'aksrt_user_badge_info_modal',
'id' => $user_id,
],
admin_url('admin-ajax.php')
);
return aksrt_get_remote_modal_link([
'tag' => 'a',
'class' => trim('user-medal-info ' . $class),
'text' => $text,
'url' => $url,
'data_class' => 'full-sm',
'height' => 240,
'mobile_bottom' => true,
]);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges', [
'methods' => 'GET',
'callback' => [$this, 'rest_list'],
'permission_callback' => '__return_true',
'args' => [
'id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
],
]);
register_rest_route('aksrt/v1', '/users/me/badges/wear', [
'methods' => 'POST',
'callback' => [$this, 'rest_wear'],
'permission_callback' => static function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges/grant', [
'methods' => 'POST',
'callback' => [$this, 'rest_grant'],
'permission_callback' => [$this, 'rest_can_manually_set'],
'args' => [
'id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
],
]);
register_rest_route('aksrt/v1', '/users/(?P<id>\d+)/badges/revoke', [
'methods' => 'POST',
'callback' => [$this, 'rest_revoke'],
'permission_callback' => [$this, 'rest_can_manually_set'],
'args' => [
'id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
],
]);
}
public function rest_can_manually_set(): bool {
return self::can_manually_set(get_current_user_id());
}
public static function enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
return false;
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
return !empty($settings['badge']['enabled']);
}
public static function public_display(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
return false;
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
return !empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display']);
}
/**
* @return list<array{slug:string,label:string,tone:string,icon_url:string}>
*/
public static function get_dictionary(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
$dict = is_array($settings['badge']['dictionary'] ?? null) ? $settings['badge']['dictionary'] : [];
$out = [];
foreach ($dict as $item) {
$slug = sanitize_key((string) ($item['slug'] ?? ''));
if ($slug === '') {
continue;
}
$out[] = [
'slug' => $slug,
'label' => (string) ($item['label'] ?? $slug),
'tone' => (string) ($item['tone'] ?? 'neutral'),
'icon_url' => (string) ($item['icon_url'] ?? ''),
];
}
return $out;
}
/**
* @return array<string,array{slug:string,label:string,tone:string,icon_url:string}>
*/
public static function get_dictionary_map(): array {
$map = [];
foreach (self::get_dictionary() as $item) {
$map[$item['slug']] = $item;
}
return $map;
}
/**
* @return list<string>
*/
public static function get_owned(int $user_id): array {
if ($user_id <= 0) {
return [];
}
$raw = get_user_meta($user_id, self::META_OWNED, true);
if (!is_array($raw)) {
$raw = preg_split('/[\s,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY);
}
$map = self::get_dictionary_map();
$out = [];
foreach ((array) $raw as $slug) {
$slug = sanitize_key((string) $slug);
if ($slug === '' || !isset($map[$slug])) {
continue;
}
$out[] = $slug;
}
return array_values(array_unique($out));
}
/**
* @param list<string> $slugs
*/
public static function set_owned(int $user_id, array $slugs): void {
if ($user_id <= 0) {
return;
}
$map = self::get_dictionary_map();
$clean = [];
foreach ($slugs as $slug) {
$slug = sanitize_key((string) $slug);
if ($slug !== '' && isset($map[$slug])) {
$clean[] = $slug;
}
}
$clean = array_values(array_unique($clean));
update_user_meta($user_id, self::META_OWNED, $clean);
$wear = self::get_wear($user_id);
if ($wear !== '' && !in_array($wear, $clean, true)) {
delete_user_meta($user_id, self::META_WEAR);
}
}
public static function get_wear(int $user_id): string {
if ($user_id <= 0) {
return '';
}
$wear = sanitize_key((string) get_user_meta($user_id, self::META_WEAR, true));
if ($wear === '') {
// zibll alias.
$wear = sanitize_key((string) get_user_meta($user_id, 'wear_medal', true));
}
$owned = self::get_owned($user_id);
if ($wear === '') {
// Default to newest/first owned badge when unset (zibll behavior).
return $owned[0] ?? '';
}
return in_array($wear, $owned, true) ? $wear : '';
}
/**
* @return true|\WP_Error
*/
public static function wear(int $user_id, string $slug) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_badge_user', __('无效用户', 'aksrt'), ['status' => 400]);
}
if (!self::enabled()) {
return new \WP_Error('aksrt_badge_disabled', __('徽章系统未启用', 'aksrt'), ['status' => 403]);
}
$slug = sanitize_key($slug);
$map = self::get_dictionary_map();
if ($slug === '' || !isset($map[$slug])) {
return new \WP_Error('aksrt_badge_unknown', __('徽章不存在', 'aksrt'), ['status' => 404]);
}
$owned = self::get_owned($user_id);
if (!in_array($slug, $owned, true)) {
return new \WP_Error('aksrt_badge_not_owned', __('尚未获得该徽章', 'aksrt'), ['status' => 403]);
}
update_user_meta($user_id, self::META_WEAR, $slug);
return true;
}
/**
* @return true|\WP_Error
*/
public static function grant(int $user_id, string $slug, int $actor = 0) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_badge_user', __('无效用户', 'aksrt'), ['status' => 400]);
}
if (!self::enabled()) {
return new \WP_Error('aksrt_badge_disabled', __('徽章系统未启用', 'aksrt'), ['status' => 403]);
}
$slug = sanitize_key($slug);
$map = self::get_dictionary_map();
if ($slug === '' || !isset($map[$slug])) {
return new \WP_Error('aksrt_badge_unknown', __('徽章不存在', 'aksrt'), ['status' => 404]);
}
$owned = self::get_owned($user_id);
if (in_array($slug, $owned, true)) {
return true;
}
$owned[] = $slug;
self::set_owned($user_id, $owned);
/**
* Fires after a badge is granted.
*
* @param int $user_id
* @param string $slug
* @param int $actor
*/
do_action('aksrt_badge_granted', $user_id, $slug, $actor);
return true;
}
/**
* @return true|\WP_Error
*/
public static function revoke(int $user_id, string $slug, int $actor = 0) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_badge_user', __('无效用户', 'aksrt'), ['status' => 400]);
}
$slug = sanitize_key($slug);
$owned = self::get_owned($user_id);
$owned = array_values(array_filter($owned, static function ($s) use ($slug) {
return $s !== $slug;
}));
self::set_owned($user_id, $owned);
/**
* @param int $user_id
* @param string $slug
* @param int $actor
*/
do_action('aksrt_badge_revoked', $user_id, $slug, $actor);
return true;
}
public static function can_manually_set(int $actor = 0): bool {
$actor = $actor > 0 ? $actor : get_current_user_id();
if ($actor <= 0) {
return false;
}
if (user_can($actor, 'manage_options')) {
return true;
}
if (class_exists('\AKSRT\Settings\Capabilities')) {
return \AKSRT\Settings\Capabilities::user_can('medal_manually_set', $actor);
}
return false;
}
/**
* Public DTO for REST / modals.
*
* @return array{enabled:bool,public_display:bool,wear:string,items:list<array>}
*/
public static function list_for_user(int $user_id, bool $include_private = false): array {
$enabled = self::enabled();
$public = self::public_display();
$viewer = get_current_user_id();
$can_see = $include_private
|| $public
|| ($viewer > 0 && $viewer === $user_id)
|| self::can_manually_set($viewer);
if (!$enabled || !$can_see || $user_id <= 0) {
return [
'enabled' => $enabled,
'public_display' => $public,
'wear' => '',
'items' => [],
];
}
$map = self::get_dictionary_map();
$owned = self::get_owned($user_id);
$wear = self::get_wear($user_id);
// Prefer worn first (Zibll).
if ($wear !== '') {
$owned = array_values(array_unique(array_merge([$wear], $owned)));
}
$items = [];
foreach ($owned as $slug) {
if (!isset($map[$slug])) {
continue;
}
$items[] = [
'slug' => $slug,
'label' => $map[$slug]['label'],
'tone' => $map[$slug]['tone'],
'icon_url' => $map[$slug]['icon_url'],
'wear' => ($slug === $wear),
];
}
return [
'enabled' => true,
'public_display' => $public,
'wear' => $wear,
'items' => $items,
];
}
public function rest_list(\WP_REST_Request $request) {
$user_id = (int) $request['id'];
if ($user_id <= 0 || !get_userdata($user_id)) {
return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
}
$data = self::list_for_user($user_id);
return rest_ensure_response(['success' => true, 'data' => $data]);
}
public function rest_wear(\WP_REST_Request $request) {
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? ''));
$result = self::wear(get_current_user_id(), $slug);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => self::list_for_user(get_current_user_id(), true),
'message' => __('已佩戴徽章', 'aksrt'),
]);
}
public function rest_grant(\WP_REST_Request $request) {
$user_id = (int) $request['id'];
if ($user_id <= 0 || !get_userdata($user_id)) {
return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? ''));
$result = self::grant($user_id, $slug, get_current_user_id());
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => self::list_for_user($user_id, true),
'message' => __('已授予徽章', 'aksrt'),
]);
}
public function rest_revoke(\WP_REST_Request $request) {
$user_id = (int) $request['id'];
if ($user_id <= 0 || !get_userdata($user_id)) {
return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? ''));
$result = self::revoke($user_id, $slug, get_current_user_id());
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => self::list_for_user($user_id, true),
'message' => __('已收回徽章', 'aksrt'),
]);
}
/**
* Auto-grant medals whose get_type metrics meet get_val (skip manually_add).
*/
public static function auto_evaluate(int $user_id): void {
if ($user_id <= 0 || !self::enabled() || !class_exists('\AKSRT\Settings\Settings')) {
return;
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
$medal = is_array($settings['medal'] ?? null) ? $settings['medal'] : [];
$items = is_array($medal['items_by_slug'] ?? null) ? $medal['items_by_slug'] : [];
if (!$items) {
// Fall back to dictionary only — no get_type → nothing to auto-grant.
return;
}
$owned = self::get_owned($user_id);
$owned_map = array_fill_keys($owned, true);
foreach ($items as $slug => $item) {
if (!is_array($item)) {
continue;
}
$slug = sanitize_key((string) $slug);
if ($slug === '' || !empty($owned_map[$slug])) {
continue;
}
$type = sanitize_key((string) ($item['get_type'] ?? 'manually_add'));
if ($type === '' || $type === 'manually_add') {
continue;
}
$need = max(0, (float) ($item['get_val'] ?? 0));
$have = (float) self::metric_value($user_id, $type);
if ($have >= $need) {
self::grant($user_id, $slug, 0);
$owned_map[$slug] = true;
}
}
}
/**
* Numeric metric for a medal get_type.
*/
public static function metric_value(int $user_id, string $type): float {
switch ($type) {
case 'registration_time':
$user = get_userdata($user_id);
if (!$user) {
return 0.0;
}
$reg = get_date_from_gmt($user->user_registered, 'Y-m-d H:i:s');
$now = current_time('Y-m-d H:i:s');
return max(0.0, (strtotime($now) - strtotime($reg)) / 86400);
case 'checkin_all_day':
// Prefer dedicated check-in total meta if present.
$v = get_user_meta($user_id, 'aksrt_checkin_all_day', true);
if ($v === '' || $v === false) {
$v = get_user_meta($user_id, 'checkin_all_day', true);
}
return max(0.0, (float) $v);
case 'new_post':
return (float) count_user_posts($user_id, 'post', true);
case 'comment':
$counts = get_comments(['user_id' => $user_id, 'count' => true, 'status' => 'approve']);
return (float) $counts;
case 'followed':
if (class_exists('AKSRT\\User\\Follow')) {
return (float) Follow::get_instance()->get_follower_count($user_id);
}
return 0.0;
case 'like':
return (float) get_user_meta($user_id, 'aksrt_posts_like_sum', true);
case 'comment_like':
return (float) get_user_meta($user_id, 'aksrt_comment_like_sum', true);
case 'views':
$sum = (float) get_user_meta($user_id, 'aksrt_posts_views_sum', true);
if ($sum > 0) {
return $sum;
}
// Lazy backfill once from postmeta.
global $wpdb;
$sum = (float) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(CAST(pm.meta_value AS UNSIGNED)),0)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = 'aksrt_post_views'
WHERE p.post_author = %d AND p.post_type = 'post' AND p.post_status = 'publish'",
$user_id
));
if ($sum > 0) {
update_user_meta($user_id, 'aksrt_posts_views_sum', $sum);
}
return $sum;
case 'pay_income':
return (float) get_user_meta($user_id, 'aksrt_pay_income_sum', true);
case 'pay_rebate':
return (float) get_user_meta($user_id, 'aksrt_pay_rebate_sum', true);
default:
return 0.0;
}
}
/**
* @param string $user_login
* @param \WP_User $user
*/
public function on_login($user_login, $user): void {
$user_id = $user instanceof \WP_User ? (int) $user->ID : 0;
if ($user_id > 0) {
self::auto_evaluate($user_id);
}
}
public function on_current_user_probe(): void {
$uid = get_current_user_id();
if ($uid > 0) {
self::auto_evaluate($uid);
}
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class BadgesAdmin {
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 (!is_admin()) {
return;
}
add_action('show_user_profile', [$this, 'render_section']);
add_action('edit_user_profile', [$this, 'render_section']);
add_action('personal_options_update', [$this, 'handle_update']);
add_action('edit_user_profile_update', [$this, 'handle_update']);
}
private function can_manage(): bool {
return class_exists('AKSRT\\User\\Badges') && Badges::can_manually_set(get_current_user_id());
}
public function render_section(\WP_User $user): void {
if (!class_exists('AKSRT\\User\\Badges') || !Badges::enabled()) {
return;
}
$dict = Badges::get_dictionary();
if (!$dict) {
return;
}
$owned = Badges::get_owned((int) $user->ID);
$wear = Badges::get_wear((int) $user->ID);
$can = $this->can_manage();
?>
<a id="aksrt-user-badges"></a>
<h2><?php esc_html_e('AKSRT 徽章', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><label><?php esc_html_e('已授予徽章', 'aksrt'); ?></label></th>
<td>
<?php if (!$can) : ?>
<p class="description"><?php esc_html_e('当前账号无权授予/收回徽章。', 'aksrt'); ?></p>
<?php endif; ?>
<fieldset <?php disabled(!$can); ?>>
<?php foreach ($dict as $item) : ?>
<?php
$slug = $item['slug'];
$checked = in_array($slug, $owned, true);
?>
<label style="display:inline-flex;align-items:center;gap:6px;margin:0 16px 10px 0;">
<input type="checkbox" name="aksrt_user_badges[]" value="<?php echo esc_attr($slug); ?>" <?php checked($checked); ?> <?php disabled(!$can); ?>>
<?php if (!empty($item['icon_url'])) : ?>
<img src="<?php echo esc_url($item['icon_url']); ?>" alt="" width="18" height="18" style="object-fit:contain;vertical-align:middle;">
<?php endif; ?>
<span><?php echo esc_html($item['label']); ?> <code><?php echo esc_html($slug); ?></code></span>
</label>
<?php endforeach; ?>
</fieldset>
<p class="description"><?php esc_html_e('勾选即授予;取消勾选并保存即收回。', 'aksrt'); ?></p>
</td>
</tr>
<tr>
<th><label for="aksrt_wear_badge"><?php esc_html_e('当前佩戴', 'aksrt'); ?></label></th>
<td>
<select name="aksrt_wear_badge" id="aksrt_wear_badge" <?php disabled(!$can); ?>>
<option value=""><?php esc_html_e('(未佩戴)', 'aksrt'); ?></option>
<?php foreach ($dict as $item) : ?>
<?php if (!in_array($item['slug'], $owned, true) && $item['slug'] !== $wear) : ?>
<?php continue; ?>
<?php endif; ?>
<option value="<?php echo esc_attr($item['slug']); ?>" <?php selected($wear, $item['slug']); ?>>
<?php echo esc_html($item['label']); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description"><?php esc_html_e('仅可佩戴已授予徽章;用户也可在前台自行佩戴。', 'aksrt'); ?></p>
</td>
</tr>
</table>
<?php
wp_nonce_field('aksrt_user_badges_admin', 'aksrt_user_badges_nonce');
}
public function handle_update(int $user_id): void {
if (!$this->can_manage()) {
return;
}
if (!isset($_POST['aksrt_user_badges_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['aksrt_user_badges_nonce'])), 'aksrt_user_badges_admin')) {
return;
}
if (!class_exists('AKSRT\\User\\Badges') || !Badges::enabled()) {
return;
}
$raw = isset($_POST['aksrt_user_badges']) ? (array) wp_unslash($_POST['aksrt_user_badges']) : [];
$slugs = array_map('sanitize_key', $raw);
Badges::set_owned($user_id, $slugs);
$wear = isset($_POST['aksrt_wear_badge']) ? sanitize_key((string) wp_unslash($_POST['aksrt_wear_badge'])) : '';
if ($wear === '') {
delete_user_meta($user_id, Badges::META_WEAR);
} else {
$res = Badges::wear($user_id, $wear);
if (is_wp_error($res)) {
// leave owned as-is; wear ignored if invalid
delete_user_meta($user_id, Badges::META_WEAR);
}
}
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class BalanceAdmin {
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 (!is_admin()) {
return;
}
add_filter('manage_users_columns', [$this, 'add_column']);
add_filter('manage_users_custom_column', [$this, 'render_column'], 10, 3);
add_filter('manage_users_sortable_columns', [$this, 'sortable']);
add_action('pre_get_users', [$this, 'sort_users']);
add_action('show_user_profile', [$this, 'render_section']);
add_action('edit_user_profile', [$this, 'render_section']);
add_action('personal_options_update', [$this, 'handle_update']);
add_action('edit_user_profile_update', [$this, 'handle_update']);
}
private function can_manage(): bool {
return current_user_can('manage_options');
}
public function add_column(array $columns): array {
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return $columns;
}
$columns['aksrt_wallet_balance'] = __('现金余额', 'aksrt');
return $columns;
}
public function render_column(string $output, string $column_name, int $user_id): string {
if ($column_name !== 'aksrt_wallet_balance') {
return $output;
}
if (!class_exists('\AKSRT\Credits\Balance')) {
return '—';
}
return '<strong>' . esc_html(\AKSRT\Credits\Balance::format(\AKSRT\Credits\Balance::balance($user_id))) . '</strong>';
}
public function sortable(array $columns): array {
$columns['aksrt_wallet_balance'] = 'aksrt_wallet_balance';
return $columns;
}
public function sort_users($query): void {
if (!is_admin() || $query->get('orderby') !== 'aksrt_wallet_balance') {
return;
}
$query->set('meta_key', 'aksrt_balance');
$query->set('orderby', 'meta_value_num');
}
public function render_section(\WP_User $user): void {
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return;
}
$balance = \AKSRT\Credits\Balance::balance((int) $user->ID);
$can = $this->can_manage();
$log = \AKSRT\Credits\Balance::get_log(['user_id' => (int) $user->ID, 'page' => 1, 'per_page' => 8]);
?>
<a id="aksrt-user-wallet"></a>
<h2><?php esc_html_e('现金钱包(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><?php esc_html_e('当前余额', 'aksrt'); ?></th>
<td><strong style="font-size:18px;"><?php echo esc_html(\AKSRT\Credits\Balance::format($balance)); ?></strong> <?php esc_html_e('元', 'aksrt'); ?></td>
</tr>
<?php if ($can) : ?>
<tr>
<th><?php esc_html_e('调整余额', 'aksrt'); ?></th>
<td>
<?php wp_nonce_field('aksrt_user_balance_adjust_' . (int) $user->ID, '_aksrt_balance_nonce'); ?>
<label for="aksrt_balance_delta"><?php esc_html_e('变动金额(正数=增加,负数=扣减)', 'aksrt'); ?></label><br>
<input type="number" step="0.01" name="aksrt_balance_delta" id="aksrt_balance_delta" value="" class="regular-text" />
<p>
<label for="aksrt_balance_reason"><?php esc_html_e('备注', 'aksrt'); ?></label><br>
<input type="text" name="aksrt_balance_reason" id="aksrt_balance_reason" class="regular-text" />
</p>
<p class="description"><?php esc_html_e('保存用户资料时生效,并写入余额流水。', 'aksrt'); ?></p>
</td>
</tr>
<?php endif; ?>
<tr>
<th><?php esc_html_e('近期流水', 'aksrt'); ?></th>
<td>
<?php if (!empty($log['items'])) : ?>
<table class="widefat striped" style="max-width:640px;">
<thead><tr><th><?php esc_html_e('时间', 'aksrt'); ?></th><th><?php esc_html_e('变动', 'aksrt'); ?></th><th><?php esc_html_e('余额', 'aksrt'); ?></th><th><?php esc_html_e('原因', 'aksrt'); ?></th></tr></thead>
<tbody>
<?php foreach ($log['items'] as $row) : ?>
<tr>
<td><?php echo esc_html(get_date_from_gmt((string) ($row['created_at'] ?? ''), 'Y-m-d H:i')); ?></td>
<td><?php echo esc_html(($row['delta'] > 0 ? '+' : '') . \AKSRT\Credits\Balance::format((float) $row['delta'])); ?></td>
<td><?php echo esc_html(\AKSRT\Credits\Balance::format((float) ($row['balance_after'] ?? 0))); ?></td>
<td><?php echo esc_html(\AKSRT\Credits\Balance::reason_label((string) ($row['reason'] ?? ''))); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p><?php esc_html_e('暂无流水', 'aksrt'); ?></p>
<?php endif; ?>
</td>
</tr>
</table>
<?php
}
public function handle_update(int $user_id): void {
if (!$this->can_manage() || !class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return;
}
if (empty($_POST['_aksrt_balance_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['_aksrt_balance_nonce'])), 'aksrt_user_balance_adjust_' . $user_id)) {
return;
}
if (!isset($_POST['aksrt_balance_delta']) || $_POST['aksrt_balance_delta'] === '') {
return;
}
$delta = round((float) wp_unslash($_POST['aksrt_balance_delta']), 2);
if (abs($delta) < 0.0001) {
return;
}
$reason = isset($_POST['aksrt_balance_reason'])
? sanitize_text_field(wp_unslash((string) $_POST['aksrt_balance_reason']))
: __('管理员调整', 'aksrt');
if ($delta > 0) {
$result = \AKSRT\Credits\Balance::add($user_id, $delta, 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
} else {
$result = \AKSRT\Credits\Balance::deduct($user_id, abs($delta), 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
}
if (is_wp_error($result)) {
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
'type' => 'error',
'msg' => $result->get_error_message(),
]], 30);
return;
}
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
'type' => 'success',
'msg' => __('现金余额已更新', 'aksrt'),
]], 30);
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
/**
* Frontend ban appeal (zibll user_ban_appeal parity) on Restriction meta.
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class BanAppeal {
public const META_STATUS = 'aksrt_ban_appeal_status'; // pending|approved|rejected|''
public const META_PAYLOAD = 'aksrt_ban_appeal_payload';
public const META_HISTORY = 'aksrt_ban_appeal_history';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('wp_ajax_aksrt_ban_appeal_submit', [$this, 'ajax_submit']);
add_action('wp_ajax_aksrt_ban_appeal_status', [$this, 'ajax_status']);
}
/**
* @return array{enabled:bool,desc:string,keys:string[]}
*/
public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? (array) \AKSRT\Settings\Settings::get_all() : [];
$keys_raw = (string) ($all['ban_appeal_keys'] ?? '');
$keys = array_values(array_filter(array_map('trim', preg_split('/[,,\n]/', $keys_raw) ?: [])));
return [
'enabled' => !isset($all['user_report_enabled']) || !empty($all['user_report_enabled']),
'desc' => (string) ($all['ban_appeal_desc'] ?? ''),
'keys' => $keys,
];
}
public static function get_status(int $user_id): string {
return sanitize_key((string) get_user_meta($user_id, self::META_STATUS, true));
}
/**
* @return array<string,mixed>
*/
public static function get_payload(int $user_id): array {
$p = get_user_meta($user_id, self::META_PAYLOAD, true);
return is_array($p) ? $p : [];
}
public function ajax_status(): void {
if (!is_user_logged_in()) {
wp_send_json(['error' => 1, 'msg' => __('请先登录', 'aksrt')]);
}
$user_id = get_current_user_id();
$ban = class_exists('\AKSRT\User\Restriction')
? \AKSRT\User\Restriction::get_instance()->get_active_ban($user_id)
: null;
wp_send_json([
'error' => 0,
'banned' => (bool) $ban,
'ban' => $ban,
'status' => self::get_status($user_id),
'payload'=> self::get_payload($user_id),
]);
}
public function ajax_submit(): void {
if (!is_user_logged_in()) {
wp_send_json(['error' => 1, 'msg' => __('请先登录', 'aksrt')]);
}
if (!check_ajax_referer('aksrt_ban_appeal', '_wpnonce', false)) {
wp_send_json(['error' => 1, 'msg' => __('安全校验失败', 'aksrt')]);
}
$user_id = get_current_user_id();
$s = self::settings();
if (!$s['enabled']) {
wp_send_json(['error' => 1, 'msg' => __('申诉功能未开启', 'aksrt')]);
}
$banned = class_exists('\AKSRT\User\Restriction')
&& \AKSRT\User\Restriction::get_instance()->is_banned($user_id);
if (!$banned) {
wp_send_json(['error' => 1, 'msg' => __('当前账号未处于禁封状态', 'aksrt')]);
}
if (self::get_status($user_id) === 'pending') {
wp_send_json(['error' => 1, 'msg' => __('申诉正在处理中,请勿重复提交', 'aksrt')]);
}
$reason_key = isset($_POST['reason_key']) ? sanitize_text_field(wp_unslash((string) $_POST['reason_key'])) : '';
$reason = isset($_POST['reason']) ? sanitize_textarea_field(wp_unslash((string) $_POST['reason'])) : '';
if ($reason === '') {
wp_send_json(['error' => 1, 'msg' => __('请填写申诉说明', 'aksrt')]);
}
$payload = [
'reason_key' => $reason_key,
'reason' => $reason,
'time' => current_time('Y-m-d H:i'),
];
update_user_meta($user_id, self::META_PAYLOAD, $payload);
update_user_meta($user_id, self::META_STATUS, 'pending');
$history = get_user_meta($user_id, self::META_HISTORY, true);
$history = is_array($history) ? $history : [];
$history[] = $payload;
update_user_meta($user_id, self::META_HISTORY, array_slice($history, -20));
// Notify admins via site message table if available.
if (class_exists('\AKSRT\Messages\Message')) {
$admins = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]);
$summary = sprintf(
/* translators: 1: user login 2: reason */
__('用户 %1$s 提交禁封申诉:%2$s', 'aksrt'),
wp_get_current_user()->user_login,
mb_substr($reason, 0, 80)
);
foreach ($admins as $admin_id) {
\AKSRT\Messages\Message::get_instance()->send(
(int) $admin_id,
'ban_appeal',
$summary,
[
'title' => __('禁封申诉', 'aksrt'),
'target_url' => admin_url('users.php?page=aksrt_user_ban&tab=appeals'),
]
);
}
}
/**
* @param int $user_id
* @param array $payload
*/
do_action('aksrt_ban_appeal_submitted', $user_id, $payload);
wp_send_json([
'error' => 0,
'msg' => __('申诉已提交,请等待管理员处理', 'aksrt'),
'reload' => true,
]);
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Center {
private static $instance = null;
/** @var array 面板注册表 */
private $panels = [];
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
$this->register_default_panels();
}
/**
* 注册默认面板
*/
private function register_default_panels(): void {
$settings = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_user_center_settings()
: [
'tabs' => ['profile', 'posts', 'comments', 'favorites'],
'avatar_upload' => true,
'cover_upload' => true,
'social_links' => true,
];
$definitions = [
'profile' => [
'label' => __('个人信息', 'aksrt'),
'icon' => 'user',
'priority' => 10,
'type' => 'panel',
],
'level' => [
'label' => __('我的等级', 'aksrt'),
'icon' => 'chart-line',
'priority' => 15,
'type' => 'panel',
],
'posts' => [
'label' => __('我的文章', 'aksrt'),
'icon' => 'file-alt',
'priority' => 20,
'type' => 'panel',
],
'comments' => [
'label' => __('我的评论', 'aksrt'),
'icon' => 'comment',
'priority' => 30,
'type' => 'panel',
],
'favorites' => [
'label' => __('我的收藏', 'aksrt'),
'icon' => 'star',
'priority' => 40,
'type' => 'panel',
],
'vip' => [
'label' => __('我的会员', 'aksrt'),
'icon' => 'crown',
'priority' => 45,
'type' => 'panel',
],
'orders' => [
'label' => __('我的订单', 'aksrt'),
'icon' => 'receipt',
'priority' => 48,
'type' => 'panel',
],
'credits' => [
'label' => class_exists('\AKSRT\Settings\Settings')
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
: __('我的积分', 'aksrt'),
'icon' => 'wallet',
'priority' => 50,
'type' => 'panel',
],
'notif' => [
'label' => __('消息通知', 'aksrt'),
'icon' => 'bell',
'priority' => 60,
'type' => 'link',
],
'auth' => [
'label' => __('官方认证', 'aksrt'),
'icon' => 'id-badge',
'priority' => 65,
'type' => 'panel',
],
'settings' => [
'label' => __('账号设置', 'aksrt'),
'icon' => 'cog',
'priority' => 70,
'type' => 'panel',
],
];
$this->panels = [];
$configured = array_values(array_filter(array_map('sanitize_key', (array) ($settings['tabs'] ?? []))));
if ($configured === []) {
$configured = ['profile', 'posts', 'comments', 'favorites'];
}
$level_on = class_exists('\AKSRT\User\Level')
&& !empty(\AKSRT\User\Level::settings()['enabled']);
$credits_on = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled();
$balance_on = class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled();
$vip_on = class_exists('\AKSRT\Settings\Settings')
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']);
// Follow configured order; always auto-include enabled surfaces.
$order = $configured;
// Growth level is a dedicated user-center surface, separate from spendable points.
if ($level_on && !in_array('level', $order, true)) {
$insert_at = array_search('profile', $order, true);
array_splice($order, $insert_at === false ? 0 : (int) $insert_at + 1, 0, ['level']);
}
if (!$level_on) {
$order = array_values(array_filter($order, static fn(string $key): bool => $key !== 'level'));
}
if ($vip_on && !in_array('vip', $order, true)) {
$insert_at = count($order);
foreach (['credits', 'favorites', 'posts'] as $anchor) {
$idx = array_search($anchor, $order, true);
if ($idx !== false) {
$insert_at = (int) $idx + 1;
break;
}
}
array_splice($order, $insert_at, 0, ['vip']);
}
// Orders panel: always available when payment stack exists (UserOrders or Order).
$orders_on = class_exists('\AKSRT\Payment\UserOrders')
|| class_exists('\AKSRT\Payment\Order');
if ($orders_on && !in_array('orders', $order, true)) {
$insert_at = count($order);
foreach (['vip', 'credits', 'favorites'] as $anchor) {
$idx = array_search($anchor, $order, true);
if ($idx !== false) {
$insert_at = (int) $idx + ($anchor === 'vip' ? 1 : 0);
if ($anchor !== 'vip') {
$insert_at = (int) $idx + 1;
}
break;
}
}
array_splice($order, $insert_at, 0, ['orders']);
}
if (($credits_on || $balance_on) && !in_array('credits', $order, true)) {
$order[] = 'credits';
}
$auth_on = class_exists('\AKSRT\User\AuthApply')
&& !empty(\AKSRT\User\AuthApply::settings()['enabled']);
if ($auth_on && !in_array('auth', $order, true)) {
$order[] = 'auth';
}
if (!in_array('settings', $order, true)) {
$order[] = 'settings';
}
foreach ($order as $key) {
if (!isset($definitions[$key])) {
continue;
}
if ($key === 'level' && !$level_on) {
continue;
}
if ($key === 'credits' && !$credits_on && !$balance_on) {
continue;
}
if ($key === 'vip' && !$vip_on) {
continue;
}
if ($key === 'orders' && !$orders_on) {
continue;
}
if ($key === 'auth' && !$auth_on) {
continue;
}
$panel = $definitions[$key];
if ($key === 'credits' && $balance_on) {
$panel['label'] = __('我的资产', 'aksrt');
$panel['icon'] = 'wallet';
}
$this->panels[$key] = $panel;
}
// Notif is a link entry (messages page), not a dashboard panel body.
$this->panels['notif'] = $definitions['notif'];
// Keep stable order by priority.
uasort($this->panels, static function (array $a, array $b): int {
return ((int) ($a['priority'] ?? 100)) <=> ((int) ($b['priority'] ?? 100));
});
/**
* 允许插件/子主题注册额外面板
*
* @param array $panels 面板注册表
*/
$this->panels = apply_filters('aksrt_user_center_panels', $this->panels);
}
/**
* 获取面板注册表
*/
public function get_panels(): array {
return $this->panels;
}
/**
* 获取可切换的本地面板(不含外链入口)。
*/
public function get_dashboard_tabs(): array {
return array_filter($this->panels, static function (array $panel): bool {
return ($panel['type'] ?? 'panel') === 'panel';
});
}
/**
* 判断当前页面是否为本人中心
*/
public static function is_page(): bool {
return function_exists('aksrt_is_user_center_page') && aksrt_is_user_center_page();
}
}
+312
View File
@@ -0,0 +1,312 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class CreditsAdmin {
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 {
add_filter('manage_users_columns', [$this, 'add_balance_column']);
add_filter('manage_users_custom_column', [$this, 'render_balance_column'], 10, 3);
add_filter('manage_users_sortable_columns', [$this, 'make_balance_sortable']);
add_action('pre_get_users', [$this, 'sort_users_by_credits']);
add_filter('user_row_actions', [$this, 'add_row_actions'], 30, 2);
add_action('show_user_profile', [$this, 'render_user_credits_section']);
add_action('edit_user_profile', [$this, 'render_user_credits_section']);
add_action('personal_options_update', [$this, 'handle_user_credits_update']);
add_action('edit_user_profile_update', [$this, 'handle_user_credits_update']);
}
private function can_manage_credits(): bool {
return current_user_can('manage_options');
}
// ========== 用户列表:积分余额列 ==========
public function add_balance_column(array $columns): array {
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
$columns['aksrt_credits_balance'] = sprintf(__('%s余额', 'aksrt'), $currency_name);
$columns['aksrt_user_commerce'] = __('资产/分成', 'aksrt');
return $columns;
}
public function render_balance_column(string $output, string $column_name, int $user_id): string {
if ($column_name === 'aksrt_user_commerce') {
return $this->render_commerce_column($user_id);
}
if ($column_name !== 'aksrt_credits_balance') {
return $output;
}
if (!class_exists('\AKSRT\Credits\Credits')) {
return '—';
}
$balance = \AKSRT\Credits\Credits::balance($user_id);
return '<strong>' . esc_html((string) $balance) . '</strong>';
}
/**
* Compact commerce snapshot: VIP, revenue, rebate, download logs link.
*/
private function render_commerce_column(int $user_id): string {
$bits = [];
if (function_exists('aksrt_get_user_vip_level')) {
$lv = (int) aksrt_get_user_vip_level($user_id);
if ($lv > 0) {
$bits[] = '<span class="aksrt-vip-badge">VIP' . esc_html((string) $lv) . '</span>';
}
}
if (class_exists('\AKSRT\Payment\Revenue')) {
$stats = \AKSRT\Payment\Revenue::get_instance()->get_stats($user_id);
$pending = (float) ($stats['pending'] ?? 0);
$total = (float) ($stats['total'] ?? 0);
if ($total > 0 || $pending > 0) {
$bits[] = sprintf(
/* translators: 1 total 2 pending */
esc_html__('分成 ¥%1$s(待结 ¥%2$s)', 'aksrt'),
number_format($total, 2),
number_format($pending, 2)
);
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_income&user_id=' . $user_id)) . '">' . esc_html__('分成明细', 'aksrt') . '</a>';
}
}
if (class_exists('\AKSRT\Payment\Rebate')) {
$list = \AKSRT\Payment\Rebate::get_instance()->get_list(['user_id' => $user_id, 'limit' => 1]);
$total_r = (int) ($list['total'] ?? 0);
if ($total_r > 0) {
$bits[] = sprintf(esc_html__('返佣 %d 笔', 'aksrt'), $total_r);
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_rebate&user_id=' . $user_id)) . '">' . esc_html__('返佣', 'aksrt') . '</a>';
}
}
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_download_logs&user_id=' . $user_id)) . '">' . esc_html__('下载记录', 'aksrt') . '</a>';
$bits[] = '<a href="' . esc_url(admin_url('admin.php?page=aksrt_orders&user_id=' . $user_id)) . '">' . esc_html__('订单', 'aksrt') . '</a>';
if (!$bits) {
return '—';
}
return '<div style="font-size:12px;line-height:1.5;">' . implode('<br>', $bits) . '</div>';
}
public function make_balance_sortable(array $columns): array {
$columns['aksrt_credits_balance'] = 'aksrt_credits_balance';
return $columns;
}
public function sort_users_by_credits($query): void {
if (!is_admin()) {
return;
}
if ($query->get('orderby') !== 'aksrt_credits_balance') {
return;
}
$query->set('meta_key', 'aksrt_credits');
$query->set('orderby', 'meta_value_num');
}
// ========== 用户列表:行操作 ==========
public function add_row_actions(array $actions, \WP_User $user): array {
if (!$this->can_manage_credits()) {
return $actions;
}
$edit_url = get_edit_user_link($user->ID);
if ($edit_url) {
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
$actions['aksrt_edit_credits'] = sprintf(
'<a href="%s#aksrt-user-credits">%s</a>',
esc_url($edit_url),
esc_html(sprintf(__('调整%s', 'aksrt'), $currency_name))
);
}
$actions['aksrt_user_dl_logs'] = sprintf(
'<a href="%s">%s</a>',
esc_url(admin_url('admin.php?page=aksrt_download_logs&user_id=' . (int) $user->ID)),
esc_html__('下载记录', 'aksrt')
);
return $actions;
}
// ========== 用户编辑页:积分管理区块 ==========
public function render_user_credits_section(\WP_User $user): void {
if (!class_exists('\AKSRT\Credits\Credits')) {
return;
}
$balance = \AKSRT\Credits\Credits::balance((int) $user->ID);
$can_manage = $this->can_manage_credits();
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
?>
<a id="aksrt-user-credits"></a>
<h2><?php echo esc_html(sprintf(__('%s管理(AKSRT)', 'aksrt'), $currency_name)); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><?php esc_html_e('当前余额', 'aksrt'); ?></th>
<td>
<strong style="font-size:18px;"><?php echo esc_html((string) $balance); ?></strong>
<span style="color:#666;margin-left:6px;"><?php echo esc_html($currency_name); ?></span>
</td>
</tr>
<?php if ($can_manage) : ?>
<tr>
<th><?php echo esc_html(sprintf(__('调整%s', 'aksrt'), $currency_name)); ?></th>
<td>
<?php wp_nonce_field('aksrt_user_credits_adjust_' . (int) $user->ID, '_aksrt_credits_nonce'); ?>
<fieldset>
<label for="aksrt_credits_delta" style="display:block;margin-bottom:6px;">
<?php esc_html_e('变动数量(正数=增加,负数=扣减)', 'aksrt'); ?>
</label>
<input
type="number"
id="aksrt_credits_delta"
name="aksrt_credits_delta"
class="regular-text"
min="-100000"
max="100000"
step="1"
value="0"
>
<p class="description" style="margin-bottom:12px;">
<?php esc_html_e('填写 0 不会执行任何变更。', 'aksrt'); ?>
</p>
<label for="aksrt_credits_reason" style="display:block;margin-bottom:6px;">
<?php esc_html_e('调整原因', 'aksrt'); ?>
</label>
<input
type="text"
id="aksrt_credits_reason"
name="aksrt_credits_reason"
class="regular-text"
maxlength="100"
placeholder="<?php echo esc_attr__('例如:手工补偿 / 运营赠送 / 退款修正', 'aksrt'); ?>"
>
<p class="description">
<?php esc_html_e('勾选后点击页面底部"更新用户"保存即可生效。', 'aksrt'); ?>
</p>
</fieldset>
</td>
</tr>
<?php endif; ?>
</table>
<?php
// ========== 近期积分流水 ==========
$log = \AKSRT\Credits\Credits::get_log(['user_id' => (int) $user->ID, 'page' => 1, 'per_page' => 20]);
$items = $log['items'] ?? [];
$reason_labels = [
'register' => __('注册赠送', 'aksrt'),
'signin' => __('每日签到', 'aksrt'),
'comment' => __('评论奖励', 'aksrt'),
'invite' => __('邀请奖励', 'aksrt'),
'invitee' => __('被邀请奖励', 'aksrt'),
'download' => __('下载消费', 'aksrt'),
'admin' => __('管理员调整', 'aksrt'),
];
?>
<h3 style="margin-top:24px;"><?php echo esc_html(sprintf(__('近期%s流水', 'aksrt'), $currency_name)); ?></h3>
<?php if (!empty($items)) : ?>
<table class="widefat striped" style="max-width:800px;">
<thead>
<tr>
<th><?php esc_html_e('时间', 'aksrt'); ?></th>
<th><?php esc_html_e('类型', 'aksrt'); ?></th>
<th><?php esc_html_e('备注', 'aksrt'); ?></th>
<th><?php esc_html_e('变动', 'aksrt'); ?></th>
<th><?php esc_html_e('余额', 'aksrt'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $row) :
$delta = (int) $row['delta'];
$reason_key = (string) ($row['reason'] ?? '');
$label = $reason_labels[$reason_key] ?? $reason_key;
?>
<tr>
<td><?php echo esc_html(get_date_from_gmt((string) $row['created_at'], 'Y-m-d H:i')); ?></td>
<td><?php echo esc_html($label); ?></td>
<td><?php echo esc_html((string) ($row['remark'] ?? '')); ?></td>
<td style="color:<?php echo $delta >= 0 ? '#16a34a' : '#ef4444'; ?>; font-weight:600;">
<?php echo esc_html(($delta > 0 ? '+' : '') . $delta); ?>
</td>
<td><?php echo esc_html((string) (int) $row['balance_after']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else : ?>
<p style="color:#888;"><?php echo esc_html(sprintf(__('暂无%s流水记录。', 'aksrt'), $currency_name)); ?></p>
<?php endif;
}
// ========== 处理积分调整 ==========
public function handle_user_credits_update(int $user_id): void {
if (!$this->can_manage_credits() || !class_exists('\AKSRT\Credits\Credits')) {
return;
}
if (!current_user_can('edit_user', $user_id)) {
return;
}
if (!isset($_POST['_aksrt_credits_nonce'])) {
return;
}
$nonce = sanitize_text_field((string) $_POST['_aksrt_credits_nonce']);
if (!wp_verify_nonce($nonce, 'aksrt_user_credits_adjust_' . $user_id)) {
return;
}
$delta = isset($_POST['aksrt_credits_delta']) ? (int) $_POST['aksrt_credits_delta'] : 0;
$reason = isset($_POST['aksrt_credits_reason']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_credits_reason'])) : '';
if ($delta === 0) {
return;
}
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
if ($reason === '') {
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
'type' => 'error',
'msg' => sprintf(__('调整%s时请填写原因。', 'aksrt'), $currency_name),
]], 30);
return;
}
if ($delta > 0) {
$result = \AKSRT\Credits\Credits::add($user_id, $delta, 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
} else {
$result = \AKSRT\Credits\Credits::deduct($user_id, abs($delta), 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason);
}
if (is_wp_error($result)) {
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
'type' => 'error',
'msg' => $result->get_error_message(),
]], 30);
return;
}
set_transient('aksrt_admin_notice_' . get_current_user_id(), [[
'type' => 'success',
'msg' => sprintf(__('%s调整成功,当前余额:%d', 'aksrt'), $currency_name, (int) $result),
]], 30);
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Follow {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
private function table(): string {
return \AKSRT\Core\DBMigrator::table('follow');
}
/**
* 关注用户
*
* @param int $follower_id 关注者 ID
* @param int $following_id 被关注者 ID
* @return bool
*/
public function follow(int $follower_id, int $following_id): bool {
if ($follower_id <= 0 || $following_id <= 0 || $follower_id === $following_id) {
return false;
}
// 已关注则跳过
if ($this->is_following($follower_id, $following_id)) {
return true;
}
global $wpdb;
$result = $wpdb->insert($this->table(), [
'follower_id' => $follower_id,
'following_id' => $following_id,
'created_at' => current_time('mysql'),
], ['%d', '%d', '%s']);
if ($result === false) {
return false;
}
do_action('aksrt_user_followed', $follower_id, $following_id);
return true;
}
/**
* 取消关注
*/
public function unfollow(int $follower_id, int $following_id): bool {
if ($follower_id <= 0 || $following_id <= 0) return false;
global $wpdb;
$result = $wpdb->delete($this->table(), [
'follower_id' => $follower_id,
'following_id' => $following_id,
], ['%d', '%d']);
return $result !== false;
}
/**
* 切换关注状态
*
* @return bool true=已关注, false=已取消
*/
public function toggle(int $follower_id, int $following_id): bool {
if ($this->is_following($follower_id, $following_id)) {
$this->unfollow($follower_id, $following_id);
return false;
}
$this->follow($follower_id, $following_id);
return true;
}
/**
* 是否已关注
*/
public function is_following(int $follower_id, int $following_id): bool {
if ($follower_id <= 0 || $following_id <= 0) return false;
global $wpdb;
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE follower_id = %d AND following_id = %d LIMIT 1",
$follower_id, $following_id
));
return (int) $exists > 0;
}
/**
* 获取粉丝数(谁关注了 target_id)
*/
public function get_follower_count(int $user_id): int {
if ($user_id <= 0) return 0;
global $wpdb;
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE following_id = %d",
$user_id
));
}
/**
* 获取关注数(target_id 关注了谁)
*/
public function get_following_count(int $user_id): int {
if ($user_id <= 0) return 0;
global $wpdb;
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE follower_id = %d",
$user_id
));
}
/**
* 获取粉丝列表
*/
public function get_followers(int $user_id, int $page = 1, int $per_page = 20): array {
return $this->get_user_list($user_id, 'following_id', $page, $per_page);
}
/**
* 获取关注列表
*/
public function get_following(int $user_id, int $page = 1, int $per_page = 20): array {
return $this->get_user_list($user_id, 'follower_id', $page, $per_page);
}
/**
* 通用用户列表查询
*/
private function get_user_list(int $user_id, string $where_field, int $page, int $per_page): array {
global $wpdb;
$other_field = $where_field === 'follower_id' ? 'following_id' : 'follower_id';
$offset = max(0, ($page - 1) * $per_page);
$total = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE {$where_field} = %d",
$user_id
));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT {$other_field} as user_id, created_at
FROM {$this->table()}
WHERE {$where_field} = %d
ORDER BY created_at DESC
LIMIT %d OFFSET %d",
$user_id, $per_page, $offset
), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
$uid = (int) $row['user_id'];
$user = get_userdata($uid);
$items[] = [
'user_id' => $uid,
'display_name' => $user ? $user->display_name : '',
'avatar_url' => $user ? get_avatar_url($uid, ['size' => 48]) : '',
'created_at' => $row['created_at'] ?? '',
];
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'total_pages' => max(1, (int) ceil($total / $per_page)),
];
}
}
+294
View File
@@ -0,0 +1,294 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Home {
private static $instance = null;
/** @var array Tab 注册表 */
private $tabs = [];
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
$this->register_default_tabs();
}
/**
* 注册默认公开 Tab
*/
private function register_default_tabs(): void {
$this->tabs = [
'posts' => [
'label' => __('文章', 'aksrt'),
'icon' => 'file-alt',
'priority' => 10,
],
'comments' => [
'label' => __('评论', 'aksrt'),
'icon' => 'comment',
'priority' => 20,
],
'favorites' => [
'label' => __('收藏', 'aksrt'),
'icon' => 'star',
'priority' => 30,
],
'credits' => [
'label' => class_exists('\AKSRT\Settings\Settings')
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
: __('我的积分', 'aksrt'),
'icon' => 'wallet',
'priority' => 40,
],
'follow' => [
'label' => __('粉丝', 'aksrt'),
'icon' => 'user-plus',
'priority' => 80,
],
];
/**
* 允许插件/子主题注册额外公开 Tab
*
* @param array $tabs Tab 注册表
*/
$this->tabs = apply_filters('aksrt_user_home_tabs', $this->tabs);
}
/**
* 获取 Tab 注册表
*/
public function get_tabs(): array {
return $this->tabs;
}
/**
* 获取当前用户主页实际可见的公开 Tab。
*/
public function get_visible_tabs(bool $is_own_profile = false): array {
$settings = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_user_home_settings()
: [
'tabs' => ['posts', 'comments', 'favorites'],
];
$visible = [];
foreach ((array) ($settings['tabs'] ?? []) as $key) {
if (!isset($this->tabs[$key])) {
continue;
}
if ($key === 'credits' && (!$is_own_profile || !class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::credits_enabled())) {
continue;
}
$visible[$key] = $this->tabs[$key];
}
if (empty($visible)) {
foreach (['posts', 'comments', 'favorites'] as $fallback) {
if (isset($this->tabs[$fallback])) {
$visible[$fallback] = $this->tabs[$fallback];
}
}
}
return $visible;
}
/**
* 获取公开展示的身份信号(认证 / 等级 / 徽章)。
*
* @return array<int,array{label:string,icon:string,tone:string,icon_url?:string,type?:string,slug?:string,wear?:bool}>
*/
public function get_profile_signals(int $user_id): array {
if ($user_id <= 0 || !class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
$signals = [];
if (!empty($settings['verify']['enabled']) && !empty($settings['verify']['public_display']) && $this->is_user_verified($user_id, $settings['verify'])) {
$info = class_exists('AKSRT\\User\\AuthApply') ? AuthApply::get_auth_info($user_id) : [];
$application = class_exists('AKSRT\\User\\AuthApply') ? AuthApply::get_apply_payload($user_id) : [];
$type = sanitize_key((string) ($info['type'] ?? $application['type'] ?? ''));
$type_settings = is_array($settings['verify']['types'][$type] ?? null) ? $settings['verify']['types'][$type] : [];
$label = trim((string) ($type_settings['badge_text'] ?? $type_settings['label'] ?? ''));
if ($label === '') {
foreach ((array) ($settings['verify']['types'] ?? []) as $configured_type) {
if (is_array($configured_type) && !empty($configured_type['badge_text'])) {
$label = trim((string) $configured_type['badge_text']);
break;
}
}
}
$signals[] = [
'type' => 'verify',
'label' => $label,
'title' => $label,
'icon' => 'check-circle',
'icon_url' => get_theme_file_uri('/assets/img/user/user-certification.svg'),
'tone' => 'success',
];
}
if (!empty($settings['level']['enabled']) && !empty($settings['level']['public_display'])) {
$level = $this->get_user_level($user_id, $settings['level']);
if ($level > 0) {
$signals[] = [
'type' => 'level',
'label' => Level::get_level_name($level),
'title' => Level::get_level_name($level),
'icon' => 'layer-group',
'icon_url' => Level::get_level_icon_url($level),
'tone' => 'info',
];
}
}
if (!empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display'])) {
$limit = max(1, (int) ($settings['badge']['limit'] ?? 3));
if (class_exists('AKSRT\\User\\Badges')) {
$list = Badges::list_for_user($user_id, true);
$badge_count = 0;
foreach ((array) ($list['items'] ?? []) as $item) {
if ($badge_count >= $limit) {
break;
}
$signals[] = [
'type' => 'badge',
'slug' => (string) ($item['slug'] ?? ''),
'label' => (string) ($item['label'] ?? ''),
'icon' => 'award',
'tone' => (string) ($item['tone'] ?? 'neutral'),
'icon_url' => (string) ($item['icon_url'] ?? ''),
'wear' => !empty($item['wear']),
];
$badge_count++;
}
} else {
$badge_map = [];
foreach ((array) ($settings['badge']['dictionary'] ?? []) as $item) {
if (!empty($item['slug'])) {
$badge_map[(string) $item['slug']] = $item;
}
}
$assigned = get_user_meta($user_id, 'aksrt_user_badges', true);
if (!is_array($assigned)) {
$assigned = preg_split('/[\s,]+/', (string) $assigned, -1, PREG_SPLIT_NO_EMPTY);
}
$wear = sanitize_key((string) get_user_meta($user_id, 'aksrt_wear_badge', true));
$assigned = array_values(array_filter(array_map(static function ($slug) {
return sanitize_key((string) $slug);
}, (array) $assigned)));
if ($wear !== '' && in_array($wear, $assigned, true) && isset($badge_map[$wear])) {
$assigned = array_values(array_unique(array_merge([$wear], $assigned)));
}
$badge_count = 0;
foreach ($assigned as $slug) {
if ($slug === '' || !isset($badge_map[$slug])) {
continue;
}
$signals[] = [
'type' => 'badge',
'slug' => $slug,
'label' => (string) $badge_map[$slug]['label'],
'icon' => 'award',
'tone' => (string) ($badge_map[$slug]['tone'] ?? 'neutral'),
'icon_url' => (string) ($badge_map[$slug]['icon_url'] ?? ''),
'wear' => ($wear !== '' && $slug === $wear),
];
$badge_count++;
if ($badge_count >= $limit) {
break;
}
}
}
}
return $signals;
}
/**
* 获取公开治理提示。
*
* @return array{state:string,text:string}|array{}
*/
public function get_profile_notice(int $user_id): array {
if ($user_id <= 0 || !class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
if (empty($settings['report']['enabled']) || empty($settings['report']['public_notice'])) {
return [];
}
$state = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_ban_state', true));
if (!in_array($state, ['muted', 'restricted', 'banned'], true)) {
return [];
}
return [
'state' => $state,
'text' => (string) $settings['report']['ban_notice_text'],
];
}
/**
* 公开主页是否显示举报入口。
*/
public function show_report_entry(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
return false;
}
$settings = \AKSRT\Settings\Settings::get_user_identity_settings();
return !empty($settings['report']['enabled']) && !empty($settings['report']['public_entry']);
}
/**
* @param array{accept_email_verified:bool} $verify_settings
*/
private function is_user_verified(int $user_id, array $verify_settings): bool {
if (class_exists('\\AKSRT\\User\\AuthApply') && AuthApply::is_verified($user_id)) {
return true;
}
if (!empty($verify_settings['accept_email_verified']) && class_exists('\AKSRT\Auth\Service')) {
return \AKSRT\Auth\Service::user_is_email_verified($user_id);
}
return false;
}
/**
* @param array{source?:string,thresholds?:array<int,int>} $level_settings
*/
private function get_user_level(int $user_id, array $level_settings): int {
unset($level_settings);
return class_exists('AKSRT\\User\\Level') ? Level::get_level($user_id) : 0;
}
/**
* 判断当前页面是否为公开用户主页
*/
public static function is_page(): bool {
return function_exists('aksrt_is_user_home_page') && aksrt_is_user_home_page();
}
}
+475
View File
@@ -0,0 +1,475 @@
<?php
/**
* User growth levels and experience awards.
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Level {
public const META_INTEGRAL = 'aksrt_level_integral';
public const META_LEVEL = 'aksrt_user_level';
public const META_DETAIL = 'aksrt_level_integral_detail';
public const META_DATE_DETAIL = 'aksrt_level_integral_date_detail';
public const META_FOLLOWED = 'aksrt_level_integral_followed';
public const META_LOGIN_DAY = 'aksrt_level_integral_login_day';
/** @var self|null */
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('user_register', [$this, 'on_register'], 20, 1);
add_action('wp_login', [$this, 'on_login'], 20, 2);
add_action('aksrt_user_signed_in', [$this, 'on_checkin'], 20, 2);
add_action('transition_post_status', [$this, 'on_post_status'], 20, 3);
add_action('comment_post', [$this, 'on_comment_post'], 20, 3);
add_action('transition_comment_status', [$this, 'on_comment_status'], 20, 3);
add_action('aksrt_user_followed', [$this, 'on_followed'], 20, 2);
add_action('aksrt_post_liked', [$this, 'on_post_like'], 20, 3);
add_action('aksrt_comment_liked', [$this, 'on_comment_like'], 20, 3);
add_action('aksrt_post_favorited', [$this, 'on_post_favorite'], 20, 3);
add_action('aksrt_user_level_changed', [$this, 'on_level_changed'], 20, 3);
}
/**
* @return array{enabled:bool,public_display:bool,max:int,level_opt:array<string,mixed>,integral_opt:array<string,mixed>}
*/
public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$max = max(3, min(30, (int) ($all['user_level_max'] ?? 10)));
return [
'enabled' => !isset($all['user_level_s']) || !empty($all['user_level_s']),
'public_display' => !isset($all['user_level_public_display']) || !empty($all['user_level_public_display']),
'max' => $max,
'level_opt' => self::normalize_array_option($all['user_level_opt'] ?? []),
'integral_opt' => self::normalize_array_option($all['user_integral_opt'] ?? []),
];
}
/** @return array<string,mixed> */
private static function normalize_array_option($value): array {
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : [];
}
return is_array($value) ? $value : [];
}
/** @return array<string,int> */
public static function integral_defaults(): array {
return [
'day_max' => 100,
'sign_up' => 20,
'sign_in' => 5,
'followed' => 5,
'post_new' => 5,
'post_like' => 1,
'post_favorite' => 2,
'comment_new' => 2,
'comment_like' => 1,
];
}
/** @return array<string,string> */
public static function integral_labels(): array {
return [
'sign_up' => __('首次注册', 'aksrt'),
'sign_in' => __('每日登录', 'aksrt'),
'checkin' => __('每日签到', 'aksrt'),
'invitation' => __('邀请奖励', 'aksrt'),
'shop_gift' => __('购物赠送', 'aksrt'),
'followed' => __('被关注', 'aksrt'),
'post_new' => __('发布文章', 'aksrt'),
'post_like' => __('文章获赞', 'aksrt'),
'post_favorite' => __('文章被收藏', 'aksrt'),
'comment_new' => __('发表评论', 'aksrt'),
'comment_like' => __('评论获赞', 'aksrt'),
];
}
public static function task_points(string $key): int {
$opt = self::settings()['integral_opt'];
$defaults = self::integral_defaults();
return max(0, (int) ($opt[$key] ?? $defaults[$key] ?? 0));
}
public static function day_max(): int {
$opt = self::settings()['integral_opt'];
return max(0, (int) ($opt['day_max'] ?? self::integral_defaults()['day_max']));
}
public static function get_integral(int $user_id): int {
return $user_id > 0 ? max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true)) : 0;
}
public static function get_level(int $user_id): int {
$cfg = self::settings();
if ($user_id <= 0 || !$cfg['enabled']) {
return 0;
}
$level = (int) get_user_meta($user_id, self::META_LEVEL, true);
if ($level <= 0) {
$level = self::resolve_level(self::get_integral($user_id), $cfg);
update_user_meta($user_id, self::META_LEVEL, $level);
}
return max(1, min($cfg['max'], $level));
}
public static function get_level_name(int $level): string {
$cfg = self::settings();
$level = max(1, min($cfg['max'], $level));
return sanitize_text_field((string) ($cfg['level_opt']['name_' . $level] ?? ('LV' . $level)));
}
public static function get_level_icon_url(int $level): string {
$cfg = self::settings();
$level = max(1, min($cfg['max'], $level));
$custom = esc_url_raw((string) ($cfg['level_opt']['icon_img_' . $level] ?? ''));
if ($custom !== '') {
return $custom;
}
$path = AKSRT_DIR . '/assets/img/user-level-' . $level . '.png';
return is_file($path) ? AKSRT_URI . '/assets/img/user-level-' . $level . '.png' : '';
}
public static function get_badge_html(int $user_id, string $class = ''): string {
$cfg = self::settings();
if (!$cfg['enabled'] || !$cfg['public_display']) {
return '';
}
$level = self::get_level($user_id);
if ($level <= 0) {
return '';
}
$name = self::get_level_name($level);
$icon = self::get_level_icon_url($level);
if ($icon !== '') {
return '<img class="aksrt-user-level-icon img-icon ' . esc_attr($class) . '" src="' . esc_url($icon) . '" alt="' . esc_attr($name) . '" title="' . esc_attr($name) . '" loading="lazy" decoding="async">';
}
return '<span class="badg badg-sm aksrt-user-level-badge ' . esc_attr($class) . '" title="' . esc_attr($name) . '">LV' . esc_html((string) $level) . '</span>';
}
/** @param array{max?:int,level_opt?:array<string,mixed>}|null $cfg */
public static function resolve_level(int $integral, ?array $cfg = null): int {
$cfg = $cfg ?? self::settings();
$max = max(1, (int) ($cfg['max'] ?? 10));
$opt = is_array($cfg['level_opt'] ?? null) ? $cfg['level_opt'] : [];
$level = 1;
for ($i = 2; $i <= $max; $i++) {
$need = max(0, (int) ($opt['upgrade_integral_' . $i] ?? (($i - 1) * 500 * $i)));
if ($integral < $need) {
break;
}
$level = $i;
}
return $level;
}
/** @return array{level:int,name:string,icon:string,integral:int,current_need:int,next_need:int,remaining:int,progress:int,is_max:bool} */
public static function get_progress(int $user_id): array {
$cfg = self::settings();
$integral = self::get_integral($user_id);
$level = self::get_level($user_id);
$current_need = $level <= 1 ? 0 : max(0, (int) ($cfg['level_opt']['upgrade_integral_' . $level] ?? (($level - 1) * 500 * $level)));
$is_max = $level >= $cfg['max'];
$next_need = $is_max ? $integral : max($current_need + 1, (int) ($cfg['level_opt']['upgrade_integral_' . ($level + 1)] ?? ($level * 500 * ($level + 1))));
$span = max(1, $next_need - $current_need);
$progress = $is_max ? 100 : (int) min(100, max(0, (($integral - $current_need) / $span) * 100));
return [
'level' => $level,
'name' => self::get_level_name($level),
'icon' => self::get_level_icon_url($level),
'integral' => $integral,
'current_need' => $current_need,
'next_need' => $next_need,
'remaining' => $is_max ? 0 : max(0, $next_need - $integral),
'progress' => $progress,
'is_max' => $is_max,
];
}
/** @return array<int,array{integral:int,key:string,value:int,time:string}> */
public static function get_details(int $user_id): array {
$details = get_user_meta($user_id, self::META_DETAIL, true);
return is_array($details) ? array_values($details) : [];
}
/** @return array<string,int> */
public static function get_date_details(int $user_id): array {
$details = get_user_meta($user_id, self::META_DATE_DETAIL, true);
return is_array($details) ? $details : [];
}
/**
* Award experience and append its immutable display ledger.
*
* @return int|false Granted amount or false.
*/
public static function add(int $user_id, string $key, int $points = 0, bool $bypass_day_max = false) {
if ($user_id <= 0) {
return false;
}
$lock = self::acquire_lock('user:' . $user_id);
if ($lock === '') {
return false;
}
try {
return self::add_locked($user_id, $key, $points, $bypass_day_max);
} finally {
self::release_lock($lock);
}
}
private static function add_locked(int $user_id, string $key, int $points, bool $bypass_day_max) {
$cfg = self::settings();
$key = sanitize_key($key);
if ($user_id <= 0 || !$cfg['enabled'] || $key === '' || $key === 'day_max') {
return false;
}
if (class_exists('\AKSRT\User\Restriction') && \AKSRT\User\Restriction::get_instance()->is_banned($user_id)) {
return false;
}
$points = $points > 0 ? $points : self::task_points($key);
if ($points <= 0) {
return false;
}
$today = current_time('Y-m-d');
$date_details = self::get_date_details($user_id);
$today_total = max(0, (int) ($date_details[$today] ?? 0));
$day_max = self::day_max();
if (!$bypass_day_max && $day_max > 0 && $today_total >= $day_max) {
return false;
}
if ($points <= 0) {
return false;
}
$before = self::get_integral($user_id);
$after = $before + $points;
update_user_meta($user_id, self::META_INTEGRAL, $after);
$date_details[$today] = $today_total + $points;
uksort($date_details, static fn(string $a, string $b): int => strcmp($b, $a));
update_user_meta($user_id, self::META_DATE_DETAIL, array_slice($date_details, 0, 30, true));
$details = self::get_details($user_id);
array_unshift($details, [
'integral' => $after,
'key' => $key,
'value' => $points,
'time' => current_time('mysql'),
]);
update_user_meta($user_id, self::META_DETAIL, array_slice($details, 0, 50));
$old_level = max(1, (int) get_user_meta($user_id, self::META_LEVEL, true));
$new_level = $old_level;
$next_need = max(0, (int) ($cfg['level_opt']['upgrade_integral_' . ($old_level + 1)] ?? PHP_INT_MAX));
if ($old_level < $cfg['max'] && $after >= $next_need) {
$new_level = self::resolve_level($after, $cfg);
}
update_user_meta($user_id, self::META_LEVEL, $new_level);
if ($new_level !== $old_level) {
do_action('aksrt_user_level_changed', $user_id, $new_level, $old_level);
}
return $points;
}
private static function acquire_lock(string $scope): string {
global $wpdb;
$name = substr($wpdb->prefix . 'aksrt_level_' . md5($scope), 0, 64);
$acquired = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $name, 5));
return $acquired === 1 ? $name : '';
}
private static function release_lock(string $name): void {
global $wpdb;
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $name));
}
private static function award_once(int $user_id, string $key, string $meta_type, int $entity_id, string $marker, int $limit = 1, int $actor_id = 0): void {
if ($user_id <= 0 || $entity_id <= 0) {
return;
}
$lock = self::acquire_lock($meta_type . ':' . $entity_id . ':' . $marker);
if ($lock === '') {
return;
}
try {
self::award_once_locked($user_id, $key, $meta_type, $entity_id, $marker, $limit, $actor_id);
} finally {
self::release_lock($lock);
}
}
private static function award_once_locked(int $user_id, string $key, string $meta_type, int $entity_id, string $marker, int $limit, int $actor_id): void {
$get = $meta_type === 'comment' ? 'get_comment_meta' : 'get_post_meta';
$update = $meta_type === 'comment' ? 'update_comment_meta' : 'update_post_meta';
$awarded = $get($entity_id, $marker, true);
$awarded = is_array($awarded) ? array_values(array_unique(array_map('intval', $awarded))) : [];
if ($actor_id > 0) {
if (in_array($actor_id, $awarded, true) || count($awarded) >= $limit) {
return;
}
} elseif ($awarded !== []) {
return;
}
if (self::add($user_id, $key)) {
$awarded[] = $actor_id > 0 ? $actor_id : 0;
$update($entity_id, $marker, $awarded);
}
}
public function on_register(int $user_id): void {
self::add($user_id, 'sign_up', 0, true);
}
public function on_login($user_login, $user): void {
$user_id = $user instanceof \WP_User ? (int) $user->ID : 0;
$today = current_time('Ymd');
if ($user_id <= 0) {
return;
}
$lock = self::acquire_lock('login:' . $user_id . ':' . $today);
if ($lock === '') {
return;
}
try {
if (get_user_meta($user_id, self::META_LOGIN_DAY, true) === $today) {
return;
}
if (self::add($user_id, 'sign_in')) {
update_user_meta($user_id, self::META_LOGIN_DAY, $today);
}
} finally {
self::release_lock($lock);
}
}
public function on_checkin(int $user_id, int $reward = 0): void {
if ($reward > 0) {
self::add($user_id, 'checkin', $reward);
}
}
public function on_post_status(string $new_status, string $old_status, $post): void {
if (!$post instanceof \WP_Post || $new_status !== 'publish' || $old_status === 'publish') {
return;
}
$key = '';
if ($post->post_type === 'post') {
$key = 'post_new';
}
if ($key !== '') {
self::award_once((int) $post->post_author, $key, 'post', (int) $post->ID, '_aksrt_level_' . $key);
}
}
public function on_comment_post($comment_id, $approved, $commentdata): void {
if ((string) $approved !== '1') {
return;
}
$comment = get_comment((int) $comment_id);
$this->award_comment_new($comment);
}
public function on_comment_status(string $new_status, string $old_status, $comment): void {
if ($new_status === 'approved' && $old_status !== 'approved') {
$this->award_comment_new($comment);
}
}
private function award_comment_new($comment): void {
if (!$comment instanceof \WP_Comment || (int) $comment->user_id <= 0) {
return;
}
self::award_once((int) $comment->user_id, 'comment_new', 'comment', (int) $comment->comment_ID, '_aksrt_level_comment_new');
}
public function on_followed($follower_id, $followed_id): void {
$follower_id = (int) $follower_id;
$followed_id = (int) $followed_id;
if ($follower_id <= 0 || $followed_id <= 0) {
return;
}
$lock = self::acquire_lock('follow:' . $followed_id);
if ($lock === '') {
return;
}
try {
$awarded = get_user_meta($followed_id, self::META_FOLLOWED, true);
$awarded = is_array($awarded) ? array_map('intval', $awarded) : [];
if (in_array($follower_id, $awarded, true)) {
return;
}
if (self::add($followed_id, 'followed')) {
$awarded[] = $follower_id;
update_user_meta($followed_id, self::META_FOLLOWED, array_values(array_unique($awarded)));
}
} finally {
self::release_lock($lock);
}
}
public function on_post_like($post_id, $user_id, $active = true): void {
if (!$active) {
return;
}
$post = get_post((int) $post_id);
if ($post instanceof \WP_Post && (int) $post->post_author !== (int) $user_id) {
self::award_once((int) $post->post_author, 'post_like', 'post', (int) $post_id, '_aksrt_level_post_like', 5, (int) $user_id);
}
}
public function on_post_favorite($post_id, $user_id, $active = true): void {
if (!$active) {
return;
}
$post = get_post((int) $post_id);
if ($post instanceof \WP_Post && (int) $post->post_author !== (int) $user_id) {
self::award_once((int) $post->post_author, 'post_favorite', 'post', (int) $post_id, '_aksrt_level_post_favorite', 5, (int) $user_id);
}
}
public function on_comment_like($comment_id, $user_id, $active = true): void {
if (!$active) {
return;
}
$comment = get_comment((int) $comment_id);
if ($comment instanceof \WP_Comment && (int) $comment->user_id !== (int) $user_id) {
self::award_once((int) $comment->user_id, 'comment_like', 'comment', (int) $comment_id, '_aksrt_level_comment_like', 2, (int) $user_id);
}
}
public function on_level_changed(int $user_id, int $new_level, int $old_level): void {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
if ($new_level <= $old_level || isset($all['user_level_notify']) && empty($all['user_level_notify'])) {
return;
}
if (class_exists('\AKSRT\Messages\Message')) {
$name = self::get_level_name($new_level);
\AKSRT\Messages\Message::get_instance()->send(
$user_id,
'level_up',
sprintf(__('恭喜你升级至「%s」', 'aksrt'), $name),
[
'title' => __('等级提升', 'aksrt'),
'target_url' => function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('level') : home_url('/'),
]
);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
/**
* Native WordPress user-level operations.
*
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class LevelAdmin {
/** @var self|null */
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!is_admin()) {
return;
}
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_filter('manage_users_columns', [$this, 'add_column']);
add_filter('manage_users_custom_column', [$this, 'render_column'], 10, 3);
add_filter('manage_users_sortable_columns', [$this, 'sortable_columns']);
add_action('pre_get_users', [$this, 'sort_users']);
add_filter('user_row_actions', [$this, 'add_row_action'], 25, 2);
add_action('show_user_profile', [$this, 'render_section']);
add_action('edit_user_profile', [$this, 'render_section']);
add_action('personal_options_update', [$this, 'handle_update']);
add_action('edit_user_profile_update', [$this, 'handle_update']);
}
private function enabled(): bool {
return class_exists(Level::class) && !empty(Level::settings()['enabled']);
}
public function enqueue_assets(string $hook): void {
if (!in_array($hook, ['users.php', 'user-edit.php', 'profile.php'], true)) {
return;
}
wp_enqueue_style(
'aksrt-user-level-admin',
get_theme_file_uri('/assets/css/admin/user-level.css'),
[],
defined('AKSRT_VERSION') ? AKSRT_VERSION : null
);
}
private function can_manage(int $user_id): bool {
return current_user_can('manage_options') && current_user_can('edit_user', $user_id);
}
public function add_column(array $columns): array {
if ($this->enabled()) {
$columns['aksrt_user_level'] = __('用户等级', 'aksrt');
}
return $columns;
}
public function render_column(string $output, string $column_name, int $user_id): string {
if ($column_name !== 'aksrt_user_level' || !$this->enabled()) {
return $output;
}
$level = Level::get_level($user_id);
$icon = Level::get_badge_html($user_id, 'aksrt-user-level-icon--admin');
return '<span style="display:inline-flex;align-items:center;gap:6px;">' . $icon
. '<strong>' . esc_html(Level::get_level_name($level)) . '</strong>'
. '<small>(' . esc_html((string) Level::get_integral($user_id)) . ' EXP)</small></span>';
}
public function sortable_columns(array $columns): array {
if ($this->enabled()) {
$columns['aksrt_user_level'] = 'aksrt_user_level';
}
return $columns;
}
public function sort_users($query): void {
if (!is_admin() || $query->get('orderby') !== 'aksrt_user_level') {
return;
}
$query->set('meta_key', Level::META_INTEGRAL);
$query->set('orderby', 'meta_value_num');
}
public function add_row_action(array $actions, \WP_User $user): array {
if ($this->enabled() && $this->can_manage((int) $user->ID)) {
$url = get_edit_user_link((int) $user->ID);
if ($url) {
$actions['aksrt_edit_level'] = '<a href="' . esc_url($url . '#aksrt-user-level') . '">' . esc_html__('调整等级经验', 'aksrt') . '</a>';
}
}
return $actions;
}
public function render_section(\WP_User $user): void {
if (!$this->enabled()) {
return;
}
$progress = Level::get_progress((int) $user->ID);
$can_manage = $this->can_manage((int) $user->ID);
?>
<a id="aksrt-user-level"></a>
<h2><?php esc_html_e('用户等级(AKSRT)', 'aksrt'); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><?php esc_html_e('当前等级', 'aksrt'); ?></th>
<td>
<span style="display:inline-flex;align-items:center;gap:8px;font-size:16px;">
<?php echo Level::get_badge_html((int) $user->ID, 'aksrt-user-level-icon--admin'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<strong><?php echo esc_html((string) $progress['name']); ?></strong>
</span>
<p class="description"><?php printf(esc_html__('累计经验:%1$d;距离下一等级:%2$d', 'aksrt'), (int) $progress['integral'], (int) $progress['remaining']); ?></p>
</td>
</tr>
<?php if ($can_manage) : ?>
<tr>
<th><label for="aksrt_user_level"><?php esc_html_e('用户等级', 'aksrt'); ?></label></th>
<td>
<?php wp_nonce_field('aksrt_user_level_admin_' . (int) $user->ID, 'aksrt_user_level_nonce'); ?>
<input type="number" class="small-text" id="aksrt_user_level" name="aksrt_user_level" min="1" max="<?php echo esc_attr((string) Level::settings()['max']); ?>" step="1" value="<?php echo esc_attr((string) $progress['level']); ?>">
<p class="description"><?php esc_html_e('手动设置等级时请同步调整累计经验,使两项符合等级阈值。', 'aksrt'); ?></p>
</td>
</tr>
<tr>
<th><label for="aksrt_level_integral"><?php esc_html_e('累计经验值', 'aksrt'); ?></label></th>
<td>
<input type="number" class="regular-text" id="aksrt_level_integral" name="aksrt_level_integral" min="0" max="9999999999999999" step="1" value="<?php echo esc_attr((string) $progress['integral']); ?>">
</td>
</tr>
<?php endif; ?>
</table>
<?php
}
public function handle_update(int $user_id): void {
if (!$this->enabled() || !$this->can_manage($user_id) || !isset($_POST['aksrt_user_level_nonce'])) {
return;
}
$nonce = sanitize_text_field(wp_unslash((string) $_POST['aksrt_user_level_nonce']));
if (!wp_verify_nonce($nonce, 'aksrt_user_level_admin_' . $user_id)) {
return;
}
$integral = max(0, (int) ($_POST['aksrt_level_integral'] ?? 0));
$level = max(1, min(Level::settings()['max'], (int) ($_POST['aksrt_user_level'] ?? 1)));
update_user_meta($user_id, Level::META_INTEGRAL, $integral);
update_user_meta($user_id, Level::META_LEVEL, $level);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Metrics {
public const META_POST_LIKE_SUM = 'aksrt_posts_like_sum';
public const META_COMMENT_LIKE_SUM = 'aksrt_comment_like_sum';
public const META_POSTS_VIEWS_SUM = 'aksrt_posts_views_sum';
public const META_PAY_INCOME = 'aksrt_pay_income_sum';
public const META_PAY_REBATE = 'aksrt_pay_rebate_sum';
public const META_CHECKIN_ALL_DAY = 'aksrt_checkin_all_day';
/** @var self|null */
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('aksrt_post_liked', [$this, 'on_post_liked'], 20, 3);
add_action('aksrt_comment_liked', [$this, 'on_comment_liked'], 20, 3);
add_action('aksrt_post_favorited', [$this, 'on_post_favorited'], 20, 3);
add_action('aksrt_user_followed', [$this, 'on_user_followed'], 20, 2);
add_action('aksrt_post_viewed', [$this, 'on_post_viewed'], 20, 2);
add_action('aksrt_user_signed_in', [$this, 'on_user_signed_in'], 20, 1);
add_action('aksrt_creator_income_added', [$this, 'on_creator_income_added'], 20, 3);
add_action('aksrt_pay_rebate_added', [$this, 'on_pay_rebate_added'], 20, 3);
}
public static function bump_user_meta(int $user_id, string $meta_key, float $delta = 1.0): float {
if ($user_id <= 0 || $meta_key === '' || $delta == 0.0) {
return 0.0;
}
$current = (float) get_user_meta($user_id, $meta_key, true);
$next = max(0.0, $current + $delta);
update_user_meta($user_id, $meta_key, $next);
return $next;
}
public static function reevaluate_medals(int $user_id): void {
if ($user_id > 0 && class_exists('AKSRT\\User\\Badges') && method_exists('AKSRT\\User\\Badges', 'auto_evaluate')) {
Badges::auto_evaluate($user_id);
}
}
public function on_post_liked(int $post_id, int $actor_id, bool $active): void {
$post = get_post($post_id);
if (!$post instanceof \WP_Post || $post->post_type !== 'post') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === $actor_id) {
return;
}
self::bump_user_meta($author, self::META_POST_LIKE_SUM, $active ? 1.0 : -1.0);
if ($active) {
self::reevaluate_medals($author);
}
}
public function on_comment_liked(int $comment_id, int $actor_id, bool $active): void {
$comment = get_comment($comment_id);
if (!$comment) {
return;
}
$author = (int) $comment->user_id;
if ($author <= 0 || $author === $actor_id) {
return;
}
self::bump_user_meta($author, self::META_COMMENT_LIKE_SUM, $active ? 1.0 : -1.0);
if ($active) {
self::reevaluate_medals($author);
}
}
public function on_post_favorited(int $post_id, int $actor_id, bool $active): void {
unset($post_id, $actor_id, $active);
// Favorite is free-points / level only; no dedicated medal get_type.
}
public function on_user_followed(int $follower_id, int $following_id): void {
unset($follower_id);
if ($following_id > 0) {
self::reevaluate_medals($following_id);
}
}
public function on_post_viewed(int $post_id, int $viewer_id = 0): void {
unset($viewer_id);
$post = get_post($post_id);
if (!$post instanceof \WP_Post || $post->post_type !== 'post') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0) {
return;
}
self::bump_user_meta($author, self::META_POSTS_VIEWS_SUM, 1.0);
// Cheap throttle: reevaluate every 50 views on author sum.
$sum = (float) get_user_meta($author, self::META_POSTS_VIEWS_SUM, true);
if (((int) $sum % 50) === 0) {
self::reevaluate_medals($author);
}
}
/**
* @param int $topic_id
* @param int $author_id
*/
public function on_user_signed_in(int $user_id): void {
if ($user_id <= 0) {
return;
}
self::bump_user_meta($user_id, self::META_CHECKIN_ALL_DAY, 1.0);
self::reevaluate_medals($user_id);
}
/**
* @param int $user_id
* @param float $amount
* @param array $context
*/
public function on_creator_income_added($user_id, $amount, $context = []): void {
unset($context);
$user_id = (int) $user_id;
$amount = (float) $amount;
if ($user_id <= 0 || $amount <= 0) {
return;
}
self::bump_user_meta($user_id, self::META_PAY_INCOME, $amount);
self::reevaluate_medals($user_id);
}
/**
* @param int $user_id
* @param float $amount
* @param array $context
*/
public function on_pay_rebate_added($user_id, $amount, $context = []): void {
unset($context);
$user_id = (int) $user_id;
$amount = (float) $amount;
if ($user_id <= 0 || $amount <= 0) {
return;
}
self::bump_user_meta($user_id, self::META_PAY_REBATE, $amount);
self::reevaluate_medals($user_id);
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\User;
defined('ABSPATH') || exit;
class Restriction {
private static $instance = null;
const TYPE_WARN = 'warn';
const TYPE_MUTE = 'mute';
const TYPE_SUSPEND = 'suspend';
const TYPE_BAN = 'ban';
const SCOPE_GLOBAL = 'global';
const SCOPE_COMMENT = 'comment';
const TYPES = [self::TYPE_WARN, self::TYPE_MUTE, self::TYPE_SUSPEND, self::TYPE_BAN];
const SCOPES = [self::SCOPE_GLOBAL, self::SCOPE_COMMENT];
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
// Enforce bans on login
add_filter('authenticate', [$this, 'enforce_login_ban'], 999, 3);
// Enforce comment mute
add_filter('pre_comment_on_post', [$this, 'enforce_comment_mute']);
}
/**
* Get the restriction table name.
*/
private function table(): string {
global $wpdb;
return $wpdb->prefix . 'aksrt_user_restriction';
}
/**
* Add a restriction to a user.
*/
public function restrict(int $user_id, string $type, string $reason, string $scope = self::SCOPE_GLOBAL, string $expires_at = '', int $created_by = 0): array {
global $wpdb;
if (!in_array($type, self::TYPES, true)) {
return ['success' => false, 'message' => 'Invalid restriction type.'];
}
if (!in_array($scope, self::SCOPES, true)) {
return ['success' => false, 'message' => 'Invalid scope.'];
}
if ($created_by <= 0) {
$created_by = get_current_user_id();
}
$data = [
'user_id' => $user_id,
'type' => $type,
'reason' => sanitize_textarea_field($reason),
'scope' => $scope,
'expires_at' => $expires_at ? gmdate('Y-m-d H:i:s', strtotime($expires_at)) : null,
'created_by' => $created_by,
'created_at' => current_time('mysql', true),
];
$result = $wpdb->insert($this->table(), $data);
if ($result === false) {
return ['success' => false, 'message' => 'Database error.'];
}
// Update user meta for quick lookup
$this->sync_user_restriction_meta($user_id);
return ['success' => true, 'id' => $wpdb->insert_id];
}
/**
* Remove a restriction by ID.
*/
public function remove_restriction(int $restriction_id): bool {
global $wpdb;
$restriction = $this->get_restriction($restriction_id);
if (!$restriction) return false;
$result = $wpdb->delete($this->table(), ['id' => $restriction_id]);
if ($result) {
$this->sync_user_restriction_meta((int) $restriction['user_id']);
}
return (bool) $result;
}
/**
* Get a single restriction by ID.
*/
public function get_restriction(int $restriction_id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d",
$restriction_id
), ARRAY_A);
return $row ?: null;
}
/**
* Get all active restrictions for a user.
*/
public function get_user_restrictions(int $user_id, bool $active_only = true): array {
global $wpdb;
$now = current_time('mysql', true);
if ($active_only) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC",
$user_id, $now
), ARRAY_A);
} else {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE user_id = %d ORDER BY created_at DESC",
$user_id
), ARRAY_A);
}
return $rows ?: [];
}
/**
* Check if a user has an active restriction of a given type and scope.
*/
public function is_restricted(int $user_id, string $type = '', string $scope = ''): bool {
$restrictions = $this->get_user_restrictions($user_id, true);
foreach ($restrictions as $r) {
if ($type && $r['type'] !== $type) continue;
if ($scope && $r['scope'] !== $scope && $r['scope'] !== self::SCOPE_GLOBAL) continue;
return true;
}
return false;
}
/**
* Check if a user is banned (global).
*/
public function is_banned(int $user_id): bool {
return $this->is_restricted($user_id, self::TYPE_BAN, self::SCOPE_GLOBAL);
}
/**
* Get the active ban restriction for a user (if any).
*/
public function get_active_ban(int $user_id): ?array {
$restrictions = $this->get_user_restrictions($user_id, true);
foreach ($restrictions as $r) {
if ($r['type'] === self::TYPE_BAN && $r['scope'] === self::SCOPE_GLOBAL) {
return $r;
}
}
return null;
}
/**
* Enforce ban on login.
*/
public function enforce_login_ban($user, string $username, string $password) {
if (is_wp_error($user) || !$user instanceof \WP_User) {
return $user;
}
if ($this->is_banned($user->ID)) {
$ban = $this->get_active_ban($user->ID);
$message = '您的账号已被封禁。';
if (!empty($ban['reason'])) {
$message .= '原因:' . $ban['reason'];
}
return new \WP_Error('user_banned', $message);
}
return $user;
}
/**
* Enforce comment mute.
*/
public function enforce_comment_mute($post_id) {
$user_id = get_current_user_id();
if ($user_id > 0 && $this->is_restricted($user_id, self::TYPE_MUTE, self::SCOPE_COMMENT)) {
wp_die('您已被限制评论。', '评论限制', ['response' => 403]);
}
return $post_id;
}
/**
* Sync restriction meta for quick lookup.
*/
private function sync_user_restriction_meta(int $user_id): void {
$active = $this->get_user_restrictions($user_id, true);
$types = array_unique(array_column($active, 'type'));
update_user_meta($user_id, '_aksrt_active_restrictions', $types);
update_user_meta($user_id, '_aksrt_is_banned', in_array(self::TYPE_BAN, $types, true) ? 1 : 0);
}
/**
* Get restriction stats for admin overview.
*/
public function get_stats(): array {
global $wpdb;
$now = current_time('mysql', true);
$table = $this->table();
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
$active = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE expires_at IS NULL OR expires_at > %s", $now
));
$banned = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE type = %s AND (expires_at IS NULL OR expires_at > %s)",
self::TYPE_BAN, $now
));
return ['total' => $total, 'active' => $active, 'banned' => $banned];
}
}