feat(withdrawal): make payout channels a configurable sortable list

The payout channel was a two-item checkbox (alipay / wechat). Sites that pay
out through a bank transfer, USDT or any other route had no way to express it,
and every surface behind it — the withdrawal modal, the server-side check, the
admin review list — hard-coded those two names.

affiliate_payout_channels is now a repeater on both the PHP and Vue schemas,
using the same draggable component as the post filters and gateway config. Each
row carries a stable key, a display label, an account placeholder, an optional
note and an enabled flag. Disabled channels disappear from the front end but
stay resolvable, so historical withdrawal records still render their original
name.

PaymentRuntime::payout_channels() normalises the stored structure: it drops rows
with an empty key or label, de-duplicates keys, and falls back to the built-in
two channels when nothing is configured. enabled_payout_channels() feeds the
front end and payout_channel() resolves a single key for record display.
CommissionLedger's validation now compares against the configured, enabled keys
instead of a literal allowlist.

The withdrawal modal renders the configured list and swaps the account
placeholder and note when the channel changes; credits.js drives that. The
admin review page reads the same settings for its method labels and filter, and
the review dialog gained a "渠道说明" row so the operator sees the payout
instruction, hidden when empty.

Default channels live in SchemaPayment as plain data — the runtime imports them
rather than the reverse, because the schema layer must not depend on a runtime
settings class (doing it the other way made the schema harness fatal).

Verification: drove payout_channels() through default / custom / dirty / duplicate
inputs and confirmed enabled filtering, note lookup and disabled-channel
resolution. Added payment-withdrawal-channels-contract.test.cjs covering the
schema shape, runtime helpers, front-end wiring and admin read path. 873 tests
pass, PSR-4 audit clean, admin SPA builds.
This commit is contained in:
kate522 committed 2026-11-04 19:16:39 +08:00
1 parent bcba0f598a
commit dcd306b428
10 files changed
+267 -20

No files matched your search

@@ -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: '<div>可提现金额达到50元后即可发起提现</div><div>申请提现需后台人工处理,一般2-3小时,请耐心等待</div><div>如有其它疑问,请与客服联系</div>',
},
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 },
],
},
],
},
{
+10 -1
View File
@@ -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();
+20
View File
@@ -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();
@@ -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'] ?? '') !== ''
));
?>
<div class="uc-modal" id="jio-withdraw-modal" hidden data-jio-withdraw-modal>
<div class="uc-modal__overlay" data-jio-withdraw-close></div>
@@ -30,11 +31,16 @@ if (!$withdraw_channels) {
<label class="muted-color em09 mt10"><?php esc_html_e('收款方式', 'jio'); ?></label>
<select class="uc-card-pass__input" name="method" data-jio-withdraw-method>
<?php foreach ($withdraw_channels as $channel) : ?>
<option value="<?php echo esc_attr($channel); ?>"><?php echo esc_html($channel === 'wechat' ? __('微信收款码', 'jio') : __('支付宝收款码', 'jio')); ?></option>
<option
value="<?php echo esc_attr((string) $channel['key']); ?>"
data-hint="<?php echo esc_attr((string) ($channel['account_hint'] ?? '')); ?>"
data-note="<?php echo esc_attr((string) ($channel['note'] ?? '')); ?>"
><?php echo esc_html((string) ($channel['label'] ?? $channel['key'])); ?></option>
<?php endforeach; ?>
</select>
<label class="muted-color em09 mt10"><?php esc_html_e('收款账户信息', 'jio'); ?></label>
<textarea class="uc-card-pass__input" name="account" rows="3" data-jio-withdraw-account placeholder="<?php esc_attr_e('姓名 + 账户 / 卡号', 'jio'); ?>"></textarea>
<textarea class="uc-card-pass__input" name="account" rows="3" data-jio-withdraw-account placeholder="<?php echo esc_attr((string) ($withdraw_channels[0]['account_hint'] ?? __('姓名 + 账户 / 卡号', 'jio'))); ?>"></textarea>
<p class="muted-2-color em09 mt6" data-jio-withdraw-note<?php echo empty($withdraw_channels[0]['note']) ? ' hidden' : ''; ?>><?php echo esc_html((string) ($withdraw_channels[0]['note'] ?? '')); ?></p>
<button type="submit" class="but jb-blue btn-block" data-jio-withdraw-submit><?php esc_html_e('提交申请', 'jio'); ?></button>
</form>
</div>
+33 -5
View File
@@ -176,12 +176,40 @@ class PayWithdrawals {
];
}
/** @return array<string, string> */
/**
* 收款渠道标识 → 名称。
*
* 渠道由后台「提现策略 → 收款渠道」的拖拽结构定义,可自定义名称;这里读同一份
* 配置,停用的渠道也会返回,历史提现记录才能按原名称展示。
*
* @return array<string, string>
*/
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 {
+2
View File
@@ -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 '<div class="jio-wd-summary__item jio-wd-summary__item--' . esc_attr($field) . '"><dt>' . esc_html($label) . '</dt><dd data-summary="' . esc_attr($field) . '"></dd></div>';
}
+19 -1
View File
@@ -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' => '<div>可提现金额达到50元后即可发起提现</div><div>申请提现需后台人工处理,一般2-3小时,请耐心等待</div><div>如有其它疑问,请与客服联系</div>', 'rows' => 4, 'help' => '用户申请提现时展示的内容,建议为提现须知等,支持HTML代码'],
@@ -568,6 +574,18 @@ class SchemaPayment {
];
}
/**
* 提现收款渠道的默认结构:与参考主题一致的两个内置渠道。
*
* @return list<array{key:string,label:string,account_hint:string,note:string,enabled:bool}>
*/
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 统一处理)。
*
+3 -1
View File
@@ -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 {
+62 -4
View File
@@ -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<array{key:string,label:string,account_hint:string,note:string,enabled:bool}>}
*/
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<array{key:string,label:string,account_hint:string,note:string,enabled:bool}>
*/
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<array{key:string,label:string,account_hint:string,note:string,enabled:bool}>
*/
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:
@@ -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' => __\('渠道说明'/);
});