doge 2 дней назад
Родитель
Сommit
f91acfec4c

+ 5 - 2
app/Http/Controllers/admin/RechargeChannel.php

@@ -230,7 +230,8 @@ class RechargeChannel extends Controller
                     && $dataType === 1 && isset($configuredLogos[(string)$row['type']])) {
                     $row['logo'] = (string)Util::ensureUrl($configuredLogos[(string)$row['type']]);
                 }
-                return $row + PaymentProviderCatalog::channelDisplay($row, $companyStatuses);
+                return $row + PaymentProviderCatalog::channelDisplay($row, $companyStatuses)
+                    + RechargeChannelModel::feeDisplay($row['rate'] ?? 0, $row['fee_fixed'] ?? 0);
             });
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
@@ -252,7 +253,9 @@ class RechargeChannel extends Controller
                 'name' => ['nullable','string','max:100'],
                 'data_type' => ['required','integer','in:1,2'],
                 'type' => ['required','string','max:100'],
-                'rate' => ['required','numeric','min:0'],
+                'fee_mode' => ['nullable', Rule::in(array_keys(RechargeChannelModel::FEE_MODES))],
+                'rate' => ['required_unless:fee_mode,fixed','nullable','numeric','min:0','max:1'],
+                'fee_fixed' => ['nullable','numeric','min:0','max:999999'],
                 'min' => ['exclude_if:type,zfbge','nullable','numeric','min:0'],
                 'max' => ['exclude_if:type,zfbge','nullable','numeric','gte:min'],
                 'fixed' => ['nullable'],

+ 9 - 2
app/Models/PaymentDirectRecharge.php

@@ -16,7 +16,7 @@ class PaymentDirectRecharge extends BaseModel
         'recharge_channel_group_ids' => 'array',
         'status' => 'integer',
     ];
-    protected $appends = ['fee_rate', 'actual_amount'];
+    protected $appends = ['fee_rate', 'fee_fixed', 'actual_amount'];
 
     public function rechargeChannel()
     {
@@ -30,9 +30,16 @@ class PaymentDirectRecharge extends BaseModel
             : '0.0000';
     }
 
+    public function getFeeFixedAttribute($value = null): string
+    {
+        return bcadd((string)($this->rechargeChannel->fee_fixed ?? 0), '0', 4);
+    }
+
+    /** 扣除三方手续费(金额 × 费率 + 每笔固定费,平台承担)后平台实际收到的金额。 */
     public function getActualAmountAttribute(): string
     {
         $rate = $this->rechargeChannel ? (string)$this->rechargeChannel->rate : '0';
-        return bcsub((string)$this->amount, bcmul((string)$this->amount, $rate, 6), 2);
+        $fee = bcadd(bcmul((string)$this->amount, $rate, 6), $this->fee_fixed, 6);
+        return bcsub((string)$this->amount, $fee, 2);
     }
 }

+ 38 - 1
app/Models/RechargeChannel.php

@@ -12,9 +12,46 @@ class RechargeChannel extends BaseModel
     use SoftDeletes;
 
     protected $table = 'recharge_channel';
-    protected $fillable = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'min', 'max', 'fixed', 'sort', 'status', 'logo'];
+    protected $fillable = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'fee_fixed', 'min', 'max', 'fixed', 'sort', 'status', 'logo'];
     protected $hidden = [];
 
+    /** 三方手续费组合方式:只按百分比、只收每笔固定费用、百分比 + 每笔固定费用。 */
+    public const FEE_MODES = ['rate' => '百分比', 'fixed' => '每笔固定', 'rate_fixed' => '百分比+每笔固定'];
+
+    /**
+     * 三方手续费展示:fee = 金额 × rate + fee_fixed(rate 为小数),平台承担。
+     * @return array{fee_mode: string, fee_mode_text: string, fee_rate_percent: string, fee_text: string}
+     */
+    public static function feeDisplay($rate, $fixed): array
+    {
+        $rate = self::decimal($rate, 6);
+        $fixed = self::decimal($fixed, 4);
+        $hasRate = bccomp($rate, '0', 6) > 0;
+        $hasFixed = bccomp($fixed, '0', 4) > 0;
+        $mode = $hasRate && $hasFixed ? 'rate_fixed' : ($hasFixed ? 'fixed' : ($hasRate ? 'rate' : 'none'));
+        $percent = self::trimZeros(bcmul($rate, '100', 4));
+        $parts = [];
+        if ($hasRate) $parts[] = $percent . '%';
+        if ($hasFixed) $parts[] = '每笔' . self::trimZeros($fixed) . '元';
+        return [
+            'fee_mode' => $mode,
+            'fee_mode_text' => self::FEE_MODES[$mode] ?? '无手续费',
+            'fee_rate_percent' => $percent,
+            'fee_text' => $parts ? implode(' + ', $parts) : '无手续费',
+        ];
+    }
+
+    private static function decimal($value, int $scale): string
+    {
+        if ($value === null || $value === '') return bcadd('0', '0', $scale);
+        return is_string($value) && is_numeric($value) ? bcadd($value, '0', $scale) : number_format((float)$value, $scale, '.', '');
+    }
+
+    private static function trimZeros(string $value): string
+    {
+        return str_contains($value, '.') ? rtrim(rtrim($value, '0'), '.') : $value;
+    }
+
     public function getLogoAttribute($value): string
     {
         return $value ? (string)Util::ensureUrl($value) : '';

+ 1 - 1
app/Services/Agent/AgentWithdrawalService.php

@@ -180,7 +180,7 @@ class AgentWithdrawalService
             }
             // 平台付给三方代付的成本(按到账金额下单);USDT / 人工提现不记(链上成本不计)。
             $channelFee = self::isProviderChannel($channel)
