Forráskód Böngészése

usdt公链、充值logo

doge 5 órája
szülő
commit
cd9c9c9b7c
28 módosított fájl, 1155 hozzáadás és 52 törlés
  1. 36 2
      app/Http/Controllers/admin/Config.php
  2. 2 1
      app/Http/Controllers/admin/PaymentOrder.php
  3. 21 0
      app/Http/Controllers/admin/RechargeBonus.php
  4. 1 0
      app/Http/Controllers/admin/RechargeChannel.php
  5. 2 1
      app/Http/Controllers/admin/Wallet.php
  6. 19 8
      app/Http/Controllers/api/Wallet.php
  7. 14 2
      app/Models/PaymentOrder.php
  8. 14 1
      app/Models/Recharge.php
  9. 11 2
      app/Models/RechargeChannel.php
  10. 19 0
      app/Services/Payment/RechargeChannelConfigurationService.php
  11. 15 32
      app/Services/PaymentOrderService.php
  12. 84 0
      app/Services/RechargeBonusAdminService.php
  13. 119 0
      app/Services/RechargeBonusService.php
  14. 10 3
      app/Services/RechargeService.php
  15. 56 0
      app/Services/UsdtRechargeConfigService.php
  16. 41 0
      database/migrations/2026_10_28_140000_create_recharge_bonuses.php
  17. 17 0
      database/migrations/2026_10_28_150000_add_recharge_channel_logo.php
  18. 29 0
      docs/前端接口/USDT公链收款地址-前台接入.md
  19. 23 0
      docs/前端接口/充值渠道Logo-前端接入.md
  20. 21 0
      docs/前端接口/充值渠道赠送-前台接入.md
  21. 13 0
      docs/后端/充值渠道Logo-部署说明.md
  22. 21 0
      docs/后端/充值渠道赠送-部署说明.md
  23. 38 0
      docs/总后台/USDT公链收款地址-后台接入.md
  24. 41 0
      docs/总后台/充值渠道赠送-后台接入.md
  25. 8 0
      routes/admin.php
  26. 22 0
      tests/Integration/PaymentChannelOptionsTest.php
  27. 239 0
      tests/Integration/RechargeBonusTest.php
  28. 219 0
      tests/Integration/UsdtRechargeConfigTest.php

+ 36 - 2
app/Http/Controllers/admin/Config.php

@@ -14,9 +14,23 @@ use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use Telegram\Bot\FileUpload\InputFile;
 use App\Services\ConfigService;
+use App\Services\UsdtRechargeConfigService;
 
 class Config extends Controller
 {
+    public function usdt(UsdtRechargeConfigService $service): JsonResponse
+    {
+        return $this->success($service->get())->header('Cache-Control', 'no-store');
+    }
+
+    public function saveUsdt(UsdtRechargeConfigService $service): JsonResponse
+    {
+        try {
+            return $this->success($service->save(request()->all()))->header('Cache-Control', 'no-store');
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        }
+    }
 
     /** 设置配置
      */
@@ -445,7 +459,9 @@ class Config extends Controller
      * @apiParam {string} brokerage 抽佣比例
      * @apiParam {string} service_charge 提现手续费
      * @apiParam {string} service_account 客服账号
-     * @apiParam {string} receiving_address 充值收款地址
+     * @apiParam {string} receiving_address TRC20充值收款地址
+     * @apiParam {string} [receiving_address_erc20] ERC20充值收款地址
+     * @apiParam {string} [public_chain] Public Chain 公链地址,最多2048字符
      * @apiParam {string} receiving_type 收款方式 1-自动 2-手动
      *
      */
@@ -460,6 +476,8 @@ class Config extends Controller
                 'service_charge' => ['required', 'integer', 'min:1'],
                 'service_account' => ['required', 'string', 'min:1'],
                 'receiving_address' => ['required', 'string', 'min:34'],
+                'receiving_address_erc20' => ['sometimes', 'nullable', 'string', 'regex:/^0x[0-9a-fA-F]{40}$/'],
+                'public_chain' => ['sometimes', 'nullable', 'string', 'max:2048'],
                 'receiving_type' => ['required', 'integer', 'in:1,2']
             ]);
             $baseScore = request()->input('base_score');
@@ -483,6 +501,22 @@ class Config extends Controller
             ConfigModel::where('field', 'receiving_address')
                 ->update(['val' => $val]);
 
+            if (request()->exists('receiving_address_erc20')) {
+                ConfigModel::updateOrCreate(['field' => 'receiving_address_erc20'], [
+                    'val' => request()->input('receiving_address_erc20') ?? '',
+                    'remark' => '收款地址_ERC20',
+                    'group_id' => 1,
+                ]);
+            }
+
+            if (request()->exists('public_chain')) {
+                ConfigModel::updateOrCreate(['field' => 'public_chain'], [
+                    'val' => trim(request()->input('public_chain') ?? ''),
+                    'remark' => 'Public Chain 公链地址',
+                    'group_id' => 1,
+                ]);
+            }
+
             $val = request()->input('receiving_type');
             ConfigModel::where('field', 'receiving_type')
                 ->update(['val' => $val]);
@@ -497,4 +531,4 @@ class Config extends Controller
         }
         return $this->success();
     }
-}
+}

+ 2 - 1
app/Http/Controllers/admin/PaymentOrder.php

@@ -112,7 +112,8 @@ class PaymentOrder extends Controller
                 $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
-                BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, $info->id, $remark);
+                $principal = BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, $info->id, $remark);
+                \App\Services\RechargeBonusService::grant('payment_order', $info, $principal);
             }
             
             DB::commit();

+ 21 - 0
app/Http/Controllers/admin/RechargeBonus.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\Controller;
+use App\Services\RechargeBonusAdminService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class RechargeBonus extends Controller
+{
+    public function index(RechargeBonusAdminService $service) { return $this->run(fn () => $service->listing()); }
+    public function save(RechargeBonusAdminService $service) { return $this->run(fn () => $service->save(request()->all())); }
+    public function status(RechargeBonusAdminService $service) { return $this->run(fn () => $service->status(request()->all())); }
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) { return $this->error(-3, $e->validator->errors()->first()); }
+        catch (\Throwable $e) { Log::error('recharge_bonus_failed', ['exception' => $e]); return $this->error(-3, '充值赠送操作失败,请稍后重试'); }
+    }
+}

+ 1 - 0
app/Http/Controllers/admin/RechargeChannel.php

@@ -208,6 +208,7 @@ class RechargeChannel extends Controller
                 'fixed' => ['nullable','string'],
                 'status' => ['nullable','integer','in:0,1'],
                 'sort' => ['nullable','integer','min:0'],
+                'logo' => ['nullable', 'string', 'max:2048'],
             ]);
 
             $service->save($params);

+ 2 - 1
app/Http/Controllers/admin/Wallet.php

@@ -377,7 +377,8 @@ class Wallet extends Controller
                 $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $available_balance;
                 $wallet->save();
-                BalanceLogService::addLog($recharge->member_id, $rate_amount, $beforeBalance, $available_balance, "充值", $recharge->id, "用户充值后台审核到账");
+                $principal = BalanceLogService::addLog($recharge->member_id, $rate_amount, $beforeBalance, $available_balance, "充值", $recharge->id, "用户充值后台审核到账");
+                \App\Services\RechargeBonusService::grant('recharge', $recharge, $principal);
 
                 $text = "充值结果通知\n";
                 $text .= "充值数量:{$amount} USDT\n";

+ 19 - 8
app/Http/Controllers/api/Wallet.php

@@ -61,6 +61,7 @@ class Wallet extends BaseController
                 return !ZimuPayService::isRechargeChannel($item['value'] ?? '');
             }));
         }
+        $list = \App\Services\RechargeBonusService::decorate($list, $this->lang, (int)request()->user->from === 1);
         return $this->success([
             'list' => $list,
         ]);
@@ -111,6 +112,9 @@ class Wallet extends BaseController
 
     /**
      * 获取充值二维码(USDT充值)
+     * type 支持 TRC20/ERC20;address 为对应公链的收款地址。
+     * public_chain 为后台独立填写的 Public Chain 地址。
+     * 手动模式读取后台配置,自动模式使用会员专属的 TRC20 地址。
      */
     public function scan()
     {
@@ -120,32 +124,39 @@ class Wallet extends BaseController
                 'type' => ['required', 'string'],
             ]);
 
