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

+ 2 - 0
app/Constants/StepStatus.php

@@ -39,5 +39,7 @@ class StepStatus
 
     const MY_INPUT_OLD_SECRET = 23;//输入原账号秘钥
     const MY_INPUT_SECRET_PASS = 24;//输入秘钥的查看密码
+    const QB_INPUT_BANK_BRANCH = 25;//输入开户行
+    const INPUT_WITHDRAW_REAL_NAME = 26;//绑定提现地址时输入真实姓名
 
 }

+ 5 - 0
app/Http/Controllers/admin/Balance.php

@@ -31,6 +31,8 @@ class Balance extends Controller
                 'account' => ['nullable', 'string', 'max:64'],
                 'first_name' => ['nullable'],
                 'is_fancai' => ['nullable', 'integer','in:0,1'],
+                'sort_field' => ['nullable', 'string', 'in:amount'],
+                'sort_order' => ['nullable', 'string', 'in:asc,desc'],
                 // 用户类型/注册来源:-1 tg用户,0 后台客服账号,1 真实用户,2 虚拟用户
                 'from' => ['nullable'],
             ]);
@@ -55,6 +57,9 @@ class Balance extends Controller
             // 统计与列表共用筛选条件;clone 避免 count/sum 影响后续分页查询
             $data['total'] = (clone $query)->count();
             $data['total_amount'] = bcadd((clone $query)->sum('balance_logs.amount'), 0, 2);
+            if (($params['sort_field'] ?? null) === 'amount') {
+                $query->orderBy('balance_logs.amount', $params['sort_order'] ?? 'desc');
+            }
             $data['data'] = $query->select("balance_logs.*", "users.first_name", "users.account", "users.admin_note as user_admin_note", "users.from as user_from")
                 ->orderByDesc('balance_logs.id')
                 ->forPage($page, $limit)

+ 21 - 8
app/Http/Controllers/admin/Withdraw.php

@@ -56,17 +56,19 @@ class Withdraw extends Controller
                 'account' => ['nullable', 'string', 'max:64'],
                 'status' => ['nullable', 'integer'],
                 'first_name' => ['nullable'],
+                'real_name' => ['nullable', 'string', 'max:64'],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
             $params['type'] = $params['type'] ?? 2;
             
             $query = PaymentOrder::join('users', 'users.member_id', '=', 'payment_orders.member_id')
-                        ->select("payment_orders.*", "payment_orders.account as recipient_name", "users.first_name", "users.account", "users.admin_note as user_admin_note");
+                        ->select("payment_orders.*", "payment_orders.account as recipient_name", "users.real_name", "users.first_name", "users.account", "users.admin_note as user_admin_note");
                         
-            $where = PaymentOrderService::getWhere($params);
-            $count = $query->where($where)->count();
-            $list = $query->where($where)
+            $where = $this->withRealNameFilter(PaymentOrderService::getWhere($params), $params['real_name'] ?? null);
+            $query->where($where);
+            $count = $query->count();
+            $list = $query
                 ->orderByRaw('CASE WHEN bot_payment_orders.status = 0 THEN 0 ELSE 1 END')
                 ->orderByDesc('payment_orders.created_at')
                 ->forpage($page, $limit)->get()->toArray();
@@ -132,14 +134,16 @@ class Withdraw extends Controller
                 'account' => ['nullable', 'string', 'max:64'],
                 'status' => ['nullable', 'integer'],
                 'first_name' => ['nullable'],
+                'real_name' => ['nullable', 'string', 'max:64'],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
             $query = WithdrawModel::join('users', 'users.member_id', '=', 'withdraws.member_id')
-                        ->select("withdraws.*", "users.first_name", "users.account", "users.admin_note as user_admin_note");
-            $where = WithdrawService::getWhere($params);
-            $count = $query->where($where)->count();
-            $list = $query->where($where)
+                        ->select("withdraws.*", "users.real_name", "users.first_name", "users.account", "users.admin_note as user_admin_note");
+            $where = $this->withRealNameFilter(WithdrawService::getWhere($params), $params['real_name'] ?? null);
+            $query->where($where);
+            $count = $query->count();
+            $list = $query
                 ->orderByRaw('CASE WHEN bot_withdraws.status = 0 THEN 0 ELSE 1 END')
                 ->orderByDesc('withdraws.created_at')
                 ->forpage($page, $limit)->get();
@@ -173,6 +177,15 @@ class Withdraw extends Controller
         return $this->success($result);
     }
 
+    private function withRealNameFilter(array $where, ?string $name): array
+    {
+        $name = trim($name ?? '');
+        if ($name !== '') {
+            $where[] = ['users.real_name', 'like', '%' . $name . '%'];
+        }
+        return $where;
+    }
+
     public function setStatus()
     {
         DB::beginTransaction();

+ 54 - 20
app/Http/Controllers/api/Wallet.php

@@ -292,14 +292,25 @@ class Wallet extends BaseController
         $list = RechargeChannel::getFormatChannel(2, $recharge_channel_group_id);
         foreach ($list as &$item) {
             $channel = $item['value'];
-            $item['requires_real_name'] = WithdrawalName::usesName($channel);
+            $item['requires_real_name'] = true;
             $item['requires_bank_name'] = WithdrawalDestination::requiresBankName($channel);
+            $item['requires_bank_branch'] = $channel === 'DF001';
+            if ($channel === 'DF001') {
+                $item['bank_name_label'] = lang('银行名称');
+                $item['bank_branch_label'] = lang('开户行');
+            }
             if ($channel === 'rgtx') {
                 $item['payout_methods'] = [];
                 foreach (WithdrawalDestination::MANUAL_METHODS as $value => $label) {
-                    $item['payout_methods'][] = ['value' => $value, 'label' => lang($label),
+                    $method = ['value' => $value, 'label' => lang($label),
                         'requires_bank_name' => $value === 'bank', 'requires_card_no' => $value !== 'wechat',
+                        'requires_bank_branch' => $value === 'bank',
                         'requires_qr_image' => $value !== 'bank'];
+                    if ($value === 'bank') {
+                        $method['bank_name_label'] = lang('银行名称');
+                        $method['bank_branch_label'] = lang('开户行');
+                    }
+                    $item['payout_methods'][] = $method;
                 }
             }
         }
@@ -329,6 +340,7 @@ class Wallet extends BaseController
             if (!password_verify($params['safe_word'], $user->payment_password)) {
                 throw new Exception(lang('资金密码错误'));
             }
+            WithdrawalName::required($user);
 
             //校验是否支持此提现方式
             $check  = RechargeChannel::checkWithdrawChannel('usdt', $user->recharge_channel_group_id);
@@ -397,6 +409,7 @@ class Wallet extends BaseController
                 'amount' => ['required', 'numeric', 'min:0.01'],
                 'channel' => ['required', 'string', 'in:' . WithdrawalName::CHANNELS],
                 'bank_name' => ['nullable', 'string', 'max:255'],
+                'bank_branch' => ['nullable', 'string', 'max:255'],
                 'card_no' => ['nullable', 'string', 'max:255'],
                 'payout_method' => ['nullable', 'string', 'in:' . implode(',', array_keys(WithdrawalDestination::MANUAL_METHODS))],
                 'qr_image' => ['nullable', 'string', 'max:255'],
@@ -443,6 +456,7 @@ class Wallet extends BaseController
                     $data['amount'] = number_format($amount, 2, '.', '');
                     $data['channel'] = $params['channel'];
                     $data['bank_name'] = $destination['bank_name'];
+                    $data['bank_branch'] = $destination['bank_branch'] ?? null;
                     $data['account'] = WithdrawalName::forPayout($user, $channel);
                     $data['card_no'] = $destination['card_no'];
                     $data['qr_image'] = $destination['qr_image'] ?? null;
@@ -470,7 +484,10 @@ class Wallet extends BaseController
                     $destination['bank_name'], $destination['card_no'], $destination['qr_image'] ?? null
                 )), '提交成功');
             } else {
-                $res = QianBaoWithdrawService::createOrder($member_id, $params['amount'], $channel, $destination['bank_name'], $destination['card_no']);
+                $res = QianBaoWithdrawService::createOrder(
+                    $member_id, $params['amount'], $channel, $destination['bank_name'], $destination['card_no'],
+                    $destination['bank_branch']
+                );
                 if ($res['code'] == 0) { 
                     return $this->success($res,'提交成功');
                 }
@@ -493,11 +510,15 @@ class Wallet extends BaseController
                 'amount' => ['required', 'numeric', 'min:0.01'],
                 'channel' => ['required', 'string'],
                 'bank_name' => ['nullable', 'string', 'max:255'],
+                'bank_branch' => ['nullable', 'string', 'max:255'],
                 'card_no' => ['required', 'string', 'max:255'],
             ]);
             $member_id = request()->user->member_id;
             $destination = WithdrawalDestination::resolve($params['channel'], $params);