-                ? PaymentOrderService::feeFields($actual, (string) ($config->rate ?? 0), PaymentOrderService::PAYOUT_FIXED_FEE)
+                ? PaymentOrderService::feeFields($actual, (string) ($config->rate ?? 0), $config->fee_fixed ?? 0)
                 : PaymentOrderService::feeFields($actual, 0);
 
             $before = bcadd((string) $lockedAgent->balance, '0', 4);

+ 2 - 0
app/Services/Payment/PaymentProviderCatalog.php

@@ -204,6 +204,8 @@ class PaymentProviderCatalog
                 'name' => (string)$channel['name'], 'key' => (string)$channel['key'], 'type' => (string)$channel['type'],
                 'rate' => bcadd((string)$channel['rate'], '0', 4),
                 'fee_rate' => bcmul((string)$channel['rate'], '100', 4),
+                'fee_fixed' => bcadd((string)($channel['fee_fixed'] ?? 0), '0', 4),
+                'fee_text' => \App\Models\RechargeChannel::feeDisplay($channel['rate'], $channel['fee_fixed'] ?? 0)['fee_text'],
                 'min_amount' => $channel['min'] ?? null, 'max_amount' => $channel['max'] ?? null,
                 'fixed_amounts' => self::types($channel['fixed'] ?? []),
                 'sort' => (int)$channel['sort'], 'status' => (int)$channel['status'],

+ 30 - 2
app/Services/Payment/RechargeChannelConfigurationService.php

@@ -117,6 +117,7 @@ class RechargeChannelConfigurationService
             $before = $isNew ? [] : $channel->toArray();
             $params = PaymentProviderCatalog::normalizeChannelSelection($params);
             $params = $this->amountConfiguration($params, $channel);
+            $params = $this->feeConfiguration($params, $channel);
             $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
             $identity = PaymentProviderCatalog::channelIdentity($params);
             if ($identityChanged && $identity) PaymentProviderService::assertEnabled($identity['provider_code']);
@@ -154,6 +155,33 @@ class RechargeChannelConfigurationService
         }, 3);
     }
 
+    /**
+     * 三方手续费组合:fee_mode 为 rate(只按百分比)/ fixed(只收每笔固定费用)/ rate_fixed(两者结合),
+     * 不传 fee_mode 时按提交的 rate、fee_fixed 原样保存(0 表示不收该部分)。
+     */
+    private function feeConfiguration(array $params, RechargeChannel $channel): array
+    {
+        $mode = $params['fee_mode'] ?? null;
+        unset($params['fee_mode']);
+        if (array_key_exists('rate', $params) && $params['rate'] === null) {
+            if ($channel->exists) unset($params['rate']); else $params['rate'] = 0;
+        }
+        if (array_key_exists('fee_fixed', $params) && $params['fee_fixed'] === null) $params['fee_fixed'] = 0;
+        if ($mode === null) return $params;
+
+        $rate = (string)($params['rate'] ?? ($channel->exists ? $channel->rate : 0));
+        $fixed = (string)($params['fee_fixed'] ?? ($channel->exists ? $channel->fee_fixed : 0));
+        if ($mode === 'fixed') $rate = '0';
+        if ($mode === 'rate') $fixed = '0';
+        if ($mode !== 'fixed' && bccomp($rate, '0', 6) <= 0) {
+            throw \Illuminate\Validation\ValidationException::withMessages(['rate' => '请填写手续费百分比']);
+        }
+        if ($mode !== 'rate' && bccomp($fixed, '0', 4) <= 0) {
+            throw \Illuminate\Validation\ValidationException::withMessages(['fee_fixed' => '请填写每笔固定手续费']);
+        }
+        return ['rate' => $rate, 'fee_fixed' => $fixed] + $params;
+    }
+
     private function amountConfiguration(array $params, RechargeChannel $channel): array
     {
         $fixedMode = (int)$params['data_type'] === 1 && $params['type'] === 'zfbge';
@@ -227,7 +255,7 @@ class RechargeChannelConfigurationService
 
     /** 通道操作日志中展示的字段(顺序即展示顺序) */
     private const AUDIT_FIELDS = [
-        'name' => '通道名称', 'key' => '通道标识Key', 'from' => '来源', 'type' => '通道类型', 'rate' => '费率',
+        'name' => '通道名称', 'key' => '通道标识Key', 'from' => '来源', 'type' => '通道类型', 'rate' => '费率', 'fee_fixed' => '每笔固定手续费',
         'min' => '最低金额', 'max' => '最高金额', 'fixed' => '固定金额', 'status' => '状态', 'sort' => '排序', 'logo' => 'Logo',
     ];
     private const AUDIT_ACTIONS = ['create' => '新增', 'update' => '修改', 'delete' => '删除', 'save' => '修改Logo'];
@@ -303,7 +331,7 @@ class RechargeChannelConfigurationService
         if ($value === null || $value === '' || $value === []) return null;
         if ($field === 'fixed') return implode(',', is_array($value) ? $value : explode(',', (string)$value));
         if (in_array($field, ['from', 'status', 'sort'], true)) return (int)$value;
-        if (in_array($field, ['rate', 'min', 'max'], true)) return rtrim(rtrim(bcadd((string)$value, '0', 4), '0'), '.');
+        if (in_array($field, ['rate', 'fee_fixed', 'min', 'max'], true)) return rtrim(rtrim(bcadd((string)$value, '0', 4), '0'), '.');
         return (string)$value;
     }
 