-            if (strtolower($params['type']) === "trc20") {
-                $address = Config::where('field', 'receiving_address')->first()->val;
-            } elseif (strtolower($params['type']) === "erc20") {
-                $address = Config::where('field', 'receiving_address_erc20')->first()->val;
-            } else {
+            $net = strtolower($params['type']);
+            if (!in_array($net, ['trc20', 'erc20'], true)) {
                 return $this->error(lang('充值类型错误'));
             }
 
             $receivingType = ConfigService::getVal("receiving_type");
             //自动充值
             if ($receivingType == 1) {
+                if ($net !== 'trc20') {
+                    return $this->error(lang('自动充值仅支持TRC20'));
+                }
                 $res = WalletService::getRechargeImageAddress($member_id);
                 $address = $res['address'];
                 $qrCode = $res['full_path'];
             } else {
                 //手动充值
+                $field = $net === 'trc20' ? 'receiving_address' : 'receiving_address_erc20';
+                $address = trim((string)Config::where('field', $field)->value('val'));
+                if ($address === '') {
+                    return $this->error(lang('该公链收款地址未配置,请联系客服'));
+                }
                 $res = WalletService::getPlatformImageAddress($address);
-                $res['net'] = $params['type'];
                 $qrCode = $res['full_path'];
             }
             
             return $this->success([
                 'qrcode' => $qrCode,
                 'address' => $address,
-                // 'photo' => InputFile::create($qrCode),
-            ]);
+                'public_chain' => (string)(Config::where('field', 'public_chain')->value('val') ?? ''),
+                'net' => strtoupper($net),
+                'coin' => 'USDT',
+                'receiving_type' => (int)$receivingType,
+            ])->header('Cache-Control', 'no-store');
         } catch (ValidationException $e) {
             return $this->error($e->validator->errors()->first());
         } catch (\Exception $e) {

+ 14 - 2
app/Models/PaymentOrder.php

@@ -16,8 +16,20 @@ class PaymentOrder extends BaseModel
 {
 
     protected $table = 'payment_orders';
-    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'account', 'card_no', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked'];
-    protected $hidden = [];
+    protected $fillable = ['type', 'order_no', 'member_id', 'amount', 'channel', 'admin_note','bank_name', 'account', 'card_no', 'status', 'callback_url', 'callback_data', 'remark', 'pay_no', 'pay_url', 'pay_data', 'fee', 'payment_type', 'is_send', 'is_locked', 'recharge_bonus_snapshot'];
+    protected $hidden = ['recharge_bonus_snapshot'];
+    protected $casts = ['recharge_bonus_snapshot' => 'array'];
+
+    protected static function booted()
+    {
+        parent::booted();
+        static::creating(function ($order) {
+            if ((int)$order->type === 3 && $order->channel === 'rgcz' && !array_key_exists('recharge_bonus_snapshot', $order->getAttributes())) {
+                $snapshot = \App\Services\RechargeBonusService::capture('rgcz', (string)$order->member_id);
+                if ($snapshot !== null) $order->recharge_bonus_snapshot = $snapshot;
+            }
+        });
+    }
     // // 添加这个
     // protected $visible = [
     //     'type', 'order_no', 'member_id', 'amount', 'channel', 

+ 14 - 1
app/Models/Recharge.php

@@ -9,7 +9,20 @@ class Recharge extends BaseModel
 
     protected $table = 'recharges';
     // protected $fillable = ['member_id', 'confirmations', 'coin', 'txid','block_time','block_height' ,'net' ,'coin' ,'amount' ,'to_address' ,'from_address' ,'status'];
-    protected $hidden = [];
+    protected $hidden = ['recharge_bonus_snapshot'];
+    protected $casts = ['recharge_bonus_snapshot' => 'array'];
+
+    protected static function booted()
+    {
+        parent::booted();
+        static::creating(function ($order) {
+            if (strtoupper((string)$order->coin) === 'USDT' && !array_key_exists('recharge_bonus_snapshot', $order->getAttributes())) {
+                $snapshot = \App\Services\RechargeBonusService::capture('usdt', (string)$order->member_id);
+                if ($snapshot !== null) $order->recharge_bonus_snapshot = $snapshot;
+            }
+        });
+    }
+
     const STATUS_STAY = 0;
     const STATUS_SUCCESS = 1;
     const STATUS_FAIL = 2;

+ 11 - 2
app/Models/RechargeChannel.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 use App\Services\Payment\PaymentProviderCatalog;
 use App\Services\Payment\PaymentProviderService;
+use App\Constants\Util;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
 class RechargeChannel extends BaseModel
@@ -11,9 +12,14 @@ 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'];
+    protected $fillable = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'min', 'max', 'fixed', 'sort', 'status', 'logo'];
     protected $hidden = [];
 
+    public function getLogoAttribute($value): string
+    {
+        return $value ? (string)Util::ensureUrl($value) : '';
+    }
+
     public function scopeAvailable($query)
     {
         $query->where('status', 1);
@@ -83,7 +89,9 @@ class RechargeChannel extends BaseModel
         }
         $query = $query->whereIn('type', $types);
         
-        $product = $query->orderBy('sort', 'asc')->select(['id', 'data_type','name','type', 'min','max','fixed','rate'])->get()->toArray();
+        $fields = ['id', 'data_type','name','type', 'min','max','fixed','rate'];
+        if ((int)$data_type === 1) $fields[] = 'logo';
+        $product = $query->orderBy('sort', 'asc')->orderBy('id')->select($fields)->get()->toArray();
         
         $list = [];
         foreach($product as $key => $pv) {
@@ -116,6 +124,7 @@ class RechargeChannel extends BaseModel
                 'value' => $pv['type'],
                 'config' => $config ?? [],
             ];
+            if ((int)$data_type === 1) $list[$pv['type']]['logo'] = (string)($config['logo'] ?? '');
         }
         $list = array_column($list, null, 'key');
     

+ 19 - 0
app/Services/Payment/RechargeChannelConfigurationService.php

@@ -104,6 +104,18 @@ class RechargeChannelConfigurationService
 
     public function save(array $params): RechargeChannel
     {
+        if (array_key_exists('logo', $params)) {
+            \Illuminate\Support\Facades\Validator::make($params, ['logo' => ['nullable', 'string', 'max:2048']])->validate();
+            $logo = trim((string)($params['logo'] ?? ''));
+            $parts = parse_url($logo);
+            $relative = str_starts_with($logo, '/') && !str_starts_with($logo, '//');
+            $absolute = filter_var($logo, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+                && !isset($parts['user']) && !isset($parts['pass']);
+            if ($logo !== '' && (preg_match('/[\x00-\x20\x7f\\\\]/', $logo) || (!$relative && !$absolute))) {
+                throw \Illuminate\Validation\ValidationException::withMessages(['logo' => '请使用上传后的图片路径或HTTP/HTTPS图片地址']);
+            }
+            $params['logo'] = $logo === '' ? null : $logo;
+        }
         return DB::transaction(function () use ($params) {
             $id = (int)($params['id'] ?? 0);
             unset($params['id']);
@@ -129,8 +141,15 @@ class RechargeChannelConfigurationService
                 if (array_key_exists($field, $params) && $params[$field] === null) unset($params[$field]);
             }
             if (!$channel->exists) $params += ['status' => 1, 'sort' => 0];
+            if ($identityChanged && (int)$params['data_type'] === 1 && !array_key_exists('logo', $params)) {
+                $peer = RechargeChannel::where('data_type', 1)->where('type', $params['type'])->orderBy('sort')->orderBy('id')->first();
+                $params['logo'] = $peer ? $peer->getRawOriginal('logo') : null;
+            }
             $channel->fill($params);
             $channel->save();
+            if ((int)$channel->data_type === 1 && array_key_exists('logo', $params)) {
+                RechargeChannel::where('data_type', 1)->where('type', $channel->type)->update(['logo' => $params['logo']]);
+            }
             return $channel;
         }, 3);
     }

+ 15 - 32
app/Services/PaymentOrderService.php

@@ -214,6 +214,7 @@ class PaymentOrderService extends BaseService
             $result['code'] = 20001;
             return $result;
         }
+        $bonusSnapshot = RechargeBonusService::capture((string)$channelConfig->type, (string)$memberId);
         $channel = ''; // 支付的通道
         $product = SanJinService::product($groupId);
         $max = (float)($channelConfig->max ?? 0);
@@ -293,6 +294,7 @@ class PaymentOrderService extends BaseService
             $data['order_no'] = $order_no;
             $data['callback_url'] = ZimuPayService::getPayNotifyUrl();
             $data['remark'] = '充值费率:' . $rate;
+            if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
             $data['status'] = self::STATUS_STAY;
 
             $ret = ZimuPayService::pay($amount, $order_no, (string)$memberId);
@@ -341,6 +343,7 @@ class PaymentOrderService extends BaseService
             $data['order_no'] = $order_no;
             $data['callback_url'] = NoPayService::getDepositNotifyUrl();
             $data['remark'] = '充值费率:' . $rate;
+            if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
             $data['status'] = self::STATUS_STAY;
 
             $ret = NoPayService::pay($amount, $order_no, (string)$memberId, $channel);
@@ -388,6 +391,7 @@ class PaymentOrderService extends BaseService
             $data['order_no'] = $order_no;
             $data['callback_url'] = JdPayService::getNotifyUrl();
             $data['remark'] = '充值费率:' . $rate;
+            if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
             $data['status'] = self::STATUS_STAY;
 
             $ret = JdPayService::pay($amount, $order_no);
@@ -430,6 +434,7 @@ class PaymentOrderService extends BaseService
         $data['order_no'] = $order_no;
         $data['callback_url'] = SanJinService::getNotifyUrl();
         $data['remark'] = '充值费率:' . $rate;
+        if ($bonusSnapshot !== null) $data['recharge_bonus_snapshot'] = $bonusSnapshot;
         $data['status'] = self::STATUS_STAY;
         $ret = SanJinService::pay(($amount * 100), $order_no, $channel);
         Log::error('三斤支付发起:', $ret);
