doge hace 2 días
padre
commit
fca003583c

+ 34 - 0
app/Http/Controllers/admin/PaymentOrder.php

@@ -130,6 +130,40 @@ class PaymentOrder extends Controller
         return $this->success();
         return $this->success();
     }
     }
 
 
+    // 在线充值(代收)拒绝:未完成的订单直接置为失败,之后三方回调/查单不会再入账
+    public function reject()
+    {
+        DB::beginTransaction();
+        try {
+            $params = request()->validate([
+                'id' => ['required', 'integer', 'min:1'],
+                'remark' => ['nullable', 'string', 'max:120'],
+            ]);
+            $order = PaymentOrderModel::where('id', $params['id'])
+                ->where('type', PaymentOrderService::TYPE_PAY)
+                ->lockForUpdate()
+                ->first();
+            if (!$order) throw new Exception('订单不存在', HttpStatus::CUSTOM_ERROR);
+            $rejectable = [PaymentOrderService::STATUS_STAY, PaymentOrderService::STATUS_PROCESS, PaymentOrderService::STATUS_TIMEOUT];
+            if (!in_array((int)$order->status, $rejectable, true)) {
+                throw new Exception('订单已结束,不可拒绝', HttpStatus::CUSTOM_ERROR);
+            }
+            $order->status = PaymentOrderService::STATUS_FAIL;
+            $order->remark = trim((string)($params['remark'] ?? '')) ?: '后台拒绝';
+            $order->save();
+            AdminRealtime::record('payment_orders', [$order->id]);
+
+            DB::commit();
+        } catch (ValidationException $e) {
+            DB::rollBack();
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            DB::rollBack();
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success();
+    }
+
     //提现审核
     //提现审核
     public function audit()
     public function audit()
     {
     {

+ 28 - 7
app/Http/Controllers/admin/RechargeChannel.php

@@ -57,7 +57,7 @@ class RechargeChannel extends Controller
     {
     {
         try {
         try {
             $params = request()->validate([
             $params = request()->validate([
-                'data_type' => ['required', 'integer', 'in:1,2,3'],
+                'data_type' => ['required', 'integer', 'in:1,2'],
                 'id' => ['nullable', 'integer', 'min:1'],
                 'id' => ['nullable', 'integer', 'min:1'],
             ]);
             ]);
             return $this->success($service->options((int)$params['data_type'], isset($params['id']) ? (int)$params['id'] : null));
             return $this->success($service->options((int)$params['data_type'], isset($params['id']) ? (int)$params['id'] : null));
@@ -73,10 +73,32 @@ class RechargeChannel extends Controller
         }
         }
     }
     }
 
 
+    /** 充值/提现通道操作日志 */
+    public function logs(RechargeChannelConfigurationService $service)
+    {
+        try {
+            $params = request()->validate([
+                'data_type' => ['required', 'integer', 'in:1,2'],
+                'channel_id' => ['nullable', 'integer', 'min:1'],
+                'operator_name' => ['nullable', 'string', 'max:100'],
+                'action' => ['nullable', 'string', 'in:create,update,delete,save'],
+                'start_time' => ['nullable', 'date'],
+                'end_time' => ['nullable', 'date'],
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+            ]);
+            return $this->success($service->logs($params));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
     //获取充值方式
     //获取充值方式
     public function getChannel()
     public function getChannel()
     {
     {
-        $data_type = request()->input('data_type', 1);
+        $data_type = (int)request()->input('data_type', 1) === 2 ? 2 : 1;
         $channel = RechargeChannelModel::getChannel($data_type);
         $channel = RechargeChannelModel::getChannel($data_type);
         return $this->success(['total' => count($channel), 'data' => $channel]);
         return $this->success(['total' => count($channel), 'data' => $channel]);
     }
     }
@@ -98,7 +120,6 @@ class RechargeChannel extends Controller
             foreach($list as &$item) {
             foreach($list as &$item) {
                 $item->rechargeTypes = $item['recharge_type'] ? RechargeChannelModel::getChannel(1, $item['recharge_type']) : [];
                 $item->rechargeTypes = $item['recharge_type'] ? RechargeChannelModel::getChannel(1, $item['recharge_type']) : [];
                 $item->withdrawTypes = $item['withdraw_type'] ? RechargeChannelModel::getChannel(2, $item['withdraw_type']) : [];
                 $item->withdrawTypes = $item['withdraw_type'] ? RechargeChannelModel::getChannel(2, $item['withdraw_type']) : [];
-                $item->activityTypes = $item['activity_type'] ? RechargeChannelModel::getChannel(3, $item['activity_type']) : [];
             }
             }
         } catch (Exception $e) {
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
             return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
@@ -117,7 +138,6 @@ class RechargeChannel extends Controller
                 'name' => ['required','string'],
                 'name' => ['required','string'],
                 'recharge_type' => ['present','array'],
                 'recharge_type' => ['present','array'],
                 'withdraw_type' => ['present','array'],
                 'withdraw_type' => ['present','array'],
-                'activity_type' => ['nullable','array'],
             ]);
             ]);
             $removed = app(RechargeChannelConfigurationService::class)->saveGroup($params);
             $removed = app(RechargeChannelConfigurationService::class)->saveGroup($params);
             return $this->success([], $removed === [] ? '' : '保存成功,已清理失效的历史通道类型');
             return $this->success([], $removed === [] ? '' : '保存成功,已清理失效的历史通道类型');
@@ -160,7 +180,7 @@ class RechargeChannel extends Controller
             $params = request()->validate([
             $params = request()->validate([
                 'page' => ['nullable', 'integer', 'min:1'],
                 'page' => ['nullable', 'integer', 'min:1'],
                 'limit' => ['nullable', 'integer', 'min:1'],
                 'limit' => ['nullable', 'integer', 'min:1'],
-                'data_type' => ['nullable', 'string'],
+                'data_type' => ['nullable', 'integer', 'in:1,2'],
                 'type' => ['nullable', 'string'],
                 'type' => ['nullable', 'string'],
                 'from' => ['nullable', 'integer'],
                 'from' => ['nullable', 'integer'],
                 'key' => ['nullable', 'string'],
                 'key' => ['nullable', 'string'],
@@ -170,7 +190,8 @@ class RechargeChannel extends Controller
             $page = request()->input('page', 1);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
             $limit = request()->input('limit', 15);
 
 
-            $query = new RechargeChannelModel();
+            // 只管理充值(1)、提现(2)通道;原活动(3)配置已停用
+            $query = RechargeChannelModel::query()->whereIn('data_type', [1, 2]);
             if (!empty($params['data_type'])) {
             if (!empty($params['data_type'])) {
                 $query = $query->where('data_type', $params['data_type']);
                 $query = $query->where('data_type', $params['data_type']);
             }
             }
@@ -229,7 +250,7 @@ class RechargeChannel extends Controller
                 'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
                 'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
                 'key' => ['nullable','string','max:100'],
                 'key' => ['nullable','string','max:100'],
                 'name' => ['nullable','string','max:100'],
                 'name' => ['nullable','string','max:100'],
-                'data_type' => ['required','integer','in:1,2,3'],
+                'data_type' => ['required','integer','in:1,2'],
                 'type' => ['required','string','max:100'],
                 'type' => ['required','string','max:100'],
                 'rate' => ['required','numeric','min:0'],
                 'rate' => ['required','numeric','min:0'],
                 'min' => ['exclude_if:type,zfbge','nullable','numeric','min:0'],
                 'min' => ['exclude_if:type,zfbge','nullable','numeric','min:0'],

+ 1 - 4
app/Models/RechargeChannel.php

@@ -78,10 +78,7 @@ class RechargeChannel extends BaseModel
         if ($data_type == 2) {
         if ($data_type == 2) {
             //提现类型
             //提现类型
             $field = 'withdraw_type';
             $field = 'withdraw_type';
-        } elseif ($data_type == 3) {
-            //提现类型
-            $field = 'activity_type';
-        } 
+        }
         $group = RechargeChannelGroup::query()->find($recharge_channel_group_id);
         $group = RechargeChannelGroup::query()->find($recharge_channel_group_id);
         $types = $group ? (array)$group->{$field} : [];
         $types = $group ? (array)$group->{$field} : [];
         if (!$types) {
         if (!$types) {

+ 3 - 22
app/Models/RechargeChannelGroup.php

@@ -8,8 +8,9 @@ class RechargeChannelGroup extends BaseModel
 {
 {
     use SoftDeletes;
     use SoftDeletes;
     protected $table = 'recharge_channel_group';
     protected $table = 'recharge_channel_group';
-    protected $fillable = ['name', 'recharge_type', 'withdraw_type', 'activity_type'];
-    protected $hidden = [];
+    protected $fillable = ['name', 'recharge_type', 'withdraw_type'];
+    // activity_type 列已停用(原按层级开关活动),不再返回
+    protected $hidden = ['activity_type'];
 
 
     // 修改器:将逗号分隔字符串转为数组
     // 修改器:将逗号分隔字符串转为数组
     public function getRechargeTypeAttribute($value)
     public function getRechargeTypeAttribute($value)
@@ -32,24 +33,4 @@ class RechargeChannelGroup extends BaseModel
     {
     {
         $this->attributes['withdraw_type'] = is_array($value) ? implode(',', $value) : $value;
         $this->attributes['withdraw_type'] = is_array($value) ? implode(',', $value) : $value;
     }
     }
-    public function getActivityTypeAttribute($value)
-    {
-        return $value ? explode(',', $value) : []; 
-    }
-
-    public function setActivityTypeAttribute($value)
-    {
-        if (!$value) {
-            $this->attributes['activity_type'] = '';
-            return;
-        }
-        $this->attributes['activity_type'] = is_array($value) ? implode(',', $value) : $value;
-    }
-
-    public static function getActivityType($id) {
-        $id = $id > 0 ? $id : 1;
-        $activity_type = self::where('id', $id)->value('activity_type');
-        return $activity_type;
-    }
-
 }
 }

+ 2 - 1
app/Services/ActivityRewardService.php

@@ -102,10 +102,11 @@ class ActivityRewardService extends BaseService
 
 
     public static function activeForMember(int $type, $memberId): ?ActivityReward
     public static function activeForMember(int $type, $memberId): ?ActivityReward
     {
     {
-        $context = ActivityEligibilityService::memberContext($memberId);
         $activities = ActivityReward::where('lang', 'zh')->where('type', $type)->where('status', 1)
         $activities = ActivityReward::where('lang', 'zh')->where('type', $type)->where('status', 1)
             ->where('start_time', '<=', time())->where('end_time', '>=', time())
             ->where('start_time', '<=', time())->where('end_time', '>=', time())
             ->orderByDesc('sort')->orderByDesc('id')->get();
             ->orderByDesc('sort')->orderByDesc('id')->get();
+        if ($activities->isEmpty()) return null;
+        $context = ActivityEligibilityService::memberContext($memberId);
         foreach ($activities as $activity) {
         foreach ($activities as $activity) {
             if (!ActivityEligibilityService::evaluate($activity, $context)['is_eligible']) continue;
             if (!ActivityEligibilityService::evaluate($activity, $context)['is_eligible']) continue;
             if (!ActivityQuotaService::canAward($activity, $memberId)) continue;
             if (!ActivityQuotaService::canAward($activity, $memberId)) continue;

+ 35 - 43
app/Services/BalanceLogService.php

@@ -9,7 +9,6 @@ use App\Models\Config;
 use App\Models\WalletBonus;
 use App\Models\WalletBonus;
 use App\Models\Wallet;
 use App\Models\Wallet;
 use App\Models\Level;
 use App\Models\Level;
-use App\Models\RechargeChannelGroup;
 use App\Models\UserLogin;
 use App\Models\UserLogin;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
@@ -244,48 +243,41 @@ class BalanceLogService extends BaseService
                     }
                     }
                 }
                 }
 
 
-                //用户活动
-                $activity_type = RechargeChannelGroup::getActivityType($user->recharge_channel_group_id);
-                if ($activity_type) {
-                    // USDT confirmation has no outer transaction. Keep every reward write
-                    // and its recipient slot pending until the wallet is saved.
-                    // 活动赠送直接进入可用余额,配置了打码倍数的才累加打码量。
-                    DB::transaction(function () use ($activity_type, $memberId, $amount, $related_id, $level, $remark_amount) {
-                        $walletInfo = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
-                        if (!$walletInfo) throw new \RuntimeException('账户钱包不存在');
-                        $credit = function (string $changeType, $bonusAmount, string $needFlow) use ($walletInfo, $memberId, $related_id, $remark_amount) {
-                            $before = (string)$walletInfo->available_balance;
-                            $after = bcadd($before, (string)$bonusAmount, 10);
-                            $log = static::$MODEL::create([
-                                'type' => 1,
-                                'member_id' => $memberId,
-                                'amount' => $bonusAmount,
-                                'before_balance' => $before,
-                                'after_balance' => $after,
-                                'change_type' => $changeType,
-                                'related_id' => $related_id,
-                                'remark' => '充值金额为:'.$remark_amount,
-                            ]);
-                            $walletInfo->available_balance = $after;
-                            WageringService::addRequirement($walletInfo, $needFlow, 'activity:' . $changeType, $log->id, '充值金额为:'.$remark_amount);
-                        };
-                        if (in_array('recharge', $activity_type)) {
-                            //即充即送-返彩活动
-                            $needFlow = '0';
-                            $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id, $needFlow);
-                            if ($bonusAmount > 0) $credit('即充即送', $bonusAmount, $needFlow);
-                        }
-
-                        if (in_array('old_user', $activity_type)) {
-                            //老用户回归-返彩活动
-                            $needFlow = '0';
-                            $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id, $needFlow);
-                            if ($bonusAmount > 0) $credit('老用户回归', $bonusAmount, $needFlow);
-                        }
-
-                        $walletInfo->save();
-                    });
-                }
+                //用户活动:即充即送、老用户回归只看活动本身的上下架与参与条件,不再按充值层级开关
+                // USDT confirmation has no outer transaction. Keep every reward write
+                // and its recipient slot pending until the wallet is saved.
+                // 活动赠送直接进入可用余额,配置了打码倍数的才累加打码量。
+                DB::transaction(function () use ($memberId, $amount, $related_id, $level, $remark_amount) {
+                    $walletInfo = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+                    if (!$walletInfo) throw new \RuntimeException('账户钱包不存在');
+                    $credit = function (string $changeType, $bonusAmount, string $needFlow) use ($walletInfo, $memberId, $related_id, $remark_amount) {
+                        $before = (string)$walletInfo->available_balance;
+                        $after = bcadd($before, (string)$bonusAmount, 10);
+                        $log = static::$MODEL::create([
+                            'type' => 1,
+                            'member_id' => $memberId,
+                            'amount' => $bonusAmount,
+                            'before_balance' => $before,
+                            'after_balance' => $after,
+                            'change_type' => $changeType,
+                            'related_id' => $related_id,
+                            'remark' => '充值金额为:'.$remark_amount,
+                        ]);
+                        $walletInfo->available_balance = $after;
+                        WageringService::addRequirement($walletInfo, $needFlow, 'activity:' . $changeType, $log->id, '充值金额为:'.$remark_amount);
+                    };
+                    //即充即送-返彩活动
+                    $needFlow = '0';
+                    $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id, $needFlow);
+                    if ($bonusAmount > 0) $credit('即充即送', $bonusAmount, $needFlow);
+
+                    //老用户回归-返彩活动
+                    $needFlow = '0';
+                    $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id, $needFlow);
+                    if ($bonusAmount > 0) $credit('老用户回归', $bonusAmount, $needFlow);
+
+                    $walletInfo->save();
+                });
             }
             }
         }
         }
         
         

