From d2447f8aa67f037bd8a2b2074953f04c149ba605 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 18 Sep 2026 19:01:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(pay):=20=E4=BF=AE=E5=A4=8D=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=94=B6=E6=AC=BE=E6=8E=A5=E5=8F=A3=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E4=B8=8D=E5=8F=AF=E7=94=A8=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=94=B6=E9=93=B6=E5=8F=B0=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台「收款接口 → 收款接口显示顺序」有三处缺陷叠加: 1) sortable 渲染组件读取 item.label,而 schema 写的是 title,导致 6 行 只剩上下箭头; 2) SchemaProvider 的 sortable 分支遍历 items 定义重建数组,而该字段的 键顺序本身就是配置值,用户排序被静默改回 schema 静态顺序; 3) 真正构建收银台支付方式列表的 aksrt_list_cash_payment_methods() 使用 硬编码顺序,Gateway::get_available()/apply_sdk_order() 那套排序逻辑 无任何调用方,配置对前台毫无影响。 改为:schema 项统一使用 label(JS 与 PHP 两处对齐);sortable 持久化按 提交键顺序重建,items 定义仅作白名单;新增唯一排序实现 aksrt_sort_payment_methods()(语义对齐参考主题 zibpay_get_payment_methods + pay_sdk_order 键顺序),并接入内容收银台、余额充值、购买积分与会员 购买四个入口。卡密输入盒改为按渠道存在与否判断,不再假定卡密固定在末位。 清理死代码:无调用方的 Gateway::get_available/apply_sdk_order/ get_status_summary、VipServicePackages 中不可达的支付方式列表回退分支、 零引用的 settings-defaults 模块及 schema/index.js 未使用的导出、支付 sortable 的 fields 死元数据、无拖拽实现却声明的 cursor:move。 未配置 pay_sdk_order 时顺序保持不变;未写数据迁移。 验证:php -l 8 个改动文件全部通过;新增 pay-sdk-order-contract 契约测试 (6 项);node --test tests/widget-system/*.test.cjs 301 项全部通过; 后台 SPA 已重新构建。 --- .gitignore | 1 + assets/admin/src/components/form/AksField.vue | 5 +- .../admin/src/constants/settings-defaults.js | 28 ----- assets/admin/src/schema/index.js | 39 ------- .../src/schema/sections/payment/gateway.js | 12 +- inc/helpers/aksrt-pay.php | 71 +++++++++++- .../user-center-renderers/credits-balance.php | 4 + .../user-center-renderers/credits-wallet.php | 4 +- src/Admin/Schema/SchemaPayment.php | 12 +- src/Admin/SchemaProvider.php | 21 +++- src/Content/VipServicePackages.php | 30 ++--- src/Credits/BalanceServiceCharge.php | 4 + src/Payment/Gateway.php | 70 ------------ .../pay-sdk-order-contract.test.cjs | 103 ++++++++++++++++++ 14 files changed, 223 insertions(+), 181 deletions(-) delete mode 100644 assets/admin/src/constants/settings-defaults.js create mode 100644 tests/widget-system/pay-sdk-order-contract.test.cjs diff --git a/.gitignore b/.gitignore index 41c9e9b2..b171dc60 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ !/tests/widget-system/card-pass-universal-redeem.test.cjs !/tests/widget-system/card-pass-modal-redeem.test.cjs !/tests/widget-system/balance-charge-modal.test.cjs +!/tests/widget-system/pay-sdk-order-contract.test.cjs !/tests/admin/ /tests/admin/* !/tests/admin/charge-cards-page-contract.test.cjs diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue index 44328092..03d805cc 100644 --- a/assets/admin/src/components/form/AksField.vue +++ b/assets/admin/src/components/form/AksField.vue @@ -498,8 +498,8 @@ ↓ -
- +
+
@@ -1365,7 +1365,6 @@ function confirmRepeaterModal() { justify-content: space-between; padding: 10px 12px; background: var(--aksrt-color-canvas); - cursor: move; } .aksrt-field__sortable-body { diff --git a/assets/admin/src/constants/settings-defaults.js b/assets/admin/src/constants/settings-defaults.js deleted file mode 100644 index 9c4b5b3c..00000000 --- a/assets/admin/src/constants/settings-defaults.js +++ /dev/null @@ -1,28 +0,0 @@ -import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js' - -function cloneDefaults(defaults = {}) { - return JSON.parse(JSON.stringify(defaults)) -} - -export function getSectionDefaults(domain, section) { - const domainKey = normalizeSchemaDomain(domain) - const sectionSchema = ALL_SCHEMAS[domainKey]?.[section] - return cloneDefaults(sectionSchema?.defaults || {}) -} - -export function getDomainDefaults(domain) { - const domainKey = normalizeSchemaDomain(domain) - const sections = Object.values(ALL_SCHEMAS[domainKey] || {}) - - return sections.reduce((defaults, schema) => { - return { - ...defaults, - ...cloneDefaults(schema?.defaults || {}), - } - }, {}) -} - -export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => { - defaults[domain] = getDomainDefaults(domain) - return defaults -}, {}) diff --git a/assets/admin/src/schema/index.js b/assets/admin/src/schema/index.js index 0ae8cc5d..d1121072 100644 --- a/assets/admin/src/schema/index.js +++ b/assets/admin/src/schema/index.js @@ -43,17 +43,6 @@ export function normalizeSchemaDomain(domain = '') { return value.replace(/-/g, '_') } -export const CANONICAL_SCHEMA_DOMAINS = [ - 'global', - 'page_display', - 'post_list', - 'permissions', - 'users', - 'payment', - 'extensions', - 'tools', -] - /** All schemas organized by domain */ export const ALL_SCHEMAS = Object.freeze({ global: globalSchemas, @@ -77,31 +66,3 @@ export function getSchema(domain, section) { if (!domainSchemas) return null return domainSchemas[section] || null } - -/** - * Get all schemas for a domain. - * @param {string} domain - * @returns {Object} - */ -export function getDomainSchemas(domain) { - return ALL_SCHEMAS[normalizeSchemaDomain(domain)] || {} -} - -export function getSchemaSectionKeys(domain) { - return Object.keys(getDomainSchemas(domain)) -} - -export function getSchemaDefaults(domain, section = '') { - const domainKey = normalizeSchemaDomain(domain) - - if (section) { - return { ...(getSchema(domainKey, section)?.defaults || {}) } - } - - return Object.values(getDomainSchemas(domainKey)).reduce((defaults, schema) => { - return { - ...defaults, - ...(schema?.defaults || {}), - } - }, {}) -} diff --git a/assets/admin/src/schema/sections/payment/gateway.js b/assets/admin/src/schema/sections/payment/gateway.js index 86fca069..c0dcb190 100644 --- a/assets/admin/src/schema/sections/payment/gateway.js +++ b/assets/admin/src/schema/sections/payment/gateway.js @@ -187,12 +187,12 @@ export const gatewaySchema = { type: 'sortable', default: {}, items: [ - { key: 'wechat', title: '微信', fields: [] }, - { key: 'alipay', title: '支付宝', fields: [] }, - { key: 'paypal', title: 'PayPal(贝宝)', fields: [] }, - { key: 'stripe', title: 'Stripe', fields: [] }, - { key: 'balance', title: '余额', fields: [] }, - { key: 'card_pass', title: '卡密', fields: [] }, + { key: 'wechat', label: '微信' }, + { key: 'alipay', label: '支付宝' }, + { key: 'paypal', label: 'PayPal(贝宝)' }, + { key: 'stripe', label: 'Stripe' }, + { key: 'balance', label: '余额' }, + { key: 'card_pass', label: '卡密' }, ], help: '收银台支付方式显示顺序;仅显示已开启的接口', }, diff --git a/inc/helpers/aksrt-pay.php b/inc/helpers/aksrt-pay.php index 223cfdf7..b8195d9e 100644 --- a/inc/helpers/aksrt-pay.php +++ b/inc/helpers/aksrt-pay.php @@ -136,7 +136,76 @@ if (!function_exists('aksrt_list_cash_payment_methods')) { // Do not inject a fake balance/alipay option when nothing is configured — // callers should surface “暂无可用支付方式” instead of a dead method. - return $methods; + return aksrt_sort_payment_methods($methods); + } +} + +/** + * 「收款接口显示顺序」槽位 → 网关 id 映射。 + * + * 后台 sortable 项的 key 与之一一对应;微信/支付宝槽位同时收纳易支付子通道。 + * + * @return array> + */ +if (!function_exists('aksrt_payment_order_slots')) { + function aksrt_payment_order_slots(): array { + return [ + 'wechat' => ['wechatpay', 'epay_wxpay'], + 'alipay' => ['alipay', 'epay_alipay'], + 'paypal' => ['paypal'], + 'stripe' => ['stripe'], + 'balance' => ['balance'], + 'card_pass' => ['card_pass'], + ]; + } +} + +/** + * 按后台「收款接口显示顺序」(pay_sdk_order)重排支付方式列表。 + * + * 语义对齐参考主题 zibpay_get_payment_methods():按配置槽位顺序把命中的方式 + * 依次提到前面,未纳入槽位的方式保持原有相对顺序并留在末尾;未配置顺序时 + * 原样返回(即保持注册顺序)。这是排序的唯一实现,所有收银台构建支付方式 + * 列表的位置都必须经由此函数。 + * + * @param array $methods + * @return array + */ +if (!function_exists('aksrt_sort_payment_methods')) { + function aksrt_sort_payment_methods(array $methods): array { + if (!$methods) { + return $methods; + } + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + $configured = is_array($settings) ? ($settings['pay_sdk_order'] ?? []) : []; + if (!is_array($configured) || $configured === []) { + return $methods; + } + + $slots = aksrt_payment_order_slots(); + $remaining = $methods; + $sorted = []; + foreach (array_keys($configured) as $slot) { + $slot = sanitize_key((string) $slot); + if ($slot === '') { + continue; + } + foreach ($slots[$slot] ?? [] as $gateway_id) { + foreach ($remaining as $index => $method) { + if ((string) ($method['id'] ?? '') !== $gateway_id) { + continue; + } + $sorted[] = $method; + unset($remaining[$index]); + break; + } + } + } + foreach ($remaining as $method) { + $sorted[] = $method; + } + + return $sorted; } } diff --git a/inc/helpers/user-center-renderers/credits-balance.php b/inc/helpers/user-center-renderers/credits-balance.php index 2feeb163..57c13cd4 100644 --- a/inc/helpers/user-center-renderers/credits-balance.php +++ b/inc/helpers/user-center-renderers/credits-balance.php @@ -73,6 +73,10 @@ $currency = (string) ($currency_name ?? __('积分', 'aksrt')); if (!empty($points_card_pass['enabled'])) { $points_methods[] = ['id' => 'card_pass', 'name' => __('卡密', 'aksrt')]; } + // 「收款接口显示顺序」:购买积分弹窗只含余额与卡密两个槽位。 + if (function_exists('aksrt_sort_payment_methods')) { + $points_methods = aksrt_sort_payment_methods($points_methods); + } $points_custom_limit_html = sprintf(__('最低购买%d积分,最高购买%d积分', 'aksrt'), $pb_min_points, $pb_daily_max_points); $points_gateway = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_gateway_settings() : []; $points_mark = (string) ($points_gateway['currency']['mark'] ?? '¥'); diff --git a/inc/helpers/user-center-renderers/credits-wallet.php b/inc/helpers/user-center-renderers/credits-wallet.php index a71c5ee0..a026f616 100644 --- a/inc/helpers/user-center-renderers/credits-wallet.php +++ b/inc/helpers/user-center-renderers/credits-wallet.php @@ -116,7 +116,9 @@ $charge_img_uri = get_template_directory_uri() . '/assets/img/'; $charge_default_product = $packages ? (string) $packages[0]['key'] : 'custom'; // 仅卡密可用时隐藏金额选择(参考主题:卡密决定充值金额)。 $charge_card_only = count($methods) === 1 && (string) ($methods[0]['id'] ?? '') === 'card_pass'; -$charge_card_in_methods = count($methods) > 0 && $methods[count($methods) - 1]['id'] === 'card_pass'; +// 卡密输入盒是 payment_method 条件块,与它在渠道行中的位置无关:卡密渠道可由 +// 「收款接口显示顺序」移到任意位置,故此处只判断渠道是否存在。 +$charge_card_in_methods = in_array('card_pass', array_column($methods, 'id'), true); $charge_mark_symbol = !in_array($mark, ['', '元'], true); $charge_mark_html = $charge_mark_symbol ? '' . esc_html($mark) . '' diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 25856197..a611d612 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -414,12 +414,12 @@ class SchemaPayment { ]], ['key' => 'sdk-order', 'title' => '收款接口显示顺序', 'fields' => [ ['key' => 'pay_sdk_order', 'label' => '显示顺序', 'type' => 'sortable', 'default' => [], 'items' => [ - ['key' => 'wechat', 'title' => '微信', 'fields' => []], - ['key' => 'alipay', 'title' => '支付宝', 'fields' => []], - ['key' => 'paypal', 'title' => 'PayPal(贝宝)', 'fields' => []], - ['key' => 'stripe', 'title' => 'Stripe', 'fields' => []], - ['key' => 'balance', 'title' => '余额', 'fields' => []], - ['key' => 'card_pass', 'title' => '卡密', 'fields' => []], + ['key' => 'wechat', 'label' => '微信'], + ['key' => 'alipay', 'label' => '支付宝'], + ['key' => 'paypal', 'label' => 'PayPal(贝宝)'], + ['key' => 'stripe', 'label' => 'Stripe'], + ['key' => 'balance', 'label' => '余额'], + ['key' => 'card_pass', 'label' => '卡密'], ], 'help' => '收银台支付方式显示顺序;仅显示已开启的接口'], ]], ['key' => 'alipay', 'title' => '支付宝官方', 'collapsible' => true, 'expandWhen' => 'pay_alipay_sdk_options', 'dependsOn' => ['key' => 'pay_alipay_sdk_options', 'value' => 'official_alipay'], 'fields' => [ diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 7bd633d7..67c3b32b 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -942,12 +942,25 @@ class SchemaProvider { if (!is_array($value)) { return []; } - $items = []; + $definitions = []; foreach ((array) ($field['items'] ?? []) as $item_schema) { - $item_key = sanitize_key((string) ($item_schema['key'] ?? '')); - if ($item_key !== '' && array_key_exists($item_key, $value) && is_array($value[$item_key])) { - $items[$item_key] = $this->sanitize_object_fields($value[$item_key], (array) ($item_schema['fields'] ?? [])); + if (!is_array($item_schema)) { + continue; } + $item_key = sanitize_key((string) ($item_schema['key'] ?? '')); + if ($item_key !== '') { + $definitions[$item_key] = $item_schema; + } + } + // 键顺序即配置值本身:必须按提交顺序重建。若改为遍历 items + // 定义,用户排序会被静默改写回 schema 静态顺序(等于排序失效)。 + $items = []; + foreach ($value as $item_key => $item_value) { + $item_key = sanitize_key((string) $item_key); + if ($item_key === '' || !isset($definitions[$item_key]) || !is_array($item_value)) { + continue; + } + $items[$item_key] = $this->sanitize_object_fields($item_value, (array) ($definitions[$item_key]['fields'] ?? [])); } return $items; } diff --git a/src/Content/VipServicePackages.php b/src/Content/VipServicePackages.php index 6ba4260e..c8d3a531 100644 --- a/src/Content/VipServicePackages.php +++ b/src/Content/VipServicePackages.php @@ -517,28 +517,9 @@ trait VipServicePackages { * @return array */ private function available_payment_methods(): array { - $methods = []; - if (function_exists('aksrt_list_cash_payment_methods')) { - $methods = aksrt_list_cash_payment_methods(true); - } else { - if (!class_exists('\AKSRT\Payment\Gateway')) { - return []; - } - $gw = \AKSRT\Payment\Gateway::get_instance(); - foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe', 'epay_alipay', 'epay_wxpay'] as $id) { - $g = $gw->get_gateway($id); - if (!$g) { - continue; - } - if (method_exists($g, 'is_configured') && !$g->is_configured()) { - continue; - } - $methods[] = [ - 'id' => $id, - 'name' => method_exists($g, 'get_name') ? (string) $g->get_name() : $id, - ]; - } - } + $methods = function_exists('aksrt_list_cash_payment_methods') + ? aksrt_list_cash_payment_methods(true) + : []; // 卡密兑换会员渠道(后台 会员购买 → 启用卡密兑换会员功能): // 提交走 /pay/card-pass/redeem type=vip,不创建支付订单。 @@ -546,7 +527,10 @@ trait VipServicePackages { if (!empty($card_pass['enabled'])) { $methods[] = ['id' => 'card_pass', 'name' => __('卡密', 'aksrt')]; } - return $methods; + // 卡密渠道追加后需按「收款接口显示顺序」重新定位(含 card_pass 槽位)。 + return function_exists('aksrt_sort_payment_methods') + ? aksrt_sort_payment_methods($methods) + : $methods; } /** diff --git a/src/Credits/BalanceServiceCharge.php b/src/Credits/BalanceServiceCharge.php index 55493af6..e0b379f4 100644 --- a/src/Credits/BalanceServiceCharge.php +++ b/src/Credits/BalanceServiceCharge.php @@ -161,6 +161,10 @@ trait BalanceServiceCharge { if ($card_pass_on) { $methods[] = ['id' => 'card_pass', 'name' => __('卡密', 'aksrt')]; } + // 卡密渠道追加后需按「收款接口显示顺序」重新定位(含 card_pass 槽位)。 + if (function_exists('aksrt_sort_payment_methods')) { + $methods = aksrt_sort_payment_methods($methods); + } return [ 'enabled' => !empty($cfg['enabled']), 'desc' => (string) ($cfg['desc'] ?? ''), diff --git a/src/Payment/Gateway.php b/src/Payment/Gateway.php index 6382f6bb..b88907c1 100644 --- a/src/Payment/Gateway.php +++ b/src/Payment/Gateway.php @@ -44,76 +44,6 @@ class Gateway { return $this->gateways; } - /** - * 获取可用网关列表(已配置的),按「收款接口显示顺序」配置排序。 - */ - public function get_available(): array { - $available = []; - foreach ($this->gateways as $id => $gateway) { - if ($gateway->is_configured()) { - $available[$id] = $gateway; - } - } - return $this->apply_sdk_order($available); - } - - /** - * 按 pay_sdk_order(收款接口显示顺序)重排可用网关。 - * 易支付子通道跟随对应渠道槽位(epay_wxpay 跟随 wechat、epay_alipay 跟随 alipay); - * 未配置顺序时保持注册顺序,未纳入槽位的扩展网关保持在末尾。 - * - * @param array $available - * @return array - */ - private function apply_sdk_order(array $available): array { - $settings = \AKSRT\Settings\Settings::get_all(); - $configured = (array) ($settings['pay_sdk_order'] ?? []); - $order = array_values(array_filter(array_map('sanitize_key', array_keys($configured)))); - if (!$order) { - return $available; - } - - $slot_map = [ - 'wechat' => ['wechatpay', 'epay_wxpay'], - 'alipay' => ['alipay', 'epay_alipay'], - 'paypal' => ['paypal'], - 'stripe' => ['stripe'], - 'balance' => ['balance'], - 'card_pass' => ['card_pass'], - ]; - - $sorted = []; - foreach ($order as $slot) { - foreach (($slot_map[$slot] ?? []) as $id) { - if (isset($available[$id]) && !isset($sorted[$id])) { - $sorted[$id] = $available[$id]; - } - } - } - foreach ($available as $id => $gateway) { - if (!isset($sorted[$id])) { - $sorted[$id] = $gateway; - } - } - return $sorted; - } - - /** - * 获取网关状态摘要(用于后台展示) - */ - public function get_status_summary(): array { - $summary = []; - foreach ($this->gateways as $id => $gateway) { - $summary[] = [ - 'id' => $id, - 'name' => $gateway->get_name(), - 'configured' => $gateway->is_configured(), - 'available' => $gateway->is_configured(), - ]; - } - return $summary; - } - /** * 绑定支付回调路由。必须在 functions.php 中初始化。 * diff --git a/tests/widget-system/pay-sdk-order-contract.test.cjs b/tests/widget-system/pay-sdk-order-contract.test.cjs new file mode 100644 index 00000000..d156bb55 --- /dev/null +++ b/tests/widget-system/pay-sdk-order-contract.test.cjs @@ -0,0 +1,103 @@ +/** + * 「收款接口显示顺序」契约(后台 sortable 控件 → 收银台支付方式顺序)。 + * + * 背景:后台 收款接口 → 收款接口显示顺序 曾整列空白、保存后顺序被静默改写, + * 且没有任何前台代码消费该配置。本契约锁定四件事: + * 1) sortable 项使用 AksField 实际读取的 `label` 键(写 `title` 会渲染成空白); + * 2) 持久化必须保留用户提交的键顺序(键顺序即配置值本身); + * 3) 排序只保留一份实现,并且真的作用到收银台; + * 4) 相关的重复实现 / 死模块不回潮。 + */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..', '..'); +const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8'); + +const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php'); +const schemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'gateway.js'); +const aksField = read('assets', 'admin', 'src', 'components', 'form', 'AksField.vue'); +const provider = read('src', 'Admin', 'SchemaProvider.php'); +const pay = read('inc', 'helpers', 'aksrt-pay.php'); +const gateway = read('src', 'Payment', 'Gateway.php'); +const wallet = read('inc', 'helpers', 'user-center-renderers', 'credits-wallet.php'); +const pointsRenderer = read('inc', 'helpers', 'user-center-renderers', 'credits-balance.php'); +const vipPackages = read('src', 'Content', 'VipServicePackages.php'); +const balanceCharge = read('src', 'Credits', 'BalanceServiceCharge.php'); + +test('sortable 项使用渲染组件读取的 label 键', () => { + // 渲染侧读取 item.label —— items 里写 title 会让每一行只剩上下箭头。 + assert.match(aksField, /\{\{\s*item\.label\s*\}\}/); + + assert.match(schemaJs, /items:\s*\[\s*\{ key: 'wechat', label: '微信' \}/); + assert.match(schemaJs, /\{ key: 'card_pass', label: '卡密' \}/); + assert.doesNotMatch(schemaJs, /title: '微信'/); + + assert.match(schemaPhp, /\['key' => 'wechat', 'label' => '微信'\]/); + assert.match(schemaPhp, /\['key' => 'card_pass', 'label' => '卡密'\]/); + assert.doesNotMatch(schemaPhp, /'key' => 'wechat', 'title' => '微信'/); +}); + +test('sortable 持久化保留提交键顺序', () => { + const start = provider.indexOf("if ($type === 'sortable') {"); + const end = provider.indexOf("if ($type === 'link') {", start); + assert.ok(start >= 0 && end > start, 'SchemaProvider 应存在 sortable 分支'); + + const block = provider.slice(start, end); + const resultLoopStart = block.indexOf('foreach ($value as $item_key'); + assert.ok(resultLoopStart > 0, '结果必须按提交顺序(遍历 $value)构建'); + + const definitionsLoop = block.slice(0, resultLoopStart); + const resultLoop = block.slice(resultLoopStart); + + // items 定义只用于白名单校验,不得据此产出结果键顺序。 + assert.doesNotMatch(definitionsLoop, /\$items\[/); + assert.match(definitionsLoop, /\$definitions\[\$item_key\] = \$item_schema;/, 'items 定义应收敛为白名单'); + // 键顺序即排序值:按提交顺序写入结果,且丢弃未定义的槽位键。 + assert.match(resultLoop, /!isset\(\$definitions\[\$item_key\]\)/); + assert.match(resultLoop, /\$items\[\$item_key\] = \$this->sanitize_object_fields/); +}); + +test('排序只有一份实现,无调用方的重复实现已移除', () => { + assert.match(pay, /if \(!function_exists\('aksrt_sort_payment_methods'\)\)/); + assert.match(pay, /function aksrt_sort_payment_methods\(array \$methods\): array/); + assert.match(pay, /\$configured = is_array\(\$settings\) \? \(\$settings\['pay_sdk_order'\] \?\? \[\]\) : \[\];/); + // 槽位映射与后台 sortable 项一一对应,易支付子通道随所属渠道槽位。 + assert.match(pay, /'wechat'\s+=> \['wechatpay', 'epay_wxpay'\]/); + assert.match(pay, /'alipay'\s+=> \['alipay', 'epay_alipay'\]/); + assert.match(pay, /'card_pass' => \['card_pass'\]/); + + // 旧的无调用方实现(第二套排序逻辑)已删除。 + assert.doesNotMatch(gateway, /function get_available\(/); + assert.doesNotMatch(gateway, /function apply_sdk_order\(/); + assert.doesNotMatch(gateway, /function get_status_summary\(/); +}); + +test('基础渠道列表与卡密渠道都经过同一排序实现', () => { + // 内容收银台 / 会员购买 / 余额充值共用基础列表。 + assert.match(pay, /return aksrt_sort_payment_methods\(\$methods\);/); + assert.match(balanceCharge, /aksrt_sort_payment_methods\(\$methods\)/); + assert.match(vipPackages, /aksrt_sort_payment_methods\(\$methods\)/); + // 购买积分弹窗自建余额 + 卡密列表,同样排序。 + assert.match(pointsRenderer, /aksrt_sort_payment_methods\(\$points_methods\)/); + // 卡密输入盒是 method 条件块,位置无关:卡密渠道可被移到任意槽位。 + assert.match(wallet, /in_array\('card_pass', array_column\(\$methods, 'id'\), true\)/); +}); + +test('会员弹窗不再保留重复的支付方式列表回退分支', () => { + assert.doesNotMatch(vipPackages, /class_exists\('\\\\AKSRT\\\\Payment\\\\Gateway'\)/); + // 卡密渠道注入契约保持(对齐参考主题 zibpay_get_payment_methods)。 + assert.match(vipPackages, /'id' => 'card_pass', 'name' => __\('卡密', 'aksrt'\)/); +}); + +test('未被引用的后台 schema 默认值死模块不回潮', () => { + assert.equal( + fs.existsSync(path.join(root, 'assets', 'admin', 'src', 'constants', 'settings-defaults.js')), + false, + 'settings-defaults.js 无任何引用方,应保持删除' + ); + const schemaIndex = read('assets', 'admin', 'src', 'schema', 'index.js'); + assert.doesNotMatch(schemaIndex, /getSchemaDefaults|getSchemaSectionKeys|getDomainSchemas|CANONICAL_SCHEMA_DOMAINS/); +});