@@ -659,7 +664,7 @@ class PaymentOrderService extends BaseService
     {
         return DB::transaction(function () use ($info, $payAmount, $status, $successStatus, $failStatus, $params) {
             $order = PaymentOrder::where('id', $info->id)->lockForUpdate()->first();
-            if (!$order || $order->status != self::STATUS_PROCESS) {
+            if (!$order || (int)$order->type !== self::TYPE_PAY || $order->status != self::STATUS_PROCESS) {
                 return false;
             }
 
@@ -680,7 +685,8 @@ class PaymentOrderService extends BaseService
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
 
-                BalanceLogService::addLog($order->member_id, $payAmount, $balance, $availableBalance, '三方充值', $order->id, '');
+                $principal = BalanceLogService::addLog($order->member_id, $payAmount, $balance, $availableBalance, '三方充值', $order->id, '');
+                RechargeBonusService::grant('payment_order', $order, $principal);
 
                 // Deposit hooks can grant an immediately available first-deposit reward.
                 $wallet->refresh();
@@ -1574,22 +1580,10 @@ class PaymentOrderService extends BaseService
                 if (($ret['code'] ?? 0) == 200) {
                     $item = $ret['data'] ?? [];
                     if ((string)($item['status'] ?? '') === JdPayService::PAY_STATUS_SUCCESS) {
-                        $info->status = self::STATUS_SUCCESS;
-                        $info->state = $item['status'];
-                        $info->callback_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
-                        $info->save();
-
-                        $wallet = WalletService::findOne(['member_id' => $info->member_id]);
-                        $balance = $wallet->available_balance;
-                        $available_balance = bcadd($balance, $info->amount, 10);
-                        $wallet->available_balance = $available_balance;
-                        $wallet->save();
-
-                        BalanceLogService::addLog($info->member_id, $info->amount, $balance, $available_balance, '三方充值', $info->id, '');
-                        self::rechargesBibiReturn($info->member_id, $info->amount, $info->id);
-
-                        $msg['code'] = self::YES;
-                        $msg['msg'] = '支付成功';
+                        $processed = self::applyPayCallback($info, JdPayService::amount($info->amount), JdPayService::PAY_STATUS_SUCCESS,
+                            JdPayService::PAY_STATUS_SUCCESS, JdPayService::PAY_STATUS_FAIL, $ret);
+                        $msg['code'] = $processed ? self::YES : self::NOT;
+                        $msg['msg'] = $processed ? '支付成功' : '订单已处理';
                     } else {
                         $msg['msg'] = '支付中';
                     }
@@ -1605,20 +1599,9 @@ class PaymentOrderService extends BaseService
                 $item = [];
                 $item['state'] = $ret['data']['state'];
                 if ($ret['data']['state'] == 1) {
-                    $item['status'] = self::STATUS_SUCCESS;
-                    $info->update($item);
-
-                    $wallet = WalletService::findOne(['member_id' => $info->member_id]);
-                    $balance = $wallet->available_balance;
-                    $available_balance = bcadd($balance, $info->amount, 10);
-                    $wallet->available_balance = $available_balance;
-                    $wallet->save();
-
-                    // 记录余额变动日志
-                    BalanceLogService::addLog($info->member_id, $info->amount, $balance, $available_balance, '三方充值', $info->id, '');
-
-                    $msg['code'] = self::YES;
-                    $msg['msg'] = '支付成功';
+                    $processed = self::applyPayCallback($info, (string)$info->amount, '1', '1', null, $ret);
+                    $msg['code'] = $processed ? self::YES : self::NOT;
+                    $msg['msg'] = $processed ? '支付成功' : '订单已处理';
                 } else {
                     $msg['msg'] = '支付中';
                 }

+ 84 - 0
app/Services/RechargeBonusAdminService.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\RechargeChannel;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+final class RechargeBonusAdminService
+{
+    private function ready(): void
+    {
+        if (!RechargeBonusService::available()) throw ValidationException::withMessages(['config' => '充值赠送配置未初始化,请先完成系统更新']);
+    }
+
+    private function channel($type): string
+    {
+        Validator::make(['channel_type' => $type], ['channel_type' => ['required', 'string', 'max:100']])->validate();
+        if (!RechargeChannel::where('data_type', 1)->where('type', $type)->exists()) throw ValidationException::withMessages(['channel_type' => '充值方式不存在']);
+        return $type;
+    }
+
+    private function texts(array $texts): array
+    {
+        $rules = []; $messages = [];
+        foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
+            $rules[$lang] = ['required', 'array:text']; $rules[$lang . '.text'] = ['required', 'string', 'max:100'];
+            $messages[$lang . '.required'] = $label . '角标文字不能为空'; $messages[$lang . '.text.required'] = $label . '角标文字不能为空';
+        }
+        $result = Validator::make($texts, $rules, $messages)->validate();
+        foreach ($result as $lang => &$text) {
+            if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $text['text']) === '') throw ValidationException::withMessages(['translations.' . $lang . '.text' => '角标文字不能为空']);
+            $text['text'] = trim($text['text']);
+        }
+        unset($text);
+        return $result;
+    }
+
+    public function listing(): array
+    {
+        $this->ready(); $rules = DB::table('recharge_bonuses')->get()->keyBy('channel_type'); $rows = [];
+        foreach (RechargeChannel::where('data_type', 1)->orderBy('sort')->orderBy('id')->get() as $channel) {
+            if (isset($rows[$channel->type])) continue;
+            $rule = $rules[$channel->type] ?? null;
+            $rows[$channel->type] = ['channel_type' => $channel->type, 'channel_name' => $channel->name,
+                'bonus_percent' => RechargeBonusService::percent($rule->bonus_percent ?? '0'), 'enabled' => (int)($rule->enabled ?? 0),
+                'translations' => $rule ? json_decode($rule->translations, true) : ['zh' => ['text' => ''], 'en' => ['text' => ''], 'vi' => ['text' => '']]];
+        }
+        return ['total' => count($rows), 'data' => array_values($rows)];
+    }
+
+    public function save(array $input): array
+    {
+        $this->ready();
+        $data = Validator::make($input, ['channel_type' => ['required', 'string'], 'bonus_percent' => ['required', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/'],
+            'enabled' => ['required', 'integer', 'in:0,1'], 'translations' => ['required', 'array:zh,en,vi']])->validate();
+        $this->channel($data['channel_type']); $data['translations'] = $this->texts($data['translations']);
+        return DB::transaction(function () use ($data) {
+            $before = DB::table('recharge_bonuses')->where('channel_type', $data['channel_type'])->lockForUpdate()->first();
+            $now = RechargeBonusService::now();
+            DB::table('recharge_bonuses')->updateOrInsert(['channel_type' => $data['channel_type']], [
+                'bonus_percent' => bcadd((string)$data['bonus_percent'], '0', 2), 'enabled' => $data['enabled'],
+                'translations' => json_encode($data['translations'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
+                'created_at' => $before->created_at ?? $now, 'updated_at' => $now]);
+            OperationAuditService::record('recharge_bonus', null, 'save', (array)$before, $data);
+            return $data;
+        });
+    }
+
+    public function status(array $input): array
+    {
+        $this->ready(); $data = Validator::make($input, ['channel_type' => ['required', 'string'], 'enabled' => ['required', 'integer', 'in:0,1']])->validate();
+        $this->channel($data['channel_type']);
+        return DB::transaction(function () use ($data) {
+            $row = DB::table('recharge_bonuses')->where('channel_type', $data['channel_type'])->lockForUpdate()->first();
+            if (!$row) throw ValidationException::withMessages(['channel_type' => '请先保存该充值方式的赠送配置']);
+            if ($data['enabled']) $this->texts(json_decode($row->translations, true));
+            DB::table('recharge_bonuses')->where('channel_type', $data['channel_type'])->update(['enabled' => $data['enabled'], 'updated_at' => RechargeBonusService::now()]);
+            OperationAuditService::record('recharge_bonus', null, 'status', (array)$row, $data);
+            return $data;
+        });
+    }
+}

+ 119 - 0
app/Services/RechargeBonusService.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\BalanceLog;
+use App\Models\User;
+use App\Models\Wallet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use RuntimeException;
+
+final class RechargeBonusService
+{
+    public static function available(): bool { return Schema::hasTable('recharge_bonuses'); }
+    public static function now(): string { return Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
+    public static function percent($value): string { $n = bcadd((string)$value, '0', 2); return str_ends_with($n, '.00') ? substr($n, 0, -3) : $n; }
+
+    public static function capture(string $channel, string $member): ?array
+    {
+        if (!self::available()) return null;
+        $rule = DB::table('recharge_bonuses')->where('channel_type', $channel)->first();
+        $user = User::where('member_id', $member)->first();
+        return ['channel_type' => $channel, 'enabled' => (bool)($rule->enabled ?? false) && $user && (int)$user->from === 1,
+            'bonus_percent' => bcadd((string)($rule->bonus_percent ?? '0'), '0', 2), 'captured_at' => self::now(), 'version' => $rule->updated_at ?? null];
+    }
+
+    public static function decorate(array $channels, string $language, bool $eligible = true): array
+    {
+        $rules = self::available() ? DB::table('recharge_bonuses')->whereIn('channel_type', array_column($channels, 'value'))->get()->keyBy('channel_type') : collect();
+        foreach ($channels as &$channel) {
+            $rule = $rules[$channel['value']] ?? null; $enabled = $eligible && (bool)($rule->enabled ?? false);
+            $texts = $rule ? json_decode($rule->translations, true) : [];
+            $channel['bonus_enabled'] = $enabled;
+            $channel['bonus_percent'] = $enabled ? self::percent($rule->bonus_percent) : '0';
+            $channel['badge_text'] = $enabled ? (string)($texts[$language]['text'] ?? '') : '';
+        }
+        unset($channel); return $channels;
+    }
+
+    public static function chainKey(string $net, string $txid): string
+    {
+        return hash('sha256', 'chain:' . strtoupper(trim($net)) . ':' . strtolower(trim($txid)));
+    }
+
+    public static function registerChain(array $row): int
+    {
+        if (!self::available()) return DB::table('recharges')->insertOrIgnore($row);
+        if (empty($row['txid']) || empty($row['net'])) return 0;
+        return DB::transaction(function () use ($row) {
+            $key = self::chainKey($row['net'], $row['txid']); $now = self::now();
+            DB::table('recharge_bonus_chain_receipts')->insertOrIgnore(['dedup_key' => $key, 'created_at' => $now, 'updated_at' => $now]);
+            $receipt = DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->lockForUpdate()->first();
+            if ($receipt->recharge_id !== null) return 0;
+            $old = self::chainRows($row['net'], $row['txid'])->orderBy('id')->first();
+            if ($old) {
+                DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $old->id]);
+                return 0;
+            }
+            $snapshot = self::capture('usdt', (string)$row['member_id']);
+            $row['recharge_bonus_snapshot'] = $snapshot === null ? null : json_encode($snapshot, JSON_THROW_ON_ERROR);
+            $row['created_at'] = $row['updated_at'] = $now;
+            $id = DB::table('recharges')->insertGetId($row);
+            DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $id]);
+            return 1;
+        }, 3);
+    }
+
+    private static function chainRows(string $net, string $txid)
+    {
+        return DB::table('recharges')->whereRaw('UPPER(TRIM(net)) = ?', [strtoupper(trim($net))])->whereRaw('LOWER(TRIM(txid)) = ?', [strtolower(trim($txid))]);
+    }
+
+    public static function chainCreditAllowed(object $order): bool
+    {
+        if (!self::available() || (int)$order->type !== 1 || empty($order->txid)) return true;
+        $key = self::chainKey((string)$order->net, (string)$order->txid); $now = self::now();
+        DB::table('recharge_bonus_chain_receipts')->insertOrIgnore(['dedup_key' => $key, 'recharge_id' => $order->id, 'created_at' => $now, 'updated_at' => $now]);
+        $receipt = DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->lockForUpdate()->first();
+        return $receipt->credited_log_id === null && !self::chainRows((string)$order->net, (string)$order->txid)->where('status', 1)->where('id', '<>', $order->id)->exists();
+    }
+
+    public static function markChainCredited(object $order, BalanceLog $log): void
+    {
+        if (!self::available() || (int)$order->type !== 1 || empty($order->txid)) return;
+        DB::table('recharge_bonus_chain_receipts')->where('dedup_key', self::chainKey((string)$order->net, (string)$order->txid))->update(['credited_log_id' => $log->id]);
+    }
+
+    /** Caller holds the source order and wallet locks in the principal-credit transaction. */
+    public static function grant(string $source, object $order, BalanceLog $principal): void
+    {
+        $snapshot = $order->recharge_bonus_snapshot ?? null;
+        if (is_string($snapshot)) $snapshot = json_decode($snapshot, true);
+        if (!$snapshot || empty($snapshot['enabled']) || bccomp((string)$principal->amount, '0', 10) <= 0) return;
+        if (!in_array($source, ['payment_order', 'recharge'], true) || (int)$principal->related_id !== (int)$order->id
+            || !in_array($principal->change_type, ['充值', '三方充值'], true) || (string)$order->member_id !== (string)$principal->member_id) {
+            throw new RuntimeException('充值赠送来源不匹配');
+        }
+        if (DB::connection()->transactionLevel() < 1) throw new RuntimeException('充值赠送必须与本金同事务处理');
+        $user = User::where('member_id', $principal->member_id)->first();
+        if (!$user || (int)$user->from !== 1 || (string)$order->member_id !== (string)$principal->member_id) return;
+        $amount = bcdiv(bcmul((string)$principal->amount, (string)$snapshot['bonus_percent'], 12), '100', 2);
+        $key = $source === 'recharge' && (int)($order->type ?? 0) === 1 && !empty($order->txid)
+            ? self::chainKey((string)$order->net, (string)$order->txid) : hash('sha256', $source . ':' . $order->id);
+        $now = self::now();
+        $wallet = Wallet::where('member_id', $principal->member_id)->lockForUpdate()->first();
+        if (!$wallet) throw new RuntimeException('充值赠送钱包不存在');
+        $created = DB::table('recharge_bonus_rewards')->insertOrIgnore(['source_type' => $source, 'source_id' => $order->id,
+            'dedup_key' => $key, 'member_id' => $principal->member_id, 'channel_type' => $snapshot['channel_type'],
+            'principal_log_id' => $principal->id, 'principal_amount' => $principal->amount, 'bonus_percent' => $snapshot['bonus_percent'],
+            'amount' => $amount, 'rule_snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => $now, 'updated_at' => $now]);
+        if (!$created || bccomp($amount, '0', 2) <= 0) return;
+        $before = (string)$wallet->available_balance; $after = bcadd($before, $amount, 10);
+        $wallet->available_balance = $after; $wallet->save();
+        $reward = DB::table('recharge_bonus_rewards')->where('source_type', $source)->where('source_id', $order->id)->first();
+        $log = BalanceLogService::addLog($principal->member_id, $amount, $before, $after, '优惠活动', $reward->id, '充值渠道赠送');
+        DB::table('recharge_bonus_rewards')->where('id', $reward->id)->update(['credited_log_id' => $log->id]);
+    }
+}

+ 10 - 3
app/Services/RechargeService.php

@@ -186,8 +186,8 @@ class RechargeService extends BaseService
             $v['updated_at'] = now();
             $data[$k] = $v;
         }
-        $m = new (static::$MODEL);
-        $result = $m->insertOrIgnore($data);
+        $result = 0;
+        foreach ($data as $record) $result += RechargeBonusService::registerChain($record);
         return $result;
     }
 
@@ -513,13 +513,20 @@ class RechargeService extends BaseService
     public static function creditConfirmedRecharge(Recharge $info, array $confirmed, string $amount): bool
     {
         return DB::transaction(function () use ($info, $confirmed, $amount) {
+            $info = static::$MODEL::where('id', $info->id)->lockForUpdate()->first();
+            if (!$info || (int)$info->status !== Recharge::STATUS_STAY) return false;
+            if (!RechargeBonusService::chainCreditAllowed($info)) {
+                $info->status = Recharge::STATUS_IGNORE; $info->save(); return false;
+            }
             $wallet = \App\Models\Wallet::where('member_id', $info->member_id)->lockForUpdate()->first();
             if (!$wallet) throw new \RuntimeException('充值钱包不存在');
             $updated = static::$MODEL::where('id', $info->id)->where('status', Recharge::STATUS_STAY)->update($confirmed);
             if (!$updated) return false;
             $before = (string)$wallet->available_balance;
             $wallet->available_balance = bcadd($before, $amount, 10); $wallet->save();
-            BalanceLogService::addLog($info->member_id, $amount, $before, $wallet->available_balance, '充值', $info->id, '');
+            $principal = BalanceLogService::addLog($info->member_id, $amount, $before, $wallet->available_balance, '充值', $info->id, '');
+            RechargeBonusService::grant('recharge', $info, $principal);
+            RechargeBonusService::markChainCredited($info, $principal);
             return true;
         }, 3);
     }

+ 56 - 0
app/Services/UsdtRechargeConfigService.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Config;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
+
+class UsdtRechargeConfigService
+{
+    public function get(): array
+    {
+        $values = Config::whereIn('field', ['receiving_type', 'receiving_address', 'receiving_address_erc20', 'public_chain'])
+            ->pluck('val', 'field');
+
+        return [
+            'receiving_type' => (int)($values['receiving_type'] ?? 2),
+            'receiving_address' => (string)($values['receiving_address'] ?? ''),
+            'receiving_address_erc20' => (string)($values['receiving_address_erc20'] ?? ''),
+            'public_chain' => (string)($values['public_chain'] ?? ''),
+        ];
+    }
+
+    public function save(array $input): array
+    {
+        foreach (['receiving_address', 'receiving_address_erc20', 'public_chain'] as $field) {
+            if (isset($input[$field]) && is_string($input[$field])) {
+                $input[$field] = trim($input[$field]);
+            }
+        }
+        $data = validator($input, [
+            'receiving_type' => ['required', 'integer', 'in:1,2'],
+            'receiving_address' => ['present', 'nullable', 'string', 'regex:/^T[1-9A-HJ-NP-Za-km-z]{33}$/'],
+            'receiving_address_erc20' => ['present', 'nullable', 'string', 'regex:/^0x[0-9a-fA-F]{40}$/'],
+            'public_chain' => ['sometimes', 'nullable', 'string', 'max:2048'],
+        ])->validate();
+
+        if ((int)$data['receiving_type'] === 2 && empty($data['receiving_address']) && empty($data['receiving_address_erc20'])) {
+            throw ValidationException::withMessages(['receiving_address' => '手动充值至少填写一个公链收款地址']);
+        }
+
+        DB::transaction(function () use ($data) {
+            $fields = ['receiving_type' => '充值方式:1自动 2手动', 'receiving_address' => '收款地址_TRC20', 'receiving_address_erc20' => '收款地址_ERC20'];
+            if (array_key_exists('public_chain', $data)) {
+                $fields['public_chain'] = 'Public Chain 公链地址';
+            }
+            foreach ($fields as $field => $remark) {
+                Config::updateOrCreate(['field' => $field], [
+                    'val' => (string)($data[$field] ?? ''), 'remark' => $remark, 'group_id' => 1,
+                ]);
+            }
+        });
+
+        return $this->get();
+    }
+}

+ 41 - 0
database/migrations/2026_10_28_140000_create_recharge_bonuses.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('recharge_bonuses', function (Blueprint $t) {
+            $t->string('channel_type', 100)->primary(); $t->decimal('bonus_percent', 5, 2)->default(0);
+            $t->json('translations'); $t->boolean('enabled')->default(false); $t->timestamps();
+        });
+        Schema::create('recharge_bonus_rewards', function (Blueprint $t) {
+            $t->id(); $t->string('source_type', 24); $t->unsignedBigInteger('source_id'); $t->string('dedup_key', 64)->unique();
+            $t->string('member_id', 100); $t->string('channel_type', 100); $t->unsignedBigInteger('principal_log_id');
+            $t->decimal('principal_amount', 24, 10); $t->decimal('bonus_percent', 5, 2); $t->decimal('amount', 24, 2);
+            $t->unsignedBigInteger('credited_log_id')->nullable(); $t->json('rule_snapshot'); $t->timestamps();
+            $t->unique(['source_type', 'source_id'], 'recharge_bonus_source');
+        });
+        Schema::create('recharge_bonus_chain_receipts', function (Blueprint $t) {
+            $t->string('dedup_key', 64)->primary(); $t->unsignedBigInteger('recharge_id')->nullable();
+            $t->unsignedBigInteger('credited_log_id')->nullable(); $t->timestamps();
+        });
+        foreach (['payment_orders', 'recharges'] as $table) {
+            Schema::table($table, function (Blueprint $t) { $t->json('recharge_bonus_snapshot')->nullable(); });
+        }
+    }
+
+    public function down(): void
+    {
+        if (DB::table('recharge_bonus_rewards')->exists() || DB::table('recharge_bonus_chain_receipts')->exists()
+            || DB::table('payment_orders')->whereNotNull('recharge_bonus_snapshot')->exists()
+            || DB::table('recharges')->whereNotNull('recharge_bonus_snapshot')->exists()) {
+            throw new \RuntimeException('已有充值赠送快照或记录,不能回滚此迁移');
+        }
+        foreach (['payment_orders', 'recharges'] as $table) Schema::table($table, function (Blueprint $t) { $t->dropColumn('recharge_bonus_snapshot'); });
+        foreach (['recharge_bonus_rewards', 'recharge_bonus_chain_receipts', 'recharge_bonuses'] as $table) Schema::dropIfExists($table);
+    }
+};