+ 4 - 5
app/Services/Payment/PaymentProviderCatalog.php

@@ -75,7 +75,7 @@ class PaymentProviderCatalog
     /** Definitions for creating channels, intentionally independent of existing channels/groups. */
     /** Definitions for creating channels, intentionally independent of existing channels/groups. */
     public static function channelFormOptions(int $dataType, ?array $statuses = null): array
     public static function channelFormOptions(int $dataType, ?array $statuses = null): array
     {
     {
-        if (!in_array($dataType, [1, 2, 3], true)) throw new InvalidArgumentException('通道方向错误');
+        if (!in_array($dataType, [1, 2], true)) throw new InvalidArgumentException('通道方向错误');
         $companies = [];
         $companies = [];
         $types = [];
         $types = [];
         foreach (self::PROVIDERS as $code => $provider) {
         foreach (self::PROVIDERS as $code => $provider) {
@@ -94,7 +94,6 @@ class PaymentProviderCatalog
         $local = match ($dataType) {
         $local = match ($dataType) {
             1 => ['usdt' => [2, 'USDT充值'], 'rgcz' => [3, '人工充值']],
             1 => ['usdt' => [2, 'USDT充值'], 'rgcz' => [3, '人工充值']],
             2 => ['usdt' => [2, 'USDT提现'], 'rgtx' => [3, '人工提现']],
             2 => ['usdt' => [2, 'USDT提现'], 'rgtx' => [3, '人工提现']],
-            3 => ['recharge' => [1, '即充即送'], 'old_user' => [1, '老用户回归'], 'yuebao' => [1, '余额宝']],
         };
         };
         foreach ($local as $type => [$from, $label]) {
         foreach ($local as $type => [$from, $label]) {
             $types[] = ['value' => $type, 'label' => $label, 'from' => $from,
             $types[] = ['value' => $type, 'label' => $label, 'from' => $from,
@@ -102,7 +101,7 @@ class PaymentProviderCatalog
         }
         }
         return [
         return [
             'data_type' => $dataType,
             'data_type' => $dataType,
-            'from_options' => $dataType === 3 ? [['value' => 1, 'label' => '活动']] : [
+            'from_options' => [
                 ['value' => 1, 'label' => '第三方支付'], ['value' => 2, 'label' => 'USDT'], ['value' => 3, 'label' => '人工'],
                 ['value' => 1, 'label' => '第三方支付'], ['value' => 2, 'label' => 'USDT'], ['value' => 3, 'label' => '人工'],
             ],
             ],
             'payment_companies' => $companies,
             'payment_companies' => $companies,
@@ -183,7 +182,7 @@ class PaymentProviderCatalog
         $selectedProvider = $isCollection ? $collector : $company;
         $selectedProvider = $isCollection ? $collector : $company;
         $selectedMethod = (string)($isCollection ? ($filters['collection_method'] ?? '') : ($filters['payment_method'] ?? ''));
         $selectedMethod = (string)($isCollection ? ($filters['collection_method'] ?? '') : ($filters['payment_method'] ?? ''));
         $groups = array_map(function ($group) {
         $groups = array_map(function ($group) {
-            foreach (['recharge_type', 'withdraw_type', 'activity_type'] as $field) {
+            foreach (['recharge_type', 'withdraw_type'] as $field) {
                 $group[$field] = self::types($group[$field] ?? []);
                 $group[$field] = self::types($group[$field] ?? []);
             }
             }
             return ['id' => (int)$group['id'], 'name' => (string)$group['name']] + $group;
             return ['id' => (int)$group['id'], 'name' => (string)$group['name']] + $group;
@@ -192,7 +191,7 @@ class PaymentProviderCatalog
         $formatted = [];
         $formatted = [];
         foreach ($channels as $channel) {
         foreach ($channels as $channel) {
             $dataType = (int)$channel['data_type'];
             $dataType = (int)$channel['data_type'];
-            $groupField = [1 => 'recharge_type', 2 => 'withdraw_type', 3 => 'activity_type'][$dataType] ?? null;
+            $groupField = [1 => 'recharge_type', 2 => 'withdraw_type'][$dataType] ?? null;
             $ids = [];
             $ids = [];
             foreach ($groups as $group) {
             foreach ($groups as $group) {
                 if ($groupField && in_array((string)$channel['type'], $group[$groupField], true)) $ids[] = $group['id'];
                 if ($groupField && in_array((string)$channel['type'], $group[$groupField], true)) $ids[] = $group['id'];

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

@@ -3,6 +3,7 @@
 namespace App\Services\Payment;
 namespace App\Services\Payment;
 
 
 use App\Models\RechargeChannel;
 use App\Models\RechargeChannel;
+use App\Models\OperationAudit;
 use App\Models\RechargeChannelGroup;
 use App\Models\RechargeChannelGroup;
 use App\Services\OperationAuditService;
 use App\Services\OperationAuditService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
@@ -17,7 +18,7 @@ class RechargeChannelConfigurationService
             $id = (int)($params['id'] ?? 0);
             $id = (int)($params['id'] ?? 0);
             unset($params['id']);
             unset($params['id']);
             $validTypes = [];
             $validTypes = [];
-            foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $direction) {
+            foreach (['recharge_type' => 1, 'withdraw_type' => 2] as $field => $direction) {
                 if (!array_key_exists($field, $params)) continue;
                 if (!array_key_exists($field, $params)) continue;
                 $params[$field] = $this->normalizeTypes((array)($params[$field] ?? []));
                 $params[$field] = $this->normalizeTypes((array)($params[$field] ?? []));
                 // Match deletion's lock order: channel rows, then the group being edited.
                 // Match deletion's lock order: channel rows, then the group being edited.
@@ -110,8 +111,10 @@ class RechargeChannelConfigurationService
             unset($params['id']);
             unset($params['id']);
             $channel = $id ? RechargeChannel::query()->lockForUpdate()->findOrFail($id) : new RechargeChannel();
             $channel = $id ? RechargeChannel::query()->lockForUpdate()->findOrFail($id) : new RechargeChannel();
             if ($channel->exists && (int)$channel->data_type !== (int)$params['data_type']) {
             if ($channel->exists && (int)$channel->data_type !== (int)$params['data_type']) {
-                throw new RuntimeException('不能修改通道所属的充值、提现或活动方向');
+                throw new RuntimeException('不能修改通道所属的充值或提现方向');
             }
             }
+            $isNew = !$channel->exists;
+            $before = $isNew ? [] : $channel->toArray();
             $params = PaymentProviderCatalog::normalizeChannelSelection($params);
             $params = PaymentProviderCatalog::normalizeChannelSelection($params);
             $params = $this->amountConfiguration($params, $channel);
             $params = $this->amountConfiguration($params, $channel);
             $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
             $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
@@ -143,6 +146,10 @@ class RechargeChannelConfigurationService
             if (RechargeChannelLogoService::supportsDirection((int)$channel->data_type) && array_key_exists('logo', $params)) {
             if (RechargeChannelLogoService::supportsDirection((int)$channel->data_type) && array_key_exists('logo', $params)) {
                 RechargeChannelLogoService::store((string)$channel->type, $params['logo'], (int)$channel->data_type);
                 RechargeChannelLogoService::store((string)$channel->type, $params['logo'], (int)$channel->data_type);
             }
             }
+            $after = $channel->fresh()->toArray();
+            if ($isNew || self::diff($before, $after) !== []) {
+                OperationAuditService::record('recharge_channel', (int)$channel->id, $isNew ? 'create' : 'update', $before, $after);
+            }
             return $channel;
             return $channel;
         }, 3);
         }, 3);
     }
     }
@@ -218,6 +225,96 @@ class RechargeChannelConfigurationService
         }, 3);
         }, 3);
     }
     }
 
 
+    /** 通道操作日志中展示的字段(顺序即展示顺序) */
+    private const AUDIT_FIELDS = [
+        'name' => '通道名称', 'key' => '通道标识Key', 'from' => '来源', 'type' => '通道类型', 'rate' => '费率',
+        'min' => '最低金额', 'max' => '最高金额', 'fixed' => '固定金额', 'status' => '状态', 'sort' => '排序', 'logo' => 'Logo',
+    ];
+    private const AUDIT_ACTIONS = ['create' => '新增', 'update' => '修改', 'delete' => '删除', 'save' => '修改Logo'];
+
+    /** 充值(1)/提现(2)通道操作日志:谁、什么时间、改了什么 */
+    public function logs(array $params): array
+    {
+        $dataType = (int)$params['data_type'];
+        $channels = RechargeChannel::withTrashed()->where('data_type', $dataType)->get(['id', 'name', 'type'])->keyBy('id');
+        $query = OperationAudit::query()->where(function ($q) use ($channels, $dataType) {
+            $q->where('resource_type', 'recharge_channel')->whereIn('resource_id', $channels->keys()->all() ?: [0]);
+            // 充值方式Logo按充值类型统一配置,归入充值通道日志
+            if ($dataType === 1) $q->orWhere('resource_type', 'recharge_channel_logo');
+        });
+        if (!empty($params['channel_id'])) {
+            $query->where('resource_type', 'recharge_channel')->where('resource_id', (int)$params['channel_id']);
+        }
+        if (!empty($params['operator_name'])) $query->where('operator_name', 'like', '%' . trim($params['operator_name']) . '%');
+        if (!empty($params['action'])) $query->where('action', $params['action']);
+        if (!empty($params['start_time'])) $query->where('created_at', '>=', $params['start_time']);
+        if (!empty($params['end_time'])) $query->where('created_at', '<=', $params['end_time']);
+        $total = (clone $query)->count();
+        $rows = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+        $data = $rows->map(function (OperationAudit $log) use ($channels) {
+            $changes = (array)$log->changes;
+            $isLogo = $log->resource_type === 'recharge_channel_logo';
+            if ($isLogo) {
+                $before = ['logo' => $changes['before']['logo'] ?? ''];
+                $after = ['logo' => $changes['after']['logo'] ?? ''];
+                $type = (string)($changes['after']['channel_type'] ?? $changes['before']['channel_type'] ?? '');
+            } else {
+                $before = (array)($changes['before'] ?? []);
+                // 删除日志的 after 为 {channel, group_changes}
+                $after = $log->action === 'delete' ? [] : (array)($changes['after'] ?? []);
+                $type = (string)($after['type'] ?? $before['type'] ?? '');
+            }
+            $channel = $isLogo ? null : $channels->get($log->resource_id);
+            $diff = self::diff($before, $after);
+            $created = $log->getRawOriginal('created_at');
+            return [
+                'id' => (int)$log->id,
+                'channel_id' => $isLogo ? null : (int)$log->resource_id,
+                'channel_name' => (string)($after['name'] ?? $before['name'] ?? ($channel->name ?? '')),
+                'channel_type' => $type,
+                'action' => $isLogo ? 'logo' : (string)$log->action,
+                'action_label' => self::AUDIT_ACTIONS[$log->action] ?? (string)$log->action,
+                'operator_id' => $log->operator_id === null ? null : (int)$log->operator_id,
+                'operator_name' => (string)$log->operator_name,
+                'created_at' => $created ? date('Y-m-d H:i:s', strtotime((string)$created)) : null,
+                'changes' => $diff,
+                'summary' => implode(';', array_map(fn ($c) => "{$c['label']}:{$c['before_text']} → {$c['after_text']}", $diff)),
+            ];
+        })->all();
+        return ['total' => $total, 'data' => $data];
+    }
+
+    private static function diff(array $before, array $after): array
+    {
+        $result = [];
+        foreach (self::AUDIT_FIELDS as $field => $label) {
+            if (!array_key_exists($field, $before) && !array_key_exists($field, $after)) continue;
+            $old = self::auditValue($field, $before[$field] ?? null);
+            $new = self::auditValue($field, $after[$field] ?? null);
+            if ($old === $new) continue;
+            $result[] = ['field' => $field, 'label' => $label, 'before' => $old, 'after' => $new,
+                'before_text' => self::auditText($field, $old), 'after_text' => self::auditText($field, $new)];
+        }
+        return $result;
+    }
+
+    private static function auditValue(string $field, $value)
+    {
+        if ($value === null || $value === '' || $value === []) return null;
+        if ($field === 'fixed') return implode(',', is_array($value) ? $value : explode(',', (string)$value));
+        if (in_array($field, ['from', 'status', 'sort'], true)) return (int)$value;
+        if (in_array($field, ['rate', 'min', 'max'], true)) return rtrim(rtrim(bcadd((string)$value, '0', 4), '0'), '.');
+        return (string)$value;
+    }
+
+    private static function auditText(string $field, $value): string
+    {
+        if ($value === null) return '空';
+        if ($field === 'status') return $value === 1 ? '启用' : '停用';
+        if ($field === 'from') return [1 => '第三方支付', 2 => 'USDT', 3 => '人工'][$value] ?? (string)$value;
+        return (string)$value;
+    }
+
     private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
     private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
     {
     {
         foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
         foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {

+ 0 - 1
app/Services/PaymentChannelLinkService.php

@@ -150,7 +150,6 @@ class PaymentChannelLinkService
     {
     {
         return match ($dataType) {
         return match ($dataType) {
             2 => 'withdraw_type',
             2 => 'withdraw_type',
-            3 => 'activity_type',
             default => 'recharge_type',
             default => 'recharge_type',
         };
         };
     }
     }

+ 2 - 0
docs/前端交付/bot-28/后台/README.md

@@ -22,6 +22,8 @@
 | 会员列表:默认按注册时间倒序,钱包/三方/冻结/余额宝/投注/打码量列服务端排序(2026-11-05) | [会员列表-默认排序与列排序](会员列表-默认排序与列排序.md) |
 | 会员列表:默认按注册时间倒序,钱包/三方/冻结/余额宝/投注/打码量列服务端排序(2026-11-05) | [会员列表-默认排序与列排序](会员列表-默认排序与列排序.md) |
 | 菜单角标:列表新增/编辑/删除,三语角标(2026-11-05) | [菜单角标配置-后台接口](菜单角标配置-后台接口.md) |
 | 菜单角标:列表新增/编辑/删除,三语角标(2026-11-05) | [菜单角标配置-后台接口](菜单角标配置-后台接口.md) |
 | 赛事列表:按是否置顶筛选(`is_rec`,2026-11-05) | [赛事列表-置顶筛选](赛事列表-置顶筛选.md) |
 | 赛事列表:按是否置顶筛选(`is_rec`,2026-11-05) | [赛事列表-置顶筛选](赛事列表-置顶筛选.md) |
+| 在线充值订单:每行“拒绝”按钮,订单直接变为失败(2026-11-05) | [在线充值订单拒绝-前端接入](在线充值订单拒绝-前端接入.md) |
+| 充值/提现通道:操作日志(谁、什么时间、改了什么),移除 data_type=3 活动配置与层级 activityTypes(2026-11-05) | [充值提现通道-操作日志与移除活动配置](充值提现通道-操作日志与移除活动配置.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |
 | 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |

+ 119 - 0
docs/前端交付/bot-28/后台/充值提现通道-操作日志与移除活动配置.md

@@ -0,0 +1,119 @@
+# 充值/提现通道:操作日志 + 移除“活动”配置(前端接入)
+
+接口项目:`bot-28`,总后台 API 域名,`Authorization: Bearer <admin_token>`,成功 `code=0`。
+
+涉及页面:
+
+- 充值通道:`/admin/rechargeChannel/list?data_type=1`
+- 提现通道:`/admin/rechargeChannel/list?data_type=2`
+- 通道层级:`/admin/rechargeChannel/groupList`
+
+---
+
+## 一、移除“活动”(data_type=3)配置
+
+原 `data_type=3` 页签(即充即送、老用户回归、余额宝)及层级里的“活动类型”已下线,前端请删除:
+
+| 位置 | 前端改动 |
+|---|---|
+| 通道管理 `data_type=3` 页签/入口 | 删除。`list`、`options`、`update` 再传 `data_type=3` 会返回 `code=-3` 参数错误 |
+| `GET /admin/rechargeChannel/list` 不传 `data_type` | 只返回充值、提现通道,不再混入活动行 |
+| `GET /admin/rechargeChannel/groupList` | 不再返回 `activityTypes`、`activity_type`,删除对应列 |
+| `POST /admin/rechargeChannel/updateGroup` | 不再提交 `activity_type`(即使提交也会被忽略),删除表单里的“活动类型”多选 |
+| `GET /admin/rechargeChannel/getChannel?data_type=3` | 不再支持,按 `data_type=1` 处理 |
+
+业务影响(供运营知悉):即充即送、老用户回归、余额宝等活动**不再按充值层级开关**,只看活动本身的上下架、时间和参与条件(活动列表里的配置)。
+
+---
+
+## 二、通道操作日志
+
+在充值通道、提现通道列表页加“操作日志”按钮(页面级,可选:每行也加“日志”按钮,传 `channel_id`)。
+
+记录范围:通道的新增、修改(含启用/停用、排序、费率、金额、Logo 等)、删除;充值通道页还包括“充值方式 Logo”配置。只有字段真正有变化才记一条。本功能上线前的修改没有日志。
+
+### 接口
+
+| 方法 | 路径 | 权限 URI |
+|---|---|---|
+| GET | `/admin/rechargeChannel/logs` | 沿用 `admin/rechargeChannel/list`(能看列表就能看日志) |
+
+### 请求参数
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `data_type` | int | 是 | `1` 充值通道,`2` 提现通道 |
+| `channel_id` | int | 否 | 只看某条通道(列表行的 `id`) |
+| `operator_name` | string | 否 | 操作人账号,模糊匹配 |
+| `action` | string | 否 | `create` 新增/`update` 修改/`delete` 删除/`save` Logo 配置 |
+| `start_time` | string | 否 | 起始时间,如 `2026-11-01 00:00:00` |
+| `end_time` | string | 否 | 结束时间 |
+| `page` | int | 否 | 默认 1 |
+| `limit` | int | 否 | 默认 20,最大 200 |
+
+```http
+GET /admin/rechargeChannel/logs?data_type=1&page=1&limit=20
+```
+
+### 返回
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "total": 2,
+    "data": [
+      {
+        "id": 18,
+        "channel_id": 31,
+        "channel_name": "改名通道",
+        "channel_type": "NOpay12",
+        "action": "update",
+        "action_label": "修改",
+        "operator_id": 7,
+        "operator_name": "bob",
+        "created_at": "2026-11-05 15:20:31",
+        "changes": [
+          { "field": "name",   "label": "通道名称", "before": "测试通道", "after": "改名通道", "before_text": "测试通道", "after_text": "改名通道" },
+          { "field": "rate",   "label": "费率",     "before": "0.03",     "after": "0.05",     "before_text": "0.03",     "after_text": "0.05" },
+          { "field": "status", "label": "状态",     "before": 1,          "after": 0,          "before_text": "启用",     "after_text": "停用" }
+        ],
+        "summary": "通道名称:测试通道 → 改名通道;费率:0.03 → 0.05;状态:启用 → 停用"
+      }
+    ]
+  }
+}
+```
+
+### 字段说明
+
+| 字段 | 说明 |
+|---|---|
+| `operator_name` / `operator_id` | 谁(管理员账号/ID) |
+| `created_at` | 什么时间,`Y-m-d H:i:s` |
+| `action` / `action_label` | 做了什么:`create` 新增、`update` 修改、`delete` 删除、`logo` 修改Logo(Logo 记录 `channel_id` 为 `null`) |
+| `channel_id` / `channel_name` / `channel_type` | 哪条通道 |
+| `changes[]` | 改了什么,只含有变化的字段。直接显示 `label`、`before_text → after_text` 即可;`before`/`after` 是原始值 |
+| `summary` | 已拼好的一句话,表格可直接展示 |
+
+`changes[].field` 可能的值:
+
+| field | label | 说明 |
+|---|---|---|
+| `name` | 通道名称 | |
+| `key` | 通道标识Key | |
+| `from` | 来源 | 文本为 第三方支付/USDT/人工 |
+| `type` | 通道类型 | 支付方式代码 |
+| `rate` | 费率 | 小数,`0.03` 即 3% |
+| `min` / `max` | 最低/最高金额 | |
+| `fixed` | 固定金额 | 逗号分隔 |
+| `status` | 状态 | 文本为 启用/停用 |
+| `sort` | 排序 | |
+| `logo` | Logo | 图片地址,可按图片预览 |
+
+空值的 `before_text`/`after_text` 为 `"空"`(如新增时的旧值、删除时的新值)。
+
+### 建议展示
+
+表格列:时间 | 操作人 | 操作 | 通道 | 修改内容(`summary`,或把 `changes` 逐行显示)。

+ 68 - 0
docs/前端交付/bot-28/后台/在线充值订单拒绝-前端接入.md

@@ -0,0 +1,68 @@
+# 在线充值订单:拒绝按钮(前端接入)
+
+接口项目:`bot-28`,使用总后台 API 域名。页面:`/admin/paymentOrder` 在线充值订单列表(代收,`type=1`)。
+
+在列表每行新增“拒绝”按钮。点击后弹出确认框(可填原因),确认后调用下面的接口,订单直接变为 **3 失败**。
+
+## 接口
+
+| 方法 | 路径 | 用途 | 权限 URI |
+|---|---|---|---|
+| POST | `/admin/paymentOrder/reject` | 拒绝单个在线充值订单 | `admin/paymentOrder/reject` |
+
+请求头使用 `Authorization: Bearer <admin_token>`;`Content-Type: application/json`。
+
+> 如需按钮级权限控制,在菜单管理中“在线充值订单”页面下新增按钮,`uri` 填 `admin/paymentOrder/reject`;未配置该按钮时所有可进入页面的管理员均可操作。
+
+## 请求参数
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `id` | int | 是 | 列表行的 `id` |
+| `remark` | string | 否 | 拒绝原因,最多120字;不填时保存为“后台拒绝” |
+
+```http
+POST /admin/paymentOrder/reject
+Content-Type: application/json
+
+{ "id": 123, "remark": "未付款" }
+```
+
+## 返回
+
+成功:
+
+```json
+{ "code": 0, "timestamp": 1762300000, "msg": "OK", "data": [] }
+```
+
+失败时 `code=-3`,直接提示 `msg`(前缀可能带“:”):
+
+| 情况 | `msg` 包含 |
+|---|---|
+| 订单不存在,或不是在线充值订单(`type≠1`) | `订单不存在` |
+| 订单已是成功/失败等结束状态 | `订单已结束,不可拒绝` |
+| 参数错误 | 校验提示,例如 `remark` 超过120字 |
+
+## 按钮显示规则
+
+只在以下状态显示“拒绝”按钮:
+
+| `status` | 含义 |
+|---|---|
+| 0 | 待处理 |
+| 1 | 处理中 |
+| 7 | 超时 |
+
+`status` 为 2 成功、3 失败时不显示。
+
+## 拒绝后的效果
+
+- 订单 `status` 变为 `3`(失败),`remark` 写入拒绝原因,列表“失败”统计(`total_fail`)会计入该订单。
+- 不涉及会员余额:在线充值未成功前不会加款,拒绝也不扣款。
+- **拒绝后不可恢复**:之后三方再推送“支付成功”回调或点“查单”(`/admin/paymentOrder/check`),都不会再给会员入账。确认框建议提示:“拒绝后即使会员已付款也不会自动到账,请确认三方未收款”。
+- 成功后刷新当前行,或等待实时推送 `admin.orders.payment`(该行会推送 `status=3`)。
+
+## 不适用范围
+
+同页面 `channel=rgcz` 的人工充值订单(`type=3`)不走本接口,仍使用原有 `POST /admin/paymentOrder/manualAudit`(`status=3`、`remark` 必填)驳回。

+ 1 - 1
docs/前端接口/支付配置/层级保存修复与删除-前端变更说明.md

@@ -6,7 +6,7 @@
 
 
 现有 `POST /admin/rechargeChannel/updateGroup` 的 Path、参数和返回结构不变。
 现有 `POST /admin/rechargeChannel/updateGroup` 的 Path、参数和返回结构不变。
 
 
-- 继续提交 `recharge_type/withdraw_type/activity_type` 的 `type` 字符串数组,不提交通道 ID、公司代码或整个选项对象。
+- 继续提交 `recharge_type/withdraw_type` 的(`activity_type` 已于 2026-11-05 移除) `type` 字符串数组,不提交通道 ID、公司代码或整个选项对象。
 - 旧组合原样保存时,已有失效类型不会再导致整次保存失败;清理发生后,成功 `msg` 为“保存成功,已清理失效的历史通道类型”。
 - 旧组合原样保存时,已有失效类型不会再导致整次保存失败;清理发生后,成功 `msg` 为“保存成功,已清理失效的历史通道类型”。
 - 新提交的错误类型仍返回业务错误,`msg` 会列出具体值。公司/通道暂时禁用不等于类型失效,不需要清空原选择。
 - 新提交的错误类型仍返回业务错误,`msg` 会列出具体值。公司/通道暂时禁用不等于类型失效,不需要清空原选择。
 - 前端按 `code=0` 判断成功,展示 `msg` 并刷新当前组合;不要依赖固定的成功提示文字。
 - 前端按 `code=0` 判断成功,展示 `msg` 并刷新当前组合;不要依赖固定的成功提示文字。

+ 119 - 0
docs/总后台/充值提现通道-操作日志与移除活动配置.md

@@ -0,0 +1,119 @@
+# 充值/提现通道:操作日志 + 移除“活动”配置(前端接入)
+
+接口项目:`bot-28`,总后台 API 域名,`Authorization: Bearer <admin_token>`,成功 `code=0`。
+
+涉及页面:
+
+- 充值通道:`/admin/rechargeChannel/list?data_type=1`
+- 提现通道:`/admin/rechargeChannel/list?data_type=2`
+- 通道层级:`/admin/rechargeChannel/groupList`
+
+---
+
+## 一、移除“活动”(data_type=3)配置
+
+原 `data_type=3` 页签(即充即送、老用户回归、余额宝)及层级里的“活动类型”已下线,前端请删除:
+
+| 位置 | 前端改动 |
+|---|---|
+| 通道管理 `data_type=3` 页签/入口 | 删除。`list`、`options`、`update` 再传 `data_type=3` 会返回 `code=-3` 参数错误 |
+| `GET /admin/rechargeChannel/list` 不传 `data_type` | 只返回充值、提现通道,不再混入活动行 |
+| `GET /admin/rechargeChannel/groupList` | 不再返回 `activityTypes`、`activity_type`,删除对应列 |
+| `POST /admin/rechargeChannel/updateGroup` | 不再提交 `activity_type`(即使提交也会被忽略),删除表单里的“活动类型”多选 |
+| `GET /admin/rechargeChannel/getChannel?data_type=3` | 不再支持,按 `data_type=1` 处理 |
+
+业务影响(供运营知悉):即充即送、老用户回归、余额宝等活动**不再按充值层级开关**,只看活动本身的上下架、时间和参与条件(活动列表里的配置)。
+
+---
+
+## 二、通道操作日志
+
+在充值通道、提现通道列表页加“操作日志”按钮(页面级,可选:每行也加“日志”按钮,传 `channel_id`)。
+
+记录范围:通道的新增、修改(含启用/停用、排序、费率、金额、Logo 等)、删除;充值通道页还包括“充值方式 Logo”配置。只有字段真正有变化才记一条。本功能上线前的修改没有日志。
+
+### 接口
+
+| 方法 | 路径 | 权限 URI |
+|---|---|---|
+| GET | `/admin/rechargeChannel/logs` | 沿用 `admin/rechargeChannel/list`(能看列表就能看日志) |
+
+### 请求参数
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `data_type` | int | 是 | `1` 充值通道,`2` 提现通道 |
+| `channel_id` | int | 否 | 只看某条通道(列表行的 `id`) |
+| `operator_name` | string | 否 | 操作人账号,模糊匹配 |
+| `action` | string | 否 | `create` 新增/`update` 修改/`delete` 删除/`save` Logo 配置 |
+| `start_time` | string | 否 | 起始时间,如 `2026-11-01 00:00:00` |
+| `end_time` | string | 否 | 结束时间 |
+| `page` | int | 否 | 默认 1 |
+| `limit` | int | 否 | 默认 20,最大 200 |
+
+```http
+GET /admin/rechargeChannel/logs?data_type=1&page=1&limit=20
+```
+
+### 返回
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "total": 2,
+    "data": [
+      {
+        "id": 18,
+        "channel_id": 31,
+        "channel_name": "改名通道",
+        "channel_type": "NOpay12",
+        "action": "update",
+        "action_label": "修改",
+        "operator_id": 7,
+        "operator_name": "bob",
+        "created_at": "2026-11-05 15:20:31",
+        "changes": [
+          { "field": "name",   "label": "通道名称", "before": "测试通道", "after": "改名通道", "before_text": "测试通道", "after_text": "改名通道" },
+          { "field": "rate",   "label": "费率",     "before": "0.03",     "after": "0.05",     "before_text": "0.03",     "after_text": "0.05" },
+          { "field": "status", "label": "状态",     "before": 1,          "after": 0,          "before_text": "启用",     "after_text": "停用" }
+        ],
+        "summary": "通道名称:测试通道 → 改名通道;费率:0.03 → 0.05;状态:启用 → 停用"
+      }
+    ]
+  }
+}
+```
+
+### 字段说明
+
+| 字段 | 说明 |
+|---|---|
+| `operator_name` / `operator_id` | 谁(管理员账号/ID) |
+| `created_at` | 什么时间,`Y-m-d H:i:s` |
+| `action` / `action_label` | 做了什么:`create` 新增、`update` 修改、`delete` 删除、`logo` 修改Logo(Logo 记录 `channel_id` 为 `null`) |
+| `channel_id` / `channel_name` / `channel_type` | 哪条通道 |
+| `changes[]` | 改了什么,只含有变化的字段。直接显示 `label`、`before_text → after_text` 即可;`before`/`after` 是原始值 |
+| `summary` | 已拼好的一句话,表格可直接展示 |
+
+`changes[].field` 可能的值:
+
+| field | label | 说明 |
+|---|---|---|
+| `name` | 通道名称 | |
+| `key` | 通道标识Key | |
+| `from` | 来源 | 文本为 第三方支付/USDT/人工 |
+| `type` | 通道类型 | 支付方式代码 |
+| `rate` | 费率 | 小数,`0.03` 即 3% |
+| `min` / `max` | 最低/最高金额 | |
+| `fixed` | 固定金额 | 逗号分隔 |
+| `status` | 状态 | 文本为 启用/停用 |
+| `sort` | 排序 | |
+| `logo` | Logo | 图片地址,可按图片预览 |
+
+空值的 `before_text`/`after_text` 为 `"空"`(如新增时的旧值、删除时的新值)。
+
+### 建议展示
+
+表格列:时间 | 操作人 | 操作 | 通道 | 修改内容(`summary`,或把 `changes` 逐行显示)。

+ 68 - 0
docs/总后台/在线充值订单拒绝-前端接入.md

@@ -0,0 +1,68 @@
+# 在线充值订单:拒绝按钮(前端接入)
+
+接口项目:`bot-28`,使用总后台 API 域名。页面:`/admin/paymentOrder` 在线充值订单列表(代收,`type=1`)。
+
+在列表每行新增“拒绝”按钮。点击后弹出确认框(可填原因),确认后调用下面的接口,订单直接变为 **3 失败**。
+
+## 接口
+
+| 方法 | 路径 | 用途 | 权限 URI |
+|---|---|---|---|
+| POST | `/admin/paymentOrder/reject` | 拒绝单个在线充值订单 | `admin/paymentOrder/reject` |
+
+请求头使用 `Authorization: Bearer <admin_token>`;`Content-Type: application/json`。
+
+> 如需按钮级权限控制,在菜单管理中“在线充值订单”页面下新增按钮,`uri` 填 `admin/paymentOrder/reject`;未配置该按钮时所有可进入页面的管理员均可操作。
+
+## 请求参数
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `id` | int | 是 | 列表行的 `id` |
+| `remark` | string | 否 | 拒绝原因,最多120字;不填时保存为“后台拒绝” |
+
+```http
+POST /admin/paymentOrder/reject
+Content-Type: application/json
+
+{ "id": 123, "remark": "未付款" }
+```
+
+## 返回
+
+成功:
+
+```json
+{ "code": 0, "timestamp": 1762300000, "msg": "OK", "data": [] }
+```
+
+失败时 `code=-3`,直接提示 `msg`(前缀可能带“:”):
+
+| 情况 | `msg` 包含 |
+|---|---|
+| 订单不存在,或不是在线充值订单(`type≠1`) | `订单不存在` |
+| 订单已是成功/失败等结束状态 | `订单已结束,不可拒绝` |
+| 参数错误 | 校验提示,例如 `remark` 超过120字 |
+
+## 按钮显示规则
+
+只在以下状态显示“拒绝”按钮:
+
+| `status` | 含义 |
+|---|---|
+| 0 | 待处理 |
+| 1 | 处理中 |
+| 7 | 超时 |
+
+`status` 为 2 成功、3 失败时不显示。
+
+## 拒绝后的效果
+
+- 订单 `status` 变为 `3`(失败),`remark` 写入拒绝原因,列表“失败”统计(`total_fail`)会计入该订单。
+- 不涉及会员余额:在线充值未成功前不会加款,拒绝也不扣款。
+- **拒绝后不可恢复**:之后三方再推送“支付成功”回调或点“查单”(`/admin/paymentOrder/check`),都不会再给会员入账。确认框建议提示:“拒绝后即使会员已付款也不会自动到账,请确认三方未收款”。
+- 成功后刷新当前行,或等待实时推送 `admin.orders.payment`(该行会推送 `status=3`)。
+
+## 不适用范围
+
+同页面 `channel=rgcz` 的人工充值订单(`type=3`)不走本接口,仍使用原有 `POST /admin/paymentOrder/manualAudit`(`status=3`、`remark` 必填)驳回。

+ 2 - 0
routes/admin.php

@@ -219,6 +219,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/audit', [PaymentOrder::class, 'audit']);
             Route::post('/audit', [PaymentOrder::class, 'audit']);
             Route::post('/setPayData', [PaymentOrder::class, 'setPayData']);
             Route::post('/setPayData', [PaymentOrder::class, 'setPayData']);
             Route::post('/manualAudit', [PaymentOrder::class, 'manualAudit']);
             Route::post('/manualAudit', [PaymentOrder::class, 'manualAudit']);
+            Route::post('/reject', [PaymentOrder::class, 'reject']);
             Route::post('/setIsLocked', [PaymentOrder::class, 'setIsLocked']);
             Route::post('/setIsLocked', [PaymentOrder::class, 'setIsLocked']);
             Route::get('/unProcessed', [PaymentOrder::class, 'unProcessed']);
             Route::get('/unProcessed', [PaymentOrder::class, 'unProcessed']);
         });
         });
@@ -434,6 +435,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/delete', [RechargeChannel::class, 'delete']);
             Route::post('/delete', [RechargeChannel::class, 'delete']);
             Route::get("/options", [RechargeChannel::class, 'options']);
             Route::get("/options", [RechargeChannel::class, 'options']);
             Route::get("/list", [RechargeChannel::class, 'list']);
             Route::get("/list", [RechargeChannel::class, 'list']);
+            Route::get('/logs', [RechargeChannel::class, 'logs'])->defaults('permission_uri', 'admin/rechargeChannel/list');
             Route::post("/update", [RechargeChannel::class, 'update']);
             Route::post("/update", [RechargeChannel::class, 'update']);
             Route::get("/groupList", [RechargeChannel::class, 'groupList']);
             Route::get("/groupList", [RechargeChannel::class, 'groupList']);
             Route::post("/updateGroup", [RechargeChannel::class, 'updateGroup']);
             Route::post("/updateGroup", [RechargeChannel::class, 'updateGroup']);

+ 69 - 2
tests/Integration/PaymentChannelOptionsTest.php

@@ -303,6 +303,74 @@ class PaymentChannelOptionsTest extends TestCase
             'rate' => '0.0300', 'name' => '测试通道', 'key' => 'test-key', 'min' => 1, 'max' => 100];
             'rate' => '0.0300', 'name' => '测试通道', 'key' => 'test-key', 'min' => 1, 'max' => 100];
     }
     }
 
 