+ 5 - 2
app/Services/PaymentChannelLinkService.php

@@ -114,6 +114,8 @@ class PaymentChannelLinkService
             'type' => (string)$channel->type,
             'rate' => number_format((float)$channel->rate, 4, '.', ''),
             'fee_rate' => bcmul((string)$channel->rate, '100', 4),
+            'fee_fixed' => bcadd((string)($channel->fee_fixed ?? 0), '0', 4),
+            'fee_text' => RechargeChannel::feeDisplay($channel->rate, $channel->fee_fixed ?? 0)['fee_text'],
             'min_amount' => $channel->min === null ? null : number_format((float)$channel->min, 2, '.', ''),
             'max_amount' => $channel->max === null ? null : number_format((float)$channel->max, 2, '.', ''),
             'fixed_amounts' => $channel->fixed ?: [],
@@ -121,6 +123,7 @@ class PaymentChannelLinkService
             'status' => (int)$channel->status,
         ] : null;
         $data['fee_rate'] = $channel ? bcmul((string)$channel->rate, '100', 4) : null;
+        $data['fee_fixed'] = $channel ? bcadd((string)($channel->fee_fixed ?? 0), '0', 4) : null;
         $data['min_amount'] = $channel?->min;
         $data['max_amount'] = $channel?->max;
         $data['sort'] = $channel ? (int)$channel->sort : null;
@@ -134,12 +137,12 @@ class PaymentChannelLinkService
             && $data['runtime_channel_available'] === 1 ? 1 : 0;
         if (array_key_exists('amount', $data)) {
             $data['actual_amount'] = $channel
-                ? bcsub((string)$model->amount, bcmul((string)$model->amount, (string)$channel->rate, 6), 2)
+                ? bcsub((string)$model->amount, bcadd(bcmul((string)$model->amount, (string)$channel->rate, 6), (string)($channel->fee_fixed ?? 0), 6), 2)
                 : null;
         }
         $data['channel_fields_readonly'] = [
             'recharge_channel.name', 'recharge_channel.key', 'recharge_channel.type',
-            'fee_rate', 'min_amount', 'max_amount', 'recharge_channel.fixed_amounts',
+            'fee_rate', 'fee_fixed', 'min_amount', 'max_amount', 'recharge_channel.fixed_amounts',
             'sort', 'channel_status',
         ];
         unset($data['level_scope']);

+ 17 - 14
app/Services/PaymentOrderService.php

@@ -45,12 +45,11 @@ class PaymentOrderService extends BaseService
     const PAY_TIMEOUT_MINUTES = 30;
     const PAY_OPEN_STATUSES = [self::STATUS_PROCESS, self::STATUS_TIMEOUT]; // 在线充值可接收回调/查询入账的状态
 
-    const PAYOUT_FIXED_FEE = 2; // 三方代付每笔固定手续费
-
     public static string $MODEL = PaymentOrder::class;
 
     /**
-     * 三方手续费快照:fee 是三方向平台收的成本(= 金额 × 费率 + 固定费),不扣客户。费率为小数(0.02 = 2%)。
+     * 三方手续费快照:fee 是三方向平台收的成本(= 金额 × 费率 + 每笔固定费),不扣客户。费率为小数(0.02 = 2%)。
+     * 费率和每笔固定费来自通道配置(recharge_channel.rate / fee_fixed),可以只配其中一项或两项组合。
      * @return array{fee_rate: string, fee_fixed: string, fee: string}
      */
     public static function feeFields($amount, $rate, $fixed = 0): array
