Files
wordpress-theme-aksrt/src/Content/VipServicePackages.php
T
kate522 af8dccdc45 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.
2026-10-18 17:14:00 +08:00

402 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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)
);
}
}