+    private function channelLogs(array $params): array
+    {
+        $this->request('/admin/rechargeChannel/logs', $params);
+        $response = (new RechargeChannel())->logs(new RechargeChannelConfigurationService())->getData(true);
+        $this->assertSame(0, $response['code'], $response['msg']);
+        return $response['data'];
+    }
+
+    public function test_channel_logs_record_who_when_and_what_changed_per_direction(): void
+    {
+        $saveAs = function (array $params, string $admin) {
+            $request = Request::create('/admin/rechargeChannel/update', 'POST', $params);
+            $request->user = (object)['id' => 7, 'username' => $admin];
+            $this->app->instance('request', $request);
+            return (new RechargeChannel())->update(new RechargeChannelConfigurationService())->getData(true);
+        };
+        $this->assertSame(0, $saveAs($this->newChannel(), 'alice')['code']);
+        $id = (int)DB::table('recharge_channel')->where('key', 'test-key')->value('id');
+        $this->assertSame(0, $saveAs(['id' => $id, 'data_type' => 1, 'payment_company' => 'no', 'type' => 'NOpay12',
+            'rate' => '0.05', 'name' => '改名通道', 'status' => 0], 'bob')['code']);
+        // 未改动任何字段不产生日志
+        $this->assertSame(0, $saveAs(['id' => $id, 'data_type' => 1, 'payment_company' => 'no', 'type' => 'NOpay12', 'rate' => '0.0500'], 'bob')['code']);
+        $withdraw = DB::table('recharge_channel')->where('data_type', 2)->where('type', 'rgtx')->first();
+        $withdrawId = (int)$withdraw->id;
+        $result = $saveAs(['id' => $withdrawId, 'data_type' => 2, 'type' => 'rgtx', 'rate' => '0.07'], 'carol');
+        $this->assertSame(0, $result['code'], $result['msg']);
+
+        $deposit = $this->channelLogs(['data_type' => 1]);
+        $this->assertSame(2, $deposit['total']);
+        [$update, $create] = $deposit['data'];
+        $this->assertSame(['update', '修改', 'bob', $id], [$update['action'], $update['action_label'], $update['operator_name'], $update['channel_id']]);
+        $this->assertSame('改名通道', $update['channel_name']);
+        $this->assertMatchesRegularExpression('/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/', $update['created_at']);
+        $byField = array_column($update['changes'], null, 'field');
+        $this->assertSame(['name', 'rate', 'status'], array_keys($byField));
+        $this->assertSame(['测试通道', '改名通道'], [$byField['name']['before'], $byField['name']['after']]);
+        $this->assertSame(['0.03', '0.05'], [$byField['rate']['before'], $byField['rate']['after']]);
+        $this->assertSame(['启用', '停用'], [$byField['status']['before_text'], $byField['status']['after_text']]);
+        $this->assertStringContainsString('费率:0.03 → 0.05', $update['summary']);
+        $this->assertSame(['create', 'alice'], [$create['action'], $create['operator_name']]);
+        $this->assertSame('空', array_column($create['changes'], null, 'field')['name']['before_text']);
+
+        $this->assertSame(1, $this->channelLogs(['data_type' => 1, 'operator_name' => 'ali'])['total']);
+        $this->assertSame(0, $this->channelLogs(['data_type' => 1, 'channel_id' => $withdrawId])['total']);
+        $withdrawLogs = $this->channelLogs(['data_type' => 2]);
+        $this->assertSame(1, $withdrawLogs['total']);
+        $this->assertSame(['carol', $withdrawId, ['rate']], [$withdrawLogs['data'][0]['operator_name'], $withdrawLogs['data'][0]['channel_id'],
+            array_column($withdrawLogs['data'][0]['changes'], 'field')]);
+
+        $this->assertSame(0, $this->deleteChannel($id)['code']);
+        $deleted = $this->channelLogs(['data_type' => 1, 'channel_id' => $id])['data'][0];
+        $this->assertSame(['delete', '删除'], [$deleted['action'], $deleted['action_label']]);
+        $this->assertSame('改名通道', $deleted['channel_name']);
+    }
+
+    public function test_activity_direction_is_no_longer_listed_or_editable(): void
+    {
+        DB::table('recharge_channel')->insert(['data_type' => 3, 'from' => 1, 'type' => 'recharge', 'name' => '即充即送', 'key' => 'act', 'rate' => 0, 'status' => 1, 'sort' => 0]);
+        $this->assertSame([], array_values(array_filter($this->channelList([])['data'], fn ($r) => $r['data_type'] === 3)));
+        $this->request('/admin/rechargeChannel/list', ['data_type' => 3]);
+        $this->assertSame(-3, (new RechargeChannel())->list()->getData(true)['code']);
+        $this->assertSame(-3, $this->saveChannel($this->newChannel(['payment_company' => null, 'data_type' => 3, 'type' => 'recharge']))['code']);
+        $this->request('/admin/rechargeChannel/groupList', []);
+        $group = (new RechargeChannel())->groupList()->getData(true)['data']['data'][0];
+        $this->assertArrayNotHasKey('activityTypes', $group);
+        $this->assertArrayNotHasKey('activity_type', $group);
+    }
+
     public function test_creation_catalog_works_without_any_database_channels_or_groups(): void
     public function test_creation_catalog_works_without_any_database_channels_or_groups(): void
     {
     {
         DB::table('recharge_channel')->delete();
         DB::table('recharge_channel')->delete();
@@ -313,7 +381,6 @@ class PaymentChannelOptionsTest extends TestCase
         $this->assertSame('', $data['identity_edit_reason']);
         $this->assertSame('', $data['identity_edit_reason']);
         $this->assertContains('NOpay12', array_column($data['types'], 'value'));
         $this->assertContains('NOpay12', array_column($data['types'], 'value'));
         $this->assertSame(['qianbao', 'jd', 'no', 'zimu'], array_column($this->formOptions(2)['payment_companies'], 'value'));
         $this->assertSame(['qianbao', 'jd', 'no', 'zimu'], array_column($this->formOptions(2)['payment_companies'], 'value'));
-        $this->assertSame([], $this->formOptions(3)['payment_companies']);
     }
     }
 
 
     public function test_create_selected_company_then_switch_company_and_type_on_same_record(): void
     public function test_create_selected_company_then_switch_company_and_type_on_same_record(): void
