From 219cd0236ef3fdb9e18cd9a0a578082a52c8f57d Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 16 Jul 2026 23:39:41 +0800 Subject: [PATCH] feat(user): badge wear and grant service Add AKSRT_User_Badges own/wear/grant REST and WP profile admin grant UI, and route identity signals through the service. --- functions.php | 7 + inc/bootstrap.php | 2 + inc/classes/class-aksrt-user-badges-admin.php | 123 ++++++ inc/classes/class-aksrt-user-badges.php | 407 ++++++++++++++++++ inc/classes/class-aksrt-user-home.php | 93 ++-- 5 files changed, 592 insertions(+), 40 deletions(-) create mode 100644 inc/classes/class-aksrt-user-badges-admin.php create mode 100644 inc/classes/class-aksrt-user-badges.php diff --git a/functions.php b/functions.php index 5be1371d..93fb92ff 100644 --- a/functions.php +++ b/functions.php @@ -364,6 +364,13 @@ if (class_exists('AKSRT_User_Balance_Admin')) { AKSRT_User_Balance_Admin::get_instance()->init(); } +if (class_exists('AKSRT_User_Badges')) { + AKSRT_User_Badges::get_instance()->init(); +} +if (class_exists('AKSRT_User_Badges_Admin')) { + AKSRT_User_Badges_Admin::get_instance()->init(); +} + // WordPress 原生侧栏:支付/订单运营中心(zibll zibpay 对齐) if (is_admin() && class_exists('AKSRT_Admin_Pay_Menus')) { AKSRT_Admin_Pay_Menus::get_instance()->init(); diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 9be8898c..2d195051 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -219,6 +219,8 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-email-rollback.php'; // 原生用户菜单:积分管理 require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-credits-admin.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-balance-admin.php'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-badges.php'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-badges-admin.php'; // 加载数据表迁移器(CMS / 积分 / 下载 token) require_once AKSRT_DIR . '/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php'; diff --git a/inc/classes/class-aksrt-user-badges-admin.php b/inc/classes/class-aksrt-user-badges-admin.php new file mode 100644 index 00000000..2b94b312 --- /dev/null +++ b/inc/classes/class-aksrt-user-badges-admin.php @@ -0,0 +1,123 @@ +ID); + $wear = AKSRT_User_Badges::get_wear((int) $user->ID); + $can = $this->can_manage(); + ?> + +

+ + + + + + + + + + + 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') || !AKSRT_User_Badges::enabled()) { + return; + } + $raw = isset($_POST['aksrt_user_badges']) ? (array) wp_unslash($_POST['aksrt_user_badges']) : []; + $slugs = array_map('sanitize_key', $raw); + AKSRT_User_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, AKSRT_User_Badges::META_WEAR); + } else { + $res = AKSRT_User_Badges::wear($user_id, $wear); + if (is_wp_error($res)) { + // leave owned as-is; wear ignored if invalid + delete_user_meta($user_id, AKSRT_User_Badges::META_WEAR); + } + } + } +} diff --git a/inc/classes/class-aksrt-user-badges.php b/inc/classes/class-aksrt-user-badges.php new file mode 100644 index 00000000..c7e1e59b --- /dev/null +++ b/inc/classes/class-aksrt-user-badges.php @@ -0,0 +1,407 @@ +\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\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\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')) { + return false; + } + $settings = AKSRT_Settings::get_user_identity_settings(); + return !empty($settings['badge']['enabled']); + } + + public static function public_display(): bool { + if (!class_exists('AKSRT_Settings')) { + return false; + } + $settings = AKSRT_Settings::get_user_identity_settings(); + return !empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display']); + } + + /** + * @return list + */ + public static function get_dictionary(): array { + if (!class_exists('AKSRT_Settings')) { + return []; + } + $settings = AKSRT_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 + */ + public static function get_dictionary_map(): array { + $map = []; + foreach (self::get_dictionary() as $item) { + $map[$item['slug']] = $item; + } + return $map; + } + + /** + * @return list + */ + 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 $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 === '') { + return ''; + } + $owned = self::get_owned($user_id); + 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)) { + $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} + */ + 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'), + ]); + } +} diff --git a/inc/classes/class-aksrt-user-home.php b/inc/classes/class-aksrt-user-home.php index 8b5d1c76..373d4cb1 100644 --- a/inc/classes/class-aksrt-user-home.php +++ b/inc/classes/class-aksrt-user-home.php @@ -162,48 +162,61 @@ class AKSRT_User_Home { } if (!empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display'])) { - $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); - } - $assigned = array_values(array_filter(array_map(static function ($slug) { - return sanitize_key((string) $slug); - }, (array) $assigned))); - - // Prefer worn badge first (Zibll wear-medal). - $wear = sanitize_key((string) get_user_meta($user_id, 'aksrt_wear_badge', true)); - if ($wear !== '' && in_array($wear, $assigned, true) && isset($badge_map[$wear])) { - $assigned = array_values(array_unique(array_merge([$wear], $assigned))); - } - $limit = max(1, (int) ($settings['badge']['limit'] ?? 3)); - $badge_count = 0; - foreach ($assigned as $slug) { - if ($slug === '' || !isset($badge_map[$slug])) { - continue; + if (class_exists('AKSRT_User_Badges')) { + $list = AKSRT_User_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++; } - - $icon_url = (string) ($badge_map[$slug]['icon_url'] ?? ''); - $signals[] = [ - 'type' => 'badge', - 'slug' => $slug, - 'label' => (string) $badge_map[$slug]['label'], - 'icon' => 'award', - 'tone' => (string) ($badge_map[$slug]['tone'] ?? 'neutral'), - 'icon_url' => $icon_url, - 'wear' => ($wear !== '' && $slug === $wear), - ]; - $badge_count++; - - if ($badge_count >= $limit) { - break; + } 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; + } } } }