+ 17 - 0
database/migrations/2026_10_28_150000_add_recharge_channel_logo.php

@@ -0,0 +1,17 @@
+<?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
+    {
+        Schema::table('recharge_channel', function (Blueprint $t) { $t->string('logo', 2048)->nullable(); });
+    }
+
+    public function down(): void
+    {
+        Schema::table('recharge_channel', function (Blueprint $t) { $t->dropColumn('logo'); });
+    }
+};

+ 29 - 0
docs/前端接口/USDT公链收款地址-前台接入.md

@@ -0,0 +1,29 @@
+# USDT公链收款地址:前台接入
+
+`GET /api/wallet/scan?type=trc20`,使用原会员登录凭证。`type` 支持 `trc20`、`erc20`,不区分大小写。
+
+成功 `code=0`,`data` 结构如下;示例中的地址及图片路径需使用实际接口返回值:
+
+```json
+{
+  "qrcode": "https://example.com/storage/recharge/<address>.png",
+  "address": "<address>",
+  "public_chain": "<后台填写的Public Chain地址>",
+  "net": "TRC20",
+  "coin": "USDT",
+  "receiving_type": 2
+}
+```
+
+- `address`:对应公链的实际收款地址,直接展示并用于复制。保留原字段名。
+- `public_chain`:新增,独立的 Public Chain 公链地址,由后台手动填写,按纯文本展示或复制。两条链及自动/手动模式共用此配置,未配置返回 `""`,此时隐藏对应展示。
+- `qrcode`:同一收款地址的二维码。
+- `net`:新增,当前公链 `TRC20` 或 `ERC20`。
+- `coin`:新增,固定为 `USDT`。
+- `receiving_type`:新增,`1` 自动、`2` 手动。
+
+手动模式的地址由后台填写,两条公链分别配置。未配置所选公链时 `code=-1`,展示 `msg`,不要复用之前的地址和二维码。
+
+自动模式使用会员专属 TRC20 地址,ERC20 请求返回 `code=-1`,不会返回其他链的地址。切换公链时重新请求本接口,并同时更新地址、二维码和链名称;响应禁止缓存。
+
+手动充值凭证仍提交 `/api/wallet/recharge`,`net` 使用返回的 `net`,`toAddress` 使用返回的 `address`,其余参数沿用原接口。

