diff --git a/assets/admin/src/pages/users/UsersLevel.vue b/assets/admin/src/pages/users/UsersLevel.vue index fb8cfe0c..9a216053 100644 --- a/assets/admin/src/pages/users/UsersLevel.vue +++ b/assets/admin/src/pages/users/UsersLevel.vue @@ -2,7 +2,7 @@
+ + diff --git a/assets/admin/src/schema/sections/users/level.js b/assets/admin/src/schema/sections/users/level.js index 42f96ce9..f87c8543 100644 --- a/assets/admin/src/schema/sections/users/level.js +++ b/assets/admin/src/schema/sections/users/level.js @@ -1,33 +1,82 @@ +/** + * User level + integral XP (Zibll user_level_s / user_level_opt / user_integral_opt). + */ + +export const INTEGRAL_CATALOG = [ + { key: 'sign_up', label: '首次注册', default: 20, group: '用户' }, + { key: 'sign_in', label: '每日登录', default: 5, group: '用户' }, + { key: 'followed', label: '被关注', default: 5, group: '用户' }, + { key: 'post_new', label: '发布文章', default: 5, group: '文章' }, + { key: 'post_like', label: '文章获赞', default: 1, group: '文章' }, + { key: 'post_favorite', label: '文章被收藏', default: 2, group: '文章' }, + { key: 'comment_new', label: '发表评论', default: 2, group: '文章' }, + { key: 'comment_like', label: '评论获赞', default: 1, group: '文章' }, + { key: 'bbs_posts_new', label: '发布帖子', default: 3, group: '论坛' }, + { key: 'bbs_score_extra', label: '帖子被加分', default: 1, group: '论坛' }, + { key: 'bbs_essence', label: '帖子评为精华', default: 2, group: '论坛' }, + { key: 'bbs_posts_hot', label: '帖子成为热门', default: 2, group: '论坛' }, + { key: 'bbs_plate_new', label: '创建版块', default: 2, group: '论坛' }, + { key: 'bbs_plate_hot', label: '版块成为热门', default: 2, group: '论坛' }, + { key: 'bbs_adopt', label: '回答被采纳', default: 2, group: '论坛' }, + { key: 'bbs_comment_hot', label: '评论成为神评', default: 2, group: '论坛' }, +] + +export function defaultLevelOpt(max = 10) { + const opt = {} + for (let i = 1; i <= max; i++) { + // Rough Zibll-like curve: LV1=0, then grows + opt['upgrade_integral_' + i] = i === 1 ? 0 : Math.round(20 * Math.pow(i - 1, 1.6)) + opt['name_' + i] = 'Lv.' + i + } + return opt +} + +export function defaultIntegralOpt() { + const opt = { day_max: 100 } + for (const row of INTEGRAL_CATALOG) { + opt[row.key] = row.default + } + return opt +} + export const levelSchema = { key: 'users.level', title: '用户等级', icon: 'fas fa-level-up-alt', - description: '配置等级展示前缀和等级阈值,当前可基于积分余额自动计算。', + description: '等级与经验值:最高等级、升级阈值、经验获取矩阵(对齐 Zibll user_level / user_integral)。', defaults: { - user_level_enabled: false, + user_level_s: true, user_level_public_display: true, - user_level_source: 'credits_balance', user_level_prefix: 'Lv.', - user_level_thresholds: '1|0\n2|30\n3|100\n4|300', + user_level_max: 10, + user_level_opt: defaultLevelOpt(10), + user_integral_opt: defaultIntegralOpt(), + user_level_notify: true, }, groups: [ { key: 'display', title: '等级显示', - icon: 'fas fa-signal', fields: [ - { key: 'user_level_enabled', label: '启用等级系统', type: 'switch', default: false }, - { key: 'user_level_public_display', label: '公开页显示等级', type: 'switch', default: true, dependsOn: { key: 'user_level_enabled', value: true } }, - { key: 'user_level_prefix', label: '等级前缀', type: 'input', default: 'Lv.', dependsOn: { key: 'user_level_enabled', value: true } }, + { key: 'user_level_s', label: '启用用户等级', type: 'switch', default: true }, + { key: 'user_level_public_display', label: '公开页显示等级', type: 'switch', default: true, dependsOn: { key: 'user_level_s', value: true } }, + { key: 'user_level_prefix', label: '等级前缀', type: 'input', default: 'Lv.', dependsOn: { key: 'user_level_s', value: true } }, + { key: 'user_level_max', label: '最高等级', type: 'number', default: 10, min: 3, max: 30, dependsOn: { key: 'user_level_s', value: true } }, + { key: 'user_level_notify', label: '升级通知', type: 'switch', default: true, dependsOn: { key: 'user_level_s', value: true } }, ], }, { - key: 'rules', - title: '等级规则', - icon: 'fas fa-list-ol', + key: 'level_opt', + title: '等级升级阈值', fields: [ - { key: 'user_level_source', label: '等级来源', type: 'radio', default: 'credits_balance', options: [{ label: '积分余额', value: 'credits_balance' }, { label: '手动 Meta', value: 'manual_meta' }], dependsOn: { key: 'user_level_enabled', value: true } }, - { key: 'user_level_thresholds', label: '等级阈值', type: 'textarea', default: '1|0\n2|30\n3|100\n4|300', help: '每行一个等级,格式:等级|所需积分,例如 3|100', dependsOn: { key: 'user_level_enabled', value: true } }, + { key: 'user_level_opt', label: '升级经验配置', type: 'json', default: defaultLevelOpt(10), help: 'upgrade_integral_N 为升到 N 级所需累计经验' }, + ], + }, + { + key: 'integral', + title: '经验值得分', + fields: [ + { key: 'user_integral_opt', label: '经验值获取', type: 'json', default: defaultIntegralOpt(), help: 'day_max + 各行为得分;对应功能未开则将分值设 0' }, ], }, ], diff --git a/functions.php b/functions.php index 93fb92ff..ec0f108a 100644 --- a/functions.php +++ b/functions.php @@ -370,6 +370,9 @@ if (class_exists('AKSRT_User_Badges')) { if (class_exists('AKSRT_User_Badges_Admin')) { AKSRT_User_Badges_Admin::get_instance()->init(); } +if (class_exists('AKSRT_User_Level')) { + AKSRT_User_Level::get_instance()->init(); +} // WordPress 原生侧栏:支付/订单运营中心(zibll zibpay 对齐) if (is_admin() && class_exists('AKSRT_Admin_Pay_Menus')) { diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 2d195051..7e9bc3b7 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -221,6 +221,7 @@ 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'; +require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-level.php'; // 加载数据表迁移器(CMS / 积分 / 下载 token) require_once AKSRT_DIR . '/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php'; diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-users.php b/inc/classes/admin/schema/class-aksrt-admin-schema-users.php index 16e91272..9eab100b 100644 --- a/inc/classes/admin/schema/class-aksrt-admin-schema-users.php +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-users.php @@ -604,37 +604,27 @@ class AKSRT_Admin_Schema_Users { 'level' => [ 'key' => 'level', 'title' => '用户等级', - 'version' => '1.0.0', + 'version' => '2.0.0', 'option_key' => 'aksrt_settings', 'groups' => [ ['key' => 'display', 'title' => '等级显示', 'fields' => [ - ['key' => 'user_level_enabled', 'label' => '启用等级系统', 'type' => 'switch', 'default' => false], + ['key' => 'user_level_s', 'label' => '启用用户等级', 'type' => 'switch', 'default' => true], + ['key' => 'user_level_enabled', 'label' => '启用等级系统(别名)', 'type' => 'switch', 'default' => true, 'help' => '与 user_level_s 同步写入'], ['key' => 'user_level_public_display', 'label' => '公开页显示等级', 'type' => 'switch', 'default' => true], ['key' => 'user_level_prefix', 'label' => '等级前缀', 'type' => 'input', 'default' => 'Lv.'], - ['key' => 'user_level_color', 'label' => '等级颜色', 'type' => 'color', 'default' => '#f04494'], - ['key' => 'user_level_icon', 'label' => '等级图标', 'type' => 'icon', 'default' => 'fas fa-star'], - ]], - ['key' => 'rules', 'title' => '等级规则', 'fields' => [ - ['key' => 'user_level_source', 'label' => '等级来源', 'type' => 'radio', 'default' => 'credits_balance'], - ['key' => 'user_level_thresholds', 'label' => '等级阈值', 'type' => 'textarea', 'default' => "1|0\n2|30\n3|100\n4|300"], - ['key' => 'user_level_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 99], - ['key' => 'user_level_decay', 'label' => '等级衰减', 'type' => 'switch', 'default' => false, 'help' => '长期不活跃自动降级'], - ['key' => 'user_level_decay_days', 'label' => '衰减周期(天)', 'type' => 'number', 'default' => 90, 'min' => 7, 'max' => 365], - ]], - ['key' => 'privilege', 'title' => '等级特权', 'fields' => [ - ['key' => 'user_level_download_limit', 'label' => '下载限制加成', 'type' => 'switch', 'default' => false, 'help' => '高等级用户每日下载次数增加'], - ['key' => 'user_level_upload_limit', 'label' => '上传限制加成', 'type' => 'switch', 'default' => false, 'help' => '高等级用户可上传更大文件'], - ['key' => 'user_level_comment_cooldown', 'label' => '评论冷却缩短', 'type' => 'switch', 'default' => false, 'help' => '高等级用户评论间隔更短'], - ['key' => 'user_level_custom_title', 'label' => '自定义头衔', 'type' => 'switch', 'default' => false, 'help' => '允许高等级用户自定义头衔'], - ]], - ['key' => 'notify', 'title' => '升级通知', 'fields' => [ + ['key' => 'user_level_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 3, 'max' => 30], ['key' => 'user_level_notify', 'label' => '升级通知', 'type' => 'switch', 'default' => true], - ['key' => 'user_level_notify_style', 'label' => '通知方式', 'type' => 'select', 'default' => 'toast', 'options' => [ - ['label' => '弹窗提示', 'value' => 'toast'], - ['label' => '站内信', 'value' => 'message'], - ['label' => '两者都有', 'value' => 'both'], + ['key' => 'user_level_source', 'label' => '等级来源', 'type' => 'radio', 'default' => 'integral', 'options' => [ + ['label' => '经验值', 'value' => 'integral'], + ['label' => '手动 Meta', 'value' => 'manual_meta'], ]], ]], + ['key' => 'level_opt', 'title' => '升级阈值', 'fields' => [ + ['key' => 'user_level_opt', 'label' => '等级参数', 'type' => 'json', 'default' => [], 'help' => 'upgrade_integral_N / name_N'], + ]], + ['key' => 'integral', 'title' => '经验值得分', 'fields' => [ + ['key' => 'user_integral_opt', 'label' => '经验值配置', 'type' => 'json', 'default' => [], 'help' => 'day_max + 行为得分键'], + ]], ], ], 'badge' => [ diff --git a/inc/classes/class-aksrt-user-home.php b/inc/classes/class-aksrt-user-home.php index 373d4cb1..6be42551 100644 --- a/inc/classes/class-aksrt-user-home.php +++ b/inc/classes/class-aksrt-user-home.php @@ -279,22 +279,24 @@ class AKSRT_User_Home { } /** - * @param array{source:string,thresholds:array} $level_settings + * @param array{source?:string,thresholds?:array} $level_settings */ private function get_user_level(int $user_id, array $level_settings): int { - if (($level_settings['source'] ?? 'credits_balance') === 'manual_meta') { + $source = (string) ($level_settings['source'] ?? 'integral'); + if ($source === 'manual_meta') { return max(0, (int) get_user_meta($user_id, 'aksrt_user_level', true)); } - + if (class_exists('AKSRT_User_Level')) { + return AKSRT_User_Level::get_level($user_id); + } + // Fallback: credits_balance thresholds (legacy) if integral class missing. $score = function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($user_id) : 0; $level = 0; - foreach ((array) ($level_settings['thresholds'] ?? []) as $candidate => $threshold) { if ($score >= (int) $threshold) { $level = (int) $candidate; } } - return $level; } diff --git a/inc/classes/class-aksrt-user-level.php b/inc/classes/class-aksrt-user-level.php new file mode 100644 index 00000000..6ba275f0 --- /dev/null +++ b/inc/classes/class-aksrt-user-level.php @@ -0,0 +1,379 @@ + [value, integral] + * aksrt_level_integral_date_detail array Y-m-d => int + * + * @package AKSRT + */ + +defined('ABSPATH') || exit; + +class AKSRT_User_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_TODAY_FOLLOW = 'aksrt_level_integral_followed'; + + /** @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_sign_in'], 20, 1); + add_action('publish_post', [$this, 'on_publish_post'], 20, 2); + add_action('comment_post', [$this, 'on_comment_post'], 20, 3); + add_action('aksrt_user_followed', [$this, 'on_followed'], 20, 2); + add_action('aksrt_post_liked', [$this, 'on_post_like'], 20, 2); + add_action('aksrt_comment_liked', [$this, 'on_comment_like'], 20, 2); + add_action('aksrt_post_favorited', [$this, 'on_post_favorite'], 20, 2); + } + + /** + * @return array{enabled:bool,public_display:bool,prefix:string,max:int,source:string,level_opt:array,integral_opt:array,notify:bool} + */ + public static function settings(): array { + $all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : []; + $enabled = !empty($all['user_level_s']) || !empty($all['user_level_enabled']); + $max = max(3, min(30, (int) ($all['user_level_max'] ?? 10))); + $source = (string) ($all['user_level_source'] ?? 'integral'); + if (!in_array($source, ['integral', 'manual_meta'], true)) { + $source = 'integral'; + } + $level_opt = $all['user_level_opt'] ?? []; + if (is_string($level_opt)) { + $decoded = json_decode($level_opt, true); + $level_opt = is_array($decoded) ? $decoded : []; + } + if (!is_array($level_opt)) { + $level_opt = []; + } + $integral_opt = $all['user_integral_opt'] ?? []; + if (is_string($integral_opt)) { + $decoded = json_decode($integral_opt, true); + $integral_opt = is_array($decoded) ? $decoded : []; + } + if (!is_array($integral_opt)) { + $integral_opt = []; + } + $prefix = trim((string) ($all['user_level_prefix'] ?? 'Lv.')); + + return [ + 'enabled' => $enabled, + 'public_display' => !isset($all['user_level_public_display']) || !empty($all['user_level_public_display']), + 'prefix' => $prefix !== '' ? $prefix : 'Lv.', + 'max' => $max, + 'source' => $source, + 'level_opt' => $level_opt, + 'integral_opt' => $integral_opt, + 'notify' => !isset($all['user_level_notify']) || !empty($all['user_level_notify']), + ]; + } + + /** + * Catalog of integral earn keys (defaults). + * + * @return array + */ + 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, + 'bbs_posts_new' => 3, + 'bbs_score_extra' => 1, + 'bbs_essence' => 2, + 'bbs_posts_hot' => 2, + 'bbs_plate_new' => 2, + 'bbs_plate_hot' => 2, + 'bbs_adopt' => 2, + 'bbs_comment_hot' => 2, + ]; + } + + public static function task_points(string $key): int { + $cfg = self::settings(); + $opt = $cfg['integral_opt']; + $def = self::integral_defaults(); + if (array_key_exists($key, $opt)) { + return max(0, (int) $opt[$key]); + } + return max(0, (int) ($def[$key] ?? 0)); + } + + public static function day_max(): int { + $cfg = self::settings(); + $opt = $cfg['integral_opt']; + if (array_key_exists('day_max', $opt)) { + return max(0, (int) $opt['day_max']); + } + return 100; + } + + public static function get_integral(int $user_id): int { + if ($user_id <= 0) { + return 0; + } + return max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true)); + } + + public static function get_level(int $user_id): int { + if ($user_id <= 0) { + return 0; + } + $cfg = self::settings(); + if (!$cfg['enabled']) { + return 0; + } + if ($cfg['source'] === 'manual_meta') { + return max(0, (int) get_user_meta($user_id, self::META_LEVEL, true)); + } + return self::resolve_level(self::get_integral($user_id), $cfg); + } + + /** + * @param array{max:int,level_opt:array} $cfg + */ + public static function resolve_level(int $integral, ?array $cfg = null): int { + $cfg = $cfg ?? self::settings(); + $max = (int) $cfg['max']; + $opt = $cfg['level_opt']; + $level = 1; + for ($i = 1; $i <= $max; $i++) { + $need = (int) ($opt['upgrade_integral_' . $i] ?? ($i === 1 ? 0 : PHP_INT_MAX)); + if ($integral >= $need) { + $level = $i; + } else { + break; + } + } + return max(0, min($max, $level)); + } + + /** + * Grant integral for a task key. Respects day_max. + * + * @return int|false points granted or false + */ + public static function add(int $user_id, string $key, int $points = 0) { + if ($user_id <= 0) { + return false; + } + $cfg = self::settings(); + if (!$cfg['enabled'] || $cfg['source'] !== 'integral') { + return false; + } + $key = sanitize_key($key); + if ($key === '' || $key === 'day_max') { + return false; + } + if ($points <= 0) { + $points = self::task_points($key); + } + if ($points <= 0) { + return false; + } + + $day_max = self::day_max(); + $today = gmdate('Y-m-d', current_time('timestamp')); + $date_detail = get_user_meta($user_id, self::META_DATE_DETAIL, true); + $date_detail = is_array($date_detail) ? $date_detail : []; + $today_sum = max(0, (int) ($date_detail[$today] ?? 0)); + if ($day_max > 0 && $today_sum >= $day_max) { + return false; + } + if ($day_max > 0) { + $points = min($points, $day_max - $today_sum); + } + if ($points <= 0) { + return false; + } + + $before = self::get_integral($user_id); + $after = $before + $points; + update_user_meta($user_id, self::META_INTEGRAL, $after); + + $date_detail[$today] = $today_sum + $points; + // Keep last 90 days. + if (count($date_detail) > 90) { + krsort($date_detail); + $date_detail = array_slice($date_detail, 0, 90, true); + } + update_user_meta($user_id, self::META_DATE_DETAIL, $date_detail); + + $detail = get_user_meta($user_id, self::META_DETAIL, true); + $detail = is_array($detail) ? $detail : []; + $prev = is_array($detail[$key] ?? null) ? $detail[$key] : ['value' => 0, 'integral' => 0]; + $detail[$key] = [ + 'value' => (int) ($prev['value'] ?? 0) + $points, + 'integral' => (int) ($prev['integral'] ?? 0) + $points, + ]; + update_user_meta($user_id, self::META_DETAIL, $detail); + + $old_level = (int) get_user_meta($user_id, self::META_LEVEL, true); + $new_level = self::resolve_level($after, $cfg); + if ($new_level !== $old_level) { + update_user_meta($user_id, self::META_LEVEL, $new_level); + /** + * @param int $user_id + * @param int $new_level + * @param int $old_level + */ + do_action('aksrt_user_level_changed', $user_id, $new_level, $old_level); + } else { + update_user_meta($user_id, self::META_LEVEL, $new_level); + } + + return $points; + } + + public function on_register(int $user_id): void { + self::add($user_id, 'sign_up'); + } + + /** + * @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) { + return; + } + // Once per calendar day for login XP. + $today = gmdate('Y-m-d', current_time('timestamp')); + $flag = (string) get_user_meta($user_id, '_aksrt_integral_sign_in_day', true); + if ($flag === $today) { + return; + } + $granted = self::add($user_id, 'sign_in'); + if ($granted) { + update_user_meta($user_id, '_aksrt_integral_sign_in_day', $today); + } + } + + public function on_sign_in(int $user_id): void { + // Dedicated check-in hook can also grant if separate from login; reuse sign_in key once/day. + $this->on_login('', get_userdata($user_id) ?: new WP_User($user_id)); + } + + /** + * @param int $post_id + * @param WP_Post $post + */ + public function on_publish_post($post_id, $post): void { + if (!$post instanceof WP_Post || $post->post_type !== 'post' || $post->post_status !== 'publish') { + return; + } + // Only when transitioning to publish first time. + if ((string) get_post_meta($post_id, '_aksrt_integral_post_new', true) === '1') { + return; + } + $author = (int) $post->post_author; + if ($author <= 0) { + return; + } + if (self::add($author, 'post_new')) { + update_post_meta($post_id, '_aksrt_integral_post_new', '1'); + } + } + + /** + * @param int $comment_id + * @param int|string $approved + * @param array $commentdata + */ + public function on_comment_post($comment_id, $approved, $commentdata): void { + if ((string) $approved !== '1' && $approved !== 1) { + return; + } + $user_id = (int) ($commentdata['user_id'] ?? 0); + if ($user_id <= 0) { + $user_id = (int) get_comment($comment_id)->user_id; + } + if ($user_id <= 0) { + return; + } + self::add($user_id, 'comment_new'); + } + + /** + * @param int $follower_id + * @param int $followed_id + */ + public function on_followed($follower_id, $followed_id): void { + $followed_id = (int) $followed_id; + if ($followed_id <= 0) { + return; + } + self::add($followed_id, 'followed'); + } + + /** + * @param int $post_id + * @param int $user_id liker + */ + public function on_post_like($post_id, $user_id): void { + $post = get_post((int) $post_id); + if (!$post instanceof WP_Post) { + return; + } + $author = (int) $post->post_author; + if ($author <= 0 || $author === (int) $user_id) { + return; + } + self::add($author, 'post_like'); + } + + /** + * @param int $comment_id + * @param int $user_id + */ + public function on_comment_like($comment_id, $user_id): void { + $c = get_comment((int) $comment_id); + if (!$c) { + return; + } + $author = (int) $c->user_id; + if ($author <= 0 || $author === (int) $user_id) { + return; + } + self::add($author, 'comment_like'); + } + + /** + * @param int $post_id + * @param int $user_id + */ + public function on_post_favorite($post_id, $user_id): void { + $post = get_post((int) $post_id); + if (!$post instanceof WP_Post) { + return; + } + $author = (int) $post->post_author; + if ($author <= 0 || $author === (int) $user_id) { + return; + } + self::add($author, 'post_favorite'); + } +} diff --git a/inc/classes/settings/class-aksrt-settings-user-runtime.php b/inc/classes/settings/class-aksrt-settings-user-runtime.php index 6e49c5dd..03d5e424 100644 --- a/inc/classes/settings/class-aksrt-settings-user-runtime.php +++ b/inc/classes/settings/class-aksrt-settings-user-runtime.php @@ -149,11 +149,12 @@ class AKSRT_Settings_User_Runtime { 'enabled' => !empty($settings['user_level_enabled']) || !empty($settings['user_level_s']), 'public_display' => !isset($settings['user_level_public_display']) || !empty($settings['user_level_public_display']), 'source' => (static function () use ($settings) { - $v = $settings['user_level_source'] ?? 'credits_balance'; - return in_array($v, ['credits_balance', 'manual_meta', 'integral'], true) ? $v : 'credits_balance'; + $v = $settings['user_level_source'] ?? 'integral'; + return in_array($v, ['credits_balance', 'manual_meta', 'integral'], true) ? $v : 'integral'; })(), 'label_prefix' => $level_prefix !== '' ? $level_prefix : 'Lv.', 'thresholds' => self::parse_level_thresholds((string) ($settings['user_level_thresholds'] ?? "1|0\n2|30\n3|100\n4|300")), + 'max' => max(3, min(30, (int) ($settings['user_level_max'] ?? 10))), ], 'medal' => $medal, // Alias for existing call sites that still read badge.*; dictionary is derived from medal catalog.