diff --git a/assets/admin/src/schema/sections/payment/withdrawal.js b/assets/admin/src/schema/sections/payment/withdrawal.js index b5891db4..13236ec2 100644 --- a/assets/admin/src/schema/sections/payment/withdrawal.js +++ b/assets/admin/src/schema/sections/payment/withdrawal.js @@ -1,6 +1,14 @@ /** * 提现策略 — 提现门槛、每日限制、收款渠道与提现协议。 */ + +/** 默认收款渠道:支付宝与微信,与参考主题一致。 */ +export function defaultPayoutChannels() { + return [ + { key: 'alipay', label: '支付宝收款码', account_hint: '姓名 + 支付宝账号', note: '', enabled: true }, + { key: 'wechat', label: '微信收款码', account_hint: '姓名 + 微信号', note: '', enabled: true }, + ] +} export const withdrawalSchema = { key: 'payment.withdrawal', title: '提现策略', @@ -12,7 +20,7 @@ export const withdrawalSchema = { affiliate_daily_withdrawal_count: 3, affiliate_daily_withdrawal_limit: '500.00', affiliate_withdrawal_reset_time: '00:00', - affiliate_payout_channels: ['alipay', 'wechat'], + affiliate_payout_channels: defaultPayoutChannels(), pay_rebate_withdraw_text_details: '
可提现金额达到50元后即可发起提现
申请提现需后台人工处理,一般2-3小时,请耐心等待
如有其它疑问,请与客服联系
', }, groups: [ @@ -40,7 +48,21 @@ export const withdrawalSchema = { title: '收款渠道', icon: 'fa fas fa-qrcode', fields: [ - { key: 'affiliate_payout_channels', label: '可用收款渠道', type: 'checkbox', default: ['alipay', 'wechat'], sanitize_type: 'array', options: [{ label: '支付宝收款码', value: 'alipay' }, { label: '微信收款码', value: 'wechat' }] }, + { + key: 'affiliate_payout_channels', + label: '收款渠道', + type: 'repeater', + addText: '添加收款渠道', + default: defaultPayoutChannels(), + description: '用户提现时可选择的收款方式,可拖拽排序、自定义名称。停用的渠道不会在前台展示,但历史提现记录仍按原名称显示。', + fields: [ + { key: 'key', label: '渠道标识', type: 'input', default: '', help: '英文小写,用于区分渠道(如 alipay、wechat、bank)。保存后请勿随意修改,历史提现记录依赖它。' }, + { key: 'label', label: '渠道名称', type: 'input', default: '', help: '前台下拉里显示的名称,可自定义。' }, + { key: 'account_hint', label: '收款信息提示', type: 'input', default: '', help: '用户填写收款账户时的占位提示,如「姓名 + 支付宝账号」。' }, + { key: 'note', label: '渠道说明', type: 'textarea', default: '', rows: 2, help: '选中该渠道时在收款信息下方展示的补充说明,可留空。' }, + { key: 'enabled', label: '启用', type: 'switch', default: true }, + ], + }, ], }, { diff --git a/assets/js/admin-withdrawals.js b/assets/js/admin-withdrawals.js index 9acaec69..a4d92cbf 100644 --- a/assets/js/admin-withdrawals.js +++ b/assets/js/admin-withdrawals.js @@ -47,7 +47,16 @@ }); form.querySelector('[data-field="status"]').value = mode; dialog.querySelectorAll('[data-summary]').forEach(function (dd) { - dd.textContent = button.dataset[dd.getAttribute('data-summary')] || '—'; + var raw = button.dataset[dd.getAttribute('data-summary')] || ''; + // 渠道说明是可选的,留空时整行隐藏,不留一条破折号。 + var row = dd.parentElement; + if (dd.getAttribute('data-summary') === 'method_note' && raw === '') { + if (row) row.hidden = true; + dd.textContent = ''; + return; + } + if (row) row.hidden = false; + dd.textContent = raw || '—'; }); if (note) note.value = ''; resetSubmits(); diff --git a/assets/js/credits.js b/assets/js/credits.js index 68890005..ff11bdfe 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -392,6 +392,26 @@ }); applyMethodVisibility((modal.querySelector('input[name="payment_method"]') || {}).value || ''); if (!form) return; + // 收款渠道由后台拖拽结构定义:切换时同步收款信息提示与渠道说明。 + var methodField = form.querySelector('[data-jio-withdraw-method]'); + var accountField = form.querySelector('[data-jio-withdraw-account]'); + var noteField = form.querySelector('[data-jio-withdraw-note]'); + function syncChannelHint() { + if (!methodField) return; + var option = methodField.options[methodField.selectedIndex]; + if (!option) return; + var hint = option.getAttribute('data-hint') || ''; + var note = option.getAttribute('data-note') || ''; + if (accountField) accountField.setAttribute('placeholder', hint || '姓名 + 账户 / 卡号'); + if (noteField) { + noteField.textContent = note; + noteField.hidden = note === ''; + } + } + if (methodField) { + methodField.addEventListener('change', syncChannelHint); + syncChannelHint(); + } resetOperationKeyOnChange(form); form.addEventListener('submit', function (e) { e.preventDefault(); diff --git a/inc/helpers/user-center-renderers/withdraw-modal.php b/inc/helpers/user-center-renderers/withdraw-modal.php index f4685a55..74470509 100644 --- a/inc/helpers/user-center-renderers/withdraw-modal.php +++ b/inc/helpers/user-center-renderers/withdraw-modal.php @@ -13,10 +13,11 @@ try { } $withdraw_usage = is_array($withdraw_usage ?? null) ? $withdraw_usage : ['remaining_count' => 0, 'remaining_amount' => '0.00']; $withdraw_fee_rate = (int) ($withdraw_settings['service_charge_rate'] ?? 5); -$withdraw_channels = array_values(array_intersect(['alipay', 'wechat'], (array) ($withdraw_settings['payout_channels'] ?? ['alipay', 'wechat']))); -if (!$withdraw_channels) { - $withdraw_channels = ['alipay', 'wechat']; -} +// 渠道由后台「提现策略 → 收款渠道」的拖拽结构定义,可自定义名称与提示。 +$withdraw_channels = array_values(array_filter( + (array) ($withdraw_settings['payout_channels'] ?? []), + static fn ($channel): bool => is_array($channel) && !empty($channel['enabled']) && (string) ($channel['key'] ?? '') !== '' +)); ?> diff --git a/src/Admin/Native/PayWithdrawals.php b/src/Admin/Native/PayWithdrawals.php index f51548ee..4a994946 100644 --- a/src/Admin/Native/PayWithdrawals.php +++ b/src/Admin/Native/PayWithdrawals.php @@ -176,12 +176,40 @@ class PayWithdrawals { ]; } - /** @return array */ + /** + * 收款渠道标识 → 名称。 + * + * 渠道由后台「提现策略 → 收款渠道」的拖拽结构定义,可自定义名称;这里读同一份 + * 配置,停用的渠道也会返回,历史提现记录才能按原名称展示。 + * + * @return array + */ private function method_labels(): array { - return [ - 'alipay' => __('支付宝', 'jio'), - 'wechat' => __('微信', 'jio'), - ]; + $labels = []; + $settings = class_exists('\Jio\Settings\Settings') ? \Jio\Settings\Settings::get_withdrawal_settings() : []; + foreach ((array) ($settings['payout_channels'] ?? []) as $channel) { + if (!is_array($channel) || (string) ($channel['key'] ?? '') === '') { + continue; + } + $labels[(string) $channel['key']] = (string) ($channel['label'] ?? $channel['key']); + } + return $labels; + } + + /** 渠道补充说明(后台「收款渠道 → 渠道说明」),审核弹窗里展示给操作人。 */ + private function method_notes(): array { + $notes = []; + $settings = class_exists('\Jio\Settings\Settings') ? \Jio\Settings\Settings::get_withdrawal_settings() : []; + foreach ((array) ($settings['payout_channels'] ?? []) as $channel) { + if (!is_array($channel) || (string) ($channel['key'] ?? '') === '') { + continue; + } + $note = trim((string) ($channel['note'] ?? '')); + if ($note !== '') { + $notes[(string) $channel['key']] = $note; + } + } + return $notes; } private function format_money(string $decimal): string { diff --git a/src/Admin/Native/PayWithdrawalsList.php b/src/Admin/Native/PayWithdrawalsList.php index 882f8a37..8f141525 100644 --- a/src/Admin/Native/PayWithdrawalsList.php +++ b/src/Admin/Native/PayWithdrawalsList.php @@ -176,6 +176,7 @@ trait PayWithdrawalsList { 'gross' => $this->format_money($gross), 'source' => $source_label, 'payee' => $method_label . ($account !== '' ? ' · ' . $account : ''), + 'method_note' => (string) ($this->method_notes()[$method] ?? ''), ]; $attrs = ''; foreach ($data as $key => $value) { @@ -328,6 +329,7 @@ trait PayWithdrawalsList { 'gross' => __('申请金额', 'jio'), 'source' => __('来源', 'jio'), 'payee' => __('收款信息', 'jio'), + 'method_note' => __('渠道说明', 'jio'), ] as $field => $label) { echo '
' . esc_html($label) . '
'; } diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php index 2957880f..952eafad 100644 --- a/src/Admin/Schema/SchemaPayment.php +++ b/src/Admin/Schema/SchemaPayment.php @@ -557,7 +557,13 @@ class SchemaPayment { ['key' => 'affiliate_withdrawal_reset_time', 'label' => '重置时间', 'type' => 'input', 'default' => '00:00', 'help' => '按站点 WordPress 时区重置,格式 HH:MM。'], ]], ['key' => 'channels', 'title' => '收款渠道', 'fields' => [ - ['key' => 'affiliate_payout_channels', 'label' => '可用收款渠道', 'type' => 'checkbox', 'options' => [['value' => 'alipay', 'label' => '支付宝收款码'], ['value' => 'wechat', 'label' => '微信收款码']], 'default' => ['alipay', 'wechat']], + ['key' => 'affiliate_payout_channels', 'label' => '收款渠道', 'type' => 'repeater', 'addText' => '添加收款渠道', 'default' => self::default_payout_channels(), 'description' => '用户提现时可选择的收款方式,可拖拽排序、自定义名称。停用的渠道不会在前台展示,但历史提现记录仍按原名称显示。', 'fields' => [ + ['key' => 'key', 'label' => '渠道标识', 'type' => 'input', 'default' => '', 'help' => '英文小写,用于区分渠道(如 alipay、wechat、bank)。保存后请勿随意修改,历史提现记录依赖它。'], + ['key' => 'label', 'label' => '渠道名称', 'type' => 'input', 'default' => '', 'help' => '前台下拉里显示的名称,可自定义。'], + ['key' => 'account_hint', 'label' => '收款信息提示', 'type' => 'input', 'default' => '', 'help' => '用户填写收款账户时的占位提示,如「姓名 + 支付宝账号」。'], + ['key' => 'note', 'label' => '渠道说明', 'type' => 'textarea', 'default' => '', 'rows' => 2, 'help' => '选中该渠道时在收款信息下方展示的补充说明,可留空。'], + ['key' => 'enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ]], ]], ['key' => 'agreement', 'title' => '提现协议', 'fields' => [ ['key' => 'pay_rebate_withdraw_text_details', 'label' => '提现协议', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '
可提现金额达到50元后即可发起提现
申请提现需后台人工处理,一般2-3小时,请耐心等待
如有其它疑问,请与客服联系
', 'rows' => 4, 'help' => '用户申请提现时展示的内容,建议为提现须知等,支持HTML代码'], @@ -568,6 +574,18 @@ class SchemaPayment { ]; } + /** + * 提现收款渠道的默认结构:与参考主题一致的两个内置渠道。 + * + * @return list + */ + public static function default_payout_channels(): array { + return [ + ['key' => 'alipay', 'label' => '支付宝收款码', 'account_hint' => '姓名 + 支付宝账号', 'note' => '', 'enabled' => true], + ['key' => 'wechat', 'label' => '微信收款码', 'account_hint' => '姓名 + 微信号', 'note' => '', 'enabled' => true], + ]; + } + /** * 会员商品/续费商品:标题行显示价格与时长摘要(行默认收起由 SPA 统一处理)。 * diff --git a/src/Payment/CommissionLedger.php b/src/Payment/CommissionLedger.php index 7158f4e4..8bdd4c87 100644 --- a/src/Payment/CommissionLedger.php +++ b/src/Payment/CommissionLedger.php @@ -525,7 +525,9 @@ final class CommissionLedger { } $minimum=\Jio\Ledger\AssetAmount::balance(is_string($settings['min_amount'] ?? null) ? $settings['min_amount'] : ''); if ($amount->minor() < $minimum->minor()) return new \WP_Error('affiliate_withdraw_min',__('未达到最低提现金额。','jio'),['status'=>400]); - if (!in_array($method,(array)$settings['payout_channels'],true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','jio'),['status'=>400]); + // 渠道来自后台可自定义的拖拽结构:按标识比对启用项,不再限定内置渠道。 + $channel_keys = array_map(static fn($c) => (string)($c['key'] ?? ''), array_filter((array)$settings['payout_channels'], static fn($c) => !empty($c['enabled']))); + if (!in_array($method,$channel_keys,true) || $account==='') return new \WP_Error('affiliate_withdraw_account',__('请提供有效的收款渠道和收款码。','jio'),['status'=>400]); $result=\Jio\Payment\Withdrawal::get_instance()->create_balance_request($user_id,$amount->minor(),$method,$account,$settings,$operation_key,$source); if(is_wp_error($result)) return $result; return new \WP_REST_Response(['success'=>true,'data'=>$result],201); } private function balance_decimal(string $value): string { diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php index 753cd191..6afdf73c 100644 --- a/src/Settings/PaymentRuntime.php +++ b/src/Settings/PaymentRuntime.php @@ -506,12 +506,10 @@ class PaymentRuntime { } /** - * @return array{min_amount:string,service_charge_rate:int,daily_count:int,daily_limit:string,reset_time:string,payout_channels:string[]} + * @return array{min_amount:string,service_charge_rate:int,daily_count:int,daily_limit:string,reset_time:string,payout_channels:list} */ public static function get_withdrawal_settings(): array { $s = Settings::get_all(); - $channels = array_values(array_intersect(['alipay', 'wechat'], array_map('sanitize_key', (array) ($s['affiliate_payout_channels'] ?? ['alipay', 'wechat'])))); - if (!$channels) { $channels = ['alipay', 'wechat']; } $reset_time_raw = (string) ($s['affiliate_withdrawal_reset_time'] ?? '00:00'); return [ 'min_amount' => self::money_decimal($s['affiliate_min_withdrawal'] ?? '50.00', '50.00', 99999900), @@ -521,10 +519,70 @@ class PaymentRuntime { 'reset_time' => preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $reset_time_raw) ? $reset_time_raw : '00:00', - 'payout_channels' => $channels, + 'payout_channels' => self::payout_channels($s['affiliate_payout_channels'] ?? []), ]; } + /** + * 规范化后台配置的收款渠道。 + * + * 渠道由后台拖拽结构定义,可自定义名称与提示;这里只做结构校验与去重, + * 不再限定具体渠道,未配置时回落到支付宝/微信两个内置渠道。 + * + * @param mixed $raw + * @return list + */ + public static function payout_channels($raw): array { + $raw = is_array($raw) ? $raw : []; + $channels = []; + $seen = []; + foreach ($raw as $row) { + if (!is_array($row)) { + continue; + } + $key = sanitize_key((string) ($row['key'] ?? '')); + $label = trim(sanitize_text_field((string) ($row['label'] ?? ''))); + if ($key === '' || $label === '' || isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $channels[] = [ + 'key' => $key, + 'label' => $label, + 'account_hint' => trim(sanitize_text_field((string) ($row['account_hint'] ?? ''))), + 'note' => trim(sanitize_textarea_field((string) ($row['note'] ?? ''))), + 'enabled' => !isset($row['enabled']) || !empty($row['enabled']), + ]; + } + if (!$channels) { + $channels = \Jio\Admin\Schema\SchemaPayment::default_payout_channels(); + } + return $channels; + } + + /** + * 前台可选的收款渠道(仅启用项)。 + * + * @return list + */ + public static function enabled_payout_channels(): array { + return array_values(array_filter( + self::get_withdrawal_settings()['payout_channels'], + static fn (array $channel): bool => !empty($channel['enabled']) + )); + } + + /** 按渠道标识取配置;找不到时返回 null(含已停用的渠道,用于历史记录展示)。 */ + public static function payout_channel(string $key): ?array { + $key = sanitize_key($key); + foreach (self::get_withdrawal_settings()['payout_channels'] as $channel) { + if ($channel['key'] === $key) { + return $channel; + } + } + return null; + } + /** * Whether a registered adapter is admin-enabled. * Gateway ids (wechatpay) map to the reference-theme channel selectors: diff --git a/tests/widget-system/payment-withdrawal-channels-contract.test.cjs b/tests/widget-system/payment-withdrawal-channels-contract.test.cjs new file mode 100644 index 00000000..9c3fedb7 --- /dev/null +++ b/tests/widget-system/payment-withdrawal-channels-contract.test.cjs @@ -0,0 +1,82 @@ +/** + * 提现收款渠道契约。 + * + * 渠道由后台「提现策略 → 收款渠道」的拖拽结构定义,可自定义名称与提示, + * 不再固定为支付宝/微信两个勾选项。前台下拉、后端校验与后台审核页都读同一份配置。 + */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('收款渠道在两侧 schema 都是可拖拽的 repeater', () => { + const php = read('src/Admin/Schema/SchemaPayment.php'); + const js = read('assets/admin/src/schema/sections/payment/withdrawal.js'); + + for (const source of [php, js]) { + assert.match(source, /affiliate_payout_channels/); + assert.match(source, /(?:type:\s*'repeater'|'type'\s*=>\s*'repeater')/); + // 结构:key / label / account_hint / note / enabled + for (const field of ['key', 'label', 'account_hint', 'note', 'enabled']) { + assert.match(source, new RegExp(`key:\\s*'${field}'|'key'\\s*=>\\s*'${field}'`), field); + } + } + + // 不再是勾选列表。 + assert.doesNotMatch(php, /affiliate_payout_channels[^\n]*'type'\s*=>\s*'checkbox'/); + assert.doesNotMatch(js, /affiliate_payout_channels[^\n]*type:\s*'checkbox'/); +}); + +test('运行时按自定义渠道解析,未配置时回落内置两项', () => { + const runtime = read('src/Settings/PaymentRuntime.php'); + + assert.match(runtime, /public static function payout_channels\(\$raw\): array/); + assert.match(runtime, /public static function enabled_payout_channels\(\): array/); + assert.match(runtime, /public static function payout_channel\(string \$key\): \?array/); + + // 不再把渠道白名单硬编码成 alipay/wechat。 + assert.doesNotMatch(runtime, /array_intersect\(\['alipay', 'wechat'\]/); + + // 默认渠道由 schema 层唯一定义(纯数据,不依赖运行时),运行时引用它。 + assert.match(read('src/Admin/Schema/SchemaPayment.php'), /public static function default_payout_channels\(\): array/); + assert.match(runtime, /SchemaPayment::default_payout_channels\(\)/); +}); + +test('前台弹窗与后端校验都按渠道 key 工作', () => { + const modal = read('inc/helpers/user-center-renderers/withdraw-modal.php'); + const ledger = read('src/Payment/CommissionLedger.php'); + const client = read('assets/js/credits.js'); + + // 弹窗按配置渲染,携带自定义名称与提示。 + assert.doesNotMatch(modal, /array_intersect\(\['alipay', 'wechat'\]/); + assert.match(modal, /data-hint="<\?php echo esc_attr/); + assert.match(modal, /data-note="<\?php echo esc_attr/); + assert.match(modal, /data-jio-withdraw-note/); + + // 切换渠道时同步提示文案。 + assert.match(client, /function syncChannelHint\(\)/); + assert.match(client, /methodField\.addEventListener\('change', syncChannelHint\)/); + + // 后端只校验渠道存在且启用,不再限定内置两项。 + assert.match(ledger, /\$channel_keys = array_map/); + assert.doesNotMatch(ledger, /in_array\(\$method,\s*\[?'alipay'/); +}); + +test('后台提现审核读取同一份渠道配置', () => { + const page = read('src/Admin/Native/PayWithdrawals.php'); + const list = read('src/Admin/Native/PayWithdrawalsList.php'); + + // 渠道名来自设置,而不是硬编码映射。 + assert.match(page, /private function method_labels\(\): array/); + assert.match(page, /get_withdrawal_settings\(\)/); + assert.doesNotMatch(page, /'alipay' => __\('支付宝'/); + assert.match(page, /private function method_notes\(\): array/); + + // 审核弹窗展示渠道说明。 + assert.match(list, /'method_note' => __\('渠道说明'/); +});