@@ -264,6 +263,7 @@ class PaymentOrderService extends BaseService
         $max = (float)($channelConfig->max ?? 0);
         $min = (float)($channelConfig->min ?? 0);
         $rate = (float)($channelConfig->rate ?? 0);
+        $feeFixed = $channelConfig->fee_fixed ?? 0;
         $selectedProduct = null;
 
         $geText = '';
@@ -281,6 +281,7 @@ class PaymentOrderService extends BaseService
                     if ($amount >= $v['min'] && $amount <= $v['max']) {
                         $channel = $k;
                         $rate = $v['rate'];
+                        $feeFixed = $v['fee_fixed'] ?? 0;
 
                     }
                     if ($min == 0) {
@@ -332,12 +333,12 @@ class PaymentOrderService extends BaseService
             $data['member_id'] = $memberId;
             $data['amount'] = ZimuPayService::amount($amount);
             $data['channel'] = $channel;
-            $data += self::feeFields($amount, $rate);
+            $data += self::feeFields($amount, $rate, $feeFixed);
             $data['bank_name'] = $bankName;
             $order_no = self::createOrderNo($orderPrefix . 'zm' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['callback_url'] = ZimuPayService::getPayNotifyUrl();
-            $data['remark'] = '充值费率:' . $rate;
+            $data['remark'] = '充值手续费:' . RechargeChannel::feeDisplay($rate, $feeFixed)['fee_text'];
             $data['status'] = self::STATUS_STAY;
 
             $ret = ZimuPayService::pay($amount, $order_no, (string)$memberId);
@@ -381,12 +382,12 @@ class PaymentOrderService extends BaseService
             $data['member_id'] = $memberId;
             $data['amount'] = NoPayService::amount($amount);
             $data['channel'] = $channel;
-            $data += self::feeFields($amount, $rate);
+            $data += self::feeFields($amount, $rate, $feeFixed);
             $data['bank_name'] = $bankName;
             $order_no = self::createOrderNo($orderPrefix . 'no' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['callback_url'] = NoPayService::getDepositNotifyUrl();
-            $data['remark'] = '充值费率:' . $rate;
+            $data['remark'] = '充值手续费:' . RechargeChannel::feeDisplay($rate, $feeFixed)['fee_text'];
             $data['status'] = self::STATUS_STAY;
 
             $ret = NoPayService::pay($amount, $order_no, (string)$memberId, $channel);
@@ -429,12 +430,12 @@ class PaymentOrderService extends BaseService
             $data['member_id'] = $memberId;
             $data['amount'] = JdPayService::amount($amount);
             $data['channel'] = $channel;
-            $data += self::feeFields($amount, $rate);
+            $data += self::feeFields($amount, $rate, $feeFixed);
             $data['bank_name'] = $bankName;
             $order_no = self::createOrderNo($orderPrefix . 'jd' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['callback_url'] = JdPayService::getNotifyUrl();
-            $data['remark'] = '充值费率:' . $rate;
+            $data['remark'] = '充值手续费:' . RechargeChannel::feeDisplay($rate, $feeFixed)['fee_text'];
             $data['status'] = self::STATUS_STAY;
 
             $ret = JdPayService::pay($amount, $order_no);
@@ -472,12 +473,12 @@ class PaymentOrderService extends BaseService
         $data['member_id'] = $memberId;
         $data['amount'] = $amount;
         $data['channel'] = $channel;
-        $data += self::feeFields($amount, $rate);
+        $data += self::feeFields($amount, $rate, $feeFixed);
         $data['bank_name'] = SanJinService::getChannel($paymentType) ?? '';
         $order_no = self::createOrderNo($orderPrefix . 'sj' . $data['type'] . '_', $memberId);
         $data['order_no'] = $order_no;
         $data['callback_url'] = SanJinService::getNotifyUrl();
-        $data['remark'] = '充值费率:' . $rate;
+        $data['remark'] = '充值手续费:' . RechargeChannel::feeDisplay($rate, $feeFixed)['fee_text'];
         $data['status'] = self::STATUS_STAY;
         $ret = SanJinService::pay(($amount * 100), $order_no, $channel);
         Log::error('三斤支付发起:', $ret);
@@ -955,6 +956,8 @@ class PaymentOrderService extends BaseService
             return $result;
         }
         $channelRate = (float)($channelConfig->rate ?? 0);
+        $channelFixed = $channelConfig->fee_fixed ?? 0;
+        $feeText = RechargeChannel::feeDisplay($channelRate, $channelFixed)['fee_text'];
 
         // 在调用三方支付前开始事务
         DB::beginTransaction();
@@ -967,7 +970,7 @@ class PaymentOrderService extends BaseService
             $order_no = self::createOrderNo('sj' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['member_id'] = $memberId;
-            $data += self::feeFields($amount, $channelRate, self::PAYOUT_FIXED_FEE);
+            $data += self::feeFields($amount, $channelRate, $channelFixed);
             $amount = number_format($amount, 2, '.', '');
             $data['amount'] = $amount;
             $data['channel'] = $channel;
@@ -987,10 +990,10 @@ class PaymentOrderService extends BaseService
                 $data['callback_url'] = QianBaoService::getNotifyUrl();
             }
             $data['status'] = self::STATUS_STAY;
-            $data['remark'] = '提现费率:' . ($channelRate * 100) . '%+2';
+            $data['remark'] = '三方提现手续费:' . $feeText;
 
             // 校验打码量 → 冻结资金 → 创建待处理的提现记录 → 重置打码周期
-            [$info, $wallet] = WithdrawFundsService::hold((string)$memberId, $default_amount, '三方提现', '钱宝提现费率:' . ($channelRate * 100) . '%+2',
+            [$info, $wallet] = WithdrawFundsService::hold((string)$memberId, $default_amount, '三方提现', '三方提现手续费:' . $feeText,
                 WithdrawFundsService::PAYMENT_ORDERS, fn () => static::$MODEL::create($data));
             $id = $info->id;
             $available_balance = $wallet->available_balance;

+ 5 - 3
app/Services/QianBaoWithdrawService.php

@@ -474,12 +474,14 @@ class QianBaoWithdrawService
             return ['chat_id' => $memberId, 'code' => HttpStatus::CUSTOM_ERROR, 'text' => "提现金额范围为{$minAmount}-{$maxAmount}"];
         }
         $channelRate = (float)($channelConfig->rate ?? 0);
+        $channelFixed = $channelConfig->fee_fixed ?? 0;
+        $feeText = RechargeChannel::feeDisplay($channelRate, $channelFixed)['fee_text'];
         try {
             $data = [];
             $data['type'] = PaymentOrderService::TYPE_PAYOUT;
             $data['order_no'] = PaymentOrderService::createOrderNo('sj' . $data['type'] . '_', $memberId);
             $data['member_id'] = $memberId;
-            $data += PaymentOrderService::feeFields($amount, $channelRate, PaymentOrderService::PAYOUT_FIXED_FEE);
+            $data += PaymentOrderService::feeFields($amount, $channelRate, $channelFixed);
             $data['amount'] = number_format($amount, 2, '.', '');
             $data['channel'] = $channel;
             $data['bank_name'] = $bank_name;
@@ -499,9 +501,9 @@ class QianBaoWithdrawService
                 $data['callback_url'] = QianBaoService::getNotifyUrl();
             }
             $data['status'] = PaymentOrderService::STATUS_STAY;
-            $data['remark'] = '提现费率:' . ($channelRate * 100) . '%+2';
+            $data['remark'] = '三方提现手续费:' . $feeText;
             // 校验打码量 → 冻结资金 → 创建待处理的提现记录 → 重置打码周期
-            [$info, $wallet] = WithdrawFundsService::hold((string)$memberId, $amount, '三方提现', '钱宝提现费率:' . ($channelRate * 100) . '%+2',
+            [$info, $wallet] = WithdrawFundsService::hold((string)$memberId, $amount, '三方提现', '三方提现手续费:' . $feeText,
                 WithdrawFundsService::PAYMENT_ORDERS, fn () => PaymentOrder::create($data));
 
             $balance = bcadd((string)$wallet->available_balance, 0, 2);

+ 27 - 0
database/migrations/2026_11_09_130000_add_fee_fixed_to_recharge_channel.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 充值/提现通道的三方手续费支持组合:只按百分比(rate)、只收每笔固定费用(fee_fixed)、或百分比 + 每笔固定费用。
+ * 三方手续费 = 金额 × rate + fee_fixed,由平台承担,不扣客户。
+ * 原来三方提现代码里固定每笔加收 2 元,迁移时写入现有三方提现通道(USDT / 人工提现除外),保持计费不变。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('recharge_channel') || Schema::hasColumn('recharge_channel', 'fee_fixed')) return;
+        Schema::table('recharge_channel', fn (Blueprint $t) => $t->decimal('fee_fixed', 18, 4)->default(0)->comment('三方每笔固定手续费'));
+        DB::table('recharge_channel')->where('data_type', 2)->whereNotIn('type', ['usdt', 'rgtx'])->update(['fee_fixed' => 2]);
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('recharge_channel', 'fee_fixed')) {
+            Schema::table('recharge_channel', fn (Blueprint $t) => $t->dropColumn('fee_fixed'));
+        }
+    }
+};

