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;
+ }
}
}
}