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.
402 lines
16 KiB
PHP
402 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Content;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
trait VipServicePackages {
|
||
|
||
|
||
|
||
/**
|
||
* Approximate days for catalog labels / order meta; activate prefers time+unit calendar math.
|
||
*/
|
||
public function duration_to_days(int $time, string $unit = 'day'): int {
|
||
if ($time <= 0) {
|
||
return 0;
|
||
}
|
||
if ($unit === 'month') {
|
||
return $time * 30;
|
||
}
|
||
return $time;
|
||
}
|
||
|
||
/**
|
||
* Build upgrade package items (legacy vip_upgrade_product parity).
|
||
*
|
||
* 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 = [];
|
||
$now = time();
|
||
|
||
// 月费会员升级月费会员:按剩余天数计价,会员有效期不变(仅提升等级)。
|
||
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',
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
// 月费会员升级永久会员:固定价格直接转为永久二级会员。
|
||
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);
|
||
$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),
|
||
'level' => 2,
|
||
'index' => 903,
|
||
'action' => 'upgrade',
|
||
'price' => $price,
|
||
'base_price' => $price,
|
||
'show_price' => $show,
|
||
'discount_tag' => (string) ($config['jump_tag'] ?? ''),
|
||
'tag_class' => (string) ($config['jump_tag_class'] ?? ''),
|
||
'days' => 0,
|
||
'title' => __('升级为永久会员', 'aksrt'),
|
||
'price_label' => $this->format_cash_label($price),
|
||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||
'days_label' => __('永久', 'aksrt'),
|
||
'cta_label' => __('立即升级', 'aksrt'),
|
||
'is_upgrade_product' => true,
|
||
'upgrade_mode' => 'jump',
|
||
];
|
||
}
|
||
}
|
||
|
||
// 永久会员升级永久会员。
|
||
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);
|
||
$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),
|
||
'level' => 2,
|
||
'index' => 901,
|
||
'action' => 'upgrade',
|
||
'price' => $price,
|
||
'base_price' => $price,
|
||
'show_price' => $show,
|
||
'discount_tag' => (string) ($config['permanent_tag'] ?? ''),
|
||
'tag_class' => (string) ($config['permanent_tag_class'] ?? ''),
|
||
'days' => 0,
|
||
'title' => __('永久会员升级', 'aksrt'),
|
||
'price_label' => $this->format_cash_label($price),
|
||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||
'days_label' => __('永久', 'aksrt'),
|
||
'cta_label' => __('立即升级', 'aksrt'),
|
||
'is_upgrade_product' => true,
|
||
'upgrade_mode' => 'permanent',
|
||
];
|
||
}
|
||
}
|
||
|
||
return $items;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 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
|
||
* @return array<int, array<string,mixed>>
|
||
*/
|
||
public function get_points_exchange_packages(array $vip, array $levels): array {
|
||
$points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : [];
|
||
$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 = [];
|
||
$i = 0;
|
||
foreach ($rows as $row) {
|
||
if (!is_array($row)) {
|
||
continue;
|
||
}
|
||
$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;
|
||
}
|
||
$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' => $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;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 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,tag_class:string}|null
|
||
*/
|
||
private function apply_action_price(
|
||
array $pkg,
|
||
string $action,
|
||
array $renew,
|
||
array $upgrade,
|
||
int $target_level,
|
||
int $user_level,
|
||
int $expires,
|
||
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;
|
||
}
|
||
|
||
if ($action === 'renew' && $is_permanent) {
|
||
return null;
|
||
}
|
||
|
||
if ($action !== 'renew') {
|
||
return [
|
||
'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 = (float) ($renew['discount'] ?? 8);
|
||
if ($discount <= 0) {
|
||
$discount = 8;
|
||
}
|
||
// 折值: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);
|
||
$tag = sprintf(
|
||
/* translators: %s: amount */
|
||
__('续费立减%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');
|
||
}
|
||
|
||
if ($price_minor < 1) {
|
||
$price_minor = 1;
|
||
}
|
||
if ($show_minor > 0 && $price_minor >= $show_minor) {
|
||
// 参考主题契约:原价仅在低于执行价时展示。
|
||
$show_minor = 0;
|
||
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,
|
||
];
|
||
}
|
||
|
||
|
||
|
||
public function encode_package_id(int $level, int $index): int {
|
||
return (max(1, min(2, $level)) * 1000) + max(1, $index);
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* @return array{level:int,index:int}|null
|
||
*/
|
||
public function decode_package_id(int $package_id): ?array {
|
||
if ($package_id < 1001) {
|
||
return null;
|
||
}
|
||
$level = intdiv($package_id, 1000);
|
||
$index = $package_id % 1000;
|
||
if ($level < 1 || $level > 2 || $index < 1) {
|
||
return null;
|
||
}
|
||
return ['level' => $level, 'index' => $index];
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* @return array<int, array{id:string,name:string}>
|
||
*/
|
||
private function available_payment_methods(): array {
|
||
$methods = function_exists('aksrt_list_cash_payment_methods')
|
||
? aksrt_list_cash_payment_methods(true)
|
||
: [];
|
||
|
||
// 卡密兑换会员渠道(后台 会员购买 → 启用卡密兑换会员功能):
|
||
// 提交走 /pay/card-pass/redeem type=vip,不创建支付订单。
|
||
$card_pass = $this->vip_card_pass_context();
|
||
if (!empty($card_pass['enabled'])) {
|
||
$methods[] = ['id' => 'card_pass', 'name' => __('卡密', 'aksrt')];
|
||
}
|
||
// 卡密渠道追加后需按「收款接口显示顺序」重新定位(含 card_pass 槽位)。
|
||
return function_exists('aksrt_sort_payment_methods')
|
||
? aksrt_sort_payment_methods($methods)
|
||
: $methods;
|
||
}
|
||
|
||
/**
|
||
* 卡密兑换会员渠道上下文(ChargeCard vip 公开选项)。
|
||
*
|
||
* @return array{enabled:bool,desc:string,only_password:bool}
|
||
*/
|
||
private function vip_card_pass_context(): array {
|
||
$empty = ['enabled' => false, 'desc' => '', 'only_password' => false];
|
||
if (!class_exists('\AKSRT\Payment\ChargeCard')) {
|
||
return $empty;
|
||
}
|
||
$opts = \AKSRT\Payment\ChargeCard::get_instance()->get_public_options();
|
||
return is_array($opts['vip'] ?? null) ? array_merge($empty, $opts['vip']) : $empty;
|
||
}
|
||
|
||
|
||
|
||
private function format_cash_label(string $amount): string {
|
||
$symbol = '¥';
|
||
if (method_exists($this, 'get_paid_display_context')) {
|
||
$paid = $this->get_paid_display_context();
|
||
$symbol = (string) ($paid['display']['currency_symbol'] ?? '¥');
|
||
} elseif (class_exists('\AKSRT\Settings\Settings')) {
|
||
$paid = \AKSRT\Settings\Settings::get_paid_content_settings();
|
||
$symbol = (string) ($paid['display']['currency_symbol'] ?? '¥');
|
||
}
|
||
$n = function_exists('aksrt_format_price') ? aksrt_format_price($amount) : (string) $amount;
|
||
return $symbol . $n;
|
||
}
|
||
|
||
private function parse_vip_money_minor($value): int {
|
||
if (!is_string($value)) {
|
||
return 0;
|
||
}
|
||
try {
|
||
return \AKSRT\Ledger\AssetAmount::balance_minor($value);
|
||
} catch (\InvalidArgumentException $e) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
private function format_expires_label(int $expires, int $level): string {
|
||
if ($level <= 0) {
|
||
return __('未开通', 'aksrt');
|
||
}
|
||
if ($expires <= 0) {
|
||
return __('永久有效', 'aksrt');
|
||
}
|
||
if ($expires < time()) {
|
||
return __('已过期', 'aksrt');
|
||
}
|
||
return sprintf(
|
||
/* translators: %s: date */
|
||
__('到期时间:%s', 'aksrt'),
|
||
wp_date('Y-m-d H:i', $expires)
|
||
);
|
||
}
|
||
|
||
}
|