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

@@ -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' => __\('渠道说明'/);
});