+ 23 - 0
docs/前端接口/充值渠道Logo-前端接入.md

@@ -0,0 +1,23 @@
+# 充值渠道Logo
+
+在现有充值通道编辑表单增加可选的Logo上传项。
+
+| 接口 | 用法 |
+| --- | --- |
+| POST `/admin/upload/uploadFile` | 上传图片,multipart字段 `image`,使用返回的 `data.path` |
+| POST `/admin/rechargeChannel/update` | 按原表单提交,额外增加 `logo` 字段 |
+| GET `/api/wallet/channel` | 每个充值方式的 `data.list[].logo` 为Logo完整地址 |
+
+上传支持jpg/jpeg/png/webp,最大10MB。
+
+保存时新增字段示例,其他原必填字段仍照常提交:
+
+```json
+{"logo":"/storage/images/channel.png"}
+```
+
+后台原通道列表返回的 `logo` 用于编辑回填。省略时保留原Logo,传空字符串或null清除。
+
+同一充值方式的多条底层通道共用Logo,修改任意一条会同步;新增同方式通道时自动沿用已有Logo。
+
+前台直接使用 `item.logo` 展示图片,未上传时为 `""`,沿用原默认图标。Logo与赠送开关独立。

+ 21 - 0
docs/前端接口/充值渠道赠送-前台接入.md

@@ -0,0 +1,21 @@
+# 充值渠道赠送:前台接入
+
+原接口 `GET /api/wallet/channel` 在 `data.list[]` 每个充值方式中新增:
+
+| 字段 | 含义 |
+| --- | --- |
+| `bonus_enabled` | 是否开启该方式的赠送,布尔值 |
+| `bonus_percent` | 赠送百分比字符串,`5` 表示5% |
+| `badge_text` | 当前语言的角标文字,按纯文本展示 |
+
+新增字段示例:
+
+```json
+{"bonus_enabled":true,"bonus_percent":"5","badge_text":"额外赠送5%"}
+```
+
+在对应充值方式上展示角标和赠送百分比。关闭或未配置时返回 `false/"0"/""`,隐藏赠送展示;原充值方式和金额配置照常使用。
+
+充值1000、比例5%,该项独立赠送50。其他活动各自按本金计算,不把其他赠送金额计入本金。
+
+普通订单按下单时的规则赠送,自动链上充值按首次识别记录时的规则赠送。赠送成功后直接进入可用余额,沿用原钱包和资金记录展示。

+ 13 - 0
docs/后端/充值渠道Logo-部署说明.md

@@ -0,0 +1,13 @@
+# 充值渠道Logo部署
+
+在bot-28项目、前台钱包接口所用的业务库执行:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_28_150000_add_recharge_channel_logo.php --force
+```
+
+增加recharge_channel.logo可空字段,已有记录返回空字符串。先迁移,再发布代码。本次无需修改melbet_sport-api;实际/api/wallet/channel属于bot-28。
+
+Logo保存复用原通道更新接口和权限,不新增菜单。相同data_type=1和type的有效通道共用Logo,提现同名type不会一起更新。原赠送配置接口不增加Logo字段。
+
+尚未执行运行环境迁移、图片上传或部署。

+ 21 - 0
docs/后端/充值渠道赠送-部署说明.md

@@ -0,0 +1,21 @@
+# 充值渠道赠送部署
+
+先在 `bot-28` 对两个项目共用的业务库执行:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_28_140000_create_recharge_bonuses.php --force
+```
+
+然后发布两个项目代码,最后在独立配置接口保存并开启赠送。迁移不创建菜单或按钮;读取、保存及开关权限分别为 `admin/rechargeBonus/list`、`admin/rechargeBonus/save`,开关复用保存权限。
+
+`recharge_bonuses` 按前台充值方式type主键保存比例、三语文字和开关。未配置默认关闭。`/admin/rechargeChannel/list` 不增加赠送字段。实际 `/api/wallet/channel` 路由属于bot-28,返回字段也在此项目增加。
+
+`payment_orders/recharges.recharge_bonus_snapshot` 创建后固定开关、比例和渠道,不使用到账时的新配置。第三方订单在请求支付商之前读取规则,并在首次订单保存时持久化;人工凭证订单在创建时读取;自动链上记录首次识别时读取。旧订单null快照不补赠,重复扫描不为旧记录补快照。
+
+奖励基于本金流水的实际人民币金额,使用BCMath截取到分。使用独立 `recharge_bonus_rewards` 记录与 `优惠活动/充值渠道赠送` 流水,不触发其他充值赠送或有效存款累计。仅前台真实会员from=1参与;后台直接上分、游客和Telegram会员不参与。所有真实充值入账点在原事务中发奖,原有其他奖励各自计算。
+
+自动USDT使用 `recharge_bonus_chain_receipts` 按网络和交易ID去重,不依赖旧recharges表是否已有txid唯一索引;重复旧记录不再次入账。JD及三斤查询补单复用统一支付入账锁和事务。已有快照或赠送记录时禁止回滚迁移,避免丢失资金追踪。
+
+melbet_sport-api原生充值凭证入口也保存USDT赠送快照,其他币种不映射为USDT。该接口会员编号使用认证用户真实member_id。
+
+本地使用隔离SQLite、真实框架和真实入账逻辑验证,Telegram通知采用测试传输替身。未执行线上迁移、真实支付、线上链扫描或部署;MySQL并发锁和支付商真实回调仍需在运行环境联调。

+ 38 - 0
docs/总后台/USDT公链收款地址-后台接入.md

@@ -0,0 +1,38 @@
+# USDT公链收款地址:后台接入
+
+在后台 USDT 充值设置中增加充值模式、TRC20 收款地址、ERC20 收款地址、Public Chain 公链地址四个输入项。`public_chain` 是独立配置字段,与已有的 USDT 收款地址分别填写。此项目提供后台接口,管理页面需按本文接入。
+
+| 方法 | 接口 | 用途 | 沿用权限 URI |
+| --- | --- | --- | --- |
+| GET | `/admin/config/usdt` | 读取当前配置 | `admin/config/getAll` |
+| POST | `/admin/config/usdt` | 保存充值配置及 Public Chain | `admin/config/set` |
+
+使用后台登录凭证 `Authorization: Bearer <admin_token>`,POST 使用 JSON。无需额外数据库迁移,沿用 `config` 表的现有字段,缺失记录在保存时创建。
+
+读取成功的 `data`、保存请求和保存成功的 `data` 使用相同结构:
+
+```json
+{
+  "receiving_type": 2,
+  "receiving_address": "",
+  "receiving_address_erc20": "",
+  "public_chain": ""
+}
+```
+
+上例表示未配置时的读取结果;保存手动模式前需至少填写一个地址。
+
+| 字段 | 页面名称 | 规则 |
+| --- | --- | --- |
+| `receiving_type` | 充值模式 | `1` 自动,`2` 手动 |
+| `receiving_address` | TRC20 公链收款地址 | T 开头、34位 Base58 字符,可留空 |
+| `receiving_address_erc20` | ERC20 公链收款地址 | 0x 开头、后接40位十六进制字符,可留空 |
+| `public_chain` | Public Chain 公链地址 | 可手动填写的独立字符串,最多2048字符,可留空 |
+
+每次保存必须提交充值模式及两个收款地址字段。地址首尾空白会移除,空字符串或 `null` 清空该链地址;手动模式至少填写一条链的地址。只校验收款地址格式,不验证链上账户或所有权。
+
+`public_chain` 首尾空白会移除,传空字符串或 `null` 清空,省略则保留原值。未配置时读取和扫码接口均返回 `""`。它是一项共用配置,`/api/wallet/scan` 在两条公链、自动及手动模式下都通过 `data.public_chain` 返回保存的内容,不从 `address` 或 `net` 推导。前台按纯文本展示或复制。
+
+`code=0` 保存成功,使用返回 `data` 回填表单;`code=-3` 显示 `msg`。保存后 `/api/wallet/scan?type=trc20` 或 `type=erc20` 按所选公链返回配置的收款地址及对应二维码;未填写该链地址时返回错误。自动模式继续使用会员专属 TRC20 地址,ERC20 请求返回错误。
+
+旧接口 `/admin/config/getAll`、`/admin/config/get`、`/admin/config/setField` 继续使用同一组配置;`/admin/config/set` 也支持可选的 `receiving_address_erc20`、`public_chain`,省略时保留原值。新页面使用独立接口,无需携带房间底分、抽佣等其他设置。

+ 41 - 0
docs/总后台/充值渠道赠送-后台接入.md

@@ -0,0 +1,41 @@
+# 充值渠道赠送:后台接入
+
+使用独立配置页面,表格只展示充值方式、赠送百分比、角标文字和开关。
+
+| 方法 | 接口 | 用途 |
+| --- | --- | --- |
+| GET | `/admin/rechargeBonus/list` | 全部充值方式及当前赠送配置 |
+| POST | `/admin/rechargeBonus/save` | 保存一行配置 |
+| POST | `/admin/rechargeBonus/status` | 开启/关闭 |
+
+成功 `code=0`,错误 `code=-3`,直接展示返回的 `msg`。
+
+列表位于 `data.data[]`,总数为 `data.total`:
+
+```json
+{
+  "channel_type": "usdt",
+  "channel_name": "USDT充值",
+  "bonus_percent": "5",
+  "enabled": 1,
+  "translations": {
+    "zh": {"text": "额外赠送5%"},
+    "en": {"text": "Extra 5% bonus"},
+    "vi": {"text": "Thưởng thêm 5%"}
+  }
+}
+```
+
+`channel_type/channel_name` 从列表读取,充值方式不可修改。不同底层通道对应同一充值方式时只显示一行。
+
+保存时提交 `channel_type/bonus_percent/enabled/translations`,不提交 `channel_name`。比例范围0~100,最多两位小数,`5` 表示5%;开关为 `0/1`。
+
+角标文字使用中文、英文、越南文三个Tab,每个Tab一个文字输入框。三种语言均必填,每种最多100字符。
+
+单独开关请求:
+
+```json
+{"channel_type":"usdt","enabled":0}
+```
+
+开启前先保存完整配置。配置修改及关闭只影响后续新订单,已创建订单按原赠送规则到账。