-            $res = PaymentOrderService::autoCreatePayout($member_id, $params['amount'], $params['channel'], $destination['bank_name'], $destination['card_no']);
+            $res = PaymentOrderService::autoCreatePayout(
+                $member_id, $params['amount'], $params['channel'], $destination['bank_name'], $destination['card_no'],
+                $destination['bank_branch'] ?? null
+            );
             if (empty($res['text'])) { 
                 return $this->success($res,'提交成功');
             }
@@ -518,6 +539,7 @@ class Wallet extends BaseController
                 'account' => 'nullable|string',
                 'card_no' => 'nullable|string|max:255',
                 'bank_name' => 'nullable|string|max:255',
+                'bank_branch' => 'nullable|string|max:255',
                 'payout_method' => 'nullable|string|in:' . implode(',', array_keys(WithdrawalDestination::MANUAL_METHODS)),
                 'qr_image' => 'nullable|string|max:255',
             ]);
@@ -535,13 +557,14 @@ class Wallet extends BaseController
                     }
                     $info = new Bank();
                 }
-                $account = WithdrawalName::bind($user, $params['channel'], $params['account'] ?? null);
+                $name = WithdrawalName::bind($user, $params['account'] ?? null);
                 if (!$info->fill([
                     'member_id' => $member_id,
                     'channel' => $params['channel'],
-                    'account' => $account,
+                    'account' => WithdrawalName::usesOrderName($params['channel']) ? $name : '',
                     'card_no' => $destination['card_no'],
                     'bank_name' => $destination['bank_name'],
+                    'bank_branch' => $destination['bank_branch'] ?? null,
                     'qr_image' => $destination['qr_image'] ?? null,
                 ])->save()) {
                     throw new Exception(lang('系统繁忙,请稍后重试!'));
@@ -588,7 +611,7 @@ class Wallet extends BaseController
             $where = !empty($params['channel']) ? ['channel' => $params['channel']] : [];
             $list = Bank::where('member_id', $member_id)->where($where)->get()->toArray();
             foreach ($list as &$account) {
-                if (WithdrawalName::usesName($account['channel'])) {
+                if (WithdrawalName::usesOrderName($account['channel'])) {
                     $account['account'] = $realName ?? '';
                 }
                 if ($account['channel'] === 'rgtx') {
@@ -612,24 +635,34 @@ class Wallet extends BaseController
         try {
             $params = request()->validate([
                 'id' => 'nullable|integer',
-                'address' => 'required',
+                'address' => 'required|string',
+                'account' => 'nullable|string',
             ]);
             $member_id = request()->user->member_id;
-            if (!empty($params['id'])) {
-                $info = Address::where('id', $params['id'])->where('member_id', $member_id)->first();
-                if (empty($info)) throw new Exception(lang('找不到此记录'));
-                $info->address = $params['address'];
-                $info->save();
-            } else {
-                $count = Address::where('member_id', $member_id)->where('address', $params['address'])->count();
-                if ($count >= 5) throw new Exception(lang('已达添加上限'));
-                Address::create([
+            $name = DB::transaction(function () use ($member_id, $params) {
+                $user = User::where('member_id', $member_id)->lockForUpdate()->firstOrFail();
+                $addresses = Address::where('member_id', $member_id);
+                if (!empty($params['id'])) {
+                    $info = $addresses->where('id', $params['id'])->lockForUpdate()->first();
+                    if (!$info) throw new Exception(lang('找不到此记录'));
+                } else {
+                    if ($addresses->where('address', $params['address'])->count() >= 5) {
+                        throw new Exception(lang('已达添加上限'));
+                    }
+                    $info = new Address();
+                }
+                $name = WithdrawalName::bind($user, $params['account'] ?? null);
+                if (!$info->fill([
                     'member_id' => $member_id,
                     'address' => $params['address'],
-                ]);
-            }
+                ])->save()) {
+                    throw new Exception(lang('系统繁忙,请稍后重试!'));
+                }
+
+                return $name;
+            });
 
-            return $this->success([],'提交成功');
+            return $this->success(['real_name' => $name], '提交成功');
         } catch (ValidationException $e) {
             return $this->error($e->validator->errors()->first());
         } catch (\Exception $e) {
@@ -664,6 +697,7 @@ class Wallet extends BaseController
 
             return $this->success([
                 'list' => $list,
+                'real_name' => request()->user->real_name,
             ]);
         } catch (\Exception $e) {
             return $this->error($e->getMessage());

+ 11 - 1
app/Models/Bank.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\Services\WithdrawalDestination;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
@@ -25,7 +26,7 @@ class Bank extends BaseModel
 
 
     protected $table = 'banks';
-    protected $fillable = ['member_id', 'channel', 'bank_name', 'account', 'card_no', 'qr_image'];
+    protected $fillable = ['member_id', 'channel', 'bank_name', 'bank_branch', 'account', 'card_no', 'qr_image'];
     protected $hidden = ['created_at', 'updated_at', 'alias'];
 
     public function getId()
@@ -53,6 +54,15 @@ class Bank extends BaseModel
         return $this->bank_name;
     }
 
+    public function getBankBranchAttribute(?string $value): ?string
+    {
+        if (!WithdrawalDestination::requiresBankBranch((string) $this->channel, (string) $this->bank_name)) {
+            return null;
+        }
+
+        return $value ?? (trim((string) ($this->attributes['alias'] ?? '')) ?: null);
+    }
+
     public function getAccount(): string
     {
         return $this->account;

+ 1 - 1
app/Models/PaymentOrder.php

@@ -16,7 +16,7 @@ class PaymentOrder extends BaseModel
 {
 
     protected $table = 'payment_orders';
-    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'account', 'card_no', 'qr_image', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot', 'frozen_amount'];
+    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'bank_branch', 'account', 'card_no', 'qr_image', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot', 'frozen_amount'];
     protected $hidden = ['recharge_bonus_snapshot'];
     protected $casts = ['recharge_bonus_snapshot' => 'array'];
 

+ 5 - 1
app/Services/PaymentOrderService.php

@@ -856,7 +856,7 @@ class PaymentOrderService extends BaseService
      * @param {*} $card_no  银行卡号/支付宝账号
      * @return {*}
      */
-    public static function autoCreatePayout($memberId, $amount, $channel, $bank_name, $card_no)
+    public static function autoCreatePayout($memberId, $amount, $channel, $bank_name, $card_no, ?string $bankBranch = null)
     {
         $default_amount = $amount;
         $result = [];
@@ -889,6 +889,7 @@ class PaymentOrderService extends BaseService
 
         try {
             $account = WithdrawalName::forPayout($user, $channel);
+            $bankBranch = WithdrawalDestination::bankBranch($channel, $bank_name, $bankBranch);
             $data = [];
             $data['type'] = self::TYPE_PAYOUT;
             $order_no = self::createOrderNo('sj' . $data['type'] . '_', $memberId);
@@ -899,6 +900,9 @@ class PaymentOrderService extends BaseService
             $data['amount'] = $amount;
             $data['channel'] = $channel;
             $data['bank_name'] = $bank_name;
+            if ($bankBranch !== null) {
+                $data['bank_branch'] = $bankBranch;
+            }
             $data['account'] = $account;
             $data['card_no'] = $card_no;
             if (NoPayService::isWithdrawChannel($channel)) {

+ 42 - 10
app/Services/QianBaoWithdrawService.php

@@ -130,9 +130,10 @@ class QianBaoWithdrawService
     public static function onMessage($chatId, $text, $messageId, $stepStatus): null|array
     {
         return match ($stepStatus) {
-            StepStatus::LEGACY_QB_INPUT_ALIAS => self::saveBank($chatId, $messageId, Cache::get("{$chatId}_QB_ACCOUNT")),
+            StepStatus::LEGACY_QB_INPUT_ALIAS => self::saveBank($chatId, $messageId, Cache::get("{$chatId}_QB_ACCOUNT"), $text),
             StepStatus::INPUT_WITHDRAW_QB_MONEY => QianBaoWithdrawService::inputQbAmount($chatId, $text, $messageId),
             StepStatus::QB_INPUT_BANK_NAME => QianBaoWithdrawService::inputBankName($chatId, $text, $messageId),
+            StepStatus::QB_INPUT_BANK_BRANCH => self::inputBankBranch($chatId, $text, $messageId),
             StepStatus::QB_INPUT_CARD_NO => QianBaoWithdrawService::inputCardNo($chatId, $text, $messageId),
             StepStatus::QB_INPUT_ACCOUNT => QianBaoWithdrawService::inputAccount($chatId, $text, $messageId),
             default => null,
@@ -376,6 +377,7 @@ class QianBaoWithdrawService
         }
         try {
             $account = WithdrawalName::forPayout(User::where('member_id', $chatId)->first(), $bank->getChannel());
+            $branch = WithdrawalDestination::bankBranch($bank->getChannel(), $bank->getBankName(), $bank->bank_branch);
         } catch (Exception $e) {
             return ['chat_id' => $chatId, 'text' => $e->getMessage(), 'reply_to_message_id' => $messageId];
         }
@@ -383,7 +385,8 @@ class QianBaoWithdrawService
         switch ($bank->getChannel()) {
             case "DF001":
                 $text = lang('银行卡提现确认') . "\n";
-                $text .= lang('开户行') . ":{$bank->getBankName()}\n";
+                $text .= lang('银行名称') . ":{$bank->getBankName()}\n";
+                $text .= lang('开户行') . ":{$branch}\n";
                 $text .= lang('姓名') . ":{$account}\n";
                 $text .= lang('提现账号') . ":{$bank->getCardNo()}\n";
                 $text .= lang('提现金额') . ":{$amount}\n";
@@ -442,9 +445,9 @@ class QianBaoWithdrawService
         //目前客户要求手动到账,后续也可以做到config 表,从后台进行控制
         $isAutoDeposit = false;
         if ($isAutoDeposit) {
-            $res = PaymentOrderService::autoCreatePayout($chatId, $amount, $bank->getChannel(), $bank->getBankName(), $bank->getCardNo());
+            $res = PaymentOrderService::autoCreatePayout($chatId, $amount, $bank->getChannel(), $bank->getBankName(), $bank->getCardNo(), $bank->bank_branch);
         } else {
-            $res = static::createOrder($chatId, $amount, $bank->getChannel(), $bank->getBankName(), $bank->getCardNo());
+            $res = static::createOrder($chatId, $amount, $bank->getChannel(), $bank->getBankName(), $bank->getCardNo(), $bank->bank_branch);
 
         }
 
@@ -454,7 +457,7 @@ class QianBaoWithdrawService
     }
 
     //创建提现订单
-    public static function createOrder($memberId, $amount, $channel, $bank_name, $card_no)
+    public static function createOrder($memberId, $amount, $channel, $bank_name, $card_no, ?string $bankBranch = null)
     {
         $result['chat_id'] = $memberId;
         $result['code'] = 0;
@@ -481,6 +484,10 @@ class QianBaoWithdrawService
             $data['channel'] = $channel;
             $data['bank_name'] = $bank_name;
             $data['account'] = WithdrawalName::forPayout($user, $channel);
+            $bankBranch = WithdrawalDestination::bankBranch($channel, $bank_name, $bankBranch);
+            if ($bankBranch !== null) {
+                $data['bank_branch'] = $bankBranch;
+            }
             $data['card_no'] = $card_no;
             if (NoPayService::isWithdrawChannel($channel)) {
                 $data['callback_url'] = NoPayService::getWithdrawNotifyUrl();
@@ -567,7 +574,8 @@ class QianBaoWithdrawService
             case "DF001":
                 $text = "*" . lang('银行卡管理') . "*\n\n";
                 $text .= lang('姓名') . ":{$account}\n";
-                $text .= lang('银行') . ":{$bank->getBankName()}\n";
+                $text .= lang('银行名称') . ":{$bank->getBankName()}\n";
+                $text .= lang('开户行') . ":{$bank->bank_branch}\n";
                 $text .= lang('卡号') . ":{$bank->getCardNo()}\n";
                 break;
             case "DF002":
@@ -613,13 +621,14 @@ class QianBaoWithdrawService
     private static function chooseChannel($chatId, $messageId, $channel)
     {
         Cache::put("{$chatId}_QB_WITHDRAW_CHANNEL", $channel);
+        Cache::delete("{$chatId}_QB_WITHDRAW_BANK_BRANCH");
 
         switch ($channel) {
             case "DF001"://银行卡
                 Cache::put(get_step_key($chatId), StepStatus::QB_INPUT_BANK_NAME);
                 return [
                     'chat_id' => $chatId,
-                    'text' => lang("请输入开户银行卡开户名称"),
+                    'text' => lang('请输入银行名称'),
                     'message_id' => $messageId,
                 ];
             case "DF002"://支付宝
@@ -652,6 +661,19 @@ class QianBaoWithdrawService
     private static function inputBankName($chatId, $bankName, $messageId): array
     {
         Cache::put("{$chatId}_QB_WITHDRAW_BANK_NAME", $bankName);
+        Cache::put(get_step_key($chatId), StepStatus::QB_INPUT_BANK_BRANCH);
+
+        return ['chat_id' => $chatId, 'text' => lang('请输入开户行支行名称'), 'message_id' => $messageId];
+    }
+
+    private static function inputBankBranch($chatId, $bankBranch, $messageId): array
+    {
+        try {
+            $bankBranch = WithdrawalDestination::bankBranch('DF001', '', $bankBranch);
+        } catch (Exception $e) {
+            return ['chat_id' => $chatId, 'text' => $e->getMessage(), 'message_id' => $messageId];
+        }
+        Cache::put("{$chatId}_QB_WITHDRAW_BANK_BRANCH", $bankBranch);
         Cache::put(get_step_key($chatId), StepStatus::QB_INPUT_CARD_NO);
         return [
             'chat_id' => $chatId,
@@ -701,7 +723,7 @@ class QianBaoWithdrawService
         return self::saveBank($chatId, $messageId, $account);
     }
 
-    private static function saveBank($chatId, $messageId, ?string $name): array
+    private static function saveBank($chatId, $messageId, ?string $name, ?string $legacyBranch = null): array
     {
         $channel = Cache::get("{$chatId}_QB_WITHDRAW_CHANNEL");
         $cardNo = Cache::get("{$chatId}_QB_WITHDRAW_CARD_NO");
@@ -709,21 +731,31 @@ class QianBaoWithdrawService
         if (!in_array($channel, ['DF001', 'DF002', 'DF005'], true)) {
             return ['chat_id' => $chatId, 'text' => lang('选择通道错误'), 'message_id' => $messageId];
         }
-        DB::transaction(function () use ($chatId, $channel, $cardNo, $bankName, $name) {
+        try {
+            $branch = WithdrawalDestination::bankBranch(
+                $channel, (string) $bankName, Cache::get("{$chatId}_QB_WITHDRAW_BANK_BRANCH") ?? $legacyBranch
+            );
+        } catch (Exception $e) {
+            Cache::put(get_step_key($chatId), StepStatus::QB_INPUT_BANK_BRANCH);
+            return ['chat_id' => $chatId, 'text' => $e->getMessage(), 'message_id' => $messageId];
+        }
+        DB::transaction(function () use ($chatId, $channel, $cardNo, $bankName, $branch, $name) {
             $user = User::where('member_id', $chatId)->lockForUpdate()->firstOrFail();
-            $account = WithdrawalName::bind($user, $channel, $name);
+            $account = WithdrawalName::bind($user, $name);
             $bank = Bank::create([
                 'member_id' => $chatId,
                 'account' => $account,
                 'channel' => $channel,
                 'card_no' => $cardNo,
                 'bank_name' => $bankName,
+                'bank_branch' => $branch,
             ]);
             if (!$bank->exists) {
                 throw new Exception(lang('系统繁忙,请稍后重试!'));
             }
         });
         Cache::delete("{$chatId}_QB_ACCOUNT");
+        Cache::delete("{$chatId}_QB_WITHDRAW_BANK_BRANCH");
         Cache::delete(get_step_key($chatId));
         return static::banks($chatId, $messageId, $channel);
     }

+ 4 - 1
app/Services/UserService.php

@@ -175,8 +175,11 @@ class UserService extends BaseService
                 ->orderBy('user_total_consume.total_consume', $sortOrder);
         } elseif ($sortField === 'wager_remaining') {
             $direction = $sortOrder === 'asc' ? 'ASC' : 'DESC';
+            $grammar = $query->getQuery()->getGrammar();
+            $required = $grammar->wrap('wallets.wager_required');
+            $completed = $grammar->wrap('wallets.wager_completed');
             $query->orderByRaw(
-                "GREATEST(CAST(wallets.wager_required AS DECIMAL(20,4)) - CAST(wallets.wager_completed AS DECIMAL(20,4)), 0) {$direction}"
+                "GREATEST(COALESCE({$required}, 0) - COALESCE({$completed}, 0), 0) {$direction}"
             );
         } elseif (in_array($sortField, self::PAGINATE_WALLET_SORT_FIELDS, true)) {
             $query->orderBy("wallets.{$sortField}", $sortOrder);

+ 36 - 1
app/Services/WithdrawService.php

@@ -109,6 +109,12 @@ class WithdrawService
                     ? self::inputAddress($chatId, $address, $messageId)
                     : self::addAddress($chatId, $messageId);
                 break;
+            case StepStatus::INPUT_WITHDRAW_REAL_NAME:
+                $address = Cache::get("{$chatId}_address");
+                $res = is_string($address) && $address !== ''
+                    ? self::saveAddress($chatId, $address, $messageId, $text)
+                    : self::addAddress($chatId, $messageId);
+                break;
             default:
                 $res = null;
                 break;
@@ -291,7 +297,31 @@ class WithdrawService
                 'reply_to_message_id' => $messageId,
             ];
         }
-        Address::create(['member_id' => $chatId, 'address' => $address]);
+        $user = User::where('member_id', $chatId)->first();
+        if ($user?->real_name === null || $user->real_name === '') {
+            Cache::put("{$chatId}_address", $address);
+            Cache::put(get_step_key($chatId), StepStatus::INPUT_WITHDRAW_REAL_NAME);
+            return ['chat_id' => $chatId, 'text' => lang('请输入真实姓名'), 'message_id' => $messageId];
+        }
+
+        return self::saveAddress($chatId, $address, $messageId);
+    }
+
+    private static function saveAddress($chatId, string $address, $messageId, ?string $name = null): array
+    {
+        try {
+            DB::transaction(function () use ($chatId, $address, $name) {
+                $user = User::where('member_id', $chatId)->lockForUpdate()->firstOrFail();
+                WithdrawalName::bind($user, $name);
+                if (!Address::create(['member_id' => $chatId, 'address' => $address])->exists) {
+                    throw new Exception(lang('系统繁忙,请稍后重试!'), HttpStatus::CUSTOM_ERROR);
+                }
+            });
+        } catch (Exception $e) {
+            if ($e->getCode() !== HttpStatus::CUSTOM_ERROR) LogService::error($e);
+            $text = $e->getCode() === HttpStatus::CUSTOM_ERROR ? $e->getMessage() : lang('系统繁忙,请稍后重试!');
+            return ['chat_id' => $chatId, 'text' => $text, 'message_id' => $messageId];
+        }
         Cache::delete("{$chatId}_address");
         Cache::delete(get_step_key($chatId));
 
@@ -342,6 +372,11 @@ class WithdrawService
 
     public static function done($chatId, $messageId, $firstName)
     {
+        try {
+            WithdrawalName::required(User::where('member_id', $chatId)->first());
+        } catch (Exception $e) {
+            return ['chat_id' => $chatId, 'text' => $e->getMessage(), 'message_id' => $messageId];
+        }
         $serviceAccount = Config::where('field', 'service_customer')->first()->val;
         $wagerLeft = WageringService::remainingFor((string)$chatId);
         if ($wagerLeft !== null) {

+ 27 - 3
app/Services/WithdrawalDestination.php

@@ -22,6 +22,26 @@ final class WithdrawalDestination
         return $channel === QianBaoService::ALIPAY_TO_CARD || ZimuPayService::isCashChannel($channel);
     }
 
+    public static function requiresBankBranch(string $channel, string $bankName = ''): bool
+    {
+        return $channel === QianBaoService::ALIPAY_TO_CARD
+            || ($channel === 'rgtx' && self::manualMethod($bankName) === 'bank');
+    }
+
+    public static function bankBranch(string $channel, string $bankName, ?string $branch): ?string
+    {
+        if (!self::requiresBankBranch($channel, $bankName)) {
+            return null;
+        }
+
+        $branch = trim($branch ?? '');
+        if ($branch === '' || mb_strlen($branch) > 255) {
+            throw new Exception(lang('请输入开户行支行名称'), HttpStatus::CUSTOM_ERROR);
+        }
+
+        return $branch;
+    }
+
     public static function resolve(string $channel, array $params): array
     {
         $bankName = trim($params['bank_name'] ?? '');
@@ -45,10 +65,10 @@ final class WithdrawalDestination
                 ];
             }
             if ($bankName === '' || self::manualMethod($bankName) !== 'bank') {
-                throw new Exception(lang('请输入开户行名称'), HttpStatus::CUSTOM_ERROR);
+                throw new Exception(lang('请输入银行名称'), HttpStatus::CUSTOM_ERROR);
             }
         } elseif (self::requiresBankName($channel) && $bankName === '') {
-            throw new Exception(lang('请输入开户行名称'), HttpStatus::CUSTOM_ERROR);
+            throw new Exception(lang('请输入银行名称'), HttpStatus::CUSTOM_ERROR);
         }
 
         if ($cardNo === '') {
@@ -64,7 +84,11 @@ final class WithdrawalDestination
             default => $bankName,
         };
 
-        return ['bank_name' => $bankName, 'card_no' => $cardNo];
+        return [
+            'bank_name' => $bankName,
+            'bank_branch' => self::bankBranch($channel, $bankName, $params['bank_branch'] ?? null),
+            'card_no' => $cardNo,
+        ];
     }
 
     public static function manualResponse(string $bankName, string $cardNo, ?string $qrImage): array

+ 8 - 9
app/Services/WithdrawalName.php

@@ -11,17 +11,20 @@ final class WithdrawalName
 {
     public const CHANNELS = 'DF001,DF002,DF005,JDpay,jdpay,NOwithdraw,ZIMUwithdraw,ZIMUcash,rgtx';
 
-    public static function usesName(string $channel): bool
+    public static function usesOrderName(string $channel): bool
     {
         return $channel !== 'usdt' && !JdPayService::isChannel($channel);
     }
 
     public static function forPayout(?User $user, string $channel): string
     {
-        if (!self::usesName($channel)) {
-            return '';
-        }
+        $name = self::required($user);
+
+        return self::usesOrderName($channel) ? $name : '';
+    }
 
+    public static function required(?User $user): string
+    {
         $name = (string) $user?->real_name;
         if ($name === '') {
             throw new Exception(lang('请先绑定收款账户并填写真实姓名'), HttpStatus::CUSTOM_ERROR);
@@ -31,12 +34,8 @@ final class WithdrawalName
     }
 
     /** Called in the same transaction as saving the bound account, with the member row locked. */
-    public static function bind(User $user, string $channel, ?string $submitted): string
+    public static function bind(User $user, ?string $submitted): string
     {
-        if (!self::usesName($channel)) {
-            return '';
-        }
-
         if ($user->real_name !== null && $user->real_name !== '') {
             return $user->real_name;
         }

+ 29 - 0
database/migrations/2026_11_06_150000_add_bank_branch_to_withdrawal_destinations.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        foreach (['banks', 'payment_orders'] as $tableName) {
+            if (!Schema::hasColumn($tableName, 'bank_branch')) {
+                Schema::table($tableName, function (Blueprint $table) {
+                    $table->string('bank_branch')->nullable();
+                });
+            }
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (['banks', 'payment_orders'] as $tableName) {
+            if (Schema::hasColumn($tableName, 'bank_branch')) {
+                Schema::table($tableName, function (Blueprint $table) {
+                    $table->dropColumn('bank_branch');
+                });
+            }
+        }
+    }
+};

+ 44 - 0
docs/前端交付/bot-28/后台/提现真实姓名与金额排序-前端接入.md

@@ -0,0 +1,44 @@
+# 提现真实姓名与金额排序
+
+以下接口沿用后台登录态。成功时 `code=0`,列表行位于 `data.data[]`。
+
+## 提现列表和人工提现
+
+| 页面 | 查询接口 |
+|---|---|
+| USDT 提现 | `GET /admin/withdraw` |
+| 人民币提现 | `GET /admin/withdraw/rmb?type=2` |
+| 人工提现 | `GET /admin/withdraw/rmb?type=4` |
+
+各页面增加「真实姓名」列和搜索框。列读取 `real_name`,值为会员绑定的真实姓名,未绑定时返回 `null`,显示「—」。人民币提现和人工提现的订单收款人仍读取 `recipient_name`。
+
+搜索框提交 `real_name`,最多 64 个字符,按姓名包含匹配。提交或清空搜索时回到第 1 页。清空时省略 `real_name`,保留其他筛选条件。
+
+```http
+GET /admin/withdraw?page=1&limit=10&real_name=小明
+GET /admin/withdraw/rmb?type=2&page=1&limit=10&real_name=小明
+GET /admin/withdraw/rmb?type=4&page=1&limit=10&real_name=小明
+```
+
+提现实时推送的订单行也包含 `real_name`。使用姓名筛选时,收到订单更新可按当前筛选条件重新查询。
+
+## 会员列表和彩金列表金额排序
+
+点击金额列时传 `sort_field` 和 `sort_order`。`asc` 为从小到大,`desc` 为从大到小。切换或清除排序时回到第 1 页,保留其他筛选条件。清除排序时省略这两个参数。
+
+| 接口 | 列 | `sort_field` |
+|---|---|---|
+| `GET /admin/user` | 钱包余额 | `available_balance` |
+| `GET /admin/user` | 三方余额 | `third_game_total_balance` |
+| `GET /admin/user` | 冻结金额 | `frozen_balance` |
+| `GET /admin/user` | 余额宝金额 | `yuebao` |
+| `GET /admin/user` | 历史总投注额 | `total_consume` |
+| `GET /admin/user` | 所需、当前、剩余打码量 | `wager_required`、`wager_completed`、`wager_remaining` |
+| `GET /admin/balance/log?is_fancai=1` | 变动金额 | `amount` |
+
+```http
+GET /admin/user?page=1&limit=15&sort_field=available_balance&sort_order=asc
+GET /admin/balance/log?is_fancai=1&page=1&limit=10&sort_field=amount&sort_order=desc
+```
+
+按接口返回的顺序渲染列表。排序在分页前执行,彩金金额相同时按记录 ID 倒序。

+ 3 - 0
lang/en/messages.php

@@ -84,6 +84,9 @@ return [
     "钱宝提现记录" => "Qianbao withdrawal records",
     "订单号" => "Order number",
     "银行" => "Bank",
+    "银行名称" => "Bank name",
+    "请输入银行名称" => "Please enter the bank name",
+    "请输入开户行支行名称" => "Please enter the account-opening branch name",
     "姓名" => "Name",
     "卡号" => "Card number",
     "待处理" => "Pending",

+ 3 - 0
lang/vi/messages.php

@@ -84,6 +84,9 @@ return [
     "钱宝提现记录" => "Lịch sử rút tiền Qianbao",
     "订单号" => "Mã đơn hàng",
     "银行" => "Ngân hàng",
+    "银行名称" => "Tên ngân hàng",
+    "请输入银行名称" => "Vui lòng nhập tên ngân hàng",
+    "请输入开户行支行名称" => "Vui lòng nhập tên chi nhánh mở tài khoản",
     "姓名" => "Tên",
     "卡号" => "Số thẻ",
     "待处理" => "Chờ xử lý",

+ 3 - 0
lang/zh/messages.php

@@ -84,6 +84,9 @@ return [
     "钱宝提现记录" => "钱宝提现记录",
     "订单号" => "订单号",
     "银行" => "银行",
+    "银行名称" => "银行名称",
+    "请输入银行名称" => "请输入银行名称",
+    "请输入开户行支行名称" => "请输入开户行支行名称",
     "姓名" => "姓名",
     "卡号" => "卡号",
     "待处理" => "待处理",

+ 13 - 2
tests/Integration/AdminBonusLogTest.php

@@ -32,7 +32,7 @@ class AdminBonusLogTest extends TestCase
         $this->app->bind('db.schema', fn () => $db->schema());
         foreach ([
             'users' => 'id INTEGER PRIMARY KEY,member_id TEXT,account TEXT,username TEXT,first_name TEXT,admin_note TEXT,status INTEGER,phone TEXT,visitor_id TEXT,register_ip TEXT,"from" INTEGER',
-            'balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT',
+            'balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount DECIMAL(30,10),before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT',
             'promotion_activities' => 'id INTEGER PRIMARY KEY,type INTEGER',
             'promotion_rewards' => 'id INTEGER PRIMARY KEY,activity_id INTEGER,credited_log_id INTEGER',
             'recharge_bonus_rewards' => 'id INTEGER PRIMARY KEY,credited_log_id INTEGER',
@@ -81,6 +81,17 @@ class AdminBonusLogTest extends TestCase
         $this->assertSame(2,$this->records(['is_fancai'=>1,'change_type'=>'余额宝利息'])['total']);
     }
 
+    public function test_bonus_amount_sorts_numerically_before_pagination(): void
+    {
+        $filters = ['is_fancai' => 1, 'account' => 'lic', 'sort_field' => 'amount', 'limit' => 2];
+        foreach ([['asc', 1, [1, 2]], ['asc', 2, [8, 4]], ['desc', 1, [3, 4]]] as [$direction, $page, $ids]) {
+            $data = $this->records($filters + ['sort_order' => $direction, 'page' => $page]);
+            $this->assertSame($ids, array_column($data['data'], 'id'));
+            $this->assertSame(5, $data['total']);
+            $this->assertSame('1176.00', $data['total_amount']);
+        }
+    }
+
     public function test_rebate_and_backflow_grants_are_bonus_types(): void
     {
         DB::table('balance_logs')->insert([
@@ -164,7 +175,7 @@ class AdminBonusLogTest extends TestCase
         }
         $row = $this->records(['is_fancai'=>1, 'change_type'=>'转盘', 'limit'=>1])['data'][0];
         $this->assertSame('100.00', $row['before_balance']);
-        $this->assertSame('20', $row['amount']);
+        $this->assertSame('20', (string) $row['amount']);
         $this->assertSame('120.00', $row['after_balance']);
     }
 

+ 33 - 0
tests/Integration/AdminRealtimeSummaryTest.php

@@ -174,6 +174,39 @@ class AdminRealtimeSummaryTest extends TestCase
         $this->assertEqualsCanonicalizing(['1001', '1005'], array_column(array_column($events, 'key'), 'member_id'));
     }
 
+    public function test_member_amount_columns_sort_before_pagination(): void
+    {
+        DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', max(...), 2);
+        Schema::create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('available_balance');
+            $t->decimal('wager_required'); $t->decimal('wager_completed');
+        });
+        Schema::create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type'); $t->string('change_type'); $t->decimal('amount');
+        });
+        DB::table('users')->insert([
+            ['user_id' => 'account2', 'member_id' => '1002'],
+            ['user_id' => 'account3', 'member_id' => '1003'],
+        ]);
+        DB::table('wallets')->insert([
+            ['member_id' => '1001', 'available_balance' => 100, 'wager_required' => 50, 'wager_completed' => 40],
+            ['member_id' => '1002', 'available_balance' => 2, 'wager_required' => 100, 'wager_completed' => 0],
+        ]);
+        $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+
+        foreach (['available_balance' => ['1002', '1001'], 'wager_remaining' => ['1001', '1002']] as $field => [$lower, $higher]) {
+            foreach ([['asc', 2, $lower], ['desc', 1, $higher]] as [$order, $page, $memberId]) {
+                $filters = ['sort_field' => $field, 'sort_order' => $order, 'page' => $page, 'limit' => 1];
+                $this->app->instance('request', Request::create('/admin/user', 'GET', $filters));
+                $body = (new AdminUser())->index()->getData(true);
+                $this->assertSame(0, $body['code'], $body['msg']);
+                $this->assertSame(3, $body['data']['total']);
+                $this->assertSame([$memberId], array_column($body['data']['data'], 'member_id'));
+            }
+        }
+    }
+
     public function test_pending_badges_track_raw_writes_claim_audits_and_aged_orders(): void
     {
         $this->tick(0);

+ 36 - 2
tests/Integration/AdminRealtimeTest.php

@@ -79,7 +79,7 @@ class AdminRealtimeTest extends TestCase
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_01_000000_add_delivery_state_to_admin_realtime_events.php')->up();
         Schema::create('users', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('user_id')->default(''); $t->string('account')->nullable(); $t->string('first_name')->default('');
-            $t->string('admin_note')->nullable(); $t->string('language')->nullable(); $t->integer('recharge_channel_group_id')->nullable(); $t->timestamps();
+            $t->string('admin_note')->nullable(); $t->string('language')->nullable(); $t->integer('recharge_channel_group_id')->nullable(); $t->string('real_name', 64)->nullable(); $t->timestamps();
         });
         Schema::create('withdraws', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->decimal('amount', 20, 10)->default(0); $t->decimal('service_charge', 20, 10)->default(0);
@@ -87,7 +87,7 @@ class AdminRealtimeTest extends TestCase
             $t->integer('status')->default(0); $t->decimal('after_balance', 20, 10)->default(0); $t->string('remark')->nullable();
             $t->string('admin_note')->nullable(); $t->integer('is_locked')->default(0); $t->integer('is_send')->default(0); $t->timestamps();
         });
-        DB::table('users')->insert(['member_id' => '9001', 'user_id' => 'u9001', 'first_name' => 'Alice', 'admin_note' => 'vip']);
+        DB::table('users')->insert(['member_id' => '9001', 'user_id' => 'u9001', 'first_name' => 'Alice', 'admin_note' => 'vip', 'real_name' => '王小明']);
     }
 
     protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
@@ -202,6 +202,7 @@ class AdminRealtimeTest extends TestCase
         $row = collect($list)->firstWhere('id', 1);
         $this->assertSame($row, $first['data'], '推送行与列表接口返回的行一致');
         $this->assertSame('Alice', $first['data']['first_name']);
+        $this->assertSame('王小明', $first['data']['real_name']);
         $this->assertSame(1, $first['data']['status']);
 
         $withdraw->delete();
@@ -213,6 +214,39 @@ class AdminRealtimeTest extends TestCase
             'SQLite 不支持在线判定 SQL,扫描失败只记日志不影响广播');
     }
 
+    public function test_withdrawal_lists_filter_by_member_real_name_and_keep_legacy_rows(): void
+    {
+        Schema::create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->integer('type'); $t->string('member_id'); $t->decimal('amount', 20, 2);
+            $t->integer('status')->default(0); $t->string('account'); $t->string('bank_name'); $t->string('card_no'); $t->timestamps();
+        });
+        DB::table('users')->insert(['member_id' => '9002', 'account' => 'legacy', 'first_name' => '王小明']);
+        foreach (['9001', '9002'] as $memberId) {
+            DB::table('withdraws')->insert(['member_id' => $memberId, 'amount' => 10]);
+            foreach ([2, 4] as $type) {
+                DB::table('payment_orders')->insert(['type' => $type, 'member_id' => $memberId, 'amount' => 10,
+                    'account' => '历史收款人', 'bank_name' => '银行', 'card_no' => '1234']);
+            }
+        }
+
+        $controller = new WithdrawController();
+        foreach ([['index', '/admin/withdraw', []], ['rmb', '/admin/withdraw/rmb', ['type' => 2]], ['rmb', '/admin/withdraw/rmb', ['type' => 4]]] as [$method, $path, $filters]) {
+            $this->app->instance('request', Request::create($path, 'GET', $filters));
+            $all = $controller->{$method}()->getData(true)['data'];
+            $this->assertSame(2, $all['total']);
+            $this->assertNull(collect($all['data'])->firstWhere('member_id', '9002')['real_name']);
+
+            $this->app->instance('request', Request::create($path, 'GET', $filters + ['real_name' => ' 小明 ']));
+            $filtered = $controller->{$method}()->getData(true)['data'];
+            $this->assertSame(1, $filtered['total']);
+            $this->assertSame(['9001'], array_column($filtered['data'], 'member_id'));
+            $this->assertSame('王小明', $filtered['data'][0]['real_name']);
+            if ($method === 'rmb') {
+                $this->assertSame('历史收款人', $filtered['data'][0]['recipient_name']);
+            }
+        }
+    }
+
     public function test_manual_recharge_orders_push_to_the_payment_order_channel(): void
     {
         Schema::create('payment_orders', function (Blueprint $t) {

+ 87 - 13
tests/Integration/ApiCryptoTest.php

@@ -91,11 +91,16 @@ class ApiCryptoTest extends TestCase
         self::assertSame('Bank card', array_column($body['data'], 'label', 'value')['DF001']);
         $channels = array_column($body['data'], null, 'value');
         self::assertTrue($channels['DF001']['requires_bank_name']);
+        self::assertTrue($channels['DF001']['requires_bank_branch']);
+        self::assertSame('Bank name', $channels['DF001']['bank_name_label']);
         self::assertTrue($channels['NOwithdraw']['requires_real_name']);
         self::assertTrue($channels['ZIMUwithdraw']['requires_real_name']);
-        self::assertFalse($channels['JDpay']['requires_real_name']);
+        self::assertTrue($channels['JDpay']['requires_real_name']);
+        self::assertTrue($channels['usdt']['requires_real_name']);
         self::assertSame(['bank', 'wechat', 'alipay'], array_column($channels['rgtx']['payout_methods'], 'value'));
         self::assertSame([true, false, true], array_column($channels['rgtx']['payout_methods'], 'requires_card_no'));
+        self::assertSame('Bank name', $channels['rgtx']['payout_methods'][0]['bank_name_label']);
+        self::assertArrayNotHasKey('bank_name_label', $channels['rgtx']['payout_methods'][1]);
 
         $lottery = $this->unwrap($this->sendEncrypted('/api/newPc/yuanTou', 'GET', (object) []));
         self::assertSame(0, $lottery['code'] ?? null, json_encode($lottery));
@@ -137,17 +142,19 @@ class ApiCryptoTest extends TestCase
         $this->createWalletFixture();
         DB::table('users')->where('member_id', '42')->update(['real_name' => 'Registered Name']);
         $binding = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
-            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345', 'account' => 'Different Name',
+            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch',
+            'card_no' => '12345', 'account' => 'Different Name',
         ], true));
         self::assertSame('Registered Name', $binding['data']['real_name']);
         self::assertSame('Registered Name', DB::table('banks')->sole()->account);
 
         $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [
-            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345',
+            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345',
             'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name',
         ], true));
         self::assertSame(0, $payout['code'], $payout['msg']);
         self::assertSame('Registered Name', DB::table('payment_orders')->sole()->account);
+        self::assertSame('Fixture Branch', DB::table('payment_orders')->sole()->bank_branch);
         $zimu = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', [
             'channel' => 'ZIMUwithdraw', 'card_no' => 'zimu-wallet-fixture',
             'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Another Name',
@@ -161,7 +168,7 @@ class ApiCryptoTest extends TestCase
     public function test_only_the_first_successful_named_binding_can_set_a_missing_real_name(): void
     {
         $this->createWalletFixture();
-        $params = ['channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345'];
+        $params = ['channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345'];
         $payout = $this->unwrap($this->sendEncrypted('/api/wallet/payout', 'POST', $params + [
             'amount' => 100, 'safe_word' => 'fixture-password', 'account' => 'Unbound Name',
         ], true));
@@ -231,16 +238,22 @@ class ApiCryptoTest extends TestCase
             self::assertSame('请先绑定收款账户并填写真实姓名', $payout($common + $wallet + ['account' => 'Injected Name'])['msg']);
         }
         DB::table('users')->update(['real_name' => 'Fixture Name']);
-        self::assertSame('请输入开户行名称', $payout($common + ['card_no' => '12345'])['msg']);
-        self::assertSame('请输入开户行名称', $payout(['channel' => 'DF001', 'card_no' => '12345'] + $common)['msg']);
+        self::assertSame('请输入银行名称', $payout($common + ['card_no' => '12345'])['msg']);
+        self::assertSame('请输入银行名称', $payout(['channel' => 'DF001', 'card_no' => '12345'] + $common)['msg']);
+        foreach (['rgtx', 'DF001'] as $channel) {
+            self::assertSame('请输入开户行支行名称', $payout([
+                'channel' => $channel, 'bank_name' => 'Fixture Bank', 'card_no' => '12345',
+            ] + $common)['msg']);
+        }
         self::assertSame('请上传有效的收款码图片', $payout($common + ['payout_method' => 'wechat', 'qr_image' => 'javascript:alert(1)'])['msg']);
         self::assertSame('请上传有效的收款码图片', $payout($common + ['payout_method' => 'alipay', 'card_no' => $alipayAccount])['msg']);
         self::assertSame('请输入支付宝账号', $payout($common + ['payout_method' => 'alipay', 'qr_image' => $qr])['msg']);
         self::assertSame('请输入支付宝账号', $payout($common + ['card_no' => $qr] + $alipay)['msg']);
-        $bank = $payout($common + ['bank_name' => 'Fixture Bank Branch', 'card_no' => '12345']);
+        $bank = $payout($common + ['bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch', 'card_no' => '12345']);
         self::assertSame(0, $bank['code'], $bank['msg']);
         self::assertSame('bank', $bank['data']['payout_method']);
-        self::assertSame('Fixture Bank Branch', $bank['data']['bank_name']);
+        self::assertSame('Fixture Bank', $bank['data']['bank_name']);
+        self::assertSame('Fixture Branch', $bank['data']['bank_branch']);
 
         self::assertSame('您已有1笔提现订单正常处理中!', $payout($common + $wechat)['msg']);
         self::assertSame(1, DB::table('payment_orders')->count());
@@ -266,6 +279,8 @@ class ApiCryptoTest extends TestCase
         $admin = (new \App\Http\Controllers\admin\Withdraw())->rmb()->getData(true);
         self::assertSame(0, $admin['code'], $admin['msg']);
         $rows = array_column($admin['data']['data'], null, 'payout_method');
+        self::assertSame('Fixture Bank', $rows['bank']['bank_name']);
+        self::assertSame('Fixture Branch', $rows['bank']['bank_branch']);
         self::assertSame('Fixture Name', $rows['wechat']['recipient_name']);
         self::assertSame('fixture-login', $rows['wechat']['account']);
         self::assertSame($qr, $rows['wechat']['qr_image']);
@@ -324,7 +339,8 @@ class ApiCryptoTest extends TestCase
     {
         $this->createWalletFixture();
         $bank = $this->unwrap($this->sendEncrypted('/api/wallet/addBank', 'POST', [
-            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'card_no' => '12345', 'account' => 'Fixture Name',
+            'channel' => 'DF001', 'bank_name' => 'Fixture Bank', 'bank_branch' => 'Fixture Branch',
+            'card_no' => '12345', 'account' => 'Fixture Name',
             'alias' => 'ignored bank alias',
         ], true));
         self::assertSame(0, $bank['code']);
@@ -332,6 +348,12 @@ class ApiCryptoTest extends TestCase
         DB::table('banks')->update(['alias' => 'old bank alias']);
         $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true));
         self::assertSame('12345', $bankList['data']['list'][0]['card_no']);
+        self::assertSame('Fixture Bank', $bankList['data']['list'][0]['bank_name']);
+        self::assertSame('Fixture Branch', $bankList['data']['list'][0]['bank_branch']);
+        self::assertArrayNotHasKey('alias', $bankList['data']['list'][0]);
+        DB::table('banks')->update(['bank_branch' => null, 'alias' => 'Legacy Branch']);
+        $bankList = $this->unwrap($this->sendEncrypted('/api/wallet/bankList', 'GET', (object) [], true));
+        self::assertSame('Legacy Branch', $bankList['data']['list'][0]['bank_branch']);
         self::assertArrayNotHasKey('alias', $bankList['data']['list'][0]);
 
         $address = 'T' . str_repeat('A', 33);
@@ -350,19 +372,24 @@ class ApiCryptoTest extends TestCase
     {
         $this->createWalletFixture();
         Cache::put('42_QB_WITHDRAW_CHANNEL', 'DF001');
-        Cache::put('42_QB_WITHDRAW_BANK_NAME', 'Fixture Bank');
+        QianBaoWithdrawService::onMessage(42, 'Fixture Bank', 1, StepStatus::QB_INPUT_BANK_NAME);
+        self::assertSame(StepStatus::QB_INPUT_BANK_BRANCH, Cache::get(get_step_key(42)));
+        QianBaoWithdrawService::onMessage(42, 'Fixture Branch', 1, StepStatus::QB_INPUT_BANK_BRANCH);
+        self::assertSame(StepStatus::QB_INPUT_CARD_NO, Cache::get(get_step_key(42)));
         QianBaoWithdrawService::onMessage(42, '12345', 1, StepStatus::QB_INPUT_CARD_NO);
         self::assertSame(StepStatus::QB_INPUT_ACCOUNT, Cache::get(get_step_key(42)));
         $saved = QianBaoWithdrawService::onMessage(42, 'Fixture Name', 2, StepStatus::QB_INPUT_ACCOUNT);
         $keyboard = json_decode($saved['reply_markup'], true)['inline_keyboard'];
         self::assertSame('12345', $keyboard[0][0]['text']);
         self::assertSame('', DB::table('banks')->where('card_no', '12345')->value('alias'));
+        self::assertSame('Fixture Branch', DB::table('banks')->where('card_no', '12345')->value('bank_branch'));
         self::assertNull(Cache::get(get_step_key(42)));
 
         Cache::put('42_QB_WITHDRAW_CARD_NO', '67890');
         Cache::put('42_QB_ACCOUNT', 'Fixture Name');
-        QianBaoWithdrawService::onMessage(42, 'ignored', 3, StepStatus::LEGACY_QB_INPUT_ALIAS);
+        QianBaoWithdrawService::onMessage(42, 'Legacy Branch', 3, StepStatus::LEGACY_QB_INPUT_ALIAS);
         self::assertSame('', DB::table('banks')->where('card_no', '67890')->value('alias'));
+        self::assertSame('Legacy Branch', DB::table('banks')->where('card_no', '67890')->value('bank_branch'));
 
         $address = 'T' . str_repeat('A', 33);
         $saved = WithdrawService::onMessage(42, $address, 4, StepStatus::INPUT_ADDRESS_TRC20);
@@ -377,6 +404,36 @@ class ApiCryptoTest extends TestCase
         self::assertSame(0, DB::table('address')->whereNotNull('alias')->count());
     }
 
+    public function test_jd_and_usdt_require_a_saved_name_without_storing_it_on_orders(): void
+    {
+        $this->createWalletFixture();
+        $address = 'T' . str_repeat('A', 33);
+        $jd = ['channel' => 'JDpay', 'card_no' => 'jd-wallet', 'amount' => 100, 'safe_word' => 'fixture-password'];
+        $usdt = ['address' => $address, 'amount' => 20, 'safe_word' => 'fixture-password'];
+        $post = fn (string $route, array $params) => $this->unwrap(
+            $this->sendEncrypted('/api/wallet/' . $route, 'POST', $params, true)
+        );
+        self::assertSame('请先绑定收款账户并填写真实姓名', $post('payout', $jd + ['account' => 'Injected Name'])['msg']);
+        self::assertSame('请先绑定收款账户并填写真实姓名', $post('withdraw', $usdt + ['account' => 'Injected Name'])['msg']);
+        self::assertSame('请输入真实姓名', $post('addBank', ['channel' => 'JDpay', 'card_no' => 'jd-wallet'])['msg']);
+        self::assertSame('请输入真实姓名', $post('addAddress', ['address' => $address])['msg']);
+        self::assertNull(DB::table('users')->value('real_name'));
+
+        $binding = $post('addAddress', ['address' => $address, 'account' => 'Fixture Name']);
+        self::assertSame(0, $binding['code']);
+        self::assertSame('Fixture Name', $binding['data']['real_name']);
+        $jdBinding = $post('addBank', ['channel' => 'JDpay', 'card_no' => 'jd-wallet', 'account' => 'Ignored Name']);
+        self::assertSame(0, $jdBinding['code']);
+        self::assertSame('', DB::table('banks')->sole()->account);
+        $jdResult = $post('payout', $jd);
+        self::assertSame(0, $jdResult['code'], $jdResult['msg']);
+        self::assertSame('', DB::table('payment_orders')->sole()->account);
+        $usdtResult = $post('withdraw', $usdt);
+        self::assertSame(0, $usdtResult['code'], $usdtResult['msg']);
+        self::assertArrayNotHasKey('account', $usdtResult['data']);
+        self::assertSame('Fixture Name', DB::table('users')->value('real_name'));
+    }
+
     public function test_cors_preflight_and_head(): void
     {
         Route::get('api/wallet/probe', fn () => response()->json(['code' => 0]));
@@ -584,14 +641,31 @@ class ApiCryptoTest extends TestCase
             $table->timestamps();
         });
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_140000_add_qr_image_to_withdrawal_destinations.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_150000_add_bank_branch_to_withdrawal_destinations.php')->up();
+        Schema::create('config', function (Blueprint $table) {
+            $table->id();
+            $table->string('field');
+            $table->string('val');
+        });
+        DB::table('config')->insert([['field' => 'service_charge', 'val' => '1'], ['field' => 'exchange_rate_rmb', 'val' => '1']]);
+        Schema::create('withdraws', function (Blueprint $table) {
+            $table->id();
+            $table->string('member_id');
+            $table->string('address');
+            $table->integer('status');
+            foreach (['amount', 'service_charge', 'to_account', 'exchange_rate', 'after_balance'] as $field) {
+                $table->decimal($field);
+            }
+            $table->timestamps();
+        });
 
         DB::table('users')->insert(['user_id' => '42', 'member_id' => '42', 'from' => 1, 'recharge_channel_group_id' => 1,
             'payment_password' => password_hash('fixture-password', PASSWORD_DEFAULT)]);
         DB::table('user_session')->insert(['user_id' => '42', 'token' => self::TOKEN,
             'expire_time' => time() + 3600, 'create_time' => time(), 'update_time' => time()]);
         DB::table('wallets')->insert(['member_id' => '42', 'available_balance' => 1000]);
-        DB::table('recharge_channel_group')->insert(['id' => 1, 'withdraw_type' => 'DF001,NOwithdraw,ZIMUwithdraw,JDpay,rgtx']);
-        foreach (['DF001' => '银行卡', 'NOwithdraw' => 'NO快捷提现', 'ZIMUwithdraw' => '808账户提现', 'JDpay' => 'JD钱包', 'rgtx' => '人工提现'] as $type => $name) {
+        DB::table('recharge_channel_group')->insert(['id' => 1, 'withdraw_type' => 'DF001,NOwithdraw,ZIMUwithdraw,JDpay,rgtx,usdt']);
+        foreach (['DF001' => '银行卡', 'NOwithdraw' => 'NO快捷提现', 'ZIMUwithdraw' => '808账户提现', 'JDpay' => 'JD钱包', 'rgtx' => '人工提现', 'usdt' => 'USDT提现'] as $type => $name) {
             DB::table('recharge_channel')->insert(['data_type' => 2, 'status' => 1, 'sort' => 0,
                 'name' => $name, 'type' => $type, 'rate' => 0, 'min' => 10, 'max' => 5000]);
         }