Files
wordpress-theme-aksrt/src/Credits/BalanceServiceRest.php
T
kate522 c1a0eade38 fix(pay): 余额充值选中预设档位不再因空 custom_price 被 REST 拒绝
选中任一所预设充值档位点「立即支付」,必然弹「无效参数:custom_price」。这是
WP REST 的参数校验层拦截,请求根本没进业务回调。

原因是 WP REST 的 default 只在「键完全缺失」时生效,键存在但值为空串时 default
不参与,空串会直接送进 pattern 校验:

  'custom_price' => ['default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$']

而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
'',于是必然校验失败。另一个更隐蔽的变体:后台若关掉「自定义充值金额」,模板里
根本不渲染该输入框,customEl 为 null,custom 仍是 '',同样报错——也就是说这个
缺陷在只放开预设档位的配置下 100% 复现。

同文件的积分兑换路径(/pay/balance/points-exchange)早已写成
custom: validCustom ? custom : '0',余额充值这条只是漏了同样的处理。

- BalanceServiceRest:custom_price 的 pattern 改为整体可选分组
  ^(?:(?:0|[1-9]\d*)(?:\.\d{1,2})?)?$,显式放行空串;rest_charge_create 把空串
  归一化为 '0',与参数声明的 default 语义对齐,避免 AssetAmount::balance() 拿到
  空串后抛 balance_decimal_invalid
- credits.js:非自定义金额时显式送 '0',与积分兑换路径写法一致
- 契约测试补 4 条断言:模式可选、允许空串、回调归一化、前端送 '0'

两层都改是有意的:JS 会被浏览器与 CDN 缓存,旧缓存仍会发空串,后端放行兜住;
后端放行则保证任何客户端都不再被这个字段卡住。放宽仅针对空串,金额格式校验
不放松——007、abc、-1、1e3、1.234 与带首尾空格的值仍一律拒绝。

验证:新 pattern 逐例实测,''、0、1、9.99、9999.99 放行,007、abc、-1、1e3、
1.234、带空格全部拒绝;变异验证把 pattern 改回旧写法后该用例立刻转红,证明回归
测试确实拦得住此缺陷;node --test 全量 370 项 / 370 通过;PHPUnit 44 项 / 106
断言通过。断言未直接比对含反斜杠的整串——JS 字面量与 PHP 单引号串的反斜杠层数
不同会静默比对失败,故拆为不含反斜杠的片段。
2026-09-18 23:41:27 +08:00