@@ -351,7 +418,7 @@ class PaymentChannelOptionsTest extends TestCase
 
 
     public function test_native_and_activity_channels_do_not_require_or_fabricate_company(): void
     public function test_native_and_activity_channels_do_not_require_or_fabricate_company(): void
     {
     {
-        foreach ([[1, 'usdt', 2], [1, 'rgcz', 3], [2, 'rgtx', 3], [3, 'recharge', 1]] as [$direction, $type, $from]) {
+        foreach ([[1, 'usdt', 2], [1, 'rgcz', 3], [2, 'rgtx', 3]] as [$direction, $type, $from]) {
             $result = $this->saveChannel($this->newChannel(['payment_company' => null, 'data_type' => $direction,
             $result = $this->saveChannel($this->newChannel(['payment_company' => null, 'data_type' => $direction,
                 'type' => $type, 'key' => 'local-' . $type]));
                 'type' => $type, 'key' => 'local-' . $type]));
             $this->assertSame(0, $result['code']);
             $this->assertSame(0, $result['code']);

+ 94 - 0
tests/Integration/PaymentOrderRejectTest.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\PaymentOrder as PaymentOrderController;
+use App\Models\PaymentOrder;
+use App\Services\PaymentOrderService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+/** 后台在线充值拒绝:未完成订单直接失败,之后迟到的成功回调不再入账。 */
+class PaymentOrderRejectTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Facade::clearResolvedInstances();
+        $this->app = $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai'], 'admin_realtime' => ['enabled' => false]]));
+        $app->instance('files', new \Illuminate\Filesystem\Filesystem());
+        $app->instance('path.lang', dirname(__DIR__, 2) . '/lang');
+        $app->register(\Illuminate\Translation\TranslationServiceProvider::class);
+        $app->register(\Illuminate\Validation\ValidationServiceProvider::class);
+        $app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, (new \ReflectionClass(\Illuminate\Routing\ResponseFactory::class))->newInstanceWithoutConstructor());
+        Request::macro('validate', function (array $rules) { return validator()->validate($this->all(), $rules); });
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $app->instance('db', $db->getDatabaseManager());
+        $db->schema()->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type'); $t->integer('status'); $t->string('state')->nullable();
+            $t->text('callback_data')->nullable(); $t->string('remark')->nullable(); $t->decimal('amount', 24, 10); $t->timestamps();
+        });
+        foreach ([[1, 1], [1, 7], [1, 2], [3, 1], [1, 0]] as [$type, $status]) {
+            DB::table('payment_orders')->insert(['member_id' => 'm1', 'type' => $type, 'status' => $status, 'amount' => 10]);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function reject(array $input): array
+    {
+        $this->app->instance('request', Request::create('/admin/paymentOrder/reject', 'POST', $input));
+        $resp = (new PaymentOrderController())->reject();
+        return json_decode($resp->getContent(), true);
+    }
+
+    private function orderStatus(int $id): int
+    {
+        return (int)DB::table('payment_orders')->where('id', $id)->value('status');
+    }
+
+    public function test_open_orders_become_failed_with_remark(): void
+    {
+        $r = $this->reject(['id' => 1, 'remark' => '未付款']); $this->assertSame(0, $r['code'], $r['msg']);
+        $this->assertSame(0, $this->reject(['id' => 2])['code']);
+        $this->assertSame(0, $this->reject(['id' => 5])['code']);
+
+        $this->assertSame([3, 3, 3], [$this->orderStatus(1), $this->orderStatus(2), $this->orderStatus(5)]);
+        $this->assertSame('未付款', DB::table('payment_orders')->where('id', 1)->value('remark'));
+        $this->assertSame('后台拒绝', DB::table('payment_orders')->where('id', 2)->value('remark'));
+    }
+
+    public function test_finished_or_other_type_orders_are_refused(): void
+    {
+        $this->assertNotSame(0, $this->reject(['id' => 3])['code']);
+        $this->assertNotSame(0, $this->reject(['id' => 4])['code']);
+        $this->assertSame([2, 1], [$this->orderStatus(3), $this->orderStatus(4)]);
+    }
+
+    public function test_late_success_callback_is_ignored_after_reject(): void
+    {
+        $this->reject(['id' => 1]);
+        $apply = new \ReflectionMethod(PaymentOrderService::class, 'applyPayCallback');
+
+        $this->assertFalse($apply->invoke(null, PaymentOrder::find(1), '10', '2', '2', '3', []));
+        $this->assertSame(PaymentOrderService::STATUS_FAIL, $this->orderStatus(1));
+    }
+}

+ 1 - 0
tests/Integration/RechargeBonusTest.php

@@ -48,6 +48,7 @@ class RechargeBonusTest extends TestCase
             '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',
             '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",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",created_at TEXT,updated_at TEXT',
             'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,recharge_type TEXT,withdraw_type TEXT,deleted_at TEXT',
             'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,recharge_type TEXT,withdraw_type TEXT,deleted_at TEXT',
+            'activity_rewards' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,lang TEXT,type INTEGER,status INTEGER,start_time INTEGER,end_time INTEGER,sort INTEGER DEFAULT 0,params TEXT',
             'payment_providers' => 'id INTEGER PRIMARY KEY,code TEXT,status INTEGER,config TEXT',
             'payment_providers' => 'id INTEGER PRIMARY KEY,code TEXT,status INTEGER,config TEXT',
             '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',
             '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',
             'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',

+ 4 - 0
tests/Integration/ShareCommissionRecordingTest.php

@@ -89,6 +89,10 @@ class ShareCommissionRecordingTest extends TestCase
         $db->schema()->create('recharge_channel_group', function (Blueprint $t) {
         $db->schema()->create('recharge_channel_group', function (Blueprint $t) {
             $t->id(); $t->text('activity_type')->nullable(); $t->softDeletes(); $t->timestamps();
             $t->id(); $t->text('activity_type')->nullable(); $t->softDeletes(); $t->timestamps();
         });
         });
+        $db->schema()->create('activity_rewards', function (Blueprint $t) {
+            $t->id(); $t->string('lang'); $t->integer('type'); $t->integer('status'); $t->integer('start_time'); $t->integer('end_time');
+            $t->integer('sort')->default(0); $t->text('params')->nullable();
+        });
         $db->schema()->create('backflow', function (Blueprint $t) {
         $db->schema()->create('backflow', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('date');
             $t->id(); $t->string('member_id'); $t->string('date');
             foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) {
             foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) {

+ 1 - 0
tests/Integration/WageringTest.php

@@ -60,6 +60,7 @@ class WageringTest extends TestCase
         });
         });
         $schema->create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); $t->timestamps(); });
         $schema->create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); $t->timestamps(); });
         $schema->create('recharge_channel_group', function (Blueprint $t) { $t->id(); $t->text('activity_type')->nullable(); $t->softDeletes(); $t->timestamps(); });
         $schema->create('recharge_channel_group', function (Blueprint $t) { $t->id(); $t->text('activity_type')->nullable(); $t->softDeletes(); $t->timestamps(); });
+        $schema->create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('lang'); $t->integer('type'); $t->integer('status'); $t->integer('start_time'); $t->integer('end_time'); $t->integer('sort')->default(0); $t->text('params')->nullable(); });
         $schema->create('backflow', function (Blueprint $t) {
         $schema->create('backflow', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('date');
             $t->id(); $t->string('member_id'); $t->string('date');
             foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) $t->decimal($field, 20, 2)->default(0);
             foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) $t->decimal($field, 20, 2)->default(0);