+ 76 - 17
docs/总后台/手续费费率与盈亏-前端接入.md

@@ -1,45 +1,104 @@
-# 总后台-订单手续费费率与手续费盈亏(前端接入)
+# 总后台-通道手续费(百分比 / 每笔固定 / 组合)、订单手续费与盈亏(前端接入)
 
-给总后台前端看。接口路径和返回结构不变,只新增字段。
+给总后台前端看。接口路径和返回结构不变,只新增字段和参数。
 
 ## 口径(页面文案需要用到)
 
-- 充值/提现通道的「费率」是**第三方向平台收的手续费**,平台承担,**不扣客户**:充值全额到账,三方提现全额出款。
+- 充值/提现通道配置的手续费是**第三方向平台收的手续费**,平台承担,**不扣客户**:充值全额到账,三方提现全额出款。
+- 三方手续费 = 金额 × 百分比 + 每笔固定费用。通道可以**只收百分比**、**只收每笔固定费用**,或**两者都收**。
 - 向客户收的手续费只有两种:会员 **USDT 提现**的固定手续费(`service_charge`,单位 U,系统配置),以及**代理提现**按代理提现费率收的手续费。
 - 人工提现(rgtx)暂不记手续费,固定为 0。USDT 链上成本不计。
-- 下面所有 `*_rate` 字段都是**小数**:`0.020000` 表示 2%,展示时乘以 100。
+- 接口里所有 `*_rate` 字段都是**小数**:`0.02` 表示 2%,输入和展示时要换算(× 100 展示,÷ 100 提交)。金额单位是元。
 
