feat(vip): restructure membership settings into structured groups

Replace the pipe-delimited VIP configuration with structured groups and
accordions, matching the reference theme (Zibll) 1:1.

Settings:
- 会员等级 keeps only the nav buttons and the two level switches, plus the
  "must enable level 1 first" guard. Level identity fields (display name,
  intro, benefits HTML, icon) and 会员商品 move into the 一级会员/二级会员
  tabs of 会员购买选项, so each level is configured in one place.
- Level tab titles bind to the configured display name via a new
  titleKey/titleFallback hook in AksFormRenderer.
- 会员商品 is a repeater with exactly the reference fields: 执行价/原价/
  促销标签/标签颜色/会员有效时间/时长单位. The tag colour uses the full
  b+jb palette and now reaches the product card instead of a hardcoded
  jb-yellow.
- 会员续费 gains the 自定义商品 type backed by per-level renew-product
  groups; the discount becomes a 0.1-9.9 slider with float sanitizing.
- 会员升级 becomes the reference three-block accordion (month-to-month
  day-rate, jump to permanent, permanent to permanent), each with its own
  price, strikethrough price, promo tag and tag colour.
- 积分兑换明细 is a repeater (points/level/duration/unit) where the
  duration accepts the literal Permanent.

Runtime:
- PaymentRuntime normalizes the structured groups into purchasable rows
  (money normalization, palette validation, zero-price and out-of-range
  rows dropped) and no longer emits any *_raw strings.
- The catalog builder carries tag_class through and routes renewals to the
  renew-product group under 自定义商品.
- The points-exchange order note carries time/unit so month durations use
  calendar math instead of a 30-day approximation.

Dead code removed: parse_level_products, parse_packages,
parse_upgrade_product_line and their pipe-splitting, plus nine zero-call
PaymentRuntime proxy methods in HomeCms. No dual-format or migration path
was added; SchemaProvider already falls back to defaults when a stored
group value is not an array, so existing pipe strings degrade safely.

Verified: php -l on every changed PHP file, 655 Node contract tests passing
(14 new ones in vip-settings-structured-contract.test.cjs exercising the
real schema build and runtime normalization via a PHP harness), and the
admin SPA builds.
This commit is contained in:
kate522 committed 2026-10-18 17:14:00 +08:00
1 parent dcb80cff6e
commit af8dccdc45
15 files changed
+1461 -489

No files matched your search

