feat(user): complete growth level chain

This commit is contained in:
2026-07-30 09:38:45 +08:00
parent b2e6045117
commit 20c19dfe31
39 changed files with 739 additions and 633 deletions
+3 -9
View File
@@ -515,7 +515,7 @@ class SchemaUsers {
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'center', 'title' => '用户中心', 'fields' => [
['key' => 'user_center_tabs', 'label' => '用户中心 Tab', 'type' => 'input', 'default' => 'profile,posts,comments,favorites,vip,credits,auth', 'help' => '逗号分隔。auth=官方认证;vip、订单和认证会按功能开关自动注入'],
['key' => 'user_center_tabs', 'label' => '用户中心 Tab', 'type' => 'input', 'default' => 'profile,level,posts,comments,favorites,vip,credits,auth', 'help' => '逗号分隔。level=我的等级;auth=官方认证;等级、VIP、订单和认证会按功能开关自动注入'],
['key' => 'user_cover_upload', 'label' => '封面上传', 'type' => 'switch', 'default' => true],
['key' => 'user_social_links', 'label' => '社交链接', 'type' => 'switch', 'default' => true],
['key' => 'user_desc_std', 'label' => '默认签名', 'type' => 'input', 'default' => '这家伙很懒…', 'desc' => '用户未设置个人简介时显示的默认文案'],
@@ -616,21 +616,15 @@ class SchemaUsers {
'groups' => [
['key' => 'display', 'title' => '等级显示', 'fields' => [
['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_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 3, 'max' => 30],
['key' => 'user_level_notify', 'label' => '升级通知', 'type' => 'switch', 'default' => true],
['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' => 'user_level_opt', 'label' => '等级参数', 'type' => 'json', 'sanitize_type' => 'array', 'default' => [], 'help' => 'name_N / icon_img_N / upgrade_integral_N'],
]],
['key' => 'integral', 'title' => '经验值得分', 'fields' => [
['key' => 'user_integral_opt', 'label' => '经验值配置', 'type' => 'json', 'default' => [], 'help' => 'day_max + 行为得分键'],
['key' => 'user_integral_opt', 'label' => '经验值配置', 'type' => 'json', 'sanitize_type' => 'array', 'default' => [], 'help' => 'day_max + 行为得分键'],
]],
],
],
+1
View File
@@ -442,6 +442,7 @@ class SchemaProvider {
'radio' => 'string',
'checkbox' => 'array',
'repeater' => 'array',
'json' => 'array',
// 自定义面板(验证码服务商等)按对象存储
'custom' => 'array',
'color' => 'string',
+8 -1
View File
@@ -745,7 +745,14 @@ trait BoardServicePart2 {
$threshold = (int) ($config['other']['hot_plate_threshold'] ?? 100);
$posts_count = (int) get_post_meta($plate_id, '_aksrt_posts_count', true);
$follow_count = (int) get_post_meta($plate_id, '_aksrt_follow_count', true);
return ($posts_count + $follow_count * 2) >= $threshold;
$is_hot = ($posts_count + $follow_count * 2) >= $threshold;
if ($is_hot && get_post_meta($plate_id, '_aksrt_level_hot_awarded', true) !== '1') {
$plate = get_post($plate_id);
$author_id = $plate instanceof \WP_Post ? (int) $plate->post_author : 0;
update_post_meta($plate_id, '_aksrt_level_hot_awarded', '1');
do_action('aksrt_forum_plate_hot', $plate_id, $author_id);
}
return $is_hot;
}
/**
+2 -2
View File
@@ -132,7 +132,7 @@ class Capabilities {
$level_threshold = (int) ($row['level'] ?? -1);
if ($level_threshold >= 0) {
$user_level = (int) get_user_meta($user_id, 'aksrt_user_level', true);
$user_level = class_exists('\AKSRT\User\Level') ? \AKSRT\User\Level::get_level($user_id) : 0;
if ($user_level >= $level_threshold) {
return true;
}
@@ -140,7 +140,7 @@ class Capabilities {
$vip_threshold = (int) ($row['vip'] ?? -1);
if ($vip_threshold >= 0) {
$user_vip = (int) get_user_meta($user_id, 'aksrt_vip_level', true);
$user_vip = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0;
if ($user_vip >= $vip_threshold) {
return true;
}
+3 -10
View File
@@ -83,14 +83,14 @@ class UserRuntime {
*/
public static function get_user_center_settings(): array {
$settings = Settings::get_all();
$raw_tabs = $settings['user_center_tabs'] ?? 'profile,posts,comments,credits,favorites,auth';
$raw_tabs = $settings['user_center_tabs'] ?? 'profile,level,posts,comments,credits,favorites,auth';
$tabs = is_array($raw_tabs)
? $raw_tabs
: preg_split('/[\s,]+/', (string) $raw_tabs, -1, PREG_SPLIT_NO_EMPTY);
// Built-in switchable panels. VIP is always auto-injected when enabled
// by AKSRT\User\Center — keep it allowed for explicit config too.
$allowed_tabs = ['profile', 'posts', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'settings'];
$allowed_tabs = ['profile', 'level', 'posts', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'settings'];
$normalized_tabs = ['profile'];
foreach ((array) $tabs as $tab) {
@@ -152,7 +152,6 @@ class UserRuntime {
public static function get_user_identity_settings(): array {
$settings = Settings::get_all();
$verify_badge_text = trim((string) ($settings['user_verify_badge_text'] ?? ''));
$level_prefix = trim((string) ($settings['user_level_prefix'] ?? ''));
$ban_notice_text = trim((string) ($settings['user_report_ban_notice_text'] ?? ''));
$medal = self::parse_medal_catalog($settings);
@@ -169,14 +168,8 @@ class UserRuntime {
'apply_option' => (string) ($settings['user_verify_apply_option'] ?? ''),
],
'level' => [
'enabled' => !empty($settings['user_level_enabled']) || !empty($settings['user_level_s']),
'enabled' => !isset($settings['user_level_s']) || !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'] ?? '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,
+1 -87
View File
@@ -77,92 +77,6 @@ class BadgeAutoAward {
if ($user_id <= 0 || !class_exists('\AKSRT\User\Badges') || !Badges::enabled()) {
return;
}
if (!class_exists('\AKSRT\Settings\Settings')) {
return;
}
$identity = \AKSRT\Settings\Settings::get_user_identity_settings();
$medal = is_array($identity['medal'] ?? null) ? $identity['medal'] : [];
$items = is_array($medal['items_by_slug'] ?? null) ? $medal['items_by_slug'] : [];
if (!$items) {
// Fall back to badge dictionary without thresholds.
return;
}
foreach ($items as $slug => $item) {
$get_type = sanitize_key((string) ($item['get_type'] ?? 'manually_add'));
if ($get_type === 'manually_add' || $get_type === '') {
continue;
}
$need = max(0, (int) ($item['get_val'] ?? 0));
if ($need <= 0) {
continue;
}
$val = $this->metric($user_id, $get_type);
if ($val >= $need) {
Badges::grant($user_id, (string) $slug, 0);
}
}
}
private function metric(int $user_id, string $type): int {
switch ($type) {
case 'registration_time':
$user = get_userdata($user_id);
if (!$user) {
return 0;
}
$reg = strtotime((string) $user->user_registered);
if ($reg <= 0) {
return 0;
}
return max(0, (int) floor((time() - $reg) / DAY_IN_SECONDS));
case 'checkin_all_day':
$v = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
if ($v <= 0) {
$v = (int) get_user_meta($user_id, 'checkin_all_day', true);
}
return $v;
case 'new_post':
return (int) count_user_posts($user_id, 'post', true);
case 'bbs_post':
return post_type_exists('aksrt_forum_topic')
? (int) count_user_posts($user_id, 'aksrt_forum_topic', true)
: 0;
case 'comment':
return (int) get_comments(['user_id' => $user_id, 'count' => true, 'status' => 'approve']);
case 'followed':
if (class_exists('\AKSRT\User\Follow')) {
return (int) Follow::get_instance()->get_follower_count($user_id);
}
return 0;
case 'like':
return (int) get_user_meta($user_id, 'aksrt_like_count', true)
?: (int) get_user_meta($user_id, 'like-posts', true);
case 'views':
return (int) get_user_meta($user_id, 'aksrt_user_views', true);
case 'pay_income':
return (int) get_user_meta($user_id, 'aksrt_pay_income', true);
case 'pay_rebate':
return (int) get_user_meta($user_id, 'aksrt_pay_rebate', true);
default:
/**
* Allow extensions to provide metric values.
*
* @param int $value
* @param int $user_id
* @param string $type
*/
return (int) apply_filters('aksrt_badge_metric_value', 0, $user_id, $type);
}
Badges::auto_evaluate($user_id);
}
}
+4 -4
View File
@@ -419,7 +419,6 @@ class Badges {
return new \WP_Error('aksrt_badge_not_owned', __('尚未获得该徽章', 'aksrt'), ['status' => 403]);
}
update_user_meta($user_id, self::META_WEAR, $slug);
update_user_meta($user_id, 'wear_medal', $slug); // zibll alias
return true;
}
@@ -439,10 +438,11 @@ class Badges {
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);
if (in_array($slug, $owned, true)) {
return true;
}
$owned[] = $slug;
self::set_owned($user_id, $owned);
/**
* Fires after a badge is granted.
*
+22 -1
View File
@@ -47,6 +47,12 @@ class Center {
'priority' => 10,
'type' => 'panel',
],
'level' => [
'label' => __('我的等级', 'aksrt'),
'icon' => 'chart-line',
'priority' => 15,
'type' => 'panel',
],
'posts' => [
'label' => __('我的文章', 'aksrt'),
'icon' => 'file-alt',
@@ -111,13 +117,25 @@ class Center {
$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 VIP/orders/credits when feature on.
// Follow configured order; always auto-include enabled product 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) {
@@ -163,6 +181,9 @@ class Center {
if (!isset($definitions[$key])) {
continue;
}
if ($key === 'level' && !$level_on) {
continue;
}
if ($key === 'credits' && !$credits_on && !$balance_on) {
continue;
}
+8 -20
View File
@@ -151,10 +151,12 @@ class Home {
$level = $this->get_user_level($user_id, $settings['level']);
if ($level > 0) {
$signals[] = [
'type' => 'level',
'label' => (string) $settings['level']['label_prefix'] . $level,
'icon' => 'layer-group',
'tone' => 'info',
'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',
];
}
}
@@ -280,22 +282,8 @@ class Home {
* @param array{source?:string,thresholds?:array<int,int>} $level_settings
*/
private function get_user_level(int $user_id, array $level_settings): int {
$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 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;
unset($level_settings);
return class_exists('AKSRT\\User\\Level') ? Level::get_level($user_id) : 0;
}
/**
+297 -215
View File
@@ -1,5 +1,7 @@
<?php
/**
* User growth levels and experience awards.
*
* @package AKSRT
*/
@@ -9,11 +11,12 @@ 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_TODAY_FOLLOW = 'aksrt_level_integral_followed';
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;
@@ -28,61 +31,49 @@ class Level {
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('aksrt_user_signed_in', [$this, 'on_checkin'], 20, 1);
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, 2);
add_action('aksrt_comment_liked', [$this, 'on_comment_like'], 20, 2);
add_action('aksrt_post_favorited', [$this, 'on_post_favorite'], 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_forum_topic_scored', [$this, 'on_forum_scored'], 20, 3);
add_action('aksrt_forum_topic_featured', [$this, 'on_forum_featured'], 20, 3);
add_action('aksrt_forum_topic_hot', [$this, 'on_forum_hot'], 20, 2);
add_action('aksrt_forum_plate_hot', [$this, 'on_forum_plate_hot'], 20, 2);
add_action('aksrt_forum_reply_adopted', [$this, 'on_forum_adopted'], 20, 3);
add_action('aksrt_forum_comment_hot', [$this, 'on_forum_comment_hot'], 20, 2);
add_action('aksrt_user_level_changed', [$this, 'on_level_changed'], 20, 3);
}
/**
* @return array{enabled:bool,public_display:bool,prefix:string,max:int,source:string,level_opt:array<string,mixed>,integral_opt:array<string,mixed>,notify:bool}
* @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() : [];
$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.'));
$max = max(3, min(30, (int) ($all['user_level_max'] ?? 10)));
return [
'enabled' => $enabled,
'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']),
'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']),
'level_opt' => self::normalize_array_option($all['user_level_opt'] ?? []),
'integral_opt' => self::normalize_array_option($all['user_integral_opt'] ?? []),
];
}
/**
* Catalog of integral earn keys (defaults).
*
* @return array<string,int>
*/
/** @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,
@@ -105,269 +96,360 @@ class Level {
];
}
/** @return array<string,string> */
public static function integral_labels(): array {
return [
'sign_up' => __('首次注册', 'aksrt'),
'sign_in' => __('每日登录', 'aksrt'),
'followed' => __('被关注', 'aksrt'),
'post_new' => __('发布文章', 'aksrt'),
'post_like' => __('文章获赞', 'aksrt'),
'post_favorite' => __('文章被收藏', 'aksrt'),
'comment_new' => __('发表评论', 'aksrt'),
'comment_like' => __('评论获赞', 'aksrt'),
'bbs_posts_new' => __('发布帖子', 'aksrt'),
'bbs_score_extra' => __('帖子被加分', 'aksrt'),
'bbs_essence' => __('帖子评为精华', 'aksrt'),
'bbs_posts_hot' => __('帖子成为热门', 'aksrt'),
'bbs_plate_new' => __('创建版块', 'aksrt'),
'bbs_plate_hot' => __('版块成为热门', 'aksrt'),
'bbs_adopt' => __('回答被采纳', 'aksrt'),
'bbs_comment_hot' => __('评论成为神评', 'aksrt'),
];
}
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));
$opt = self::settings()['integral_opt'];
$defaults = self::integral_defaults();
return max(0, (int) ($opt[$key] ?? $defaults[$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;
$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 {
if ($user_id <= 0) {
return 0;
}
return max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true));
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 {
if ($user_id <= 0) {
if ($user_id <= 0 || !self::settings()['enabled']) {
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);
return self::resolve_level(self::get_integral($user_id));
}
/**
* @param array{max:int,level_opt:array} $cfg
*/
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 = (int) $cfg['max'];
$opt = $cfg['level_opt'];
$max = max(1, (int) ($cfg['max'] ?? 10));
$opt = is_array($cfg['level_opt'] ?? null) ? $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 {
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 max(0, min($max, $level));
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::resolve_level($integral, $cfg);
$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 : [];
}
/**
* Grant integral for a task key. Respects day_max.
* Award experience and append its immutable display ledger.
*
* @return int|false points granted or false
* @return int|false Granted amount or false.
*/
public static function add(int $user_id, string $key, int $points = 0) {
if ($user_id <= 0) {
return false;
}
public static function add(int $user_id, string $key, int $points = 0, bool $bypass_day_max = false) {
$cfg = self::settings();
if (!$cfg['enabled'] || $cfg['source'] !== 'integral') {
return false;
}
$key = sanitize_key($key);
if ($key === '' || $key === 'day_max') {
if ($user_id <= 0 || !$cfg['enabled'] || $key === '' || $key === 'day_max') {
return false;
}
if ($points <= 0) {
$points = self::task_points($key);
}
$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();
$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 (!$bypass_day_max && $day_max > 0) {
if ($today_total >= $day_max) {
return false;
}
$points = min($points, $day_max - $today_total);
}
if ($points <= 0) {
return false;
}
$before = self::get_integral($user_id);
$after = $before + $points;
$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);
$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));
$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);
$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 = (int) get_user_meta($user_id, self::META_LEVEL, true);
$old_level = max(1, (int) get_user_meta($user_id, self::META_LEVEL, true));
$new_level = self::resolve_level($after, $cfg);
update_user_meta($user_id, self::META_LEVEL, $new_level);
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;
}
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;
}
$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');
self::add($user_id, 'sign_up', 0, true);
}
/**
* @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) {
$today = current_time('Ymd');
if ($user_id <= 0 || get_user_meta($user_id, self::META_LOGIN_DAY, true) === $today) {
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);
if (self::add($user_id, 'sign_in')) {
update_user_meta($user_id, self::META_LOGIN_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));
public function on_checkin(int $user_id): void {
$this->on_login('', get_userdata($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') {
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;
}
// Only when transitioning to publish first time.
if ((string) get_post_meta($post_id, '_aksrt_integral_post_new', true) === '1') {
return;
$key = '';
if ($post->post_type === 'post') {
$key = 'post_new';
} elseif ($post->post_type === 'aksrt_forum_topic') {
$key = 'bbs_posts_new';
} elseif ($post->post_type === 'aksrt_forum_plate') {
$key = 'bbs_plate_new';
}
$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');
if ($key !== '') {
self::award_once((int) $post->post_author, $key, 'post', (int) $post->ID, '_aksrt_level_' . $key);
}
}
/**
* @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) {
if ((string) $approved !== '1') {
return;
}
$user_id = (int) ($commentdata['user_id'] ?? 0);
if ($user_id <= 0) {
$user_id = (int) get_comment($comment_id)->user_id;
$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);
}
if ($user_id <= 0) {
}
private function award_comment_new($comment): void {
if (!$comment instanceof \WP_Comment || (int) $comment->user_id <= 0) {
return;
}
self::add($user_id, 'comment_new');
$post = get_post((int) $comment->comment_post_ID);
$key = $post instanceof \WP_Post && $post->post_type === 'aksrt_forum_topic' ? 'bbs_posts_new' : 'comment_new';
if ($key === 'comment_new') {
self::award_once((int) $comment->user_id, $key, 'comment', (int) $comment->comment_ID, '_aksrt_level_comment_new');
}
}
/**
* @param int $follower_id
* @param int $followed_id
*/
public function on_followed($follower_id, $followed_id): void {
$follower_id = (int) $follower_id;
$followed_id = (int) $followed_id;
if ($followed_id <= 0) {
$awarded = get_user_meta($followed_id, self::META_FOLLOWED, true);
$awarded = is_array($awarded) ? array_map('intval', $awarded) : [];
if ($follower_id <= 0 || $followed_id <= 0 || in_array($follower_id, $awarded, true)) {
return;
}
self::add($followed_id, 'followed');
if (self::add($followed_id, 'followed')) {
$awarded[] = $follower_id;
update_user_meta($followed_id, self::META_FOLLOWED, array_values(array_unique($awarded)));
}
}
/**
* @param int $post_id
* @param int $user_id liker
*/
public function on_post_like($post_id, $user_id): void {
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) {
return;
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);
}
$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) {
public function on_post_favorite($post_id, $user_id, $active = true): void {
if (!$active) {
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) {
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;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === (int) $user_id) {
$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_forum_scored($topic_id, $author_id, $amount): void {
if ((float) $amount > 0) {
self::award_once((int) $author_id, 'bbs_score_extra', 'post', (int) $topic_id, '_aksrt_level_bbs_score_extra', 5);
}
}
public function on_forum_featured($topic_id, $author_id): void {
self::award_once((int) $author_id, 'bbs_essence', 'post', (int) $topic_id, '_aksrt_level_bbs_essence');
}
public function on_forum_hot($topic_id, $author_id): void {
self::award_once((int) $author_id, 'bbs_posts_hot', 'post', (int) $topic_id, '_aksrt_level_bbs_posts_hot');
}
public function on_forum_plate_hot($plate_id, $author_id): void {
self::award_once((int) $author_id, 'bbs_plate_hot', 'post', (int) $plate_id, '_aksrt_level_bbs_plate_hot');
}
public function on_forum_adopted($topic_id, $comment_id, $author_id): void {
self::award_once((int) $author_id, 'bbs_adopt', 'comment', (int) $comment_id, '_aksrt_level_bbs_adopt');
}
public function on_forum_comment_hot($comment_id, $author_id): void {
self::award_once((int) $author_id, 'bbs_comment_hot', 'comment', (int) $comment_id, '_aksrt_level_bbs_comment_hot');
}
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;
}
self::add($author, 'post_favorite');
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('/'),
]
);
}
}
}
+136
View File
@@ -0,0 +1,136 @@
<?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_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']);
}
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_level_integral"><?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="regular-text" id="aksrt_level_integral" name="aksrt_level_integral" min="0" max="9999999999999999" step="1" value="<?php echo esc_attr((string) $progress['integral']); ?>">
<p class="description"><?php esc_html_e('保存后按当前等级阈值重新计算等级。直接替换当前经验,不追加经验流水。', 'aksrt'); ?></p>
</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));
update_user_meta($user_id, Level::META_INTEGRAL, $integral);
update_user_meta($user_id, Level::META_LEVEL, Level::resolve_level($integral));
}
}
-109
View File
@@ -47,19 +47,6 @@ class Metrics {
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);
// Free-points / social hooks also feed level XP.
add_action('aksrt_post_liked', [$this, 'level_on_post_liked'], 25, 3);
add_action('aksrt_comment_liked', [$this, 'level_on_comment_liked'], 25, 3);
add_action('aksrt_post_favorited', [$this, 'level_on_post_favorited'], 25, 3);
add_action('aksrt_user_followed', [$this, 'level_on_user_followed'], 25, 2);
add_action('aksrt_forum_topic_featured', [$this, 'level_on_forum_topic_featured'], 25, 2);
add_action('aksrt_forum_reply_adopted', [$this, 'level_on_forum_reply_adopted'], 25, 3);
add_action('aksrt_forum_comment_hot', [$this, 'level_on_forum_comment_hot'], 25, 2);
add_action('aksrt_forum_topic_hot', [$this, 'level_on_forum_topic_hot'], 25, 2);
add_action('aksrt_forum_topic_scored', [$this, 'level_on_forum_topic_scored'], 25, 3);
add_action('aksrt_forum_topic_created', [$this, 'level_on_forum_topic_created'], 25, 2);
add_action('aksrt_user_signed_in', [$this, 'level_on_user_signed_in'], 25, 1);
}
public static function bump_user_meta(int $user_id, string $meta_key, float $delta = 1.0): float {
@@ -259,100 +246,4 @@ class Metrics {
self::bump_user_meta($user_id, self::META_PAY_REBATE, $amount);
self::reevaluate_medals($user_id);
}
/* ---------- level XP bridges ---------- */
private static function level_add(int $user_id, string $key): void {
if ($user_id > 0 && class_exists('AKSRT\\User\\Level')) {
Level::add($user_id, $key);
}
}
public function level_on_post_liked(int $post_id, int $actor_id, bool $active): void {
if (!$active) {
return;
}
$post = get_post($post_id);
if (!$post instanceof \WP_Post) {
return;
}
$author = (int) $post->post_author;
if ($author > 0 && $author !== $actor_id) {
self::level_add($author, 'post_like');
}
}
public function level_on_comment_liked(int $comment_id, int $actor_id, bool $active): void {
if (!$active) {
return;
}
$c = get_comment($comment_id);
if (!$c) {
return;
}
$author = (int) $c->user_id;
if ($author > 0 && $author !== $actor_id) {
self::level_add($author, 'comment_like');
}
}
public function level_on_post_favorited(int $post_id, int $actor_id, bool $active): void {
if (!$active) {
return;
}
$post = get_post($post_id);
if (!$post instanceof \WP_Post) {
return;
}
$author = (int) $post->post_author;
if ($author > 0 && $author !== $actor_id) {
self::level_add($author, 'post_favorite');
}
}
public function level_on_user_followed(int $follower_id, int $following_id): void {
unset($follower_id);
self::level_add((int) $following_id, 'followed');
}
public function level_on_forum_topic_featured($topic_id, $author_id = 0): void {
unset($topic_id);
self::level_add((int) $author_id, 'bbs_essence');
}
public function level_on_forum_reply_adopted($topic_id, $comment_id, $adoptee_id): void {
unset($topic_id, $comment_id);
self::level_add((int) $adoptee_id, 'bbs_adopt');
}
public function level_on_forum_comment_hot($comment_id, $author_id = 0): void {
unset($comment_id);
self::level_add((int) $author_id, 'bbs_comment_hot');
}
public function level_on_forum_topic_hot($topic_id, $author_id = 0): void {
unset($topic_id);
self::level_add((int) $author_id, 'bbs_posts_hot');
}
public function level_on_forum_topic_scored($topic_id, $author_id, $delta = 0): void {
unset($topic_id);
if ((float) $delta > 0) {
self::level_add((int) $author_id, 'bbs_score_extra');
}
}
/**
* @param int $user_id
* @param int $topic_id
*/
public function level_on_forum_topic_created($user_id, $topic_id = 0): void {
unset($topic_id);
self::level_add((int) $user_id, 'bbs_posts_new');
}
public function level_on_user_signed_in(int $user_id): void {
// Sign-in XP: use sign_in key once/day via existing login guard path as well.
self::level_add($user_id, 'sign_in');
}
}