-## 1. 会员充值 / 提现订单(`payment_orders` 行)
+## 1. 充值 / 提现通道配置(新增「每笔固定手续费」)
+
+### 列表 `GET /admin/rechargeChannel/list`
+
+每行新增:
+
+| 字段 | 说明 |
+|---|---|
+| `fee_fixed` | 每笔固定手续费(元),字符串,如 `"2.0000"`;0 表示不收 |
+| `fee_mode` | 组合方式:`rate` 只收百分比 / `fixed` 只收每笔固定 / `rate_fixed` 百分比+每笔固定 / `none` 不收 |
+| `fee_mode_text` | 组合方式中文:`百分比` / `每笔固定` / `百分比+每笔固定` / `无手续费` |
+| `fee_rate_percent` | 百分比数值,如 `"2.5"`(已 × 100,去掉多余的 0) |
+| `fee_text` | 直接展示用,如 `2.5% + 每笔1.5元`、`2%`、`每笔2元`、`无手续费` |
+
+`rate` 仍然返回(小数)。建议「费率」列直接显示 `fee_text`。
+
+### 新增 / 编辑 `POST /admin/rechargeChannel/update`
+
+新增两个参数,`rate` 改为可选:
+
+| 参数 | 必填 | 说明 |
+|---|---|---|
+| `fee_mode` | 否 | `rate` / `fixed` / `rate_fixed`。建议表单用下拉框选择 |
+| `rate` | `fee_mode` 不是 `fixed` 时必填 | 百分比,**提交小数**(2% 提交 `0.02`),范围 0~1 |
+| `fee_fixed` | `fee_mode` 为 `fixed` 或 `rate_fixed` 时必填 | 每笔固定手续费(元),≥ 0 |
+
+规则:
+
+- `fee_mode = rate`:只保存百分比,`fee_fixed` 自动清为 0;`rate` 必须 > 0。
+- `fee_mode = fixed`:只保存每笔固定费用,`rate` 自动清为 0;`fee_fixed` 必须 > 0。
+- `fee_mode = rate_fixed`:两项都必须 > 0。
+- 不传 `fee_mode` 时按提交的 `rate`、`fee_fixed` 原样保存(兼容旧页面);编辑时不传 `fee_fixed` 保持原值,新增时默认 0。
+- 校验失败的提示:`请填写手续费百分比`、`请填写每笔固定手续费`。
+
+编辑回显:用列表里的 `fee_mode` 选中下拉框;`none`(两项都为 0)时可以默认选「百分比」并填 0。
+
+表单示例:
+
+```json
+{"id": 25, "data_type": 2, "payment_company": "jd", "type": "JDpay", "fee_mode": "rate_fixed", "rate": "0.002", "fee_fixed": "2"}
+```
+
+### 操作日志 `GET /admin/rechargeChannel/logs`
+
+`changes` 里新增字段 `fee_fixed`,标签「每笔固定手续费」,如 `每笔固定手续费:2 → 3`。
+
+### 已有数据
+
+原来三方提现在代码里固定每笔加收 2 元。迁移时已经把 2 元写进所有三方提现通道的 `fee_fixed`(USDT、人工提现除外),现在的计费不变。以后改成按通道配置的值收。
+
+### 支付配置 `GET /admin/paymentConfig/options` 等
+
+通道信息里新增 `fee_fixed`、`fee_text`,含义同上;`fee_rate` 仍是百分数(已 × 100)。
+
+## 2. 会员充值 / 提现订单(`payment_orders` 行)
 
 `GET /admin/paymentOrder` 等返回 payment_orders 行的列表,新增:
 
 | 字段 | 说明 |
 |---|---|