+ 8 - 0
routes/admin.php

@@ -278,6 +278,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
 
         Route::prefix('/config')->group(function () {
+            Route::get('/usdt', [Config::class, 'usdt'])->defaults('permission_uri', 'admin/config/getAll');
+            Route::post('/usdt', [Config::class, 'saveUsdt'])->defaults('permission_uri', 'admin/config/set');
             Route::get('/pcConfig', [Config::class, 'pcConfig']);
             Route::post('/pc28Switch', [Config::class, 'pc28Switch']);
             Route::get('/gameList', [Config::class, 'gameList']);
@@ -392,6 +394,12 @@ Route::middleware(['admin.jwt'])->group(function () {
 
         });
 
+        Route::prefix('/rechargeBonus')->group(function () {
+            Route::get('/list', [\App\Http\Controllers\admin\RechargeBonus::class, 'index']);
+            Route::post('/save', [\App\Http\Controllers\admin\RechargeBonus::class, 'save']);
+            Route::post('/status', [\App\Http\Controllers\admin\RechargeBonus::class, 'status'])->defaults('permission_uri', 'admin/rechargeBonus/save');
+        });
+
         Route::prefix('/rechargeChannel')->group(function () {
             Route::post('/delete', [RechargeChannel::class, 'delete']);
             Route::get("/options", [RechargeChannel::class, 'options']);

+ 22 - 0
tests/Integration/PaymentChannelOptionsTest.php

@@ -66,6 +66,7 @@ class PaymentChannelOptionsTest extends TestCase
         DB::table('recharge_channel_group')->insert($fixture['groups']);
         (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
         (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();
         $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();
@@ -93,6 +94,27 @@ class PaymentChannelOptionsTest extends TestCase
         return $response['data'];
     }
 
+    public function test_deposit_logos_upload_save_share_by_method_and_can_be_cleared(): void
+    {
+        $this->app['config']->set('app.url', 'https://admin.example.test');
+        $before = DB::table('recharge_channel')->where('id', 24)->first();
+        $this->assertSame(0, $this->saveChannel(['id' => 24, 'data_type' => 1, 'payment_company' => 'jd', 'type' => 'JDpay', 'rate' => '0.01', 'logo' => '/storage/images/jd.png'])['code']);
+        $row = \App\Models\RechargeChannel::find(24);
+        $this->assertSame('https://admin.example.test/storage/images/jd.png', $row->logo);
+        $this->assertSame((string)$before->type, $row->type);
+        $this->assertSame(0, $this->saveChannel($this->newChannel(['data_type' => 1, 'payment_company' => 'jd', 'type' => 'JDpay', 'rate' => '0.02']))['code']);
+        $peers = \App\Models\RechargeChannel::where('data_type', 1)->where('type', 'JDpay')->get();
+        $this->assertCount(2, $peers);
+        foreach ($peers as $peer) $this->assertSame($row->logo, $peer->logo);
+        $this->assertSame(0, $this->saveChannel(['id' => 24, 'data_type' => 1, 'payment_company' => 'jd', 'type' => 'JDpay', 'rate' => '0.01', 'logo' => 'https://cdn.example.test/jd.webp'])['code']);
+        foreach (\App\Models\RechargeChannel::where('data_type', 1)->where('type', 'JDpay')->get() as $peer) $this->assertSame('https://cdn.example.test/jd.webp', $peer->logo);
+        $this->assertSame(0, $this->saveChannel(['id' => 24, 'data_type' => 1, 'payment_company' => 'jd', 'type' => 'JDpay', 'rate' => '0.01', 'logo' => null])['code']);
+        foreach (\App\Models\RechargeChannel::where('data_type', 1)->where('type', 'JDpay')->get() as $peer) $this->assertSame('', $peer->logo);
+        foreach (['javascript:alert(1)', '//evil.test/a.png', ['bad']] as $logo) {
+            $this->assertSame(-3, $this->saveChannel(['id' => 24, 'data_type' => 1, 'payment_company' => 'jd', 'type' => 'JDpay', 'rate' => '0.01', 'logo' => $logo])['code']);
+        }
+    }
+
     public function test_company_options_use_all_rows_not_first_page(): void
     {
         $page1 = $this->channelList(['data_type' => '1']);

+ 239 - 0
tests/Integration/RechargeBonusTest.php

@@ -0,0 +1,239 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Models\PaymentOrder;
+use App\Models\Recharge;
+use App\Services\PaymentOrderService;
+use App\Services\RechargeBonusAdminService;
+use App\Services\RechargeBonusService;
+use App\Services\RechargeService;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+class RechargeBonusTest extends TestCase
+{
+    private Application $app;
+    private string $originalTimezone;
+    protected function setUp(): void
+    {
+        $this->originalTimezone = date_default_timezone_get(); date_default_timezone_set('Asia/Shanghai');
+        \Illuminate\Database\Eloquent\Model::clearBootedModels();
+        require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
+        Facade::clearResolvedInstances(); Carbon::setTestNow(Carbon::parse('2026-10-28 12:00:00', 'Asia/Shanghai'));
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']])); Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new \Illuminate\Events\Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
+        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        $this->app->instance('validator', new Factory($this->app['translator'], $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new \Illuminate\Http\JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        $request = Request::create('/admin/rechargeBonus/save', 'POST'); $request->user = (object)['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
+        foreach ([
+            'users' => 'id INTEGER PRIMARY KEY,member_id TEXT,user_id TEXT,"from" INTEGER,language TEXT,level INTEGER DEFAULT 0,agent_user_code TEXT,recharge_channel_group_id INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT',
+            'wallets' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT UNIQUE,available_balance TEXT DEFAULT "0",frozen_balance TEXT DEFAULT "0",created_at TEXT,updated_at TEXT',
+            'balance_logs' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER DEFAULT 1,amount TEXT,change_type TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT',
+            'payment_orders' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER,channel TEXT,status INTEGER,state TEXT,amount TEXT,order_no TEXT,callback_data TEXT,remark TEXT,created_at TEXT,updated_at TEXT',
+            'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER,status INTEGER DEFAULT 0,coin TEXT,net TEXT,txid TEXT,amount TEXT,exchange_rate TEXT,created_at TEXT,updated_at TEXT',
+            'recharge_channel' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,data_type INTEGER,type TEXT,name TEXT,sort INTEGER DEFAULT 0,status INTEGER DEFAULT 1,deleted_at TEXT,min TEXT,max TEXT,fixed TEXT,rate TEXT DEFAULT "0.02"',
+            'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,recharge_type TEXT,withdraw_type TEXT,deleted_at TEXT',
+            'payment_providers' => 'id INTEGER PRIMARY KEY,code TEXT,status INTEGER',
+            'operation_audits' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,resource_type TEXT,resource_id INTEGER,action TEXT,changes TEXT,operator_id INTEGER,operator_name TEXT,created_at TEXT',
+            'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
+            'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
+            'user_login' => 'id INTEGER PRIMARY KEY,user_id TEXT,status INTEGER,platform TEXT',
+            'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT',
+        ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_160000_create_promotion_activities.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_140000_add_promotion_activity_translations.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_140000_create_recharge_bonuses.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_150000_add_recharge_channel_logo.php')->up();
+        DB::table('recharge_channel_group')->insert(['id' => 1, 'activity_type' => '', 'recharge_type' => 'JDpay,rgcz,usdt']);
+        foreach (['JDpay', 'rgcz', 'usdt'] as $type) DB::table('recharge_channel')->insert(['data_type' => 1, 'type' => $type, 'name' => $type]);
+        foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0', 'recharges_bibi_return' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
+        foreach ([1 => 1, 2 => 2, 3 => -1] as $id => $from) {
+            DB::table('users')->insert(['id' => $id, 'member_id' => 'm' . $id, 'user_id' => 'u' . $id, 'from' => $from, 'language' => 'zh']);
+            DB::table('wallets')->insert(['member_id' => 'm' . $id]);
+        }
+        DB::table('payment_providers')->insert(['id' => 1, 'code' => 'jd', 'status' => 1]);
+        $this->configure('JDpay'); $this->configure('rgcz'); $this->configure('usdt');
+    }
+    protected function tearDown(): void { date_default_timezone_set($this->originalTimezone); Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    private function configure(string $type, string $percent = '5', int $enabled = 1): array
+    {
+        return (new RechargeBonusAdminService())->save(['channel_type' => $type, 'bonus_percent' => $percent, 'enabled' => $enabled,
+            'translations' => ['zh' => ['text' => '额外赠送'], 'en' => ['text' => 'Extra bonus'], 'vi' => ['text' => 'Thưởng thêm']]]);
+    }
+    private function order(string $member = 'm1', bool $legacy = false): PaymentOrder
+    {
+        $order = new PaymentOrder(['member_id' => $member, 'type' => 1, 'status' => 1, 'channel' => 'JDpay', 'amount' => '1000']);
+        if (!$legacy) $order->recharge_bonus_snapshot = RechargeBonusService::capture('JDpay', $member);
+        $order->save(); return $order;
+    }
+    private function pay(PaymentOrder $order): bool
+    {
+        $method = new \ReflectionMethod(PaymentOrderService::class, 'applyPayCallback'); $method->setAccessible(true);
+        return $method->invoke(null, $order, (string)$order->amount, '1', '1', '2', ['status' => '1']);
+    }
+    private function balance(string $member = 'm1'): string { return bcadd((string)DB::table('wallets')->where('member_id', $member)->value('available_balance'), '0', 2); }
+
+    public function test_independent_five_percent_first_deposit_and_old_bonus_all_use_original_principal(): void
+    {
+        (new \App\Services\PromotionAdminService())->save(['type' => 8, 'status' => 1, 'start_time' => '2026-10-01', 'end_time' => '2026-12-01',
+            'translations' => array_fill_keys(['zh','en','vi'], ['title' => 'First', 'content' => 'First bonus']), 'params' => ['min_deposit' => '1000', 'reward_amount' => '25']]);
+        DB::table('config')->where('field', 'recharges_bibi_return')->update(['val' => '0.10']);
+        $order = $this->order(); $this->assertTrue($this->pay($order)); $this->assertFalse($this->pay($order));
+        $this->assertSame('1175.00', $this->balance());
+        $this->assertSame('50.00', bcadd((string)DB::table('recharge_bonus_rewards')->value('amount'), '0', 2));
+        $this->assertSame(1, DB::table('recharge_bonus_rewards')->count());
+        $this->assertSame(1, DB::table('balance_logs')->where('change_type', '三方充值')->count());
+        $this->assertSame(1, DB::table('promotion_rewards')->count());
+        $this->assertSame(1, DB::table('balance_logs')->where('remark', '充值渠道赠送')->count());
+    }
+    public function test_snapshots_survive_changes_and_closed_guest_telegram_and_old_orders_do_not_get_back_awards(): void
+    {
+        $pending = $this->order(); $this->configure('JDpay', '20', 0); $this->assertTrue($this->pay($pending));
+        $this->assertSame('1050.00', $this->balance());
+        $closed = $this->order(); $this->configure('JDpay', '20', 1); $this->pay($closed);
+        $this->assertSame('2050.00', $this->balance());
+        $this->pay($this->order('m1', true)); $this->pay($this->order('m2')); $this->pay($this->order('m3'));
+        $this->assertSame('3050.00', $this->balance());
+        $this->assertSame('1000.00', $this->balance('m2')); $this->assertSame('1000.00', $this->balance('m3'));
+        $this->assertSame(1, DB::table('recharge_bonus_rewards')->count());
+    }
+    public function test_chain_first_detection_snapshot_conversion_and_duplicate_transactions(): void
+    {
+        $row = ['member_id' => 'm1', 'type' => 1, 'coin' => 'USDT', 'net' => 'trc20', 'txid' => 'ABC123', 'amount' => '10', 'status' => 0];
+        $this->assertSame(1, RechargeBonusService::registerChain($row)); $this->assertSame(0, RechargeBonusService::registerChain($row));
+        $id = (int)DB::table('recharges')->value('id'); $this->configure('usdt', '50', 0);
+        $this->assertTrue(RechargeService::creditConfirmedRecharge(Recharge::find($id), ['status' => 1], '70'));
+        $this->assertFalse(RechargeService::creditConfirmedRecharge(Recharge::find($id), ['status' => 1], '70'));
+        $this->assertSame('73.50', $this->balance());
+        $duplicate = DB::table('recharges')->insertGetId($row);
+        $this->assertFalse(RechargeService::creditConfirmedRecharge(Recharge::find($duplicate), ['status' => 1], '70'));
+        $this->assertSame(3, (int)DB::table('recharges')->where('id', $duplicate)->value('status'));
+        $this->assertSame('73.50', $this->balance());
+    }
+    public function test_legacy_chain_scan_is_not_given_a_new_snapshot(): void
+    {
+        $row = ['member_id' => 'm1', 'type' => 1, 'coin' => 'USDT', 'net' => 'TRC20', 'txid' => 'old', 'amount' => '10', 'status' => 0];
+        $id = DB::table('recharges')->insertGetId($row);
+        $this->assertSame(0, RechargeBonusService::registerChain($row));
+        $this->assertTrue(RechargeService::creditConfirmedRecharge(Recharge::find($id), ['status' => 1], '70'));
+        $this->assertSame('70.00', $this->balance()); $this->assertSame(0, DB::table('recharge_bonus_rewards')->count());
+    }
+    public function test_failed_reward_rolls_back_principal_order_status_and_logs(): void
+    {
+        $order = $this->order();
+        DB::statement("CREATE TRIGGER reject_new_bonus BEFORE INSERT ON bot_recharge_bonus_rewards BEGIN SELECT RAISE(ABORT, 'bonus offline'); END");
+        try { $this->pay($order); $this->fail('Expected failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('bonus offline', $e->getMessage()); }
+        $this->assertSame('0.00', $this->balance()); $this->assertSame(1, (int)$order->fresh()->status);
+        $this->assertSame(0, DB::table('balance_logs')->count()); $this->assertSame(0, DB::table('promotion_first_deposits')->count());
+    }
+    public function test_manual_proof_snapshot_and_audit_credit_the_bonus_without_affecting_admin_balance_adjustments(): void
+    {
+        $order = PaymentOrder::create(['member_id' => 'm1', 'type' => 3, 'channel' => 'rgcz', 'status' => 5, 'amount' => '1000']);
+        $this->configure('rgcz', '50', 0);
+        $request = Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => $order->id, 'status' => 2]);
+        $request->user = (object)['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
+        $result = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
+        $this->assertSame(0, $result['code'], $result['msg']); $this->assertSame('1050.00', $this->balance());
+        $again = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
+        $this->assertNotSame(0, $again['code']); $this->assertSame('1050.00', $this->balance());
+    }
+    public function test_fractional_rewards_truncate_and_failed_or_payout_orders_never_grant(): void
+    {
+        $order = $this->order(); $order->amount = '1.239'; $order->save(); $this->pay($order);
+        $this->assertSame('0.06', bcadd((string)DB::table('recharge_bonus_rewards')->value('amount'), '0', 2));
+        $this->assertSame('1.2990000000', bcadd((string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'), '0', 10));
+        $payout = $this->order(); $payout->type = 2; $payout->save(); $this->assertFalse($this->pay($payout));
+        $failed = $this->order();
+        $method = new \ReflectionMethod(PaymentOrderService::class, 'applyPayCallback'); $method->setAccessible(true);
+        $this->assertTrue($method->invoke(null, $failed, '1000', '2', '1', '2', []));
+        $this->assertSame(1, DB::table('recharge_bonus_rewards')->count());
+        $this->assertSame('1.29', $this->balance());
+    }
+
+    public function test_usdt_proof_creation_freezes_the_rule_and_manual_approval_uses_converted_principal(): void
+    {
+        $order = new Recharge();
+        foreach (['member_id' => 'm1', 'type' => 2, 'coin' => 'USDT', 'net' => 'TRC20', 'amount' => '10', 'status' => 0] as $key => $value) $order->$key = $value;
+        $order->save(); $this->configure('usdt', '20', 0);
+        $this->assertSame('5.00', $order->recharge_bonus_snapshot['bonus_percent']);
+        DB::table('config')->insert(['field' => 'exchange_rate_rmb', 'val' => '7']);
+        $request = Request::create('/admin/wallet/verifyRecharge', 'POST', ['id' => $order->id, 'status' => 1]);
+        $request->user = (object)['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
+        // Isolate only the outbound Telegram transport; all wallet/audit/bonus code remains real.
+        if (class_exists(\Telegram\Bot\Api::class, false)) $this->markTestSkipped('Telegram transport is already loaded');
+        eval('namespace Telegram\\Bot; class Api { public function __construct($token = null) {} public function sendMessage($params) { return []; } }');
+        $body = (new \App\Http\Controllers\admin\Wallet())->verifyRecharge()->getData(true);
+        $this->assertSame(0, $body['code'], $body['msg']);
+        $this->assertSame('73.50', $this->balance());
+    }
+
+    public function test_actual_wallet_channel_response_localizes_bonus_and_retains_original_channel_settings(): void
+    {
+        $request = Request::create('/api/wallet/channel', 'GET'); $request->headers->set('lang', 'en');
+        $request->user = \App\Models\User::find(1); $this->app->instance('request', $request);
+        $this->app['config']->set('app.url', 'https://admin.example.test');
+        DB::table('recharge_channel')->where('type', 'JDpay')->update(['logo' => '/storage/images/jd.png']);
+        $body = (new \App\Http\Controllers\api\Wallet())->getChannel()->getData(true);
+        $this->assertSame(0, $body['code']); $this->assertCount(3, $body['data']['list']);
+        $methods = array_column($body['data']['list'], null, 'value');
+        $this->assertSame('Extra bonus', $methods['JDpay']['badge_text']);
+        $this->assertSame('5', $methods['JDpay']['bonus_percent']);
+        $this->assertSame('https://admin.example.test/storage/images/jd.png', $methods['JDpay']['logo']);
+        $this->assertSame('', $methods['usdt']['logo']);
+        $this->assertSame('0.02', $methods['JDpay']['config']['rate']);
+        $this->assertArrayNotHasKey('bonus_percent', \App\Models\RechargeChannel::first()->toArray());
+        $request->user = \App\Models\User::find(2);
+        $guest = (new \App\Http\Controllers\api\Wallet())->getChannel()->getData(true);
+        foreach ($guest['data']['list'] as $method) $this->assertFalse($method['bonus_enabled']);
+    }
+
+    public function test_channel_display_and_config_validate_three_languages_and_do_not_duplicate_method_rows(): void
+    {
+        DB::table('recharge_channel')->insert(['data_type' => 1, 'type' => 'JDpay', 'name' => 'Another underlying product']);
+        $this->assertSame(3, (new RechargeBonusAdminService())->listing()['total']);
+        $rows = RechargeBonusService::decorate([['value' => 'JDpay', 'config' => ['rate' => '0.01']], ['value' => 'unknown']], 'en');
+        $this->assertSame('5', $rows[0]['bonus_percent']); $this->assertSame('Extra bonus', $rows[0]['badge_text']);
+        $this->assertSame('0.01', $rows[0]['config']['rate']); $this->assertFalse($rows[1]['bonus_enabled']);
+        $this->assertFalse(RechargeBonusService::decorate([['value' => 'JDpay']], 'zh', false)[0]['bonus_enabled']);
+        foreach (['zh','en','vi'] as $lang) {
+            $bad = ['channel_type' => 'JDpay', 'bonus_percent' => '5', 'enabled' => 1, 'translations' => array_fill_keys(['zh','en','vi'], ['text' => 'Text'])];
+            $bad['translations'][$lang]['text'] = '';
+            try { (new RechargeBonusAdminService())->save($bad); $this->fail('Missing text accepted'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
+        }
+        (new RechargeBonusAdminService())->status(['channel_type' => 'JDpay', 'enabled' => 0]);
+        $closed = RechargeBonusService::decorate([['value' => 'JDpay']], 'zh')[0];
+        $this->assertSame('0', $closed['bonus_percent']); $this->assertSame('', $closed['badge_text']);
+        $body = (new \App\Http\Controllers\admin\RechargeBonus())->index(new RechargeBonusAdminService())->getData(true);
+        $this->assertSame(0, $body['code']); $this->assertSame(3, $body['data']['total']);
+        foreach (['-1', '100.01', '0.001', '1e2'] as $percent) {
+            try { $this->configure('JDpay', $percent); $this->fail('Invalid percentage accepted'); }
+            catch (ValidationException $e) { $this->assertArrayHasKey('bonus_percent', $e->errors()); }
+        }
+        $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
+        $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        foreach (['list' => 'GET', 'save' => 'POST', 'status' => 'POST'] as $path => $method) {
+            $route = $router->getRoutes()->match(Request::create('/admin/rechargeBonus/' . $path, $method));
+            $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
+        }
+        $this->assertSame('admin/rechargeBonus/save', $route->defaults['permission_uri']);
+
+    }
+}

+ 219 - 0
tests/Integration/UsdtRechargeConfigTest.php

@@ -0,0 +1,219 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Config as AdminConfig;
+use App\Http\Controllers\api\Wallet;
+use App\Services\UsdtRechargeConfigService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Routing\UrlGenerator;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+
+class UsdtRechargeConfigTest extends TestCase
+{
+    private Application $app;
+    private UsdtRechargeConfigService $service;
+    private string $storage;
+    private const TRC = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb';
+    private const ERC = '0x0123456789abcdef0123456789abcdef01234567';
+
+    protected function setUp(): void
+    {
+        require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        $db->schema()->create('config', function (Blueprint $table) {
+            $table->id();
+            $table->string('field')->unique();
+            $table->text('val');
+            $table->string('remark')->nullable();
+            $table->integer('group_id')->default(1);
+            $table->timestamps();
+        });
+        $this->storage = sys_get_temp_dir() . '/bot28-usdt-' . bin2hex(random_bytes(6));
+        $this->app->useStoragePath($this->storage);
+        mkdir($this->storage . '/app/public/recharge', 0777, true);
+        foreach ([self::TRC, self::ERC] as $address) {
+            // Exercise the real scan path with cached QR files, without generating images or using a chain service.
+            file_put_contents($this->storage . '/app/public/recharge/' . $address . '.png', 'cached-qr-fixture');
+        }
+        $request = Request::create('https://example.test/admin/config/usdt');
+        $this->app->instance('request', $request);
+        $router = new Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
+        $this->app->instance('url', new UrlGenerator($router->getRoutes(), $request));
+        $this->service = new UsdtRechargeConfigService();
+    }
+
+    protected function tearDown(): void
+    {
+        foreach ([self::TRC, self::ERC] as $address) {
+            unlink($this->storage . '/app/public/recharge/' . $address . '.png');
+        }
+        foreach (['/app/public/recharge', '/app/public', '/app', ''] as $directory) rmdir($this->storage . $directory);
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function settings(array $changes = []): array
+    {
+        return array_replace(['receiving_type' => 2, 'receiving_address' => self::TRC, 'receiving_address_erc20' => self::ERC, 'public_chain' => ''], $changes);
+    }
+
+    private function request(string $path, string $method, array $params): void
+    {
+        $request = Request::create($path, $method, $params);
+        $request->user = (object)['member_id' => 'member-1'];
+        $this->app->instance('request', $request);
+    }
+
+    private function scan(string $type): array
+    {
+        $this->request('/api/wallet/scan', 'GET', ['type' => $type]);
+        return (new Wallet())->scan()->getData(true);
+    }
+
+    public function test_admin_saves_and_reads_both_addresses_without_other_system_settings(): void
+    {
+        $this->assertSame($this->settings(['receiving_address' => '', 'receiving_address_erc20' => '']), $this->service->get());
+        $this->request('/admin/config/usdt', 'POST', $this->settings(['receiving_address' => ' ' . self::TRC . ' ']));
+        $response = (new AdminConfig())->saveUsdt($this->service);
+        $this->assertSame(0, $response->getData(true)['code']);
+        $this->assertSame($this->settings(), $response->getData(true)['data']);
+        $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+        $this->assertSame($this->settings(), (new AdminConfig())->usdt($this->service)->getData(true)['data']);
+        $this->service->save($this->settings());
+        $this->assertSame(4, DB::table('config')->count());
+    }
+
+    public function test_scan_returns_selected_manually_configured_address_and_matching_qr(): void
+    {
+        $this->service->save($this->settings());
+        foreach (['TrC20' => self::TRC, 'erc20' => self::ERC] as $type => $address) {
+            $result = $this->scan($type);
+            $this->assertSame(0, $result['code']);
+            $this->assertSame([
+                'qrcode' => 'https://example.test/storage/recharge/' . $address . '.png',
+                'address' => $address, 'public_chain' => '', 'net' => strtoupper($type), 'coin' => 'USDT', 'receiving_type' => 2,
+            ], $result['data']);
+        }
+    }
+
+    public function test_public_chain_saves_independently_returns_in_scan_and_can_be_cleared(): void
+    {
+        $this->request('/admin/config/usdt', 'POST', $this->settings(['public_chain' => ' public-chain-address ']));
+        $saved = (new AdminConfig())->saveUsdt($this->service)->getData(true);
+        $this->assertSame(0, $saved['code']);
+        $this->assertSame('public-chain-address', $saved['data']['public_chain']);
+        foreach (['trc20', 'erc20'] as $type) {
+            $this->assertSame('public-chain-address', $this->scan($type)['data']['public_chain']);
+        }
+        $withoutPublicChain = $this->settings();
+        unset($withoutPublicChain['public_chain']);
+        $this->service->save($withoutPublicChain);
+        $this->assertSame('public-chain-address', $this->service->get()['public_chain']);
+        $this->service->save($this->settings(['public_chain' => null]));
+        $this->assertSame('', $this->scan('trc20')['data']['public_chain']);
+        $this->assertSame(self::TRC, $this->scan('trc20')['data']['address']);
+    }
+
+    public function test_blank_or_missing_chain_address_does_not_generate_a_qr_or_use_other_chain(): void
+    {
+        $this->service->save($this->settings(['receiving_address_erc20' => '']));
+        $this->assertSame(-1, $this->scan('erc20')['code']);
+        DB::table('config')->where('field', 'receiving_address')->delete();
+        $this->assertSame(-1, $this->scan('trc20')['code']);
+        $this->assertCount(2, glob($this->storage . '/app/public/recharge/*'));
+    }
+
+    public function test_automatic_mode_rejects_erc20_instead_of_returning_a_tron_address(): void
+    {
+        $this->service->save($this->settings(['receiving_type' => 1]));
+        $this->assertSame(-1, $this->scan('erc20')['code']);
+        $this->assertSame(-1, $this->scan('bep20')['code']);
+    }
+
+    public function test_automatic_trc20_uses_member_wallet_even_without_platform_addresses(): void
+    {
+        $this->service->save($this->settings(['receiving_type' => 1, 'receiving_address' => '', 'receiving_address_erc20' => '']));
+        DB::table('config')->where('field', '!=', 'receiving_type')->delete();
+        DB::statement('CREATE TABLE bot_wallets (id INTEGER PRIMARY KEY,member_id TEXT,coin TEXT,net TEXT,address TEXT,available_balance TEXT,frozen_balance TEXT)');
+        DB::table('wallets')->insert(['id' => 1, 'member_id' => 'member-1', 'coin' => 'USDT', 'net' => 'TRC20',
+            'address' => self::TRC, 'available_balance' => '0', 'frozen_balance' => '0']);
+        $this->app->instance('cache', new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore()));
+        $result = $this->scan('trc20');
+        $this->assertSame(0, $result['code']);
+        $this->assertSame(self::TRC, $result['data']['address']);
+        $this->assertSame(1, $result['data']['receiving_type']);
+        $this->assertArrayHasKey('member-1', $this->app['cache']->get('usdt_recharge_pending_sync_members'));
+    }
+
+    public function test_invalid_settings_do_not_partially_overwrite_saved_addresses(): void
+    {
+        $this->service->save($this->settings());
+        foreach ([['receiving_type' => 3], ['receiving_address' => self::ERC],
+            ['receiving_address_erc20' => self::TRC], ['receiving_address' => '../invalid'],
+            ['receiving_address' => '', 'receiving_address_erc20' => ''],
+            ['public_chain' => ['invalid']], ['public_chain' => str_repeat('a', 2049)]] as $change) {
+            $this->request('/admin/config/usdt', 'POST', $this->settings($change));
+            $this->assertSame(-3, (new AdminConfig())->saveUsdt($this->service)->getData(true)['code']);
+            $this->assertSame($this->settings(), $this->service->get());
+        }
+    }
+
+    public function test_existing_config_save_can_include_erc20_and_omission_preserves_it(): void
+    {
+        $this->service->save($this->settings());
+        $params = $this->settings(['receiving_address_erc20' => '0x' . str_repeat('a', 40), 'public_chain' => 'public-chain-address']) + [
+            'base_score' => [1], 'brokerage' => '0.1', 'service_charge' => 1, 'service_account' => 'support',
+        ];
+        $this->request('/admin/config/set', 'POST', $params);
+        $this->assertSame(0, (new AdminConfig())->set()->getData(true)['code']);
+        unset($params['receiving_address_erc20'], $params['public_chain']);
+        $this->request('/admin/config/set', 'POST', $params);
+        $this->assertSame(0, (new AdminConfig())->set()->getData(true)['code']);
+        $this->assertSame('0x' . str_repeat('a', 40), $this->service->get()['receiving_address_erc20']);
+        $this->assertSame('public-chain-address', $this->service->get()['public_chain']);
+    }
+
+    public function test_routes_keep_admin_auth_and_existing_configuration_permissions(): void
+    {
+        $router = new Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
+        $this->app->instance('router', $router);
+        $router->prefix('admin')->group(dirname(__DIR__, 2) . '/routes/admin.php');
+        foreach (['GET' => 'admin/config/getAll', 'POST' => 'admin/config/set'] as $method => $permission) {
+            $route = $router->getRoutes()->match(Request::create('/admin/config/usdt', $method));
+            $this->assertContains('admin.jwt', $route->gatherMiddleware());
+            $this->assertContains('check.button.uri', $route->gatherMiddleware());
+            $this->assertSame($permission, $route->defaults['permission_uri']);
+        }
+    }
+}