Files
wordpress-theme-aksrt/inc/classes/class-aksrt-user-badges.php
T
kate522 487681a6c4 feat(identity): tip QR upload, metrics engine, comment moderate caps
Close identity 1:1 gaps after p1: user-center weixin/alipay tip QR
upload+save, AKSRT_User_Metrics counters for medal get_type and level
XP bridges, forum/revenue/signin hooks, comment moderate via user_caps,
and clearer free-task deep links/status CTAs.
2026-07-17 01:52:35 +08:00

555 lines
16 KiB
PHP

<?php
/**
* User badges — own / wear / grant (Zibll medal stack parity).
*
* Meta:
* aksrt_user_badges string[]|CSV owned slug list
* aksrt_wear_badge string currently worn slug
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_User_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']);
}
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')) {
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<array{slug:string,label:string,tone:string,icon_url:string}>
*/
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<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 === '') {
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<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')) {
return;
}
$settings = AKSRT_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 'bbs_post':
$types = post_type_exists('forum_topic') ? 'forum_topic' : (post_type_exists('topic') ? 'topic' : '');
return $types !== '' ? (float) count_user_posts($user_id, $types, true) : 0.0;
case 'comment':
$counts = get_comments(['user_id' => $user_id, 'count' => true, 'status' => 'approve']);
return (float) $counts;
case 'followed':
if (class_exists('AKSRT_Follow')) {
return (float) AKSRT_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 'bbs_adopt':
return (float) get_user_meta($user_id, 'aksrt_bbs_adopt_count', true);
case 'bbs_comment_hot':
return (float) get_user_meta($user_id, 'aksrt_bbs_comment_hot_count', true);
case 'bbs_post_hot':
return (float) get_user_meta($user_id, 'aksrt_bbs_post_hot_count', true);
case 'bbs_essence':
return (float) get_user_meta($user_id, 'aksrt_bbs_essence_count', true);
case 'bbs_score':
return (float) get_user_meta($user_id, 'aksrt_bbs_score_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);
}
}
}