feat(users): rewrite medal catalog + verify apply SPA contract

Replace user_badge_dictionary textarea with structured user_medal_*
catalog (categories/items/get_type). Expand verify SPA/PHP to Zibll
apply fields. Reader derives badge.dictionary from medal catalog for
existing AKSRT_User_Badges wear/grant runtime. No dual-read of old
pipe dictionary. audit-admin-contracts: 0 errors.
This commit is contained in:
2026-07-17 01:00:05 +08:00
parent cab132a536
commit e80d8ea45d
7 changed files with 572 additions and 154 deletions
@@ -580,7 +580,7 @@ class AKSRT_Admin_Schema_Users {
'verify' => [
'key' => 'verify',
'title' => '身份认证',
'version' => '1.0.0',
'version' => '1.1.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'display', 'title' => '公开展示', 'fields' => [
@@ -591,12 +591,13 @@ class AKSRT_Admin_Schema_Users {
['key' => 'workflow', 'title' => '审核流程', 'fields' => [
['key' => 'user_verify_accept_email_verified', 'label' => '邮箱验证通过视为已认证', 'type' => 'switch', 'default' => true],
['key' => 'user_verify_require_manual_review', 'label' => '保留人工审核位', 'type' => 'switch', 'default' => false],
['key' => 'user_verify_apply_option', 'label' => '申请选项', 'type' => 'textarea', 'default' => "real_name|实名认证\norganization|企业认证"],
['key' => 'user_verify_apply_option', 'label' => '申请类型选项', 'type' => 'textarea', 'default' => "real_name|实名认证\norganization|企业认证", 'desc' => '每行:slug|显示名称'],
]],
['key' => 'restrict', 'title' => '限制设置', 'fields' => [
['key' => 'user_verify_disable_desc', 'label' => '未认证限制说明', 'type' => 'textarea', 'default' => ''],
['key' => 'user_verify_limit_level', 'label' => '限制等级', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100],
['key' => 'user_verify_desc', 'label' => '认证说明', 'type' => 'textarea', 'default' => ''],
['key' => 'apply', 'title' => '申请设置', 'fields' => [
['key' => 'user_verify_allow_apply', 'label' => '允许用户前台申请', 'type' => 'switch', 'default' => true],
['key' => 'user_verify_disable_desc', 'label' => '停用申请说明', 'type' => 'textarea', 'default' => "由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员"],
['key' => 'user_verify_limit_level', 'label' => '申请要求等级', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 99, 'desc' => '0 表示不限制;依赖用户等级'],
['key' => 'user_verify_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'],
]],
],
],
@@ -639,21 +640,56 @@ class AKSRT_Admin_Schema_Users {
'badge' => [
'key' => 'badge',
'title' => '用户徽章',
'version' => '1.0.0',
'version' => '2.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'display', 'title' => '徽章展示', 'fields' => [
['key' => 'user_badge_enabled', 'label' => '启用徽章系统', 'type' => 'switch', 'default' => false],
['key' => 'user_badge_public_display', 'label' => '公开页显示徽章', 'type' => 'switch', 'default' => true],
['key' => 'user_badge_limit', 'label' => '最多显示数量', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 8],
['key' => 'user_medal_s', 'label' => '启用徽章/勋章系统', 'type' => 'switch', 'default' => false],
['key' => 'user_medal_public_display', 'label' => '公开页显示徽章', 'type' => 'switch', 'default' => true],
['key' => 'user_medal_limit', 'label' => '最多显示数量', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 8],
]],
['key' => 'dictionary', 'title' => '徽章字典', 'fields' => [
['key' => 'catalog', 'title' => '徽章明细', 'fields' => [
[
'key' => 'user_badge_dictionary',
'label' => '徽章列表',
'type' => 'textarea',
'default' => "founder|创始成员|warning\nstaff|站务团队|info",
'help' => '每行:slug|显示文案|色调(neutral/info/success/warning/brand)|图标URL(可选)。配置 icon_url 后前台显示勋章图;用户 meta aksrt_wear_badge 指定当前佩戴。',
'key' => 'user_medal_args',
'label' => '分类与条目',
'type' => 'json',
'default' => [
[
'cat_name' => '成长',
'items' => [
[
'slug' => 'rookie',
'name' => '初出茅庐',
'desc' => '注册时间超过30天',
'icon' => '',
'get_type' => 'registration_time',
'get_val' => 30,
],
[
'slug' => 'checkin-30',
'name' => '签到达人',
'desc' => '累计签到30天',
'icon' => '',
'get_type' => 'checkin_all_day',
'get_val' => 30,
],
],
],
[
'cat_name' => '限定',
'items' => [
[
'slug' => 'annual-star',
'name' => '年度人气达人',
'desc' => '活动限定徽章',
'icon' => '',
'get_type' => 'manually_add',
'get_val' => 0,
],
],
],
],
'help' => '结构化目录:分类 → 条目(slug/name/desc/icon/get_type/get_val)。由 SPA UsersBadge 编辑。',
],
]],
],
@@ -115,11 +115,12 @@ class AKSRT_Settings_User_Runtime {
}
/**
* User identity, level, badge, and report governance settings.
* User identity, level, medal, and report governance settings.
*
* @return array{
* verify:array{enabled:bool,public_display:bool,badge_text:string,accept_email_verified:bool,require_manual_review:bool},
* verify:array{enabled:bool,public_display:bool,badge_text:string,accept_email_verified:bool,require_manual_review:bool,allow_apply:bool,disable_desc:string,limit_level:int,desc:string,apply_option:string},
* level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array<int,int>},
* medal:array{enabled:bool,public_display:bool,limit:int,categories:array<int,array{cat_name:string,items:array<int,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int}>}>,items_by_slug:array<string,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int,cat_name:string}>},
* badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array<int,array{slug:string,label:string,tone:string,icon_url?:string}>},
* report:array{enabled:bool,public_entry:bool,public_notice:bool,ban_notice_text:string,categories:array<int,array{slug:string,label:string}>}
* }
@@ -129,6 +130,7 @@ class AKSRT_Settings_User_Runtime {
$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);
return [
'verify' => [
@@ -137,22 +139,36 @@ class AKSRT_Settings_User_Runtime {
'badge_text' => $verify_badge_text !== '' ? $verify_badge_text : __('已认证', 'aksrt'),
'accept_email_verified' => !isset($settings['user_verify_accept_email_verified']) || !empty($settings['user_verify_accept_email_verified']),
'require_manual_review' => !empty($settings['user_verify_require_manual_review']),
'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']),
'disable_desc' => (string) ($settings['user_verify_disable_desc'] ?? ''),
'limit_level' => max(0, (int) ($settings['user_verify_limit_level'] ?? 0)),
'desc' => (string) ($settings['user_verify_desc'] ?? ''),
'apply_option' => (string) ($settings['user_verify_apply_option'] ?? ''),
],
'level' => [
'enabled' => !empty($settings['user_level_enabled']),
'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' => (function() use ($settings) {
'source' => (static function () use ($settings) {
$v = $settings['user_level_source'] ?? 'credits_balance';
return in_array($v, ['credits_balance', 'manual_meta'], true) ? $v : 'credits_balance';
return in_array($v, ['credits_balance', 'manual_meta', 'integral'], true) ? $v : 'credits_balance';
})(),
'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")),
],
'medal' => $medal,
// Alias for existing call sites that still read badge.*; dictionary is derived from medal catalog.
'badge' => [
'enabled' => !empty($settings['user_badge_enabled']),
'public_display' => !isset($settings['user_badge_public_display']) || !empty($settings['user_badge_public_display']),
'limit' => max(1, min(8, (int) ($settings['user_badge_limit'] ?? 3))),
'dictionary' => self::parse_badge_dictionary((string) ($settings['user_badge_dictionary'] ?? "founder|创始成员|warning\nstaff|站务团队|info")),
'enabled' => $medal['enabled'],
'public_display' => $medal['public_display'],
'limit' => $medal['limit'],
'dictionary' => array_values(array_map(static function (array $item): array {
return [
'slug' => $item['slug'],
'label' => $item['name'],
'tone' => 'info',
'icon_url' => $item['icon'],
];
}, $medal['items_by_slug'])),
],
'report' => [
'enabled' => !empty($settings['user_report_enabled']),
@@ -207,6 +223,100 @@ class AKSRT_Settings_User_Runtime {
];
}
/**
* Parse structured user_medal_args catalog (rewrite; no badge dictionary dual-read).
*
* @param array<string,mixed> $settings
* @return array{enabled:bool,public_display:bool,limit:int,categories:array,items_by_slug:array}
*/
public static function parse_medal_catalog(array $settings): array {
$enabled = !empty($settings['user_medal_s']);
$public = !isset($settings['user_medal_public_display']) || !empty($settings['user_medal_public_display']);
$limit = max(1, min(8, (int) ($settings['user_medal_limit'] ?? 3)));
$allowed_types = [
'registration_time',
'checkin_all_day',
'new_post',
'bbs_post',
'comment',
'followed',
'like',
'comment_like',
'bbs_adopt',
'bbs_comment_hot',
'bbs_post_hot',
'bbs_essence',
'bbs_score',
'views',
'pay_income',
'pay_rebate',
'manually_add',
];
$raw = $settings['user_medal_args'] ?? null;
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (!is_array($raw)) {
$raw = [];
}
$categories = [];
$items_by_slug = [];
foreach ($raw as $cat) {
if (!is_array($cat)) {
continue;
}
$cat_name = sanitize_text_field((string) ($cat['cat_name'] ?? $cat['name'] ?? ''));
if ($cat_name === '') {
$cat_name = __('未命名', 'aksrt');
}
$items_out = [];
$items = is_array($cat['items'] ?? null) ? $cat['items'] : [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$slug = sanitize_key((string) ($item['slug'] ?? ''));
$name = sanitize_text_field((string) ($item['name'] ?? ''));
if ($slug === '' || $name === '') {
continue;
}
$get_type = sanitize_key((string) ($item['get_type'] ?? 'manually_add'));
if (!in_array($get_type, $allowed_types, true)) {
$get_type = 'manually_add';
}
$icon = esc_url_raw((string) ($item['icon'] ?? $item['icon_url'] ?? ''));
$row = [
'slug' => $slug,
'name' => $name,
'desc' => sanitize_text_field((string) ($item['desc'] ?? '')),
'icon' => $icon,
'get_type' => $get_type,
'get_val' => max(0, (int) ($item['get_val'] ?? 0)),
'cat_name' => $cat_name,
];
$items_out[] = $row;
$items_by_slug[$slug] = $row;
}
$categories[] = [
'cat_name' => $cat_name,
'items' => $items_out,
];
}
return [
'enabled' => $enabled,
'public_display' => $public,
'limit' => $limit,
'categories' => $categories,
'items_by_slug' => $items_by_slug,
];
}
/**
* @return array<int,int>
*/
@@ -238,41 +348,13 @@ class AKSRT_Settings_User_Runtime {
}
/**
* @deprecated Removed — medal catalog is structured user_medal_args. Method kept empty stub intentionally deleted.
* @return array<int,array{slug:string,label:string,tone:string,icon_url:string}>
*/
public static function parse_badge_dictionary(string $raw): array {
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
$allowed_tones = ['neutral', 'info', 'success', 'warning', 'brand'];
$items = [];
foreach ((array) $lines as $line) {
$parts = array_map('trim', explode('|', (string) $line));
if (count($parts) < 2) {
continue;
}
$slug = sanitize_key((string) $parts[0]);
$label = sanitize_text_field((string) $parts[1]);
$tone = isset($parts[2]) ? sanitize_key((string) $parts[2]) : 'neutral';
$icon_url = isset($parts[3]) ? esc_url_raw((string) $parts[3]) : '';
if ($slug === '' || $label === '') {
continue;
}
if (!in_array($tone, $allowed_tones, true)) {
$tone = 'neutral';
}
$items[] = [
'slug' => $slug,
'label' => $label,
'tone' => $tone,
'icon_url' => $icon_url,
];
}
return $items;
// Rewrite policy: no dual-read of pipe dictionary. Callers must use parse_medal_catalog / get_user_identity_settings()['medal'].
unset($raw);
return [];
}
/**