+120 -314
View File
@@ -11,82 +11,6 @@ trait VipServicePackages {
/**
* Parse per-level product lines (legacy vip_product).
*
* Preferred: price|show_price|time|unit|tag|title
* Legacy: price|days|title
*
* @return array<int, array{price:string,show_price:string,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
*/
public function parse_level_products(string $raw): array {
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$out = [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = array_map('trim', explode('|', $line));
$price_minor = $this->parse_vip_money_minor($parts[0] ?? '');
if ($price_minor <= 0) {
continue;
}
$n = count($parts);
// New format: price|show_price|time|unit|tag|title (6+)
// Or: price|show_price|time|unit|title (5)
if ($n >= 4 && (in_array(strtolower((string) ($parts[3] ?? '')), ['day', 'month', 'd', 'm'], true)
|| (isset($parts[3]) && $parts[3] !== '' && !is_numeric($parts[3])))) {
$show_minor = $this->parse_vip_money_minor($parts[1] ?? '0');
$time = isset($parts[2]) ? (int) $parts[2] : 0;
$unit_raw = strtolower((string) ($parts[3] ?? 'month'));
$unit = ($unit_raw === 'day' || $unit_raw === 'd') ? 'day' : 'month';
$tag = '';
$title = '';
if ($n >= 6) {
$tag = sanitize_text_field((string) ($parts[4] ?? ''));
$title = sanitize_text_field((string) ($parts[5] ?? ''));
} elseif ($n === 5) {
// price|show|time|unit|title
$title = sanitize_text_field((string) ($parts[4] ?? ''));
}
} else {
// Legacy: price|days|title
$show_minor = 0;
$days_legacy = isset($parts[1]) ? (int) $parts[1] : 0;
$time = $days_legacy;
$unit = 'day';
$tag = '';
$title = isset($parts[2]) ? sanitize_text_field($parts[2]) : '';
}
$time = max(0, $time);
$days = $this->duration_to_days($time, $unit);
if ($title === '') {
if ($time <= 0) {
$title = __('永久会员', 'aksrt');
} elseif ($unit === 'day') {
$title = sprintf(__('%d天会员', 'aksrt'), $time);
} else {
$title = sprintf(__('%d个月会员', 'aksrt'), $time);
}
}
$out[] = [
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor),
'time' => $time,
'unit' => $unit,
'tag' => $tag,
'tag_class' => '',
'title' => $title,
'days' => $days,
];
}
return $out;
}
/**
* Approximate days for catalog labels / order meta; activate prefers time+unit calendar math.
*/
@@ -100,118 +24,59 @@ trait VipServicePackages {
return $time;
}
/**
* @deprecated Use parse_level_products — kept as thin adapter for upgrade fixed lines.
*
* @return array<int, array{price:string,days:int,title:string}>
*/
public function parse_packages(string $raw): array {
$parsed = $this->parse_level_products($raw);
$out = [];
foreach ($parsed as $p) {
$out[] = [
'price' => (string) $p['price'],
'days' => (int) $p['days'],
'title' => (string) $p['title'],
];
}
return $out;
}
/**
* Build upgrade package items (legacy vip_upgrade_product parity).
*
* upgrade.product multi-line formats:
* - permanent VIP1: permanent|price|title[|show_price]
* - fixed (alias): price|days|title
* - timed unit: unit|daily_price|title
* - timed jump permanent: jump|price|title[|show_price]
*
* Rules:
* - permanent user: only permanent/fixed (days=0 preferred)
* - timed user: unit (keep expiry) + optional jump (to permanent)
*
* package index: 901 permanent/fixed, 902 unit, 903 jump
*
* @return array<int, array<string,mixed>>
*/
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, string $raw): array {
$raw = trim($raw);
if ($raw === '') {
return [];
}
* Three blocks (reference theme vip_upgrade_product accordion):
* - month_to_month_s: timed VIP1 → VIP2, priced by remaining days, expiry unchanged
* - jump_s: timed VIP1 → permanent VIP2 at a fixed price
* - permanent_*: permanent VIP1 → permanent VIP2 at a fixed price
*
* package index: 901 permanent, 902 unit, 903 jump
*
* @param array<string,mixed> $config
* @return array<int, array<string,mixed>>
*/
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, array $config): array {
$items = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$has_permanent = false;
$has_unit = false;
$has_jump = false;
$now = time();
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
// 月费会员升级月费会员:按剩余天数计价,会员有效期不变(仅提升等级)。
if (!$is_permanent && $expires > $now && !empty($config['month_to_month_s'])) {
$daily_minor = $this->parse_vip_money_minor((string) ($config['unit_price'] ?? ''));
if ($daily_minor > 0) {
$remaining = (int) ceil(max(1, ($expires - $now) / DAY_IN_SECONDS));
if ($daily_minor <= intdiv(PHP_INT_MAX, $remaining)) {
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($remaining * $daily_minor);
$items[] = [
'package_id' => $this->encode_package_id(2, 902),
'level' => 2,
'index' => 902,
'action' => 'upgrade',
'price' => $price,
'base_price' => $price,
'show_price' => '0.00',
'discount_tag' => (string) ($config['unit_tag'] ?? ''),
'tag_class' => (string) ($config['unit_tag_class'] ?? ''),
'days' => $remaining,
'title' => sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining),
'price_label' => $this->format_cash_label($price),
'show_price_label' => '',
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
'cta_label' => __('立即升级', 'aksrt'),
'is_upgrade_product' => true,
'upgrade_mode' => 'unit',
];
}
}
$parts = array_map('trim', explode('|', $line));
$mode = strtolower((string) ($parts[0] ?? ''));
}
// unit|daily|title — timed VIP1 only.
if ($mode === 'unit') {
if ($is_permanent || $expires <= time() || $has_unit) {
continue;
}
$daily_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($daily_minor <= 0) {
continue;
}
$remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS));
if ($daily_minor > intdiv(PHP_INT_MAX, $remaining)) {
continue;
}
$price_minor = $remaining * $daily_minor;
// 月费会员升级永久会员:固定价格直接转为永久二级会员。
if (!$is_permanent && !empty($config['jump_s'])) {
$price_minor = $this->parse_vip_money_minor((string) ($config['jump_price'] ?? ''));
if ($price_minor > 0) {
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining);
}
$items[] = [
'package_id' => $this->encode_package_id(2, 902),
'level' => 2,
'index' => 902,
'action' => 'upgrade',
'price' => $price,
'base_price' => $price,
'show_price' => '0.00',
'discount_tag' => __('剩余天数', 'aksrt'),
'days' => $remaining,
'title' => $title,
'price_label' => $this->format_cash_label($price),
'show_price_label' => '',
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
'cta_label' => __('立即升级', 'aksrt'),
'is_upgrade_product' => true,
'upgrade_mode' => 'unit',
];
$has_unit = true;
continue;
}
// jump|price|title[|show_price] — timed VIP1 only → permanent VIP2.
if ($mode === 'jump') {
if ($is_permanent || $has_jump) {
continue;
}
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($price_minor <= 0) {
continue;
}
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = __('升级为永久会员', 'aksrt');
}
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
$show_minor = $this->parse_vip_money_minor((string) ($config['jump_show_price'] ?? ''));
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
$items[] = [
'package_id' => $this->encode_package_id(2, 903),
@@ -221,9 +86,10 @@ trait VipServicePackages {
'price' => $price,
'base_price' => $price,
'show_price' => $show,
'discount_tag' => __('永久升级', 'aksrt'),
'discount_tag' => (string) ($config['jump_tag'] ?? ''),
'tag_class' => (string) ($config['jump_tag_class'] ?? ''),
'days' => 0,
'title' => $title,
'title' => __('升级为永久会员', 'aksrt'),
'price_label' => $this->format_cash_label($price),
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
'days_label' => __('永久', 'aksrt'),
@@ -231,25 +97,15 @@ trait VipServicePackages {
'is_upgrade_product' => true,
'upgrade_mode' => 'jump',
];
$has_jump = true;
continue;
}
}
// permanent|price|title[|show_price]
if ($mode === 'permanent') {
if ($has_permanent) {
continue;
}
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($price_minor <= 0) {
continue;
}
// 永久会员升级永久会员。
if ($is_permanent) {
$price_minor = $this->parse_vip_money_minor((string) ($config['permanent_price'] ?? ''));
if ($price_minor > 0) {
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = __('永久会员升级', 'aksrt');
}
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
$show_minor = $this->parse_vip_money_minor((string) ($config['permanent_show_price'] ?? ''));
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
$items[] = [
'package_id' => $this->encode_package_id(2, 901),
@@ -259,9 +115,10 @@ trait VipServicePackages {
'price' => $price,
'base_price' => $price,
'show_price' => $show,
'discount_tag' => __('升级专享', 'aksrt'),
'discount_tag' => (string) ($config['permanent_tag'] ?? ''),
'tag_class' => (string) ($config['permanent_tag_class'] ?? ''),
'days' => 0,
'title' => $title,
'title' => __('永久会员升级', 'aksrt'),
'price_label' => $this->format_cash_label($price),
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
'days_label' => __('永久', 'aksrt'),
@@ -269,49 +126,7 @@ trait VipServicePackages {
'is_upgrade_product' => true,
'upgrade_mode' => 'permanent',
];
$has_permanent = true;
continue;
}
// fixed: price|days|title
$parsed = $this->parse_packages($line);
if (empty($parsed[0]) || $has_permanent) {
continue;
}
// For permanent VIP1, only accept days=0 fixed lines as permanent upgrade.
if ($is_permanent && (int) $parsed[0]['days'] > 0) {
continue;
}
$pkg = $parsed[0];
$items[] = [
'package_id' => $this->encode_package_id(2, 901),
'level' => 2,
'index' => 901,
'action' => 'upgrade',
'price' => (string) $pkg['price'],
'base_price' => (string) $pkg['price'],
'show_price' => '0.00',
'discount_tag' => __('升级专享', 'aksrt'),
'days' => (int) $pkg['days'],
'title' => (string) $pkg['title'],
'price_label' => $this->format_cash_label((string) $pkg['price']),
'show_price_label' => '',
'days_label' => ((int) $pkg['days'] <= 0)
? __('永久', 'aksrt')
: sprintf(__('%d 天', 'aksrt'), (int) $pkg['days']),
'cta_label' => __('立即升级', 'aksrt'),
'is_upgrade_product' => true,
'upgrade_mode' => ((int) $pkg['days'] <= 0) ? 'permanent' : 'fixed',
];
$has_permanent = true;
}
// Permanent VIP1 should not see unit/jump.
if ($is_permanent) {
$items = array_values(array_filter($items, static function ($it) {
$m = (string) ($it['upgrade_mode'] ?? '');
return in_array($m, ['permanent', 'fixed'], true);
}));
}
return $items;
@@ -320,7 +135,10 @@ trait VipServicePackages {
/**
* Points exchange packages: level|points|days|title
* Points exchange packages from the structured 积分兑换明细 group.
*
* Each row is points / level / time / unit, where time is the literal
* string "Permanent" for a permanent grant (reference theme contract).
*
* @param array<string,mixed> $vip
* @param array<int,mixed> $levels
@@ -328,47 +146,49 @@ trait VipServicePackages {
*/
public function get_points_exchange_packages(array $vip, array $levels): array {
$points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : [];
$raw = (string) ($points_ex['packages_raw'] ?? '');
$rows = isset($points_ex['packages']) && is_array($points_ex['packages']) ? $points_ex['packages'] : [];
$currency = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
$out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 3) {
continue;
}
$lv = (int) ($parts[0] ?? 0);
$pts = (int) ($parts[1] ?? 0);
$days = (int) ($parts[2] ?? 0);
$title = sanitize_text_field((string) ($parts[3] ?? ''));
$lv = (int) ($row['level'] ?? 0);
$pts = max(0, (int) ($row['points'] ?? 0));
if ($lv < 1 || $lv > 2 || $pts <= 0) {
continue;
}
if (empty($levels[$lv]['enabled'])) {
continue;
}
$i++;
if ($title === '') {
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$title = $days > 0
? sprintf(__('%1$s · %2$d天', 'aksrt'), $lv_name, $days)
: sprintf(__('%s · 永久', 'aksrt'), $lv_name);
$is_permanent = !empty($row['is_permanent']);
$time = max(0, (int) ($row['time'] ?? 0));
$unit = ((string) ($row['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
$days = $is_permanent ? 0 : max(0, (int) ($row['days'] ?? 0));
if (!$is_permanent && $time <= 0) {
continue;
}
$i++;
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$out[] = [
'package_id' => 5000 + $i,
'level' => $lv,
'index' => $i,
'points' => $pts,
'days' => max(0, $days),
'title' => $title,
'price_label' => $pts . ' ' . $currency,
'days_label' => $days > 0 ? sprintf(__('%d 天', 'aksrt'), $days) : __('永久', 'aksrt'),
'cta_label' => __('立即兑换', 'aksrt'),
'package_id' => 5000 + $i,
'level' => $lv,
'index' => $i,
'points' => $pts,
'days' => $days,
'time' => $time,
'unit' => $unit,
'is_permanent' => $is_permanent,
'title' => $is_permanent
? sprintf(__('%s · 永久', 'aksrt'), $lv_name)
: sprintf(__('%1$s · %2$d%3$s', 'aksrt'), $lv_name, $time, $unit === 'month' ? __('个月', 'aksrt') : __('天', 'aksrt')),
'price_label' => $pts . ' ' . $currency,
'days_label' => $is_permanent
? __('永久', 'aksrt')
: ($unit === 'month' ? sprintf(__('%d 个月', 'aksrt'), $time) : sprintf(__('%d 天', 'aksrt'), $time)),
'duration_label' => $is_permanent ? __('永久', 'aksrt') : ($unit === 'month' ? sprintf(__('%d 个月', 'aksrt'), $time) : sprintf(__('%d 天', 'aksrt'), $time)),
'cta_label' => __('立即兑换', 'aksrt'),
];
}
return $out;
@@ -376,41 +196,18 @@ trait VipServicePackages {
/**
* Single upgrade product line: price|days|title
*
* @return array{price:float,days:int,title:string}|null
*/
public function parse_upgrade_product_line(string $raw): ?array {
$raw = trim($raw);
if ($raw === '') {
return null;
}
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '' || str_starts_with(strtolower($line), 'unit|')) {
continue;
}
$parsed = $this->parse_packages($line);
if (!empty($parsed[0])) {
return $parsed[0];
}
}
return null;
}
/**
* Apply renew discount/reduce to same-level packages.
*
* `customize` reuses the dedicated renew product's own price/show_price/tag,
* so the caller passes that row's whole item in as $pkg.
*
* @param array<string,mixed> $renew
* @param array<string,mixed> $upgrade
* @return array{price:string,show_price:string,tag:string}|null
* @return array{price:string,show_price:string,tag:string,tag_class:string}|null
*/
private function apply_action_price(
string $base_price,
array $pkg,
string $action,
array $renew,
array $upgrade,
@@ -420,6 +217,7 @@ trait VipServicePackages {
bool $is_permanent,
int $days
): ?array {
$base_price = (string) ($pkg['price'] ?? '');
$base_minor = $this->parse_vip_money_minor($base_price);
if ($base_minor <= 0) {
return null;
@@ -434,32 +232,31 @@ trait VipServicePackages {
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor),
'show_price' => '0.00',
'tag' => '',
'tag_class' => '',
];
}
$type = sanitize_key((string) ($renew['price_type'] ?? 'discount'));
// discount/reduce 的标签由优惠幅度计算得出,若实际没有优惠则属于虚假折扣。
$computed_tag = in_array($type, ['discount', 'reduce'], true);
$price_minor = $base_minor;
$show_minor = $base_minor;
$tag = '';
$tag_class = '';
if ($type === 'discount') {
$discount = (int) ($renew['discount'] ?? 8);
$discount = (float) ($renew['discount'] ?? 8);
if ($discount <= 0) {
$discount = 8;
}
if ($discount <= 10) {
$percent = $discount * 10;
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
$tag = sprintf(
/* translators: %s: discount like 8 */
__('%s折续费', 'aksrt'),
(string) $discount
);
} else {
$percent = min(100, $discount);
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
$tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount);
}
// 折值:8 = 八折(参考主题 slider 0.1–9.9 语义)。
$discount = max(0.1, min(9.9, $discount));
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $discount * 10);
$tag = sprintf(
/* translators: %s: discount like 8 */
__('%s折优惠', 'aksrt'),
rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.') ?: '0'
);
} elseif ($type === 'reduce') {
$reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20');
$price_minor = max(1, $base_minor - $reduce_minor);
@@ -468,6 +265,11 @@ trait VipServicePackages {
__('续费立减%s', 'aksrt'),
$this->format_cash_label(\AKSRT\Ledger\AssetAmount::decimal_from_minor($reduce_minor))
);
} elseif ($type === 'customize') {
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
$show_minor = $this->parse_vip_money_minor((string) ($pkg['show_price'] ?? ''));
$tag = (string) ($pkg['tag'] ?? '');
$tag_class = (string) ($pkg['tag_class'] ?? '');
} else {
$show_minor = 0;
$tag = __('续费同价', 'aksrt');
@@ -477,14 +279,18 @@ trait VipServicePackages {
$price_minor = 1;
}
if ($show_minor > 0 && $price_minor >= $show_minor) {
// 参考主题契约:原价仅在低于执行价时展示。
$show_minor = 0;
$tag = __('续费', 'aksrt');
if ($computed_tag) {
$tag = __('续费', 'aksrt');
}
}
return [
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0),
'tag' => $tag,
'tag_class' => $tag_class,
];
}