-| `fee_rate` | 三方手续费率快照,下单时的通道费率 |
-| `fee_fixed` | 三方每笔固定手续费,三方提现为 2,其他为 0 |
-| `fee` | 三方手续费 = 金额 × `fee_rate` + `fee_fixed`(原有字段,含义不变) |
+| `fee_rate` | 下单时通道的百分比快照(小数) |
+| `fee_fixed` | 下单时通道的每笔固定手续费快照(元) |
+| `fee` | 三方手续费 = 金额 × `fee_rate` + `fee_fixed`(原有字段) |
+
+建议列:「三方费率」显示为 `fee_rate × 100`% + 每笔 `fee_fixed` 元(为 0 的部分不显示),「三方手续费」显示 `fee`。订单备注 `remark` 也会写成 `充值手续费:1.5% + 每笔1元` 或 `三方提现手续费:0.2% + 每笔2元`。
 
-建议列名:「三方费率」「三方手续费」。历史订单的 `fee_rate` 由已记录的 `fee` 反推。
+历史订单的 `fee_rate` 由已记录的 `fee` 反推,三方提现历史订单的 `fee_fixed` 为 2。
 
-## 2. 代理人民币充值 `GET /admin/agent/payment-orders`
+## 3. 代理人民币充值 `GET /admin/agent/payment-orders`
 
-新增 `fee_rate`、`fee_fixed`,含义同上。代理端接口不再返回 `fee` / `fee_rate` / `fee_fixed`。
+新增 `fee_rate`、`fee_fixed`,含义同上。代理端接口不返回 `fee` / `fee_rate` / `fee_fixed`。
 
-## 3. 代理提现 `GET /admin/agent/withdrawals`
+## 4. 代理提现 `GET /admin/agent/withdrawals`
 
 | 字段 | 说明 |
 |---|---|
 | `fee` | 向代理收的提现手续费(收入),原有字段 |
 | `fee_rate` | 代理提现费率快照(小数) |
-| `channel_fee_rate` | 三方代付费率快照(小数) |
-| `channel_fee_fixed` | 三方代付每笔固定手续费(2) |
+| `channel_fee_rate` | 三方代付百分比快照(小数) |
+| `channel_fee_fixed` | 三方代付每笔固定手续费快照(元),取自通道配置 |
 | `channel_fee` | 平台付给三方的成本 = 到账金额 × `channel_fee_rate` + `channel_fee_fixed`;USDT / 人工提现为 0;**`null` 表示历史订单未记录**,显示「未记录」 |
 
 建议分两列:「代理手续费」(`fee`)和「三方成本」(`channel_fee`)。`channel_*` 字段只在总后台返回。
 
-## 4. 资金汇总 `GET /admin/funds/summary`
+## 5. 资金汇总 `GET /admin/funds/summary`
+
+所有三方手续费金额都已包含百分比部分和每笔固定部分。
 
-- 收入里的「会员出款扣除」(`member_withdraw_deduction`)现在只含 USDT 提现手续费,不再把人工提现的 `fee` 算进去。
+- 收入里的「会员出款扣除」(`member_withdraw_deduction`)只含 USDT 提现手续费,不再把人工提现的 `fee` 算进去。
 - 支出 `expense` 里平台承担的三方手续费(都计入 `expense_total`):
 
 | key | 名称 | 说明 |
@@ -49,7 +108,7 @@
 | `agent_deposit_channel_fee` | 代理三方充值手续费 | **新增** |
 | `agent_payout_channel_fee` | 代理三方提现手续费 | **新增**,历史代理提现未记录成本的不计入 |
 