505 lines
20 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
trait BalanceServiceRest {
public function register_routes(): void {
register_rest_route('aksrt/v1', '/pay/assets', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_asset_snapshot'],
'permission_callback' => [$this, 'check_asset_rest'],
]);
register_rest_route('aksrt/v1', '/pay/balance/charge', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_charge_options'],
'permission_callback' => [$this, 'check_asset_rest'],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_charge_create'],
'permission_callback' => [$this, 'check_asset_rest'],
'args' => [
'package' => ['type' => 'string', 'default' => 'custom', 'sanitize_callback' => 'sanitize_text_field'],
// 选中预设档位时 custom_price 无意义,但前端仍会带上该键(空串或不存在),
// 而 WP REST 只在「键缺失」时应用 default,空串会直接进 pattern 校验,
// 因此模式必须显式放行空串,否则报 rest_invalid_param(无效参数:custom_price)。
'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(?:(?:0|[1-9]\\d*)(?:\\.\\d{1,2})?)?$'],
'payment_method' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
],
],
]);
register_rest_route('aksrt/v1', '/pay/transfer', [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_transfer'],
'permission_callback' => [$this, 'check_asset_rest'],
'args' => [
'type' => ['type' => 'string', 'required' => true, 'enum' => ['balance', 'points']],
'recipient' => ['type' => 'integer', 'required' => true, 'minimum' => 1],
'amount' => [
'required' => true,
'oneOf' => [
['type' => 'integer', 'minimum' => 1],
['type' => 'string', 'pattern' => '^(0|[1-9]\\d*)(?:\\.\\d{1,2})?$'],
],
],
],
],
]);
register_rest_route('aksrt/v1', '/pay/transfer/search', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_transfer_search'],
'permission_callback' => [$this, 'check_asset_rest'],
],
]);
register_rest_route('aksrt/v1', '/pay/balance/points-exchange', [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_exchange_balance_for_points'],
'permission_callback' => [$this, 'check_asset_rest'],
'args' => [
'product' => ['type' => 'string', 'default' => 'custom', 'maxLength' => 30],
'custom' => ['type' => 'string', 'default' => '0', 'pattern' => '^\d{1,9}$'],
],
],
]);
}
public function rest_asset_snapshot(\WP_REST_Request $request) {
$user_id = get_current_user_id();
return rest_ensure_response([
'success' => true,
'data' => [
'wallet_balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id),
'credit_balance' => (string) \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
],
]);
}
public function check_asset_rest(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
$nonce = (string) $request->get_header('X-WP-Nonce');
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
return true;
}
private function balance_feature_enabled(): bool {
if (!class_exists('\\AKSRT\\Settings\\Settings')) {
return false;
}
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
return !empty($cfg['enabled']);
}
/**
* GET charge options (packages + methods + limits).
*/
public function rest_charge_options(\WP_REST_Request $request) {
if (!$this->balance_feature_enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$user_id = get_current_user_id();
return rest_ensure_response([
'success' => true,
'data' => $this->get_charge_view($user_id),
]);
}
/**
* POST create recharge order and initiate payment.
*/
public function rest_charge_create(\WP_REST_Request $request) {
if (!$this->balance_feature_enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$user_id = get_current_user_id();
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'recharge-create', $user_id);
if (is_wp_error($operation_key)) {
return $operation_key;
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
// 空串与键缺失等价(预设档位路径不消费本字段),归一化为 '0' 以对齐参数声明的 default。
$custom_price = trim((string) ($params['custom_price'] ?? ''));
if ($custom_price === '') {
$custom_price = '0';
}
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method, $operation_key);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response(['success' => true, 'data' => $result]);
}
/**
* Transfer balance or points between users.
*/
public function rest_transfer(\WP_REST_Request $request) {
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
$recipient = (int) ($params['recipient'] ?? 0);
$amount_raw = $params['amount'] ?? '';
$from_id = get_current_user_id();
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'transfer', $from_id);
if (is_wp_error($operation_key)) {
return $operation_key;
}
if (!in_array($type, ['balance', 'points'], true)) {
return new \WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]);
}
if ($from_id <= 0) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
if ($recipient <= 0 || $recipient === $from_id) {
return new \WP_Error('aksrt_transfer_user', __('请选择有效的收款用户', 'aksrt'), ['status' => 400]);
}
if (!get_userdata($recipient)) {
return new \WP_Error('aksrt_transfer_user', __('收款用户不存在', 'aksrt'), ['status' => 404]);
}
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
if ($type === 'balance') {
if (empty($cfg['enabled']) || empty($cfg['transfer']['enabled'])) {
return new \WP_Error('aksrt_transfer_closed', __('余额转账未开启', 'aksrt'), ['status' => 403]);
}
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_transfer', $from_id)) {
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]);
}
try {
$amount = \AKSRT\Ledger\AssetAmount::balance(is_string($amount_raw) ? $amount_raw : '');
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
}
if ($amount->minor() <= 0) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
}
$fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0)));
$fee_minor = $this->percentage_minor($amount->minor(), $fee_rate);
$from_user = wp_get_current_user();
$to_user = get_userdata($recipient);
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
$from_id,
$recipient,
\AKSRT\Ledger\AssetType::BALANCE,
$amount->minor(),
$fee_minor,
$operation_key,
'transfer',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
['fee_rate' => $fee_rate]
);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($from_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
],
]);
}
// points transfer
if (!class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
return new \WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]);
}
$points_settings = \AKSRT\Settings\Settings::get_all();
if (empty($points_settings['points_transfer_s'])) {
return new \WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]);
}
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('points_transfer', $from_id)) {
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]);
}
if (!is_int($amount_raw) || $amount_raw <= 0) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]);
}
$amount = $amount_raw;
$min_amount = max(1, (int) ($points_settings['points_transfer_min'] ?? 1));
if ($amount < $min_amount) {
return new \WP_Error('aksrt_transfer_minimum', sprintf(__('单笔转账至少需要 %d 积分', 'aksrt'), $min_amount), ['status' => 400]);
}
$fee_rate = max(0, min(50, (int) ($points_settings['points_service_charge'] ?? 0)));
$fee = $this->percentage_minor($amount, $fee_rate);
$from_user = wp_get_current_user();
$to_user = get_userdata($recipient);
$result = \AKSRT\Ledger\LedgerService::get_instance()->transfer(
$from_id,
$recipient,
\AKSRT\Ledger\AssetType::POINTS,
$amount,
$fee,
$operation_key,
'transfer',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient),
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id),
['fee_rate' => $fee_rate]
);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'balance' => $result->balance($from_id, \AKSRT\Ledger\AssetType::POINTS),
],
]);
}
public function rest_transfer_search(\WP_REST_Request $request) {
$s = sanitize_text_field((string) $request->get_param('s'));
if (mb_strlen($s) < 1) {
return rest_ensure_response(['success' => true, 'data' => ['users' => []]]);
}
$exclude = [get_current_user_id()];
$q = new \WP_User_Query([
'search' => '*' . $s . '*',
'search_columns' => ['user_login', 'user_nicename', 'display_name', 'user_email'],
'exclude' => $exclude,
'number' => 20,
'fields' => ['ID', 'display_name', 'user_login'],
]);
$users = [];
foreach ((array) $q->get_results() as $u) {
$users[] = [
'id' => (int) $u->ID,
'display_name' => (string) $u->display_name,
'user_login' => (string) $u->user_login,
'avatar' => get_avatar_url((int) $u->ID, ['size' => 48]),
];
}
return rest_ensure_response(['success' => true, 'data' => ['users' => $users]]);
}
/**
* Buy points with cash-wallet balance (积分购买弹窗 · 余额支付).
*
* Body: { product: 'custom'|<package_key>, custom?: <points int> }
* 自定义按汇率折算支付金额;商品按配置销售金额支付(参考主题同款语义)。
*/
public function rest_exchange_balance_for_points(\WP_REST_Request $request) {
if (!$this->balance_feature_enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
if (!class_exists('AKSRT\\Settings\\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
return new \WP_Error('aksrt_points_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
}
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
$exchange = is_array($cfg['points_exchange'] ?? null) ? $cfg['points_exchange'] : [];
if (empty($exchange['enabled'])) {
return new \WP_Error('aksrt_exchange_closed', __('余额兑换积分未开启', 'aksrt'), ['status' => 403]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$rate = max(1, (int) ($exchange['rate'] ?? 100));
$daily_max = max(1, (int) ($exchange['daily_max_points'] ?? 100000));
$product_key = sanitize_key((string) ($params['product'] ?? 'custom'));
$points = 0;
$pay_minor = 0;
$product_title = '';
if ($product_key !== '' && $product_key !== 'custom') {
$found = null;
foreach ($this->parse_points_packages((string) ($exchange['products_raw'] ?? '')) as $pkg) {
if ((string) ($pkg['key'] ?? '') === $product_key) {
$found = $pkg;
break;
}
}
if (!$found) {
return new \WP_Error('aksrt_exchange_product', __('积分商品无效', 'aksrt'), ['status' => 400]);
}
$points = (int) $found['points'];
try {
$pay_minor = \AKSRT\Ledger\AssetAmount::balance((string) $found['pay_price'])->minor();
} catch (\InvalidArgumentException $e) {
$pay_minor = 0;
}
$product_title = (string) $found['title'];
} else {
$product_key = 'custom';
$points = absint((string) ($params['custom'] ?? '0'));
$min_points = max(1, (int) ($exchange['min_points'] ?? 100));
if ($points <= 0) {
return new \WP_Error('aksrt_exchange_amount', __('请输入需购买的积分数额', 'aksrt'), ['status' => 400]);
}
if ($points < $min_points) {
return new \WP_Error('aksrt_exchange_min', sprintf(__('最低购买%d积分', 'aksrt'), $min_points), ['status' => 400]);
}
// 自定义按汇率折算支付金额(参考主题语义:积分 * 100 / 汇率 后四舍五入)。
// 纯整数实现:intdiv(2a + b, 2b) 等价于 a / b 四舍五入,避免浮点。
$pay_minor = intdiv($points * 200 + $rate, $rate * 2);
$product_title = sprintf(__('购买 %d 积分', 'aksrt'), $points);
}
if ($points <= 0) {
return new \WP_Error('aksrt_exchange_product', __('积分商品无效', 'aksrt'), ['status' => 400]);
}
if ($pay_minor <= 0) {
return new \WP_Error('aksrt_exchange_precision', __('支付金额无效', 'aksrt'), ['status' => 400]);
}
if ($points > $daily_max) {
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d', 'aksrt'), $daily_max), ['status' => 400]);
}
$user_id = get_current_user_id();
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'points-exchange', $user_id);
if (is_wp_error($operation_key)) {
return $operation_key;
}
$site_now = current_datetime();
$start = (clone $site_now)->setTime(0, 0, 0);
$end = (clone $start)->modify('+1 day');
$utc = new \DateTimeZone('UTC');
$start_gmt = $start->setTimezone($utc)->format('Y-m-d H:i:s.u');
$end_gmt = $end->setTimezone($utc)->format('Y-m-d H:i:s.u');
$guard = static function () use ($user_id, $points, $daily_max, $start_gmt, $end_gmt) {
$used = \AKSRT\Ledger\LedgerQuery::get_instance()->sum_credits($user_id, 'points_exchange', $start_gmt, $end_gmt);
if ($used + $points > $daily_max) {
return new \WP_Error('aksrt_exchange_daily_limit', sprintf(__('今日购买积分上限为 %d,当前还可购买 %d', 'aksrt'), $daily_max, max(0, $daily_max - $used)), ['status' => 400]);
}
return true;
};
if (!class_exists('\\AKSRT\\Payment\\OrderRepository')) {
return new \WP_Error('aksrt_order_unavailable', __('订单服务不可用', 'aksrt'), ['status' => 500]);
}
$pay_decimal = \AKSRT\Ledger\AssetAmount::decimal_from_minor($pay_minor);
$note = wp_json_encode([
'points_exchange' => 1,
'points' => $points,
'product' => $product_key,
'product_title' => $product_title,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$existing_order = $repo->get_by_request_key($operation_key);
$is_new_order = !is_array($existing_order);
if (is_array($existing_order)) {
$existing_meta = json_decode((string) ($existing_order['note'] ?? ''), true);
$existing_meta = is_array($existing_meta) ? $existing_meta : [];
if ((int) ($existing_order['user_id'] ?? 0) !== $user_id
|| sanitize_key((string) ($existing_order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS
|| (string) ($existing_order['amount'] ?? '') !== $pay_decimal
|| sanitize_key((string) ($existing_order['currency'] ?? '')) !== 'cny'
|| sanitize_key((string) ($existing_order['payment_method'] ?? '')) !== 'balance'
|| (int) ($existing_meta['points'] ?? 0) !== $points
|| (string) ($existing_meta['product'] ?? '') !== $product_key
) {
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他积分购买请求', 'aksrt'), ['status' => 409]);
}
$order_number = (string) ($existing_order['order_number'] ?? '');
} else {
$order_number = $repo->allocate_order_number();
}
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('积分订单创建失败', 'aksrt'), ['status' => 500]);
}
$business_mutation = static function (int $transaction_id) use ($is_new_order, $repo, $user_id, $pay_decimal, $points, $operation_key, $order_number, $note) {
if (!$is_new_order) {
return true;
}
$created = $repo->create([
'order_number' => $order_number,
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS,
'product_id' => 0,
'amount' => $pay_decimal,
'currency' => 'CNY',
'status' => \AKSRT\Payment\Order::STATUS_PAID,
'payment_method' => 'balance',
'payment_transaction_id' => 'ledger_' . $transaction_id,
'paid_at' => current_time('mysql'),
'note' => is_string($note) ? $note : '',
'request_key' => $operation_key,
]);
if ($created !== $order_number) {
return new \WP_Error('aksrt_order_create_failed', __('积分订单创建失败', 'aksrt'), ['status' => 500]);
}
if (class_exists('\\AKSRT\\Payment\\CommissionLedger')) {
return \AKSRT\Payment\CommissionLedger::get_instance()->record_points_exchange($user_id, $pay_decimal, $points, $operation_key, $order_number);
}
return true;
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->exchange(
$user_id,
$pay_minor,
$points,
$operation_key,
'points_exchange',
['type' => 'order', 'key' => $order_number],
sprintf(__('余额购买 %d 积分', 'aksrt'), $points),
['rate' => $rate, 'product' => $product_key],
$guard,
$business_mutation
);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response(['success' => true, 'data' => [
'message' => sprintf(__('购买成功,获得 %d 积分', 'aksrt'), $points),
'transaction_id' => $result->transaction_id,
'replayed' => $result->replayed,
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
'credit_balance' => $result->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'points' => $points,
'order_number' => $order_number,
'reload' => true,
]]);
}
private function percentage_minor(int $amount, int $rate): int {
if ($amount <= 0 || $rate <= 0) {
return 0;
}
return (intdiv($amount, 100) * $rate) + intdiv(($amount % 100) * $rate, 100);
}
}