-- 新增 `fee_summary`:
+- 新增 `fee_summary`(手续费盈亏小结):
 
 ```json
 {

+ 5 - 2
tests/Integration/AgentPaymentOrderTest.php

@@ -209,14 +209,17 @@ class AgentPaymentOrderTest extends TestCase
     {
         $this->payments();
         $this->fullPaymentOrders();
-        DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->update(['rate' => '0.0150']);
+        DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->update(['rate' => '0.0150', 'fee_fixed' => '1']);
         $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PAY-2', 'url' => '']]);
         $result = PaymentOrderService::createPayOrder(PaymentOrder::class, 'M1001', 100, 'JDpay', 2,
             RechargeChannel::checkRechargeChannel('JDpay', 2));
         $this->assertSame(0, $result['code']);
         $this->assertStringStartsWith('jd1_', $result['order_no']);
         $found = PaymentOrderService::findPayOrder($result['order_no']);
-        $this->assertSame(['0.015000', '1.5000'], [bcadd((string) $found->fee_rate, '0', 6), bcadd((string) $found->fee, '0', 4)]);
+        // 百分比 + 每笔固定:100 × 1.5% + 1
+        $this->assertSame(['0.015000', '1.0000', '2.5000'], [bcadd((string) $found->fee_rate, '0', 6),
+            bcadd((string) $found->fee_fixed, '0', 4), bcadd((string) $found->fee, '0', 4)]);
+        $this->assertSame('充值手续费:1.5% + 每笔1元', $found->remark);
         $this->assertSame(PaymentOrder::class, get_class($found));
         $this->assertSame('M1001', $found->member_id);
         $this->assertSame(0, DB::table('agent_payment_orders')->count());

+ 33 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -64,6 +64,7 @@ class PaymentChannelOptionsTest extends TestCase
         (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_150000_add_recharge_channel_logo.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_160000_create_recharge_method_logos.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_130000_add_fee_fixed_to_recharge_channel.php')->up();
         $db->schema()->create('operation_audits', function (Blueprint $t) {
             $t->id(); $t->string('resource_type'); $t->unsignedBigInteger('resource_id')->nullable();
             $t->string('action'); $t->json('changes'); $t->unsignedBigInteger('operator_id')->nullable();
@@ -311,6 +312,38 @@ class PaymentChannelOptionsTest extends TestCase
         return $response['data'];
     }
 
+    public function test_channel_fee_can_be_rate_fixed_or_both_and_is_listed_and_logged(): void
+    {
+        // 迁移把原来写死的每笔 2 元写入三方提现通道,USDT / 人工提现不收。
+        $withdraw = DB::table('recharge_channel')->where('data_type', 2)->pluck('fee_fixed', 'type')->map(fn ($v) => (float)$v);
+        $this->assertSame([2.0, 0.0, 0.0], [$withdraw['JDpay'], $withdraw['rgtx'], $withdraw['usdt']]);
+
+        $this->assertSame(0, $this->saveChannel($this->newChannel(['fee_mode' => 'fixed', 'rate' => '0.05', 'fee_fixed' => '3']))['code']);
+        $id = (int)DB::table('recharge_channel')->where('key', 'test-key')->value('id');
+        $this->assertSame(['rate' => 0.0, 'fee_fixed' => 3.0], array_map('floatval', (array)DB::table('recharge_channel')->where('id', $id)->first(['rate', 'fee_fixed'])),
+            'fixed mode drops the percentage');
+        $edit = ['id' => $id, 'data_type' => 1, 'payment_company' => 'no', 'type' => 'NOpay12'];
+        $this->assertSame(0, $this->saveChannel($edit + ['fee_mode' => 'rate_fixed', 'rate' => '0.025', 'fee_fixed' => '1.5'])['code']);
+        $row = collect($this->channelList(['data_type' => 1, 'key' => 'test-key'])['data'])->first();
+        $this->assertSame(['rate_fixed', '百分比+每笔固定', '2.5', '2.5% + 每笔1.5元'],
+            [$row['fee_mode'], $row['fee_mode_text'], $row['fee_rate_percent'], $row['fee_text']]);
+        $this->assertSame(0, $this->saveChannel($edit + ['fee_mode' => 'rate', 'rate' => '0.02', 'fee_fixed' => '9'])['code']);
+        $row = collect($this->channelList(['data_type' => 1, 'key' => 'test-key'])['data'])->first();
+        $this->assertSame(['rate', '2%', 0.0], [$row['fee_mode'], $row['fee_text'], (float)$row['fee_fixed']], 'rate mode drops the fixed fee');
+
+        $missing = $this->saveChannel($edit + ['fee_mode' => 'rate_fixed', 'rate' => '0.02', 'fee_fixed' => '0']);
+        $this->assertNotSame(0, $missing['code']);
+        $this->assertSame('请填写每笔固定手续费', $missing['msg']);
+        $this->assertNotSame(0, $this->saveChannel($edit + ['fee_mode' => 'fixed'])['code'], 'fixed mode needs an amount');
+        $this->assertNotSame(0, $this->saveChannel($edit + ['rate' => '1.5'])['code'], 'rate is a fraction, at most 1');
+
+        $this->assertSame(0, $this->saveChannel($edit + ['rate' => '0.02', 'fee_fixed' => '4'])['code'], 'without fee_mode both fields save as sent');
+        $this->assertSame('2% + 每笔4元', collect($this->channelList(['data_type' => 1, 'key' => 'test-key'])['data'])->first()['fee_text']);
+        $logs = $this->channelLogs(['data_type' => 1, 'channel_id' => $id]);
+        $change = array_column($logs['data'][0]['changes'], null, 'field')['fee_fixed'];
+        $this->assertSame(['每笔固定手续费', '0', '4'], [$change['label'], $change['before'], $change['after']]);
+    }
+
     public function test_channel_logs_record_who_when_and_what_changed_per_direction(): void
     {
         $saveAs = function (array $params, string $admin) {

+ 1 - 1
tests/Support/AgentTestEnvironment.php

@@ -149,7 +149,7 @@ trait AgentTestEnvironment
             '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php',
             '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php',
             '2026_11_08_120000_create_agent_payment_orders.php', '2026_11_08_170000_add_agent_withdrawal_channels.php',
-            '2026_11_09_120000_add_fee_snapshots_to_payment_orders.php'] as $file) {
+            '2026_11_09_120000_add_fee_snapshots_to_payment_orders.php', '2026_11_09_130000_add_fee_fixed_to_recharge_channel.php'] as $file) {
             (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
         }
     }