Преглед на файлове

feat: 新增运营配置并关联充值通道

doge преди 5 часа
родител
ревизия
38b8a0640c
променени са 42 файла, в които са добавени 2694 реда и са изтрити 56 реда
  1. 267 0
      app/Http/Controllers/admin/AppConfiguration.php
  2. 387 0
      app/Http/Controllers/admin/PaymentConfiguration.php
  3. 19 10
      app/Http/Controllers/admin/RechargeChannel.php
  4. 212 0
      app/Http/Controllers/admin/ShareEarning.php
  5. 1 0
      app/Http/Kernel.php
  6. 23 0
      app/Http/Middleware/OperationsAdminMiddleware.php
  7. 14 0
      app/Models/AppDownloadEvent.php
  8. 15 0
      app/Models/AppIosReviewSetting.php
  9. 15 0
      app/Models/AppPackage.php
  10. 11 0
      app/Models/AppSetting.php
  11. 14 0
      app/Models/OperationAudit.php
  12. 23 0
      app/Models/PaymentCollectionChannel.php
  13. 38 0
      app/Models/PaymentDirectRecharge.php
  14. 30 0
      app/Models/PaymentGateway.php
  15. 15 12
      app/Models/RechargeChannel.php
  16. 5 3
      app/Models/RechargeChannelGroup.php
  17. 15 0
      app/Models/ShareBlacklist.php
  18. 14 0
      app/Models/ShareCommissionRecord.php
  19. 15 0
      app/Models/ShareSetting.php
  20. 61 0
      app/Services/OperationAuditService.php
  21. 18 10
      app/Services/Payment/SanJinService.php
  22. 132 0
      app/Services/PaymentChannelLinkService.php
  23. 37 12
      app/Services/PaymentOrderService.php
  24. 18 4
      app/Services/QianBaoWithdrawService.php
  25. 9 5
      app/Services/SanJinRechargeService.php
  26. 267 0
      app/Services/ShareEarningReportService.php
  27. 8 0
      config/operations.php
  28. 213 0
      database/migrations/2026_08_30_120000_create_operation_configuration_tables.php
  29. 44 0
      database/migrations/2026_08_31_120000_link_payment_configs_to_recharge_channels.php
  30. 53 0
      docs/前端接口/APP配置/IOS上架设置/接口.md
  31. 63 0
      docs/前端接口/APP配置/下载统计/接口.md
  32. 85 0
      docs/前端接口/APP配置/安装包设置/接口.md
  33. 43 0
      docs/前端接口/分享赚钱/推广会员列表/接口.md
  34. 53 0
      docs/前端接口/分享赚钱/推广汇总/接口.md
  35. 41 0
      docs/前端接口/分享赚钱/推广设定/接口.md
  36. 52 0
      docs/前端接口/分享赚钱/禁止推广列表/接口.md
  37. 58 0
      docs/前端接口/实现差异与未完成项.md
  38. 52 0
      docs/前端接口/支付配置/与充值通道关联说明.md
  39. 63 0
      docs/前端接口/支付配置/代收代付/接口.md
  40. 68 0
      docs/前端接口/支付配置/扫描直充/接口.md
  41. 73 0
      docs/前端接口/支付配置/网关支付/接口.md
  42. 50 0
      routes/admin.php

+ 267 - 0
app/Http/Controllers/admin/AppConfiguration.php

@@ -0,0 +1,267 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Models\AppDownloadEvent;
+use App\Models\AppIosReviewSetting;
+use App\Models\AppPackage;
+use App\Models\AppSetting;
+use App\Models\OperationAudit;
+use App\Services\OperationAuditService;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class AppConfiguration extends Controller
+{
+    public function display()
+    {
+        return $this->run(fn () => AppSetting::query()->first() ?: [
+            'id' => null,
+            'ios_frontend_visible' => 0,
+            'android_frontend_visible' => 0,
+        ]);
+    }
+
+    public function saveDisplay()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'ios_frontend_visible' => ['required', Rule::in([0, 1])],
+                'android_frontend_visible' => ['required', Rule::in([0, 1])],
+            ]);
+            return DB::transaction(function () use ($params) {
+                $settingId = AppSetting::query()->value('id');
+                $setting = $settingId
+                    ? AppSetting::query()->lockForUpdate()->findOrFail($settingId)
+                    : AppSetting::query()->create([]);
+                $before = $setting->toArray();
+                $setting->fill($params + OperationAuditService::actor())->save();
+                OperationAuditService::record('app_display_setting', (int)$setting->id, 'update', $before, $setting);
+                return $setting->fresh();
+            });
+        });
+    }
+
+    public function packages()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'platform' => ['nullable', Rule::in(['android', 'ios'])],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]));
+            $query = AppPackage::query();
+            foreach (['platform', 'status'] as $field) {
+                if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
+                    $query->where($field, $params[$field]);
+                }
+            }
+            return $this->paginate($query->orderByDesc('id'), $params);
+        });
+    }
+
+    public function savePackage()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'id' => ['nullable', 'integer'],
+                'platform' => ['required', Rule::in(['android', 'ios'])],
+                'version' => ['required', 'string', 'max:50'],
+                'package_name' => ['required', 'string', 'max:150'],
+                'force_update' => ['required', Rule::in([0, 1])],
+                'package_type' => ['required', Rule::in(['android_apk', 'testflight', 'app_store', 'enterprise', 'web'])],
+                'download_url' => ['required', 'url', 'max:1000'],
+                'update_content' => ['nullable', 'string', 'max:10000'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]);
+            $allowedTypes = $params['platform'] === 'android'
+                ? ['android_apk', 'web']
+                : ['testflight', 'app_store', 'enterprise', 'web'];
+            if (!in_array($params['package_type'], $allowedTypes, true)) {
+                throw ValidationException::withMessages(['package_type' => '安装包类型与平台不匹配']);
+            }
+            return $this->saveModel(AppPackage::class, $params, 'app_package');
+        });
+    }
+
+    public function packageStatus()
+    {
+        return $this->setStatus(AppPackage::class, 'app_package');
+    }
+
+    public function deletePackage()
+    {
+        return $this->deleteModel(AppPackage::class, 'app_package');
+    }
+
+    public function downloadStats()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $page = (int)($params['page'] ?? 1);
+            $limit = (int)($params['limit'] ?? 20);
+            $endDate = $params['end_date'] ?? date('Y-m-d');
+            $startDate = $params['start_date'] ?? date('Y-m-d', strtotime($endDate . ' -30 days'));
+            if (strtotime($startDate) > strtotime($endDate)) {
+                throw ValidationException::withMessages(['end_date' => '结束日期不能早于开始日期']);
+            }
+            if (strtotime($endDate) - strtotime($startDate) > 366 * 86400) {
+                throw ValidationException::withMessages(['end_date' => '单次查询日期范围不能超过366天']);
+            }
+            $base = AppDownloadEvent::query()->whereBetween('occurred_at', [
+                $startDate . ' 00:00:00', $endDate . ' 23:59:59',
+            ]);
+
+            $clickQuery = (clone $base)->where('event_type', 'click')
+                ->select(['platform', 'source', 'download_url'])
+                ->selectRaw('COUNT(*) AS click_count')
+                ->groupBy(['platform', 'source', 'download_url']);
+            $clickTotal = DB::query()->fromSub(clone $clickQuery, 'click_stats')->count();
+            $clicks = $clickQuery->orderBy('platform')->orderBy('source')
+                ->forPage($page, $limit)->get();
+
+            $openQuery = (clone $base)->where('event_type', 'open')
+                ->select(['platform', 'package_type'])
+                ->selectRaw('COUNT(*) AS open_count')
+                ->groupBy(['platform', 'package_type']);
+            $openTotal = DB::query()->fromSub(clone $openQuery, 'open_stats')->count();
+            $opens = $openQuery->orderBy('platform')->orderBy('package_type')
+                ->forPage($page, $limit)->get();
+            return [
+                'start_date' => $startDate,
+                'end_date' => $endDate,
+                'clicks' => ['total' => $clickTotal, 'data' => $clicks],
+                'opens' => ['total' => $openTotal, 'data' => $opens],
+            ];
+        });
+    }
+
+    public function iosReviews()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]));
+            $query = AppIosReviewSetting::query();
+            if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
+            if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
+            if (array_key_exists('status', $params) && $params['status'] !== null) $query->where('status', $params['status']);
+            return $this->paginate($query->orderByDesc('id'), $params);
+        });
+    }
+
+    public function saveIosReview()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'id' => ['nullable', 'integer'],
+                'store_version' => ['required', 'string', 'max:50'],
+                'operating_version' => ['required', 'string', 'max:50'],
+                'review_user_ids' => ['nullable', 'array', 'max:1000'],
+                'review_user_ids.*' => ['string', 'max:64'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]);
+            $params['platform'] = 'ios';
+            $params['review_user_ids'] = array_values(array_unique(array_filter($params['review_user_ids'] ?? [])));
+            return $this->saveModel(AppIosReviewSetting::class, $params, 'app_ios_review_setting');
+        });
+    }
+
+    public function iosReviewStatus()
+    {
+        return $this->setStatus(AppIosReviewSetting::class, 'app_ios_review_setting');
+    }
+
+    public function deleteIosReview()
+    {
+        return $this->deleteModel(AppIosReviewSetting::class, 'app_ios_review_setting');
+    }
+
+    public function logs()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'resource_type' => ['required', Rule::in(['app_display_setting', 'app_package', 'app_ios_review_setting'])],
+                'resource_id' => ['required', 'integer'],
+                'operator_name' => ['nullable', 'string', 'max:100'],
+            ]));
+            $query = OperationAudit::query()->where('resource_type', $params['resource_type'])->where('resource_id', $params['resource_id']);
+            if (!empty($params['operator_name'])) $query->where('operator_name', 'like', '%' . $params['operator_name'] . '%');
+            return $this->paginate($query->orderByDesc('id'), $params);
+        });
+    }
+
+    private function saveModel(string $modelClass, array $params, string $resourceType): array
+    {
+        return DB::transaction(function () use ($modelClass, $params, $resourceType) {
+            $id = (int)($params['id'] ?? 0);
+            unset($params['id']);
+            $model = $id ? $modelClass::query()->lockForUpdate()->findOrFail($id) : new $modelClass();
+            $before = $model->exists ? $model->toArray() : [];
+            $model->fill($params + OperationAuditService::actor())->save();
+            OperationAuditService::record($resourceType, (int)$model->id, $id ? 'update' : 'create', $before, $model);
+            return $model->fresh()->toArray();
+        });
+    }
+
+    private function setStatus(string $modelClass, string $resourceType)
+    {
+        return $this->run(function () use ($modelClass, $resourceType) {
+            $params = request()->validate(['id' => ['required', 'integer'], 'status' => ['required', Rule::in([0, 1])]]);
+            return DB::transaction(function () use ($modelClass, $resourceType, $params) {
+                $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
+                $before = $model->toArray();
+                $model->fill(['status' => $params['status']] + OperationAuditService::actor())->save();
+                OperationAuditService::record($resourceType, (int)$model->id, 'status', $before, $model);
+                return $model->fresh()->toArray();
+            });
+        });
+    }
+
+    private function deleteModel(string $modelClass, string $resourceType)
+    {
+        return $this->run(function () use ($modelClass, $resourceType) {
+            $params = request()->validate(['id' => ['required', 'integer']]);
+            return DB::transaction(function () use ($modelClass, $resourceType, $params) {
+                $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
+                $before = $model->toArray();
+                $model->delete();
+                OperationAuditService::record($resourceType, (int)$model->id, 'delete', $before, []);
+                return [];
+            });
+        });
+    }
+
+    private function listRules(array $extra): array
+    {
+        return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']] + $extra;
+    }
+
+    private function paginate($query, array $params): array
+    {
+        $page = (int)($params['page'] ?? 1);
+        $limit = (int)($params['limit'] ?? 20);
+        return ['total' => (clone $query)->count(), 'data' => $query->forPage($page, $limit)->get()];
+    }
+
+    private function run(callable $callback)
+    {
+        try {
+            return $this->success($callback());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Throwable $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 387 - 0
app/Http/Controllers/admin/PaymentConfiguration.php

@@ -0,0 +1,387 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Models\OperationAudit;
+use App\Models\PaymentCollectionChannel;
+use App\Models\PaymentDirectRecharge;
+use App\Models\PaymentGateway;
+use App\Models\RechargeChannel;
+use App\Models\RechargeChannelGroup;
+use App\Services\OperationAuditService;
+use App\Services\PaymentChannelLinkService;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class PaymentConfiguration extends Controller
+{
+    private PaymentChannelLinkService $channelLinks;
+
+    public function __construct(PaymentChannelLinkService $channelLinks)
+    {
+        parent::__construct();
+        $this->channelLinks = $channelLinks;
+    }
+
+    public function options()
+    {
+        return $this->run(function () {
+            $groups = RechargeChannelGroup::query()->orderBy('id')->get()->map(fn ($group) => [
+                'id' => (int)$group->id,
+                'name' => (string)$group->name,
+                'recharge_type' => (array)$group->recharge_type,
+                'withdraw_type' => (array)$group->withdraw_type,
+                'activity_type' => (array)$group->activity_type,
+            ]);
+            $channels = RechargeChannel::query()->orderBy('data_type')->orderBy('sort')->orderBy('id')
+                ->get()->map(function ($channel) use ($groups) {
+                    $groupField = match ((int)$channel->data_type) {
+                        2 => 'withdraw_type',
+                        3 => 'activity_type',
+                        default => 'recharge_type',
+                    };
+                    return [
+                        'id' => (int)$channel->id,
+                        'data_type' => (int)$channel->data_type,
+                        'name' => (string)$channel->name,
+                        'key' => (string)$channel->key,
+                        'type' => (string)$channel->type,
+                        'rate' => number_format((float)$channel->rate, 4, '.', ''),
+                        'fee_rate' => bcmul((string)$channel->rate, '100', 4),
+                        'min_amount' => $channel->min,
+                        'max_amount' => $channel->max,
+                        'fixed_amounts' => $channel->fixed ?: [],
+                        'sort' => (int)$channel->sort,
+                        'status' => (int)$channel->status,
+                        'available_group_ids' => $groups->filter(fn ($group) => in_array(
+                            (string)$channel->type,
+                            (array)$group[$groupField],
+                            true
+                        ))->pluck('id')->values(),
+                    ];
+                });
+            return [
+                'recharge_channels' => $channels,
+                'channel_groups' => $groups,
+                'levels' => $groups->map(fn ($group) => [
+                    'value' => $group['id'],
+                    'label' => $group['name'],
+                ])->values(),
+                'payment_companies' => PaymentGateway::query()->distinct()->orderBy('payment_company')->pluck('payment_company')->filter()->values(),
+                'payment_methods' => PaymentGateway::query()->distinct()->orderBy('payment_method')->pluck('payment_method')->filter()->values(),
+                'collection_providers' => PaymentCollectionChannel::query()->distinct()->orderBy('provider_name')->pluck('provider_name')->filter()->values(),
+                'collection_methods' => PaymentCollectionChannel::query()->distinct()->orderBy('collection_method')->pluck('collection_method')->filter()->values(),
+            ];
+        });
+    }
+
+    public function directList()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'name' => ['nullable', 'string', 'max:100'],
+                'recharge_channel_id' => ['nullable', 'integer'],
+                'group_id' => ['nullable', 'integer'],
+                'status' => ['nullable', Rule::in([0, 1])],
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]));
+            $query = PaymentDirectRecharge::query()->with('rechargeChannel');
+            $this->applyDateAndLike($query, $params, 'name');
+            $this->applyChannelFilters($query, $params);
+            return $this->paginatePayment($query->orderByDesc('id'), $params);
+        });
+    }
+
+    public function directSave()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'id' => ['nullable', 'integer'],
+                'recharge_channel_id' => ['required', 'integer'],
+                'recharge_channel_group_ids' => ['required', 'array', 'min:1'],
+                'recharge_channel_group_ids.*' => ['integer'],
+                'recharge_code' => ['required', 'string', 'max:64'],
+                'name' => ['required', 'string', 'max:100'],
+                'amount' => ['required', 'numeric', 'gt:0'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]);
+            [, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
+                (int)$params['recharge_channel_id'],
+                $params['recharge_channel_group_ids'],
+                1
+            );
+            $id = (int)($params['id'] ?? 0);
+            $duplicate = PaymentDirectRecharge::query()->where('recharge_code', $params['recharge_code']);
+            if ($id) $duplicate->where('id', '<>', $id);
+            if ($duplicate->exists()) throw new \RuntimeException('充值ID已存在');
+            return $this->saveModel(PaymentDirectRecharge::class, $params, 'payment_direct_recharge');
+        });
+    }
+
+    public function directStatus()
+    {
+        return $this->setStatus(PaymentDirectRecharge::class, 'payment_direct_recharge');
+    }
+
+    public function directDelete()
+    {
+        return $this->deleteModel(PaymentDirectRecharge::class, 'payment_direct_recharge');
+    }
+
+    public function gatewayList()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'kind' => ['required', Rule::in(['deposit', 'withdraw'])],
+                'recharge_channel_id' => ['nullable', 'integer'],
+                'group_id' => ['nullable', 'integer'],
+                'payment_company' => ['nullable', 'string', 'max:100'],
+                'payment_method' => ['nullable', 'string', 'max:64'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]));
+            $query = PaymentGateway::query()->with('rechargeChannel')->where('kind', $params['kind']);
+            foreach (['payment_company', 'payment_method', 'status'] as $field) {
+                if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
+                    $query->where($field, $params[$field]);
+                }
+            }
+            $this->applyChannelFilters($query, $params);
+            return $this->paginatePayment($query->orderByDesc('id'), $params);
+        });
+    }
+
+    public function gatewaySave()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'id' => ['nullable', 'integer'],
+                'recharge_channel_id' => ['required', 'integer'],
+                'recharge_channel_group_ids' => ['required', 'array', 'min:1'],
+                'recharge_channel_group_ids.*' => ['integer'],
+                'kind' => ['required', Rule::in(['deposit', 'withdraw'])],
+                'currency' => ['nullable', 'string', 'max:16'],
+                'payment_company' => ['required', 'string', 'max:100'],
+                'merchant_no' => ['required', 'string', 'max:128'],
+                'payment_method' => ['nullable', 'string', 'max:64'],
+                'channel_code' => ['nullable', 'string', 'max:64'],
+                'channel_name' => ['nullable', 'string', 'max:100'],
+                'withdrawal_secret' => ['nullable', 'string', 'max:10000'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]);
+            $expectedDataType = $params['kind'] === 'withdraw' ? 2 : 1;
+            [, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
+                (int)$params['recharge_channel_id'],
+                $params['recharge_channel_group_ids'],
+                $expectedDataType
+            );
+            $existing = !empty($params['id']) ? PaymentGateway::query()->findOrFail($params['id']) : null;
+            if ($existing && $existing->kind !== $params['kind']) {
+                throw ValidationException::withMessages(['kind' => '网关类型创建后不能修改']);
+            }
+            if ($params['kind'] === 'deposit' && empty($params['payment_method'])) {
+                throw ValidationException::withMessages(['payment_method' => '存款网关必须填写支付方式']);
+            }
+            $hasSavedSecret = $existing ? $existing->has_withdrawal_secret : false;
+            if ($params['kind'] === 'withdraw' && empty($params['withdrawal_secret']) && !$hasSavedSecret) {
+                throw ValidationException::withMessages(['withdrawal_secret' => '出款网关必须填写出款密钥']);
+            }
+            if ($params['kind'] === 'deposit') {
+                $params['withdrawal_secret'] = null;
+            } elseif (empty($params['withdrawal_secret'])) {
+                unset($params['withdrawal_secret']);
+            }
+            return $this->saveModel(PaymentGateway::class, $params, 'payment_gateway');
+        });
+    }
+
+    public function gatewayStatus()
+    {
+        return $this->setStatus(PaymentGateway::class, 'payment_gateway');
+    }
+
+    public function gatewayDelete()
+    {
+        return $this->deleteModel(PaymentGateway::class, 'payment_gateway');
+    }
+
+    public function collectionList()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'kind' => ['required', Rule::in(['collection_scan', 'collection_third_party'])],
+                'recharge_channel_id' => ['nullable', 'integer'],
+                'group_id' => ['nullable', 'integer'],
+                'provider_name' => ['nullable', 'string', 'max:100'],
+                'collection_method' => ['nullable', 'string', 'max:64'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]));
+            $query = PaymentCollectionChannel::query()->with('rechargeChannel')->where('kind', $params['kind']);
+            foreach (['provider_name', 'collection_method', 'status'] as $field) {
+                if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
+                    $query->where($field, $params[$field]);
+                }
+            }
+            $this->applyChannelFilters($query, $params);
+            return $this->paginatePayment($query->orderByDesc('id'), $params);
+        });
+    }
+
+    public function collectionSave()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'id' => ['nullable', 'integer'],
+                'recharge_channel_id' => ['required', 'integer'],
+                'recharge_channel_group_ids' => ['required', 'array', 'min:1'],
+                'recharge_channel_group_ids.*' => ['integer'],
+                'kind' => ['required', Rule::in(['collection_scan', 'collection_third_party'])],
+                'currency' => ['nullable', 'string', 'max:16'],
+                'provider_name' => ['required', 'string', 'max:100'],
+                'group_name' => ['required', 'string', 'max:100'],
+                'collection_method' => ['required', 'string', 'max:64'],
+                'channel_code' => ['required', 'string', 'max:64'],
+                'channel_name' => ['required', 'string', 'max:100'],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]);
+            [, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
+                (int)$params['recharge_channel_id'],
+                $params['recharge_channel_group_ids'],
+                1
+            );
+            return $this->saveModel(PaymentCollectionChannel::class, $params, 'payment_collection_channel');
+        });
+    }
+
+    public function collectionStatus()
+    {
+        return $this->setStatus(PaymentCollectionChannel::class, 'payment_collection_channel');
+    }
+
+    public function collectionDelete()
+    {
+        return $this->deleteModel(PaymentCollectionChannel::class, 'payment_collection_channel');
+    }
+
+    public function logs()
+    {
+        return $this->run(function () {
+            $params = request()->validate($this->listRules([
+                'resource_type' => ['required', Rule::in([
+                    'payment_direct_recharge', 'payment_gateway', 'payment_collection_channel',
+                ])],
+                'resource_id' => ['required', 'integer'],
+                'operator_name' => ['nullable', 'string', 'max:100'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]));
+            $query = OperationAudit::query()
+                ->where('resource_type', $params['resource_type'])
+                ->where('resource_id', $params['resource_id']);
+            if (!empty($params['operator_name'])) $query->where('operator_name', 'like', '%' . $params['operator_name'] . '%');
+            if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
+            if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
+            return $this->paginate($query->orderByDesc('id'), $params);
+        });
+    }
+
+    private function saveModel(string $modelClass, array $params, string $resourceType): array
+    {
+        return DB::transaction(function () use ($modelClass, $params, $resourceType) {
+            $id = (int)($params['id'] ?? 0);
+            unset($params['id']);
+            $params += OperationAuditService::actor();
+            $model = $id ? $modelClass::query()->lockForUpdate()->findOrFail($id) : new $modelClass();
+            $before = $model->exists ? $model->replicate()->setRawAttributes($model->getRawOriginal()) : [];
+            $model->fill($params);
+            $model->save();
+            OperationAuditService::record($resourceType, (int)$model->id, $id ? 'update' : 'create', $before, $model);
+            return $this->channelLinks->formatOne($model->fresh()->load('rechargeChannel'));
+        });
+    }
+
+    private function setStatus(string $modelClass, string $resourceType)
+    {
+        return $this->run(function () use ($modelClass, $resourceType) {
+            $params = request()->validate(['id' => ['required', 'integer'], 'status' => ['required', Rule::in([0, 1])]]);
+            return DB::transaction(function () use ($modelClass, $resourceType, $params) {
+                $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
+                $before = $model->toArray();
+                $model->fill(['status' => $params['status']] + OperationAuditService::actor())->save();
+                OperationAuditService::record($resourceType, (int)$model->id, 'status', $before, $model);
+                return $this->channelLinks->formatOne($model->fresh()->load('rechargeChannel'));
+            });
+        });
+    }
+
+    private function deleteModel(string $modelClass, string $resourceType)
+    {
+        return $this->run(function () use ($modelClass, $resourceType) {
+            $params = request()->validate(['id' => ['required', 'integer']]);
+            return DB::transaction(function () use ($modelClass, $resourceType, $params) {
+                $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
+                $before = $model->toArray();
+                $model->delete();
+                OperationAuditService::record($resourceType, (int)$model->id, 'delete', $before, []);
+                return [];
+            });
+        });
+    }
+
+    private function listRules(array $extra): array
+    {
+        return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']] + $extra;
+    }
+
+    private function paginate(Builder $query, array $params): array
+    {
+        $page = (int)($params['page'] ?? 1);
+        $limit = (int)($params['limit'] ?? 20);
+        return ['total' => (clone $query)->count(), 'data' => $query->forPage($page, $limit)->get()];
+    }
+
+    private function paginatePayment(Builder $query, array $params): array
+    {
+        $page = (int)($params['page'] ?? 1);
+        $limit = (int)($params['limit'] ?? 20);
+        $total = (clone $query)->count();
+        $models = $query->forPage($page, $limit)->get();
+        return ['total' => $total, 'data' => $this->channelLinks->formatMany($models)];
+    }
+
+    private function applyChannelFilters(Builder $query, array $params): void
+    {
+        if (!empty($params['recharge_channel_id'])) {
+            $query->where('recharge_channel_id', (int)$params['recharge_channel_id']);
+        }
+        if (!empty($params['group_id'])) {
+            $query->whereJsonContains('recharge_channel_group_ids', (int)$params['group_id']);
+        }
+        if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
+            $query->where('status', (int)$params['status']);
+        }
+    }
+
+    private function applyDateAndLike(Builder $query, array $params, string $likeField): void
+    {
+        if (!empty($params[$likeField])) $query->where($likeField, 'like', '%' . $params[$likeField] . '%');
+        if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
+        if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
+    }
+
+    private function run(callable $callback)
+    {
+        try {
+            return $this->success($callback());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Throwable $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 19 - 10
app/Http/Controllers/admin/RechargeChannel.php

@@ -57,6 +57,15 @@ class RechargeChannel extends Controller
                 'withdraw_type' => ['required','array'],
                 'activity_type' => ['nullable','array'],
             ]);
+            foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $dataType) {
+                $types = array_values(array_unique(array_filter(array_map('strval', $params[$field] ?? []))));
+                if ($types) {
+                    $existingCount = RechargeChannelModel::query()->where('data_type', $dataType)
+                        ->whereIn('type', $types)->distinct()->count('type');
+                    if ($existingCount !== count($types)) throw new Exception("{$field} 包含不存在的通道类型");
+                }
+                $params[$field] = $types;
+            }
             
             if (empty($params['id'])) {
                 RechargeChannelGroup::create($params);
@@ -127,16 +136,16 @@ class RechargeChannel extends Controller
             $params = request()->validate([
                 'id' => ['nullable','integer'],
                 'from' => ['nullable','integer'],
-                'key' => ['nullable','string'],
-                'name' => ['nullable','string'],
-                'data_type' => ['required','string'],
-                'type' => ['required','string'],
-                'rate' => ['required','numeric'],
-                'min' => ['nullable','integer'],
-                'max' => ['nullable','integer'],
+                'key' => ['nullable','string','max:100'],
+                'name' => ['nullable','string','max:100'],
+                'data_type' => ['required','integer','in:1,2,3'],
+                'type' => ['required','string','max:100'],
+                'rate' => ['required','numeric','min:0'],
+                'min' => ['nullable','numeric','min:0'],
+                'max' => ['nullable','numeric','gte:min'],
                 'fixed' => ['nullable','string'],
-                'status' => ['nullable','integer'],
-                'sort' => ['nullable','integer'],
+                'status' => ['nullable','integer','in:0,1'],
+                'sort' => ['nullable','integer','min:0'],
             ]);
 
             if (empty($params['id'])) {
@@ -154,4 +163,4 @@ class RechargeChannel extends Controller
         }
     }
 
-}
+}

+ 212 - 0
app/Http/Controllers/admin/ShareEarning.php

@@ -0,0 +1,212 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Models\ShareBlacklist;
+use App\Models\ShareSetting;
+use App\Models\User;
+use App\Services\OperationAuditService;
+use App\Services\ShareEarningReportService;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class ShareEarning extends Controller
+{
+    public function memberList(ShareEarningReportService $service)
+    {
+        return $this->report(fn ($params) => $service->memberDays($params), $this->dateListRules());
+    }
+
+    public function promoterDetails(ShareEarningReportService $service)
+    {
+        return $this->report(fn ($params) => $service->promoterDetails($params), $this->detailRules());
+    }
+
+    public function registrationDetails(ShareEarningReportService $service)
+    {
+        return $this->report(fn ($params) => $service->registrationDetails($params), $this->detailRules());
+    }
+
+    public function summary(ShareEarningReportService $service)
+    {
+        return $this->report(fn ($params) => $service->summary($params), $this->dateListRules([
+            'account' => ['nullable', 'string', 'max:100'],
+        ]));
+    }
+
+    public function settings()
+    {
+        return $this->run(fn () => ShareSetting::query()->first() ?: [
+            'id' => null,
+            'enabled' => 0,
+            'share_domain' => '',
+            'valid_member_min_deposit' => '20.00',
+            'benefit_enabled' => 0,
+            'non_seller_threshold' => '200.00',
+            'tier_one_rate' => '4.0000',
+            'tier_two_rate' => '6.0000',
+        ]);
+    }
+
+    public function saveSettings()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'enabled' => ['required', Rule::in([0, 1])],
+                'share_domain' => ['required', 'string', 'max:500'],
+                'valid_member_min_deposit' => ['required', 'numeric', 'gte:0'],
+                'benefit_enabled' => ['required', Rule::in([0, 1])],
+                'non_seller_threshold' => ['required', 'numeric', 'gte:0'],
+                'tier_one_rate' => ['required', 'numeric', 'between:0,100'],
+                'tier_two_rate' => ['required', 'numeric', 'between:0,100'],
+            ]);
+            $domain = trim($params['share_domain']);
+            if (!preg_match('~^https?://~i', $domain)) $domain = 'https://' . $domain;
+            if (!filter_var($domain, FILTER_VALIDATE_URL)) {
+                throw ValidationException::withMessages(['share_domain' => '分享赚钱域名格式错误']);
+            }
+            $params['share_domain'] = rtrim($domain, '/');
+            return DB::transaction(function () use ($params) {
+                $settingId = ShareSetting::query()->value('id');
+                $setting = $settingId
+                    ? ShareSetting::query()->lockForUpdate()->findOrFail($settingId)
+                    : ShareSetting::query()->create([]);
+                $before = $setting->toArray();
+                $setting->fill($params + OperationAuditService::actor())->save();
+                OperationAuditService::record('share_setting', (int)$setting->id, 'update', $before, $setting);
+                return $setting->fresh();
+            });
+        });
+    }
+
+    public function blacklists()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+                'account' => ['nullable', 'string', 'max:100'],
+            ]);
+            $query = ShareBlacklist::query();
+            if (!empty($params['account'])) {
+                $ids = User::query()->where('account', 'like', '%' . $params['account'] . '%')->pluck('id');
+                $query->whereIn('user_id', $ids);
+            }
+            $total = $query->count();
+            $rows = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+            $users = User::query()->whereIn('id', $rows->pluck('user_id'))->get()->keyBy('id');
+            $rows->each(function ($row) use ($users) {
+                $user = $users->get($row->user_id);
+                $row->account = (string)($user->account ?? '');
+                $row->nickname = (string)($user->first_name ?? '');
+            });
+            return ['total' => $total, 'data' => $rows];
+        });
+    }
+
+    public function saveBlacklist()
+    {
+        return $this->run(function () {
+            $params = request()->validate([
+                'id' => ['nullable', 'integer'],
+                'account' => ['required_without:id', 'string', 'max:100'],
+                'blocked_categories' => ['required', 'array', 'min:1'],
+                'blocked_categories.*' => [Rule::in(['sports', 'lottery', 'third_party_game'])],
+                'status' => ['nullable', Rule::in([0, 1])],
+            ]);
+            return DB::transaction(function () use ($params) {
+                $id = (int)($params['id'] ?? 0);
+                $model = $id ? ShareBlacklist::query()->lockForUpdate()->findOrFail($id) : new ShareBlacklist();
+                $before = $model->exists ? $model->toArray() : [];
+                if (!$id) {
+                    $user = User::query()->where('account', $params['account'])->first();
+                    if (!$user) throw new \RuntimeException('会员账号不存在');
+                    $existing = ShareBlacklist::withTrashed()->where('user_id', $user->id)->lockForUpdate()->first();
+                    if ($existing && !$existing->trashed()) throw new \RuntimeException('该会员已在禁止推广列表');
+                    if ($existing) {
+                        $model = $existing;
+                        $model->restore();
+                        $before = [];
+                    } else {
+                        $model->user_id = $user->id;
+                        $model->member_id = (string)$user->member_id;
+                    }
+                }
+                $model->blocked_categories = array_values(array_unique($params['blocked_categories']));
+                $model->status = (int)($params['status'] ?? ($model->status ?? 1));
+                $model->fill(OperationAuditService::actor())->save();
+                OperationAuditService::record('share_blacklist', (int)$model->id, $id ? 'update' : 'create', $before, $model);
+                return $model->fresh();
+            });
+        });
+    }
+
+    public function blacklistStatus()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['id' => ['required', 'integer'], 'status' => ['required', Rule::in([0, 1])]]);
+            return DB::transaction(function () use ($params) {
+                $model = ShareBlacklist::query()->lockForUpdate()->findOrFail($params['id']);
+                $before = $model->toArray();
+                $model->fill(['status' => $params['status']] + OperationAuditService::actor())->save();
+                OperationAuditService::record('share_blacklist', (int)$model->id, 'status', $before, $model);
+                return $model->fresh();
+            });
+        });
+    }
+
+    public function deleteBlacklist()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['id' => ['required', 'integer']]);
+            return DB::transaction(function () use ($params) {
+                $model = ShareBlacklist::query()->lockForUpdate()->findOrFail($params['id']);
+                $before = $model->toArray();
+                $model->delete();
+                OperationAuditService::record('share_blacklist', (int)$model->id, 'delete', $before, []);
+                return [];
+            });
+        });
+    }
+
+    private function dateListRules(array $extra = []): array
+    {
+        return [
+            'page' => ['nullable', 'integer', 'min:1'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+            'start_date' => ['nullable', 'date_format:Y-m-d'],
+            'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+        ] + $extra;
+    }
+
+    private function detailRules(): array
+    {
+        return [
+            'date' => ['required', 'date_format:Y-m-d'],
+            'page' => ['nullable', 'integer', 'min:1'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+            'member_id' => ['nullable', 'string', 'max:64'],
+            'account' => ['nullable', 'string', 'max:100'],
+        ];
+    }
+
+    private function report(callable $callback, array $rules)
+    {
+        return $this->run(fn () => $callback(request()->validate($rules)));
+    }
+
+    private function run(callable $callback)
+    {
+        try {
+            return $this->success($callback());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Throwable $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 1 - 0
app/Http/Kernel.php

@@ -70,6 +70,7 @@ class Kernel extends HttpKernel
         'check.token' => \App\Http\Middleware\CheckToken::class,
         'agent.auth' => \App\Http\Middleware\AgentAuthMiddleware::class,
         'agent.admin' => \App\Http\Middleware\AgentAdminMiddleware::class,
+        'operations.admin' => \App\Http\Middleware\OperationsAdminMiddleware::class,
 
         // 系统默认的中间件
         'auth' => Authenticate::class,

+ 23 - 0
app/Http/Middleware/OperationsAdminMiddleware.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+class OperationsAdminMiddleware
+{
+    public function handle(Request $request, Closure $next)
+    {
+        $adminId = (int)($request->user->id ?? 0);
+        if (!in_array($adminId, config('operations.admin_ids', [1]), true)) {
+            return response()->json([
+                'code' => -1,
+                'timestamp' => time(),
+                'msg' => '无运营配置管理权限',
+                'data' => [],
+            ]);
+        }
+        return $next($request);
+    }
+}

+ 14 - 0
app/Models/AppDownloadEvent.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Models;
+
+class AppDownloadEvent extends BaseModel
+{
+    public $timestamps = false;
+    const CREATED_AT = 'occurred_at';
+    const UPDATED_AT = null;
+
+    protected $table = 'app_download_events';
+    protected $guarded = [];
+    protected $casts = ['occurred_at' => 'datetime'];
+}

+ 15 - 0
app/Models/AppIosReviewSetting.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class AppIosReviewSetting extends BaseModel
+{
+    use SoftDeletes;
+
+    protected $table = 'app_ios_review_settings';
+    protected $guarded = [];
+    protected $hidden = ['deleted_at'];
+    protected $casts = ['review_user_ids' => 'array', 'status' => 'integer'];
+}

+ 15 - 0
app/Models/AppPackage.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class AppPackage extends BaseModel
+{
+    use SoftDeletes;
+
+    protected $table = 'app_packages';
+    protected $guarded = [];
+    protected $hidden = ['deleted_at'];
+    protected $casts = ['force_update' => 'integer', 'status' => 'integer'];
+}

+ 11 - 0
app/Models/AppSetting.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+class AppSetting extends BaseModel
+{
+    protected $table = 'app_settings';
+    protected $guarded = [];
+    protected $hidden = [];
+    protected $casts = ['ios_frontend_visible' => 'integer', 'android_frontend_visible' => 'integer'];
+}

+ 14 - 0
app/Models/OperationAudit.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Models;
+
+class OperationAudit extends BaseModel
+{
+    public $timestamps = false;
+    const UPDATED_AT = null;
+
+    protected $table = 'operation_audits';
+    protected $guarded = [];
+    protected $hidden = [];
+    protected $casts = ['changes' => 'array', 'created_at' => 'datetime'];
+}

+ 23 - 0
app/Models/PaymentCollectionChannel.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class PaymentCollectionChannel extends BaseModel
+{
+    use SoftDeletes;
+
+    protected $table = 'payment_collection_channels';
+    protected $guarded = [];
+    protected $hidden = ['deleted_at'];
+    protected $casts = [
+        'recharge_channel_group_ids' => 'array',
+        'status' => 'integer',
+    ];
+
+    public function rechargeChannel()
+    {
+        return $this->belongsTo(RechargeChannel::class, 'recharge_channel_id');
+    }
+}

+ 38 - 0
app/Models/PaymentDirectRecharge.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class PaymentDirectRecharge extends BaseModel
+{
+    use SoftDeletes;
+
+    protected $table = 'payment_direct_recharges';
+    protected $guarded = [];
+    protected $hidden = ['deleted_at'];
+    protected $casts = [
+        'amount' => 'decimal:2',
+        'recharge_channel_group_ids' => 'array',
+        'status' => 'integer',
+    ];
+    protected $appends = ['fee_rate', 'actual_amount'];
+
+    public function rechargeChannel()
+    {
+        return $this->belongsTo(RechargeChannel::class, 'recharge_channel_id');
+    }
+
+    public function getFeeRateAttribute($value = null): string
+    {
+        return $this->rechargeChannel
+            ? bcmul((string)$this->rechargeChannel->rate, '100', 4)
+            : '0.0000';
+    }
+
+    public function getActualAmountAttribute(): string
+    {
+        $rate = $this->rechargeChannel ? (string)$this->rechargeChannel->rate : '0';
+        return bcsub((string)$this->amount, bcmul((string)$this->amount, $rate, 6), 2);
+    }
+}

+ 30 - 0
app/Models/PaymentGateway.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class PaymentGateway extends BaseModel
+{
+    use SoftDeletes;
+
+    protected $table = 'payment_gateways';
+    protected $guarded = [];
+    protected $hidden = ['withdrawal_secret', 'deleted_at'];
+    protected $casts = [
+        'withdrawal_secret' => 'encrypted',
+        'recharge_channel_group_ids' => 'array',
+        'status' => 'integer',
+    ];
+    protected $appends = ['has_withdrawal_secret'];
+
+    public function getHasWithdrawalSecretAttribute(): bool
+    {
+        return !empty($this->attributes['withdrawal_secret'] ?? null);
+    }
+
+    public function rechargeChannel()
+    {
+        return $this->belongsTo(RechargeChannel::class, 'recharge_channel_id');
+    }
+}

+ 15 - 12
app/Models/RechargeChannel.php

@@ -6,7 +6,7 @@ class RechargeChannel extends BaseModel
 {
 
     protected $table = 'recharge_channel';
-    protected $fillable = ['id','data_type','key', 'name', 'type', 'rate','min','max' ,'fixed' ,'sort'];
+    protected $fillable = ['id', 'data_type', 'from', 'key', 'name', 'type', 'rate', 'min', 'max', 'fixed', 'sort', 'status'];
     protected $hidden = [];
 
     public function getFixedAttribute($value)
@@ -50,11 +50,12 @@ class RechargeChannel extends BaseModel
             //提现类型
             $field = 'activity_type';
         } 
-        $type = RechargeChannelGroup::where('id', $recharge_channel_group_id)->value($field);
-        if (!$type) {
+        $group = RechargeChannelGroup::query()->find($recharge_channel_group_id);
+        $types = $group ? (array)$group->{$field} : [];
+        if (!$types) {
             return [];
         }
-        $query = $query->whereIn('type', $type);
+        $query = $query->whereIn('type', $types);
         
         $product = $query->orderBy('sort', 'asc')->select(['id', 'data_type','name','type', 'min','max','fixed','rate'])->get()->toArray();
         
@@ -98,22 +99,24 @@ class RechargeChannel extends BaseModel
     //校验是否支持此提现方式
     public static function checkWithdrawChannel($type, $recharge_channel_group_id = 1) {
         $recharge_channel_group_id = $recharge_channel_group_id > 0 ? $recharge_channel_group_id : 1;
-        $withdraw_type = RechargeChannelGroup::where('id', $recharge_channel_group_id)->value('withdraw_type');
-        if (in_array($type, $withdraw_type)) {
-            return self::where('data_type', 2)->where('type', $type)->first();
-        } else {
+        $group = RechargeChannelGroup::query()->find($recharge_channel_group_id);
+        $withdrawTypes = $group ? (array)$group->withdraw_type : [];
+        if (!in_array($type, $withdrawTypes, true)) {
             return false;
         }
+        return self::query()->where('data_type', 2)->where('type', $type)
+            ->where('status', 1)->orderBy('sort')->first() ?: false;
     }
     
     //校验是否支持此充值方式
     public static function checkRechargeChannel($type, $recharge_channel_group_id = 1) {
         $recharge_channel_group_id = $recharge_channel_group_id > 0 ? $recharge_channel_group_id : 1;
-        $recharge_type = RechargeChannelGroup::where('id', $recharge_channel_group_id)->value('recharge_type');
-        if (in_array($type, $recharge_type)) {
-            return true;
-        } else {
+        $group = RechargeChannelGroup::query()->find($recharge_channel_group_id);
+        $rechargeTypes = $group ? (array)$group->recharge_type : [];
+        if (!in_array($type, $rechargeTypes, true)) {
             return false;
         }
+        return self::query()->where('data_type', 1)->where('type', $type)
+            ->where('status', 1)->orderBy('sort')->first() ?: false;
     }
 }

+ 5 - 3
app/Models/RechargeChannelGroup.php

@@ -36,8 +36,10 @@ class RechargeChannelGroup extends BaseModel
 
     public function setActivityTypeAttribute($value)
     {
-        if (!$value)
-            return '';
+        if (!$value) {
+            $this->attributes['activity_type'] = '';
+            return;
+        }
         $this->attributes['activity_type'] = is_array($value) ? implode(',', $value) : $value;
     }
 
@@ -47,4 +49,4 @@ class RechargeChannelGroup extends BaseModel
         return $activity_type;
     }
 
-}
+}

+ 15 - 0
app/Models/ShareBlacklist.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class ShareBlacklist extends BaseModel
+{
+    use SoftDeletes;
+
+    protected $table = 'share_blacklists';
+    protected $guarded = [];
+    protected $hidden = ['deleted_at'];
+    protected $casts = ['blocked_categories' => 'array', 'status' => 'integer'];
+}

+ 14 - 0
app/Models/ShareCommissionRecord.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Models;
+
+class ShareCommissionRecord extends BaseModel
+{
+    protected $table = 'share_commission_records';
+    protected $guarded = [];
+    protected $hidden = [];
+    protected $casts = [
+        'stat_date' => 'date:Y-m-d', 'base_amount' => 'decimal:4',
+        'rate' => 'decimal:4', 'amount' => 'decimal:4', 'status' => 'integer',
+    ];
+}

+ 15 - 0
app/Models/ShareSetting.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Models;
+
+class ShareSetting extends BaseModel
+{
+    protected $table = 'share_settings';
+    protected $guarded = [];
+    protected $hidden = [];
+    protected $casts = [
+        'enabled' => 'integer', 'benefit_enabled' => 'integer',
+        'valid_member_min_deposit' => 'decimal:2', 'non_seller_threshold' => 'decimal:2',
+        'tier_one_rate' => 'decimal:4', 'tier_two_rate' => 'decimal:4',
+    ];
+}

+ 61 - 0
app/Services/OperationAuditService.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\OperationAudit;
+use Illuminate\Database\Eloquent\Model;
+
+class OperationAuditService
+{
+    private const SENSITIVE_KEYS = ['secret', 'key', 'password', 'token', 'private'];
+
+    public static function record(string $resourceType, ?int $resourceId, string $action, $before, $after): void
+    {
+        $admin = request()->user ?? null;
+        OperationAudit::query()->create([
+            'resource_type' => $resourceType,
+            'resource_id' => $resourceId,
+            'action' => $action,
+            'changes' => [
+                'before' => self::sanitize(self::toArray($before)),
+                'after' => self::sanitize(self::toArray($after)),
+            ],
+            'operator_id' => $admin->id ?? null,
+            'operator_name' => (string)($admin->username ?? ''),
+        ]);
+    }
+
+    public static function actor(): array
+    {
+        $admin = request()->user ?? null;
+        return [
+            'operator_id' => $admin->id ?? null,
+            'operator_name' => (string)($admin->username ?? ''),
+        ];
+    }
+
+    private static function toArray($value): array
+    {
+        if ($value instanceof Model) {
+            return $value->toArray();
+        }
+        return is_array($value) ? $value : [];
+    }
+
+    private static function sanitize(array $value): array
+    {
+        foreach ($value as $key => $item) {
+            $normalized = strtolower((string)$key);
+            foreach (self::SENSITIVE_KEYS as $needle) {
+                if (str_contains($normalized, $needle)) {
+                    $value[$key] = empty($item) ? null : '******';
+                    continue 2;
+                }
+            }
+            if (is_array($item)) {
+                $value[$key] = self::sanitize($item);
+            }
+        }
+        return $value;
+    }
+}

+ 18 - 10
app/Services/Payment/SanJinService.php

@@ -8,6 +8,7 @@ use GuzzleHttp\Psr7\Response;
 use App\Services\BaseService;
 use Illuminate\Support\Facades\Lang;
 use App\Models\RechargeChannel;
+use App\Models\RechargeChannelGroup;
 
 class SanJinService extends BaseService
 {
@@ -30,18 +31,21 @@ class SanJinService extends BaseService
      * @description: 获取支付频道
      * @return {*}
      */    
-    public static function getChannel($type = '')
+    public static function getChannel($type = '', ?int $groupId = null)
     {
         if ($type) {
-            $name = RechargeChannel::where('type', $type)->value('name');
+            if ($groupId && RechargeChannel::checkRechargeChannel($type, $groupId) === false) return '';
+            $name = RechargeChannel::where('data_type', 1)->where('from', 1)
+                ->where('status', 1)->where('type', $type)->value('name');
             return Lang($name);
         } else {
-            $channel = [];
-            $product = self::product();
-            foreach($product as $v){
-                $channel[$v['type']] = lang($v['name']);
+            $query = RechargeChannel::query()->where('status', 1)->where('data_type', 1)->where('from', 1);
+            if ($groupId) {
+                $group = RechargeChannelGroup::query()->find($groupId);
+                $query->whereIn('type', $group ? (array)$group->recharge_type : []);
             }
-            return $channel;
+            return $query->orderBy('sort')->get(['type', 'name'])
+                ->mapWithKeys(fn ($row) => [(string)$row->type => lang($row->name)])->all();
         }
     }
 
@@ -130,9 +134,13 @@ class SanJinService extends BaseService
     //         'min' => 500
     //     ],
     // ];
-    public static function product()
+    public static function product(?int $groupId = null)
     {
-        return RechargeChannel::product(1);
+        $product = RechargeChannel::product(1);
+        if (!$groupId) return $product;
+        $group = RechargeChannelGroup::query()->find($groupId);
+        $types = $group ? (array)$group->recharge_type : [];
+        return array_filter($product, fn ($row) => in_array((string)$row['type'], $types, true));
     }
 
     // 获取商户ID
@@ -263,4 +271,4 @@ class SanJinService extends BaseService
         return [];
     }
 
-}
+}

+ 132 - 0
app/Services/PaymentChannelLinkService.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\RechargeChannel;
+use App\Models\RechargeChannelGroup;
+use Illuminate\Database\Eloquent\Collection as EloquentCollection;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Collection;
+use RuntimeException;
+
+class PaymentChannelLinkService
+{
+    public function validate(int $channelId, array $groupIds, int $expectedDataType): array
+    {
+        $channel = RechargeChannel::query()->find($channelId);
+        if (!$channel) throw new RuntimeException('充值通道不存在');
+        if ((int)$channel->data_type !== $expectedDataType) {
+            throw new RuntimeException($expectedDataType === 2 ? '请选择提现通道' : '请选择充值通道');
+        }
+
+        $groupIds = array_values(array_unique(array_filter(array_map('intval', $groupIds))));
+        if (!$groupIds) throw new RuntimeException('至少选择一个通道组合');
+        $groups = RechargeChannelGroup::query()->whereIn('id', $groupIds)->get();
+        if ($groups->count() !== count($groupIds)) throw new RuntimeException('部分通道组合不存在');
+
+        $field = $this->groupField($expectedDataType);
+        foreach ($groups as $group) {
+            if (!in_array((string)$channel->type, (array)$group->{$field}, true)) {
+                throw new RuntimeException("通道组合“{$group->name}”没有包含通道“{$channel->name}”");
+            }
+        }
+        return [$channel, $groupIds];
+    }
+
+    public function formatMany($models): Collection
+    {
+        $models = $models instanceof Collection ? $models : collect($models);
+        if ($models instanceof EloquentCollection) $models->loadMissing('rechargeChannel');
+        $groupIds = $models->flatMap(fn ($model) => (array)($model->recharge_channel_group_ids ?? []))
+            ->map(fn ($id) => (int)$id)->filter()->unique()->values();
+        $groups = RechargeChannelGroup::query()->whereIn('id', $groupIds)->get()->keyBy('id');
+        return $models->map(fn (Model $model) => $this->formatOne($model, $groups));
+    }
+
+    public function formatOne(Model $model, ?Collection $groups = null): array
+    {
+        $model->loadMissing('rechargeChannel');
+        $groups ??= RechargeChannelGroup::query()
+            ->whereIn('id', (array)($model->recharge_channel_group_ids ?? []))->get()->keyBy('id');
+        $channel = $model->rechargeChannel;
+        $assignedIds = array_values(array_unique(array_filter(array_map(
+            'intval',
+            (array)($model->recharge_channel_group_ids ?? [])
+        ))));
+        $effectiveIds = [];
+        $unavailableIds = [];
+        $groupRows = [];
+        foreach ($assignedIds as $groupId) {
+            $group = $groups->get($groupId);
+            $effective = false;
+            if ($group && $channel) {
+                $effective = in_array(
+                    (string)$channel->type,
+                    (array)$group->{$this->groupField((int)$channel->data_type)},
+                    true
+                );
+            }
+            if ($effective) {
+                $effectiveIds[] = $groupId;
+            } else {
+                $unavailableIds[] = $groupId;
+            }
+            $groupRows[] = [
+                'id' => $groupId,
+                'name' => (string)($group->name ?? ''),
+                'effective' => $effective ? 1 : 0,
+            ];
+        }
+
+        $data = $model->toArray();
+        $data['recharge_channel_group_ids'] = $assignedIds;
+        $data['effective_group_ids'] = $effectiveIds;
+        $data['unavailable_group_ids'] = $unavailableIds;
+        $data['groups'] = $groupRows;
+        $data['recharge_channel'] = $channel ? [
+            'id' => (int)$channel->id,
+            'data_type' => (int)$channel->data_type,
+            'name' => (string)$channel->name,
+            'key' => (string)$channel->key,
+            'type' => (string)$channel->type,
+            'rate' => number_format((float)$channel->rate, 4, '.', ''),
+            'fee_rate' => bcmul((string)$channel->rate, '100', 4),
+            'min_amount' => $channel->min === null ? null : number_format((float)$channel->min, 2, '.', ''),
+            'max_amount' => $channel->max === null ? null : number_format((float)$channel->max, 2, '.', ''),
+            'fixed_amounts' => $channel->fixed ?: [],
+            'sort' => (int)$channel->sort,
+            'status' => (int)$channel->status,
+        ] : null;
+        $data['fee_rate'] = $channel ? bcmul((string)$channel->rate, '100', 4) : null;
+        $data['min_amount'] = $channel?->min;
+        $data['max_amount'] = $channel?->max;
+        $data['sort'] = $channel ? (int)$channel->sort : null;
+        $data['config_status'] = (int)$model->status;
+        $data['channel_status'] = $channel ? (int)$channel->status : 0;
+        $data['runtime_channel_available'] = $channel
+            && (int)$channel->status === 1 && count($effectiveIds) > 0 ? 1 : 0;
+        $data['effective_status'] = (int)$model->status === 1
+            && $data['runtime_channel_available'] === 1 ? 1 : 0;
+        if (array_key_exists('amount', $data)) {
+            $data['actual_amount'] = $channel
+                ? bcsub((string)$model->amount, bcmul((string)$model->amount, (string)$channel->rate, 6), 2)
+                : null;
+        }
+        $data['channel_fields_readonly'] = [
+            'recharge_channel.name', 'recharge_channel.key', 'recharge_channel.type',
+            'fee_rate', 'min_amount', 'max_amount', 'recharge_channel.fixed_amounts',
+            'sort', 'channel_status',
+        ];
+        unset($data['level_scope']);
+        return $data;
+    }
+
+    private function groupField(int $dataType): string
+    {
+        return match ($dataType) {
+            2 => 'withdraw_type',
+            3 => 'activity_type',
+            default => 'recharge_type',
+        };
+    }
+}

+ 37 - 12
app/Services/PaymentOrderService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Constants\HttpStatus;
 use App\Models\PaymentOrder;
+use App\Models\RechargeChannel;
 use App\Models\User;
 use App\Models\Wallet as WalletModel;
 use Exception;
@@ -199,11 +200,19 @@ class PaymentOrderService extends BaseService
         $result['chat_id'] = $memberId;
         $result['code'] = 0;
         $result['url'] = '';
+        $user = User::query()->where('member_id', $memberId)->first();
+        $groupId = (int)($user->recharge_channel_group_id ?? 1);
+        $channelConfig = RechargeChannel::checkRechargeChannel($paymentType, $groupId);
+        if ($channelConfig === false) {
+            $result['text'] = '不支持此充值方式';
+            $result['code'] = 20001;
+            return $result;
+        }
         $channel = ''; // 支付的通道
-        $product = SanJinService::product();
-        $max = 0;
-        $min = 0;
-        $rate = 0;
+        $product = SanJinService::product($groupId);
+        $max = (float)($channelConfig->max ?? 0);
+        $min = (float)($channelConfig->min ?? 0);
+        $rate = (float)($channelConfig->rate ?? 0);
         $selectedProduct = null;
 
         $geText = '';
@@ -240,6 +249,15 @@ class PaymentOrderService extends BaseService
             }
         }
 
+        $isDirectProvider = JdPayService::isChannel($paymentType)
+            || NoPayService::isRechargeChannel($paymentType)
+            || ZimuPayService::isRechargeChannel($paymentType);
+        if ($isDirectProvider && ($amount < $min || ($max > 0 && $amount > $max))) {
+            $result['text'] = "❌ 此充值通道充值金额{$min}-{$max}请务必输入区间金额!";
+            $result['code'] = 20001;
+            return $result;
+        }
+
         // 没有找到支付通道
         if (empty($channel) && !JdPayService::isChannel($paymentType) && !NoPayService::isRechargeChannel($paymentType) && !ZimuPayService::isRechargeChannel($paymentType)) {
             // $text = "发起充值失败 \n";
@@ -849,15 +867,20 @@ class PaymentOrderService extends BaseService
         $default_amount = $amount;
         $result = [];
         $result['chat_id'] = $memberId;
-
-        if ($amount < 100) {
-            $result['text'] = '提现金额最少100';
+        $user = User::query()->where('member_id', $memberId)->first();
+        $groupId = (int)($user->recharge_channel_group_id ?? 1);
+        $channelConfig = RechargeChannel::checkWithdrawChannel($channel, $groupId);
+        if ($channelConfig === false) {
+            $result['text'] = '不支持此提现方式';
             return $result;
         }
-        if ($amount > 49999) {
-            $result['text'] = '提现金额最多49999';
+        $minAmount = (float)($channelConfig->min ?? 0);
+        $maxAmount = (float)($channelConfig->max ?? 0);
+        if ($amount < $minAmount || ($maxAmount > 0 && $amount > $maxAmount)) {
+            $result['text'] = "提现金额范围为{$minAmount}-{$maxAmount}";
             return $result;
         }
+        $channelRate = (float)($channelConfig->rate ?? 0);
 
         // 在调用三方支付前开始事务
         DB::beginTransaction();
@@ -865,12 +888,14 @@ class PaymentOrderService extends BaseService
         try {
             $wallet = WalletService::findOne(['member_id' => $memberId]);
             if (!$wallet) {
+                DB::rollBack();
                 $result['text'] = '钱包不存在!';
                 return $result;
             }
 
             $balance = $wallet->available_balance;
             if (bccomp($balance, $amount, 2) < 0) {
+                DB::rollBack();
                 $result['text'] = '您的钱包余额不足!';
                 return $result;
             }
@@ -882,7 +907,7 @@ class PaymentOrderService extends BaseService
             $order_no = self::createOrderNo('sj' . $data['type'] . '_', $memberId);
             $data['order_no'] = $order_no;
             $data['member_id'] = $memberId;
-            $data['fee'] = $amount * 0.002 + 2;
+            $data['fee'] = $amount * $channelRate + 2;
             $amount = number_format($amount, 2, '.', '');
             $data['amount'] = $amount;
             $data['channel'] = $channel;
@@ -899,7 +924,7 @@ class PaymentOrderService extends BaseService
                 $data['callback_url'] = QianBaoService::getNotifyUrl();
             }
             $data['status'] = self::STATUS_STAY;
-            $data['remark'] = '提现费率:0.2%+2';
+            $data['remark'] = '提现费率:' . ($channelRate * 100) . '%+2';
 
             // 先预扣款(锁定资金)
             $wallet->available_balance = $available_balance;
@@ -914,7 +939,7 @@ class PaymentOrderService extends BaseService
             $id = $info->id;
 
             // 记录余额变动日志
-            BalanceLogService::addLog($memberId, $default_amount * -1, $balance, $available_balance, '三方提现', $id, '钱宝提现费率:0.2%+2');
+            BalanceLogService::addLog($memberId, $default_amount * -1, $balance, $available_balance, '三方提现', $id, '钱宝提现费率:' . ($channelRate * 100) . '%+2');
 
             // 提交事务,确保预扣款成功
             DB::commit();

+ 18 - 4
app/Services/QianBaoWithdrawService.php

@@ -8,6 +8,8 @@ use App\Models\ActivityUser;
 use App\Models\Bank;
 use App\Models\Config;
 use App\Models\PaymentOrder;
+use App\Models\RechargeChannel;
+use App\Models\User;
 use App\Models\Wallet;
 use App\Services\Payment\JdPayService;
 use App\Services\Payment\NoPayService;
@@ -443,10 +445,22 @@ class QianBaoWithdrawService
     //创建提现订单
     public static function createOrder($memberId, $amount, $channel, $bank_name, $account, $card_no)
     {
-        DB::beginTransaction();
         $result['chat_id'] = $memberId;
         $result['code'] = 0;
         $default_amount = $amount;
+        $user = User::query()->where('member_id', $memberId)->first();
+        $groupId = (int)($user->recharge_channel_group_id ?? 1);
+        $channelConfig = RechargeChannel::checkWithdrawChannel($channel, $groupId);
+        if ($channelConfig === false) {
+            return ['chat_id' => $memberId, 'code' => HttpStatus::CUSTOM_ERROR, 'text' => '不支持此提现方式'];
+        }
+        $minAmount = (float)($channelConfig->min ?? 0);
+        $maxAmount = (float)($channelConfig->max ?? 0);
+        if ($amount < $minAmount || ($maxAmount > 0 && $amount > $maxAmount)) {
+            return ['chat_id' => $memberId, 'code' => HttpStatus::CUSTOM_ERROR, 'text' => "提现金额范围为{$minAmount}-{$maxAmount}"];
+        }
+        $channelRate = (float)($channelConfig->rate ?? 0);
+        DB::beginTransaction();
         try {
             $wallet = WalletService::findOne(['member_id' => $memberId]);
             if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
@@ -465,7 +479,7 @@ class QianBaoWithdrawService
             $data['type'] = PaymentOrderService::TYPE_PAYOUT;
             $data['order_no'] = PaymentOrderService::createOrderNo('sj' . $data['type'] . '_', $memberId);
             $data['member_id'] = $memberId;
-            $data['fee'] = $amount * 0.002 + 2;
+            $data['fee'] = $amount * $channelRate + 2;
             $data['amount'] = number_format($amount, 2, '.', '');
             $data['channel'] = $channel;
             $data['bank_name'] = $bank_name;
@@ -481,11 +495,11 @@ class QianBaoWithdrawService
                 $data['callback_url'] = QianBaoService::getNotifyUrl();
             }
             $data['status'] = PaymentOrderService::STATUS_STAY;
-            $data['remark'] = '提现费率:0.2%+2';
+            $data['remark'] = '提现费率:' . ($channelRate * 100) . '%+2';
             // 创建待处理状态的提现记录
             $info = PaymentOrder::create($data);
             // 记录余额变动日志
-            BalanceLogService::addLog($memberId, $default_amount * -1, $balance, $available_balance, '三方提现', $info->id, '钱宝提现费率:0.2%+2');
+            BalanceLogService::addLog($memberId, $default_amount * -1, $balance, $available_balance, '三方提现', $info->id, '钱宝提现费率:' . ($channelRate * 100) . '%+2');
 
 
             $balance = bcadd($available_balance, 0, 2);

+ 9 - 5
app/Services/SanJinRechargeService.php

@@ -7,6 +7,7 @@ use App\Models\Bank;
 use App\Models\Config;
 use App\Models\PaymentOrder;
 use App\Models\Wallet;
+use App\Models\User;
 use Illuminate\Support\Facades\Cache;
 use Telegram\Bot\Api;
 use App\Services\Payment\SanJinService;
@@ -59,7 +60,8 @@ class SanJinRechargeService extends BaseService
             $k = $matches[1]; 
             
             // 验证 $k 是否有效
-            $channel = SanJinService::getChannel();
+            $groupId = (int)(User::query()->where('member_id', $chatId)->value('recharge_channel_group_id') ?: 1);
+            $channel = SanJinService::getChannel('', $groupId);
             if (!isset($channel[$k])) {
                 // 处理无效的通道
                 $text = lang("无效的支付通道!");
@@ -76,7 +78,7 @@ class SanJinRechargeService extends BaseService
             Cache::put(get_step_key($chatId), StepStatus::INPUT_RECHARGE_SJ_MONEY);
 
             $paymentType = $k;
-            $product = SanJinService::product();
+            $product = SanJinService::product($groupId);
             $max = 0;
             $min = 0;
             $rate = 0;
@@ -227,7 +229,8 @@ class SanJinRechargeService extends BaseService
 
         $text = lang("请选择支付的通道")." \n";
         $keyboard = [];
-        $channel = SanJinService::getChannel();
+        $groupId = (int)(User::query()->where('member_id', $chatId)->value('recharge_channel_group_id') ?: 1);
+        $channel = SanJinService::getChannel('', $groupId);
         $keyboard[] = [
             ['text' => 'USDT', 'callback_data' => "topup@@topup"],
         ];
@@ -275,7 +278,8 @@ class SanJinRechargeService extends BaseService
         }
 
         // 验证 $k 是否有效
-        $channel = SanJinService::getChannel();
+        $groupId = (int)(User::query()->where('member_id', $chatId)->value('recharge_channel_group_id') ?: 1);
+        $channel = SanJinService::getChannel('', $groupId);
         if (!isset($channel[$paymentType])) {
             // 处理无效的通道
             $text = lang("无效的支付通道!");
@@ -307,4 +311,4 @@ class SanJinRechargeService extends BaseService
     {
         return [];
     }
-}
+}

+ 267 - 0
app/Services/ShareEarningReportService.php

@@ -0,0 +1,267 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\BalanceLog;
+use App\Models\ShareCommissionRecord;
+use App\Models\ShareSetting;
+use App\Models\User;
+use App\Models\UserLogin;
+use Illuminate\Support\Facades\DB;
+
+class ShareEarningReportService
+{
+    public function memberDays(array $params): array
+    {
+        [$start, $end] = $this->dateRange($params);
+        $page = (int)($params['page'] ?? 1);
+        $limit = (int)($params['limit'] ?? 20);
+        $dateQuery = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+            ->whereBetween('created_at', [$start . ' 00:00:00', $end . ' 23:59:59'])
+            ->selectRaw('DATE(created_at) AS stat_date')
+            ->groupByRaw('DATE(created_at)')
+            ->orderByDesc('stat_date');
+        $dates = $dateQuery->pluck('stat_date');
+        $pageDates = $dates->slice(($page - 1) * $limit, $limit)->values();
+        if ($pageDates->isEmpty()) return ['total' => $dates->count(), 'data' => []];
+        $pageStart = (string)$pageDates->min();
+        $pageEnd = (string)$pageDates->max();
+
+        $basicRows = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+            ->whereBetween('created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
+            ->selectRaw('DATE(created_at) AS stat_date')
+            ->selectRaw('COUNT(*) AS registered_members')
+            ->selectRaw('COUNT(DISTINCT agent_user_code) AS promoter_count')
+            ->groupByRaw('DATE(created_at)')->get()->keyBy('stat_date');
+
+        $inviteeBase = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+            ->whereBetween('created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
+            ->select(['id', 'member_id'])->selectRaw('DATE(created_at) AS stat_date');
+        $memberMetrics = DB::query()->fromSub($inviteeBase, 'invitee')
+            ->leftJoin('balance_logs as balance_log', 'balance_log.member_id', '=', 'invitee.member_id')
+            ->select(['invitee.id', 'invitee.stat_date'])
+            ->selectRaw('SUM(CASE WHEN change_type IN (?, ?) THEN amount ELSE 0 END) AS deposit', ['充值', '三方充值'])
+            ->selectRaw('ABS(SUM(CASE WHEN change_type IN (?, ?, ?) THEN amount ELSE 0 END)) AS withdraw', ['提现', '三方提现', '人工提现'])
+            ->selectRaw('ABS(SUM(CASE WHEN change_type LIKE ? THEN amount ELSE 0 END)) AS consume', ['%投注%'])
+            ->selectRaw('SUM(CASE WHEN change_type IN (?, ?, ?, ?, ?, ?) THEN amount ELSE 0 END) AS bonus', ['注册赠送', '优惠活动', '返水', '回水', '笔笔返', '比比返'])
+            ->groupBy(['invitee.id', 'invitee.stat_date']);
+        $validMin = (float)(ShareSetting::query()->value('valid_member_min_deposit') ?? 0);
+        $metricRows = DB::query()->fromSub($memberMetrics, 'member_metric')
+            ->select('stat_date')
+            ->selectRaw('SUM(deposit) AS deposit_total')
+            ->selectRaw('SUM(withdraw) AS withdraw_total')
+            ->selectRaw('SUM(consume) AS consume_total')
+            ->selectRaw('SUM(bonus) AS bonus_total')
+            ->selectRaw('SUM(CASE WHEN deposit >= ? THEN 1 ELSE 0 END) AS valid_members', [$validMin])
+            ->groupBy('stat_date')->get()->keyBy('stat_date');
+
+        $pageMemberIds = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+            ->whereBetween('created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
+            ->select('member_id');
+        $firstLoginIds = UserLogin::query()->where('status', UserLogin::STATUS_SUCCESS)
+            ->whereIn('user_id', $pageMemberIds)
+            ->select('user_id')->selectRaw('MIN(id) AS first_login_id')->groupBy('user_id');
+        $prefix = DB::getTablePrefix();
+        $userAlias = $prefix . 'invitee_user';
+        $loginAlias = $prefix . 'first_login_row';
+        $platformRows = User::query()->from('users as invitee_user')
+            ->leftJoinSub($firstLoginIds, 'first_login', 'first_login.user_id', '=', 'invitee_user.member_id')
+            ->leftJoin('user_login as first_login_row', 'first_login_row.id', '=', 'first_login.first_login_id')
+            ->whereNotNull('invitee_user.agent_user_code')->where('invitee_user.agent_user_code', '<>', '')
+            ->whereBetween('invitee_user.created_at', [$pageStart . ' 00:00:00', $pageEnd . ' 23:59:59'])
+            ->selectRaw("DATE({$userAlias}.created_at) AS stat_date")
+            ->selectRaw("SUM(CASE WHEN {$loginAlias}.platform = ? THEN 1 ELSE 0 END) AS h5_registered", ['H5'])
+            ->selectRaw("SUM(CASE WHEN {$loginAlias}.platform = ? THEN 1 ELSE 0 END) AS ios_registered", ['iOS'])
+            ->selectRaw("SUM(CASE WHEN {$loginAlias}.platform = ? THEN 1 ELSE 0 END) AS android_registered", ['Android'])
+            ->groupByRaw("DATE({$userAlias}.created_at)")->get()->keyBy('stat_date');
+
+        $rows = $pageDates->map(function ($day) use ($basicRows, $metricRows, $platformRows) {
+            $basic = $basicRows->get($day);
+            $metric = $metricRows->get($day);
+            $platform = $platformRows->get($day);
+            $deposit = (float)($metric->deposit_total ?? 0);
+            $withdraw = (float)($metric->withdraw_total ?? 0);
+            $bonus = (float)($metric->bonus_total ?? 0);
+            return [
+                'date' => (string)$day,
+                'promoter_count' => (int)($basic->promoter_count ?? 0),
+                'registered_members' => (int)($basic->registered_members ?? 0),
+                'valid_members' => (int)($metric->valid_members ?? 0),
+                'h5_registered' => (int)($platform->h5_registered ?? 0),
+                'ios_registered' => (int)($platform->ios_registered ?? 0),
+                'android_registered' => (int)($platform->android_registered ?? 0),
+                'deposit_total' => number_format($deposit, 4, '.', ''),
+                'consume_total' => number_format((float)($metric->consume_total ?? 0), 4, '.', ''),
+                'company_profit' => number_format($deposit - $withdraw - $bonus, 4, '.', ''),
+            ];
+        });
+        return ['total' => $dates->count(), 'data' => $rows->values()];
+    }
+
+    public function promoterDetails(array $params): array
+    {
+        $day = $params['date'];
+        $promoterCodes = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+            ->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
+            ->select('agent_user_code')->distinct();
+        $query = User::query()->whereIn('user_code', $promoterCodes);
+        if (!empty($params['member_id'])) $query->where('member_id', $params['member_id']);
+        if (!empty($params['account'])) $query->where('account', 'like', '%' . $params['account'] . '%');
+        $total = $query->count();
+        $promoters = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+        $invitees = User::query()->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
+            ->whereIn('agent_user_code', $promoters->pluck('user_code'))
+            ->get(['id', 'member_id', 'agent_user_code']);
+        $records = ShareCommissionRecord::query()->where('stat_date', $day)->where('status', 1)
+            ->whereIn('promoter_user_id', $promoters->pluck('id'))->get()->groupBy('promoter_user_id');
+        $setting = ShareSetting::query()->first();
+        $metricsByMember = $this->moneyMetricsByMember($invitees->pluck('member_id')->all());
+        $rows = $promoters->map(function ($promoter) use ($invitees, $records, $setting, $day, $metricsByMember) {
+            $children = $invitees->where('agent_user_code', $promoter->user_code);
+            $metrics = $this->sumMetrics($metricsByMember, $children->pluck('member_id')->all());
+            $commission = $records->get($promoter->id, collect())->where('type', 'commission')->sum('amount');
+            return [
+                'date' => $day,
+                'member_id' => (string)$promoter->member_id,
+                'account' => (string)$promoter->account,
+                'nickname' => (string)$promoter->first_name,
+                'deposit_total' => $metrics['deposit'],
+                'registered_count' => $children->count(),
+                'commission' => number_format((float)$commission, 4, '.', ''),
+                'current_fee_rate' => $setting ? (string)$setting->tier_one_rate : '0.0000',
+            ];
+        });
+        return ['total' => $total, 'data' => $rows->values()];
+    }
+
+    public function registrationDetails(array $params): array
+    {
+        $query = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
+            ->whereBetween('created_at', [$params['date'] . ' 00:00:00', $params['date'] . ' 23:59:59']);
+        if (!empty($params['member_id'])) $query->where('member_id', $params['member_id']);
+        if (!empty($params['account'])) $query->where('account', 'like', '%' . $params['account'] . '%');
+        $total = $query->count();
+        $users = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+        $metricsByMember = $this->moneyMetricsByMember($users->pluck('member_id')->all());
+        $rows = $users->map(function ($user) use ($params, $metricsByMember) {
+            $metrics = $metricsByMember[(string)$user->member_id] ?? $this->emptyMetrics();
+            return [
+                'date' => $params['date'],
+                'member_id' => (string)$user->member_id,
+                'account' => (string)$user->account,
+                'nickname' => (string)$user->first_name,
+                'deposit_total' => $metrics['deposit'],
+                'bet_total' => $metrics['consume'],
+                'withdraw_total' => $metrics['withdraw'],
+            ];
+        });
+        return ['total' => $total, 'data' => $rows->values()];
+    }
+
+    public function summary(array $params): array
+    {
+        [$start, $end] = $this->dateRange($params);
+        $page = (int)($params['page'] ?? 1);
+        $limit = (int)($params['limit'] ?? 20);
+        $prefix = DB::getTablePrefix();
+        $recordAlias = $prefix . 'share_record';
+        $query = ShareCommissionRecord::query()->from('share_commission_records as share_record')
+            ->leftJoin('users as promoter', 'promoter.id', '=', 'share_record.promoter_user_id')
+            ->whereBetween('share_record.stat_date', [$start, $end])
+            ->where('share_record.status', 1);
+        if (!empty($params['account'])) $query->where('promoter.account', 'like', '%' . $params['account'] . '%');
+        $grouped = $query->select([
+            'share_record.stat_date', 'share_record.promoter_user_id',
+            'promoter.member_id as promoter_member_id', 'promoter.account as promoter_account',
+            'promoter.first_name as promoter_nickname',
+        ])->selectRaw("SUM(CASE WHEN {$recordAlias}.type = 'commission' THEN {$recordAlias}.base_amount ELSE 0 END) AS deposit")
+            ->selectRaw("SUM(CASE WHEN {$recordAlias}.type = 'commission' THEN {$recordAlias}.amount ELSE 0 END) AS commission")
+            ->selectRaw("SUM(CASE WHEN {$recordAlias}.type = 'fee_waiver' THEN {$recordAlias}.base_amount ELSE 0 END) AS sales_amount")
+            ->selectRaw("MAX(CASE WHEN {$recordAlias}.type = 'fee_waiver' THEN {$recordAlias}.rate ELSE 0 END) AS fee_waiver_rate")
+            ->groupBy([
+                'share_record.stat_date', 'share_record.promoter_user_id',
+                'promoter.member_id', 'promoter.account', 'promoter.first_name',
+            ]);
+        $total = DB::query()->fromSub(clone $grouped, 'share_summary')->count();
+        $rows = $grouped->orderByDesc('share_record.stat_date')->orderByDesc('share_record.promoter_user_id')
+            ->forPage($page, $limit)->get()->map(fn ($row) => [
+                'date' => $row->stat_date instanceof \DateTimeInterface
+                    ? $row->stat_date->format('Y-m-d')
+                    : substr((string)$row->stat_date, 0, 10),
+                'promoter_member_id' => (string)($row->promoter_member_id ?? ''),
+                'promoter_account' => (string)($row->promoter_account ?? ''),
+                'promoter_nickname' => (string)($row->promoter_nickname ?? ''),
+                'deposit' => number_format((float)$row->deposit, 4, '.', ''),
+                'commission' => number_format((float)$row->commission, 4, '.', ''),
+                'sales_amount' => number_format((float)$row->sales_amount, 4, '.', ''),
+                'fee_waiver_rate' => number_format((float)$row->fee_waiver_rate, 4, '.', ''),
+            ]);
+
+        $totalQuery = ShareCommissionRecord::query()->from('share_commission_records as share_record')
+            ->leftJoin('users as promoter', 'promoter.id', '=', 'share_record.promoter_user_id')
+            ->whereBetween('share_record.stat_date', [$start, $end])
+            ->where('share_record.status', 1);
+        if (!empty($params['account'])) $totalQuery->where('promoter.account', 'like', '%' . $params['account'] . '%');
+        $totals = $totalQuery->selectRaw("SUM(CASE WHEN {$recordAlias}.type = 'commission' THEN {$recordAlias}.amount ELSE 0 END) AS total_commission")
+            ->selectRaw("MAX(CASE WHEN {$recordAlias}.type = 'fee_waiver' THEN {$recordAlias}.rate ELSE 0 END) AS highest_fee_waiver_rate")->first();
+        return [
+            'total' => $total,
+            'data' => $rows->values(),
+            'summary' => [
+                'total_commission' => number_format((float)($totals->total_commission ?? 0), 4, '.', ''),
+                'highest_fee_waiver_rate' => number_format((float)($totals->highest_fee_waiver_rate ?? 0), 4, '.', ''),
+            ],
+        ];
+    }
+
+    private function moneyMetricsByMember(array $memberIds): array
+    {
+        $memberIds = array_values(array_unique(array_filter(array_map('strval', $memberIds))));
+        if (!$memberIds) return [];
+        $metrics = array_fill_keys($memberIds, $this->emptyMetrics());
+        $sets = [
+            'deposit' => fn ($query) => $query->whereIn('change_type', ['充值', '三方充值']),
+            'withdraw' => fn ($query) => $query->whereIn('change_type', ['提现', '三方提现', '人工提现']),
+            'consume' => fn ($query) => $query->where('change_type', 'like', '%投注%'),
+            'bonus' => fn ($query) => $query->whereIn('change_type', ['注册赠送', '优惠活动', '返水', '回水', '笔笔返', '比比返']),
+        ];
+        foreach ($sets as $field => $scope) {
+            $query = BalanceLog::query()->whereIn('member_id', $memberIds);
+            $rows = $scope($query)->groupBy('member_id')->get(['member_id', DB::raw('SUM(amount) as total')]);
+            foreach ($rows as $row) {
+                $value = (float)$row->total;
+                $metrics[(string)$row->member_id][$field] = number_format(in_array($field, ['withdraw', 'consume'], true) ? abs($value) : $value, 4, '.', '');
+            }
+        }
+        return $metrics;
+    }
+
+    private function sumMetrics(array $metricsByMember, array $memberIds): array
+    {
+        $totals = ['deposit' => 0.0, 'withdraw' => 0.0, 'consume' => 0.0, 'bonus' => 0.0];
+        foreach (array_unique(array_map('strval', $memberIds)) as $memberId) {
+            foreach ($totals as $field => $value) {
+                $totals[$field] += (float)($metricsByMember[$memberId][$field] ?? 0);
+            }
+        }
+        return array_map(fn ($value) => number_format($value, 4, '.', ''), $totals);
+    }
+
+    private function emptyMetrics(): array
+    {
+        return ['deposit' => '0.0000', 'withdraw' => '0.0000', 'consume' => '0.0000', 'bonus' => '0.0000'];
+    }
+
+    private function dateRange(array $params): array
+    {
+        $end = $params['end_date'] ?? date('Y-m-d');
+        $start = $params['start_date'] ?? date('Y-m-d', strtotime($end . ' -30 days'));
+        if (strtotime($start) > strtotime($end)) {
+            throw new \RuntimeException('结束日期不能早于开始日期');
+        }
+        if (strtotime($end) - strtotime($start) > 366 * 86400) {
+            throw new \RuntimeException('单次查询日期范围不能超过 366 天');
+        }
+        return [$start, $end];
+    }
+}

+ 8 - 0
config/operations.php

@@ -0,0 +1,8 @@
+<?php
+
+return [
+    'admin_ids' => array_values(array_filter(array_map(
+        'intval',
+        explode(',', (string) env('OPERATIONS_ADMIN_IDS', '1'))
+    ))),
+];

+ 213 - 0
database/migrations/2026_08_30_120000_create_operation_configuration_tables.php

@@ -0,0 +1,213 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('payment_direct_recharges', function (Blueprint $table) {
+            $table->id();
+            $table->string('recharge_code', 64)->unique()->comment('充值ID/前端识别码');
+            $table->string('name', 100);
+            $table->decimal('amount', 20, 2);
+            $table->decimal('fee_rate', 8, 4)->default(0)->comment('手续费百分比');
+            $table->tinyInteger('status')->default(1)->index();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+            $table->softDeletes();
+        });
+
+        Schema::create('payment_gateways', function (Blueprint $table) {
+            $table->id();
+            $table->string('kind', 16)->index()->comment('deposit/withdraw');
+            $table->string('currency', 16)->default('RMB');
+            $table->string('payment_company', 100);
+            $table->string('merchant_no', 128);
+            $table->string('payment_method', 64)->default('');
+            $table->string('channel_code', 64)->default('');
+            $table->string('channel_name', 100)->default('');
+            $table->decimal('fee_rate', 8, 4)->default(0);
+            $table->text('withdrawal_secret')->nullable();
+            $table->decimal('min_amount', 20, 2)->default(0);
+            $table->decimal('max_amount', 20, 2)->default(0);
+            $table->string('level_scope', 64)->default('all');
+            $table->unsignedInteger('sort')->default(0);
+            $table->tinyInteger('status')->default(1)->index();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+            $table->softDeletes();
+            $table->index(['kind', 'payment_company']);
+        });
+
+        Schema::create('payment_collection_channels', function (Blueprint $table) {
+            $table->id();
+            $table->string('kind', 24)->index()->comment('collection_scan/collection_third_party');
+            $table->string('currency', 16)->default('RMB');
+            $table->string('provider_name', 100);
+            $table->string('group_name', 100);
+            $table->string('collection_method', 64);
+            $table->string('channel_code', 64);
+            $table->string('channel_name', 100);
+            $table->decimal('fee_rate', 8, 4)->default(0);
+            $table->decimal('min_amount', 20, 2)->default(0);
+            $table->decimal('max_amount', 20, 2)->default(0);
+            $table->string('level_scope', 64)->default('all');
+            $table->unsignedInteger('sort')->default(0);
+            $table->tinyInteger('status')->default(1)->index();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+            $table->softDeletes();
+        });
+
+        Schema::create('operation_audits', function (Blueprint $table) {
+            $table->id();
+            $table->string('resource_type', 64)->index();
+            $table->unsignedBigInteger('resource_id')->nullable()->index();
+            $table->string('action', 32);
+            $table->json('changes')->nullable();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamp('created_at')->useCurrent()->index();
+            $table->index(['resource_type', 'resource_id', 'created_at'], 'idx_operation_audits_resource');
+        });
+
+        Schema::create('app_settings', function (Blueprint $table) {
+            $table->id();
+            $table->tinyInteger('ios_frontend_visible')->default(0);
+            $table->tinyInteger('android_frontend_visible')->default(0);
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+        });
+
+        Schema::create('app_packages', function (Blueprint $table) {
+            $table->id();
+            $table->string('platform', 16)->index();
+            $table->string('version', 50);
+            $table->string('package_name', 150);
+            $table->tinyInteger('force_update')->default(0);
+            $table->string('package_type', 32);
+            $table->string('download_url', 1000);
+            $table->text('update_content')->nullable();
+            $table->tinyInteger('status')->default(1)->index();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+            $table->softDeletes();
+            $table->index(['platform', 'version']);
+        });
+
+        Schema::create('app_download_events', function (Blueprint $table) {
+            $table->id();
+            $table->string('event_key', 64)->unique()->comment('客户端事件幂等键哈希');
+            $table->unsignedBigInteger('package_id')->nullable()->index();
+            $table->string('event_type', 16)->index()->comment('click/open');
+            $table->string('platform', 16)->index();
+            $table->string('package_type', 32)->default('');
+            $table->string('source', 100)->default('direct');
+            $table->string('download_url', 1000)->default('')->comment('事件发生时下载链接快照');
+            $table->string('app_version', 50)->default('');
+            $table->string('device_id_hash', 64)->default('')->index();
+            $table->string('ip_hash', 64)->default('');
+            $table->timestamp('occurred_at')->useCurrent()->index();
+            $table->index(['event_type', 'occurred_at']);
+            $table->index(['ip_hash', 'occurred_at']);
+            $table->index(['device_id_hash', 'occurred_at']);
+        });
+
+        Schema::create('app_ios_review_settings', function (Blueprint $table) {
+            $table->id();
+            $table->string('platform', 16)->default('ios');
+            $table->string('store_version', 50)->index();
+            $table->string('operating_version', 50);
+            $table->json('review_user_ids')->nullable();
+            $table->tinyInteger('status')->default(1)->index();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+            $table->softDeletes();
+        });
+
+        Schema::create('share_settings', function (Blueprint $table) {
+            $table->id();
+            $table->tinyInteger('enabled')->default(0);
+            $table->string('share_domain', 500)->default('');
+            $table->decimal('valid_member_min_deposit', 20, 2)->default(20);
+            $table->tinyInteger('benefit_enabled')->default(0);
+            $table->decimal('non_seller_threshold', 20, 2)->default(200);
+            $table->decimal('tier_one_rate', 8, 4)->default(4);
+            $table->decimal('tier_two_rate', 8, 4)->default(6);
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+        });
+
+        Schema::create('share_blacklists', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('user_id')->index();
+            $table->string('member_id', 64)->index();
+            $table->json('blocked_categories');
+            $table->tinyInteger('status')->default(1)->index();
+            $table->unsignedBigInteger('operator_id')->nullable();
+            $table->string('operator_name', 100)->default('');
+            $table->timestamps();
+            $table->softDeletes();
+            $table->unique('user_id');
+        });
+
+        Schema::create('share_commission_records', function (Blueprint $table) {
+            $table->id();
+            $table->date('stat_date')->index();
+            $table->unsignedBigInteger('promoter_user_id')->index();
+            $table->unsignedBigInteger('invitee_user_id')->nullable()->index();
+            $table->string('type', 24)->comment('commission/fee_waiver');
+            $table->string('category', 32)->default('');
+            $table->decimal('base_amount', 20, 4)->default(0);
+            $table->decimal('rate', 8, 4)->default(0);
+            $table->decimal('amount', 20, 4)->default(0);
+            $table->string('source_type', 64)->default('');
+            $table->string('source_id', 100)->default('');
+            $table->tinyInteger('status')->default(1);
+            $table->timestamps();
+            $table->index(['promoter_user_id', 'stat_date', 'type'], 'idx_share_commission_promoter');
+            $table->unique(['source_type', 'source_id', 'type', 'promoter_user_id'], 'uniq_share_commission_source');
+        });
+
+        DB::table('app_settings')->insert([
+            'ios_frontend_visible' => 0,
+            'android_frontend_visible' => 0,
+            'created_at' => now(),
+            'updated_at' => now(),
+        ]);
+        DB::table('share_settings')->insert([
+            'enabled' => 0,
+            'share_domain' => '',
+            'valid_member_min_deposit' => 20,
+            'benefit_enabled' => 0,
+            'non_seller_threshold' => 200,
+            'tier_one_rate' => 4,
+            'tier_two_rate' => 6,
+            'created_at' => now(),
+            'updated_at' => now(),
+        ]);
+    }
+
+    public function down(): void
+    {
+        foreach ([
+            'share_commission_records', 'share_blacklists', 'share_settings',
+            'app_ios_review_settings', 'app_download_events', 'app_packages', 'app_settings',
+            'operation_audits', 'payment_collection_channels', 'payment_gateways',
+            'payment_direct_recharges',
+        ] as $table) {
+            Schema::dropIfExists($table);
+        }
+    }
+};

+ 44 - 0
database/migrations/2026_08_31_120000_link_payment_configs_to_recharge_channels.php

@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    private const TABLES = [
+        'payment_direct_recharges',
+        'payment_gateways',
+        'payment_collection_channels',
+    ];
+
+    public function up(): void
+    {
+        foreach (self::TABLES as $tableName) {
+            if (!Schema::hasTable($tableName)) continue;
+            Schema::table($tableName, function (Blueprint $table) use ($tableName) {
+                if (!Schema::hasColumn($tableName, 'recharge_channel_id')) {
+                    $table->unsignedBigInteger('recharge_channel_id')->nullable()->after('id')->index();
+                }
+                if (!Schema::hasColumn($tableName, 'recharge_channel_group_ids')) {
+                    $table->json('recharge_channel_group_ids')->nullable()->after('recharge_channel_id');
+                }
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (self::TABLES as $tableName) {
+            if (!Schema::hasTable($tableName)) continue;
+            Schema::table($tableName, function (Blueprint $table) use ($tableName) {
+                if (Schema::hasColumn($tableName, 'recharge_channel_group_ids')) {
+                    $table->dropColumn('recharge_channel_group_ids');
+                }
+                if (Schema::hasColumn($tableName, 'recharge_channel_id')) {
+                    $table->dropColumn('recharge_channel_id');
+                }
+            });
+        }
+    }
+};

+ 53 - 0
docs/前端接口/APP配置/IOS上架设置/接口.md

@@ -0,0 +1,53 @@
+# APP配置 / IOS上架设置
+
+## 后台列表
+
+`GET /admin/appConfig/iosReviews?page=1&limit=20&start_date=2026-08-01&end_date=2026-08-30&status=1`
+
+请求头:`Authorization: Bearer <admin_token>`。
+
+| 字段 | 中文含义 |
+|---|---|
+| `platform` | 固定 `ios` |
+| `store_version` | 上架版本,如 `20.0` |
+| `operating_version` | 正式运营版本,如 `123` |
+| `review_user_ids` | 提供给审核人员的会员 ID 数组 |
+| `status` | `1` 启用,`0` 停用 |
+| `created_at/updated_at` | 创建/修改时间 |
+
+## 新增或编辑
+
+`POST /admin/appConfig/iosReviews/save`
+
+```json
+{
+  "id": 1,
+  "store_version": "20.0",
+  "operating_version": "123",
+  "review_user_ids": ["66811", "66812", "66813"],
+  "status": 1
+}
+```
+
+`id` 不传为新增。状态:`POST /admin/appConfig/iosReviews/status`,请求 `{"id":1,"status":0}`。删除:`POST /admin/appConfig/iosReviews/delete`,请求 `{"id":1}`。
+
+操作记录:`GET /admin/appConfig/logs?resource_type=app_ios_review_setting&resource_id=1&page=1&limit=20`。
+
+## iOS 客户端判断审核模式
+
+`GET /api/app_config/iosReview?store_version=20.0`
+
+可不登录;若配置了 `review_user_ids`,必须携带该审核会员的有效登录 Token 才能命中。匿名请求不会接受查询参数伪造会员 ID。`review_user_ids` 为空时,该上架版本的所有客户端都会进入审核模式。
+
+```json
+{
+  "code": 1,
+  "data": {
+    "review_mode": 1,
+    "store_version": "20.0",
+    "operating_version": "123"
+  }
+}
+```
+
+`review_mode=1`:命中启用的上架版本,且审核用户列表为空或当前会员在列表内。`review_mode=0`:使用正常运营内容。

+ 63 - 0
docs/前端接口/APP配置/下载统计/接口.md

@@ -0,0 +1,63 @@
+# APP配置 / 下载统计
+
+统计由 APP/下载页先上报事件,后台再读取汇总。
+
+## 上报下载点击或 APP 启动
+
+`POST /api/app_config/trackDownload`
+
+无需登录。JSON 请求:
+
+```json
+{
+  "event_id": "018fe28b-4ab2-7d91-a165-3359f598b91d",
+  "package_id": 1,
+  "event_type": "click",
+  "platform": "android",
+  "package_type": "android_apk",
+  "source": "baidu",
+  "app_version": "123",
+  "device_id": "客户端安装标识"
+}
+```
+
+| 参数 | 必填 | 中文含义 |
+|---|---|---|
+| `event_id` | 是 | 客户端每次事件生成的唯一 ID;重试必须复用同一值,允许字母、数字、`.`、`_`、`:`、`-` |
+| `package_id` | 点击必填 | 后台安装包 ID;传入时必须是启用状态 |
+| `event_type` | 是 | `click` 点击下载链接;`open` 下载后启动 APP |
+| `platform` | 是 | `android` 或 `ios`;有 `package_id` 时后端以安装包记录为准 |
+| `package_type` | 启动事件必填 | 无 `package_id` 的启动事件需要传安装包类型,必须与平台匹配 |
+| `source` | 否 | 来源,默认 `direct`;如 `baidu`、`google_ads` |
+| `app_version` | 否 | 当前 APP 版本 |
+| `device_id` | 是 | 客户端安装标识;服务端仅保存 SHA-256,不保存原值 |
+
+建议:下载按钮点击时上报一次 `click`;APP 首次启动时上报一次 `open`。相同 `event_id` 的重试返回 `duplicate=1`,不会重复计数。接口同时限制单 IP 每分钟 120 次、单设备每分钟 60 次。
+
+成功响应中的 `data.duplicate`:`0` 表示本次新记录,`1` 表示相同事件已记录、本次为幂等重试。
+
+## 后台汇总
+
+`GET /admin/appConfig/downloadStats?start_date=2026-08-01&end_date=2026-08-30&page=1&limit=20`
+
+请求头:`Authorization: Bearer <admin_token>`。
+
+```json
+{
+  "code": 0,
+  "data": {
+    "start_date": "2026-08-01",
+    "end_date": "2026-08-30",
+    "clicks": {
+      "total": 1,
+      "data": [{"platform":"android","source":"baidu","download_url":"https://show2.cc/ywx.apk","click_count":19}]
+    },
+    "opens": {
+      "total": 1,
+      "data": [{"platform":"android","package_type":"android_apk","open_count":213}]
+    }
+  }
+}
+```
+
+日期不传默认最近 31 天,单次最多查询 366 天。`clicks` 和 `opens` 共用 `page/limit`,各自返回 `total/data`。点击记录保存事件发生时的下载链接快照,之后修改安装包链接不会改写历史统计。

+ 85 - 0
docs/前端接口/APP配置/安装包设置/接口.md

@@ -0,0 +1,85 @@
+# APP配置 / 安装包设置
+
+后台接口在 `bot-28`,前缀 `/admin`;请求头 `Authorization: Bearer <admin_token>`。APP 读取接口在 `melbet_sport-api`,前缀 `/api`。
+
+## 前台显示开关
+
+- `GET /admin/appConfig/display`
+- `POST /admin/appConfig/display/save`
+
+```json
+{
+  "ios_frontend_visible": 1,
+  "android_frontend_visible": 1
+}
+```
+
+字段值:`1` 前台显示,`0` 前台隐藏。
+
+## 安装包列表
+
+`GET /admin/appConfig/packages?page=1&limit=20&platform=android&status=1`
+
+| 字段 | 中文含义 |
+|---|---|
+| `platform` | `android` 安卓;`ios` 苹果 |
+| `version` | 版本号,字符串 |
+| `package_name` | 安装包名 |
+| `force_update` | `1` 强制更新,`0` 普通更新 |
+| `package_type` | `android_apk/testflight/app_store/enterprise/web` |
+| `download_url` | 安装包或上架页链接 |
+| `update_content` | 更新内容 |
+| `status` | `1` 启用,`0` 停用 |
+| `created_at/updated_at` | 创建/修改时间 |
+
+## 新增或编辑
+
+`POST /admin/appConfig/packages/save`
+
+```json
+{
+  "id": 1,
+  "platform": "android",
+  "version": "123",
+  "package_name": "ywx.apk",
+  "force_update": 1,
+  "package_type": "android_apk",
+  "download_url": "https://show2.cc/ywx.apk",
+  "update_content": "1. 更新了首页,提升用户体验",
+  "status": 1
+}
+```
+
+`id` 不传为创建。状态:`POST /admin/appConfig/packages/status`,请求 `{"id":1,"status":0}`。删除:`POST /admin/appConfig/packages/delete`,请求 `{"id":1}`。
+
+操作记录:`GET /admin/appConfig/logs?resource_type=app_package&resource_id=1&page=1&limit=20`。
+
+## APP 获取安装包
+
+`GET /api/app_config/packages?platform=android&current_version=122`
+
+无需登录。成功码使用 `melbet_sport-api` 规则:`code=1`。
+
+```json
+{
+  "code": 1,
+  "data": {
+    "frontend_visible": 1,
+    "packages": [
+      {
+        "id": 1,
+        "platform": "android",
+        "version": "123",
+        "package_name": "ywx.apk",
+        "force_update": 1,
+        "package_type": "android_apk",
+        "download_url": "https://show2.cc/ywx.apk",
+        "update_content": "1. 更新了首页",
+        "update_required": 1
+      }
+    ]
+  }
+}
+```
+
+`update_required=1` 表示安装包版本高于客户端传入版本;是否强制弹窗仍同时判断 `force_update=1`。

+ 43 - 0
docs/前端接口/分享赚钱/推广会员列表/接口.md

@@ -0,0 +1,43 @@
+# 分享赚钱 / 推广会员列表
+
+后台请求头:`Authorization: Bearer <admin_token>`。成功码 `code=0`。
+
+## 按注册日汇总
+
+`GET /admin/shareEarning/members?start_date=2026-08-01&end_date=2026-08-30&page=1&limit=20`
+
+日期不传时默认最近 31 天。
+
+| 返回字段 | 中文含义 |
+|---|---|
+| `date` | 被邀请会员注册日期 |
+| `promoter_count` | 当日产生注册的推广会员数 |
+| `registered_members` | 当日邀请码注册会员数 |
+| `valid_members` | 累计存款达到“有效会员最低存款”的注册会员数 |
+| `h5_registered/ios_registered/android_registered` | 会员首次成功登录端统计 |
+| `deposit_total` | 这些注册会员累计充值 |
+| `consume_total` | 这些会员累计投注扣款 |
+| `company_profit` | 充值-提现-优惠/返水 |
+
+## 推广会员详情弹窗
+
+`GET /admin/shareEarning/members/promoters?date=2026-08-30&page=1&limit=20`
+
+可追加 `member_id`、`account` 查询。返回:推广会员 ID、账号、昵称、其当日新注册会员的累计存款、注册人数、已落库佣金、当前一级比例。
+
+主要字段:`member_id`、`account`、`nickname`、`deposit_total`、`registered_count`、`commission`、`current_fee_rate`。
+
+## 注册会员详情弹窗
+
+`GET /admin/shareEarning/members/registrations?date=2026-08-30&page=1&limit=20`
+
+可追加 `member_id`、`account`。返回字段:`date`、`member_id`、`account`、`nickname`、`deposit_total`、`bet_total`、`withdraw_total`。
+
+## APP 会员查看自己的邀请数据
+
+请求头:`Authorization: <user_token>`,`melbet_sport-api` 成功码为 `code=1`。
+
+- `GET /api/share/profile`:返回 `invitation_code`、`share_url`、`registered_count`、`commission_total`。
+- `GET /api/share/invitees?page=1&limit=20`:返回自己的邀请码注册会员和累计充值。
+
+会员关联使用现有字段:邀请人 `user_code`,被邀请人 `agent_user_code`;不会使用可猜测的数据库用户 ID。

+ 53 - 0
docs/前端接口/分享赚钱/推广汇总/接口.md

@@ -0,0 +1,53 @@
+# 分享赚钱 / 推广汇总
+
+`GET /admin/shareEarning/summary`
+
+请求头:`Authorization: Bearer <admin_token>`。
+
+查询参数:
+
+| 参数 | 必填 | 中文含义 |
+|---|---|---|
+| `start_date/end_date` | 否 | 日期范围,默认最近 31 天 |
+| `account` | 否 | 推广账号,模糊查询 |
+| `page/limit` | 否 | 页码/每页条数 |
+
+响应:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "date": "2026-08-30",
+        "promoter_member_id": "10001",
+        "promoter_account": "qq900",
+        "promoter_nickname": "原味大哥",
+        "deposit": "20.0000",
+        "commission": "12.0000",
+        "sales_amount": "0.0000",
+        "fee_waiver_rate": "0.0000"
+      }
+    ],
+    "summary": {
+      "total_commission": "12.0000",
+      "highest_fee_waiver_rate": "0.0000"
+    }
+  }
+}
+```
+
+字段说明:
+
+| 字段 | 中文含义 |
+|---|---|
+| `deposit` | 佣金记录对应的计算基数 |
+| `commission` | 已生成的推广佣金 |
+| `sales_amount` | 手续费减免记录对应的销售额/订单额 |
+| `fee_waiver_rate` | 当日最高手续费减免比例 |
+| `total_commission` | 筛选区间佣金合计 |
+| `highest_fee_waiver_rate` | 筛选区间最高减免比例 |
+
+该接口只汇总 `share_commission_records` 中已经生成的记录,不根据截图样例临时计算或直接改会员余额。

+ 41 - 0
docs/前端接口/分享赚钱/推广设定/接口.md

@@ -0,0 +1,41 @@
+# 分享赚钱 / 推广设定
+
+## 后台读取和保存
+
+- `GET /admin/shareEarning/settings`
+- `POST /admin/shareEarning/settings/save`
+
+请求头:`Authorization: Bearer <admin_token>`。
+
+```json
+{
+  "enabled": 1,
+  "share_domain": "https://show888.com/register",
+  "valid_member_min_deposit": 20,
+  "benefit_enabled": 1,
+  "non_seller_threshold": 200,
+  "tier_one_rate": 4,
+  "tier_two_rate": 6
+}
+```
+
+| 字段 | 中文含义 |
+|---|---|
+| `enabled` | 分享赚钱开关,同时用于前台显示 |
+| `share_domain` | 分享注册链接域名/完整注册页地址 |
+| `valid_member_min_deposit` | 有效会员最低累计存款 |
+| `benefit_enabled` | 佣金/手续费减免设置开关 |
+| `non_seller_threshold` | 非卖家单笔订单阈值 |
+| `tier_one_rate` | 第一阶梯百分比 |
+| `tier_two_rate` | 第二阶梯百分比 |
+
+百分比范围均为 0~100。分享链接由后端拼接 `user_code=<当前会员邀请码>`。
+`share_domain` 可传 `show888.com/register` 或完整 `https://show888.com/register`,后端会补齐 HTTPS 并保存规范化地址。
+
+## APP 读取设置
+
+`GET /api/share/config`
+
+无需登录,成功码 `code=1`。返回上述设置字段;APP 应在 `enabled=0` 时隐藏分享赚钱入口。
+
+登录会员通过 `GET /api/share/profile` 获取服务端生成的 `share_url`,不要在客户端自行拼数据库用户 ID。

+ 52 - 0
docs/前端接口/分享赚钱/禁止推广列表/接口.md

@@ -0,0 +1,52 @@
+# 分享赚钱 / 禁止推广列表
+
+后台请求头:`Authorization: Bearer <admin_token>`。
+
+## 列表
+
+`GET /admin/shareEarning/blacklists?page=1&limit=20&account=yad123`
+
+列表项:
+
+| 字段 | 中文含义 |
+|---|---|
+| `id` | 记录 ID |
+| `member_id/account/nickname` | 禁止推广会员信息 |
+| `blocked_categories` | 不能返佣的类目数组 |
+| `status` | `1` 生效,`0` 停用 |
+| `operator_id/operator_name` | 最近操作人 |
+
+类目枚举:`sports` 体育、`lottery` 彩票、`third_party_game` 第三方游戏。
+
+## 新增或修改
+
+`POST /admin/shareEarning/blacklists/save`
+
+新增:
+
+```json
+{
+  "account": "yad123",
+  "blocked_categories": ["sports", "lottery"],
+  "status": 1
+}
+```
+
+修改:
+
+```json
+{
+  "id": 1,
+  "blocked_categories": ["sports"],
+  "status": 1
+}
+```
+
+账号必须存在;同一会员只能有一条未删除记录。
+
+## 状态和删除
+
+- `POST /admin/shareEarning/blacklists/status`:`{"id":1,"status":0}`。
+- `POST /admin/shareEarning/blacklists/delete`:`{"id":1}`,软删除。
+
+当前类目限制供佣金结算流程判断“该会员该类目是否返佣”,不限制会员正常进入游戏或下注。

+ 58 - 0
docs/前端接口/实现差异与未完成项.md

@@ -0,0 +1,58 @@
+# 实现差异与未完成项
+
+## 已按截图实现
+
+- 支付配置:扫描直充、存款/出款网关、代收扫描/代收三方的列表、新增、编辑、状态、软删除、操作记录。
+- APP配置:前台显示开关、安装包 CRUD、下载点击/启动事件统计、iOS 上架设置及 APP 读取接口。
+- 分享赚钱:按现有会员邀请码 `user_code -> agent_user_code` 统计注册关系,提供推广会员列表、注册详情、推广汇总、推广设置、禁止返佣类目和会员端分享链接。
+- 所有支付密钥字段只允许写入,使用 Laravel 加密字段保存;列表、保存响应、操作记录都不返回明文。
+- 支付配置已关联现有 `recharge_channel` 和 `recharge_channel_group`;费率、限额、排序、通道启停和会员组合不再由支付配置重复维护。
+
+## 与截图有意调整
+
+1. 截图的支付“充值ID”看起来是纯数字,但接口使用字符串 `recharge_code`,避免前导 0、超长编号或第三方字母编号丢失。
+2. 截图用“层级”文字;实际系统对应充值通道的“层级设置/通道组合”,接口使用 `recharge_channel_group_ids`,不是会员等级,也不再使用独立 `level_scope`。
+3. 截图把“代收扫描/代收三方”放在同一 Tab 内;接口使用 `kind=collection_scan/collection_third_party` 区分,字段保持一致。
+4. iOS 截图右侧部分列被遮挡。实现补充了 `status`、最近操作人和操作记录,平台固定为 `ios`。
+5. 下载统计不是安装包配置创建后的历史推算。上线后由下载页/APP 调用事件上报接口,统计从上报时刻开始累计;事件要求唯一 `event_id`,并带频率限制和下载链接快照。
+6. 注册端统计使用会员注册后的第一条成功登录记录判定 `H5/iOS/Android`;旧会员无登录日志时不会被猜测归类。
+
+## 当前不能安全替代线上逻辑的部分
+
+### 1. 支付商真实下单/回调
+
+现有系统的 JD、NO、808 等支付服务各自使用不同网关地址、签名字段、回调验签和错误码。截图只有通用表单,没有提供每家支付商的请求协议,因此本次完成的是后台配置和审计 API,**没有让通用配置表直接替换现有线上支付服务**。
+
+要完成动态切换,需要每个支付公司的:
+
+- 代收、代付创建订单地址;
+- 商户号、密钥和签名算法字段映射;
+- 查单、回调验签、幂等规则;
+- 支付方式/通道编号与现有 `RechargeChannel` 的映射;
+- 测试商户和成功/失败回调样例。
+
+在这些资料齐全前,前端可以管理配置,但不能把“保存成功”理解成该支付公司已经可真实下单。
+
+### 2. 分享佣金自动结算和入账
+
+已创建 `share_commission_records` 明细表并提供汇总读取接口,但没有根据截图示例自动生成资金佣金,也没有直接修改会员钱包。原因是截图未确定:
+
+- 一级/二级比例分别作用于充值、有效投注还是平台手续费;
+- “非卖家单笔阈值”“销售额”“手续费减免”的精确公式;
+- 体育、彩票、第三方游戏订单的有效/取消/退款口径;
+- 佣金何时结算、撤单时如何冲正、是否需要审核;
+- 佣金入会员钱包还是独立推广钱包。
+
+资金规则确认后,应新增幂等结算任务,以业务订单 ID 作为唯一来源,并在同一数据库事务内写佣金记录、钱包余额和资金流水。禁止推广列表已经按类目持久化,可在该任务中使用。
+
+## 部署和验证边界
+
+1. 先备份共享数据库,再在 `bot-28` 执行:
+
+   ```bash
+   php artisan migrate
+   ```
+
+2. 同时发布 `bot-28` 和 `melbet_sport-api`;两者必须连接同一个包含 `bot_` 表的数据库。
+3. `bot-28` 当前工作副本没有 `.env`、`vendor` 和数据库连接,因此本次只完成 PHP 语法、路由源码和静态差异检查;未在此环境执行迁移、真实后台鉴权请求或支付/钱包集成测试。
+4. 后台菜单由前端维护,本次没有写菜单数据。新接口另有 `operations.admin` 白名单保护,默认只有管理员 ID 1;生产环境用 `OPERATIONS_ADMIN_IDS=1,2` 配置允许人员,并清理配置缓存。还应把新 URI 配进现有按钮权限,不能只靠隐藏菜单控制权限。

+ 52 - 0
docs/前端接口/支付配置/与充值通道关联说明.md

@@ -0,0 +1,52 @@
+# 支付配置与充值通道的关联
+
+## 数据职责
+
+支付相关页面必须共用现有充值通道体系,不能分别维护两份费率和限额。
+
+```text
+会员.recharge_channel_group_id
+  -> recharge_channel_group(充值/提现/活动 type 组合)
+  -> recharge_channel(名称、Key、type、费率、限额、固定金额、排序、状态)
+  -> payment_*(商户号、密钥、支付商通道编号、展示名称等扩展配置)
+```
+
+权威字段:
+
+| 字段 | 唯一维护位置 |
+|---|---|
+| 通道名称、Key、type | 充值通道 / 通道管理 |
+| 费率、最小/最大金额、固定金额 | 充值通道 / 通道管理 |
+| 排序、实际通道启停 | 充值通道 / 通道管理 |
+| 哪些会员组合可见 | 充值通道 / 层级设置 |
+| 支付公司、商户号、出款密钥、第三方通道编号 | 支付配置 |
+| 扫描直充金额档位和名称 | 支付配置 / 扫描直充 |
+
+充值通道现有接口保持不变:
+
+- `GET /admin/rechargeChannel/list?data_type=1`:充值通道;`data_type=2`:提现通道;`data_type=3`:活动通道。
+- `POST /admin/rechargeChannel/update`:修改通道费率、限额、固定金额、排序和实际启停。
+- `GET /admin/rechargeChannel/groupList`:通道组合列表。
+- `POST /admin/rechargeChannel/updateGroup`:修改组合包含的充值/提现/活动 `type`。
+
+## 前端页面调整
+
+1. 新增/编辑支付配置时先请求 `GET /admin/paymentConfig/options`。
+2. 根据 Tab 过滤 `recharge_channels`:
+   - 存款网关、代收、扫描直充:`data_type=1`;
+   - 出款网关:`data_type=2`。
+3. 选择通道后,只展示该通道 `available_group_ids` 指向的 `channel_groups`。
+4. 费率、限额、固定金额、排序、`channel_status` 在支付配置表单中只读;需要修改时跳转充值通道菜单。
+5. `status` 是支付商扩展配置状态;最终是否有效看 `effective_status`。其计算同时要求:
+   - `config_status=1`;
+   - `channel_status=1`;
+   - 至少一个所选组合仍包含该通道。
+   `runtime_channel_available` 只表示现有充值/提现接口实际允许该通道;当前支付商仍使用环境变量时,`config_status` 不会单独关闭线上通道,实际启停必须修改充值通道的 `channel_status`。
+6. 若返回 `unavailable_group_ids`,说明层级设置后来移除了该通道,前端应显示“组合已失效”并要求重新选择。
+7. 旧支付配置若尚未关联通道,会返回 `recharge_channel=null`、`effective_status=0`;必须编辑并选择通道及组合后才能视为有效配置。
+
+## 与现有用户接口的关系
+
+现有 `GET /api/wallet/channel`、`GET /api/wallet/withdrawChannel`、创建充值和提现接口仍以 `RechargeChannel::getFormatChannel/checkRechargeChannel/checkWithdrawChannel` 为准。此次同时补强了服务端校验:被停用或不属于会员组合的通道不能通过直接请求绕过。
+
+支付商真实下单和回调仍由现有 JD、NO、808、三斤、钱宝服务完成;支付配置的商户扩展字段在各支付商协议接入数据库配置前,不会取代现有环境变量。

+ 63 - 0
docs/前端接口/支付配置/代收代付/接口.md

@@ -0,0 +1,63 @@
+# 支付配置 / 代收代付
+
+后台接口前缀:`/admin`。请求头:`Authorization: Bearer <admin_token>`。
+
+下拉选项使用 `GET /admin/paymentConfig/options`。通道选择使用 `recharge_channels` 中 `data_type=1` 的记录;层级使用 `channel_groups`,兼容字段 `levels` 同样表示通道组合。
+
+## 列表
+
+`GET /admin/paymentConfig/collections`
+
+| 参数 | 类型 | 必填 | 中文含义 |
+|---|---|---|---|
+| `kind` | string | 是 | `collection_scan` 代收扫描;`collection_third_party` 代收三方 |
+| `provider_name` | string | 否 | 代收三方名称 |
+| `collection_method` | string | 否 | 收款方式,如微信、支付宝、UPI |
+| `recharge_channel_id` | int | 否 | 关联充值通道 ID |
+| `group_id` | int | 否 | 关联通道组合 ID |
+| `status` | int | 否 | `1` 启用,`0` 停用 |
+| `page/limit` | int | 否 | 页码/每页条数 |
+
+返回 `data.total` 和 `data.data`。列表项:
+
+| 字段 | 中文含义 |
+|---|---|
+| `provider_name` | 代收三方 |
+| `group_name` | 群名称 |
+| `collection_method` | 收款方式 |
+| `channel_code/channel_name` | 通道编号/通道名称 |
+| `recharge_channel` | 充值通道菜单中的权威通道配置 |
+| `fee_rate/min_amount/max_amount/sort` | 从关联通道实时读取,只读 |
+| `groups` | 关联组合及有效状态 |
+| `config_status/channel_status/runtime_channel_available/effective_status` | 扩展配置状态/通道状态/线上通道可用/后台配置最终状态 |
+| `operator_id/operator_name` | 最近操作人 |
+
+## 新增或编辑
+
+`POST /admin/paymentConfig/collections/save`
+
+```json
+{
+  "id": 1,
+  "recharge_channel_id": 2,
+  "recharge_channel_group_ids": [1, 2],
+  "kind": "collection_third_party",
+  "currency": "RMB",
+  "provider_name": "富基代收",
+  "group_name": "AA支付",
+  "collection_method": "微信",
+  "channel_code": "90",
+  "channel_name": "支付1",
+  "status": 1
+}
+```
+
+`id` 不传为新增。关联通道必须是充值通道,且所有选中组合都必须包含其 `type`。费率、限额、排序及通道启停统一在“充值通道”菜单维护。
+
+## 状态、删除、操作记录
+
+- `POST /admin/paymentConfig/collections/status`:`{"id":1,"status":0}`。
+- `POST /admin/paymentConfig/collections/delete`:`{"id":1}`。
+- `GET /admin/paymentConfig/logs?resource_type=payment_collection_channel&resource_id=1&page=1&limit=20`
+
+统一成功码为 `code=0`;校验或业务错误为非 0,错误文字在 `msg`。

+ 68 - 0
docs/前端接口/支付配置/扫描直充/接口.md

@@ -0,0 +1,68 @@
+# 支付配置 / 扫描直充
+
+后台接口前缀:`/admin`。请求头:`Authorization: Bearer <admin_token>`。
+
+扫描直充必须关联“充值通道”菜单中的充值通道和通道组合。费率、限额、排序及实际通道状态只从充值通道读取,本菜单不重复保存。
+
+统一成功返回:`{"code":0,"msg":"OK","data":...}`;分页数据为 `data.total`、`data.data`。
+
+## 列表
+
+`GET /admin/paymentConfig/direct`
+
+查询参数:
+
+| 参数 | 类型 | 必填 | 中文含义 |
+|---|---|---|---|
+| `page` | int | 否 | 页码,默认 1 |
+| `limit` | int | 否 | 每页条数,默认 20,最大 200 |
+| `start_date` | date | 否 | 创建开始日期,`YYYY-MM-DD` |
+| `end_date` | date | 否 | 创建结束日期 |
+| `name` | string | 否 | 充值名称,模糊查询 |
+| `recharge_channel_id` | int | 否 | 关联的充值通道 ID |
+| `group_id` | int | 否 | 关联的通道组合 ID |
+| `status` | int | 否 | 支付配置自身状态 |
+
+列表项字段:
+
+| 字段 | 中文含义 |
+|---|---|
+| `id` | 数据主键 |
+| `recharge_code` | 充值 ID/前端识别码 |
+| `name` | 充值名称 |
+| `amount` | 充值金额 |
+| `recharge_channel_id` | 关联充值通道 ID |
+| `recharge_channel_group_ids` | 生效的通道组合 ID 数组 |
+| `recharge_channel` | 通道名称、Key、类型、原始费率、百分比费率、限额、排序、状态 |
+| `fee_rate` | 从充值通道实时换算的百分比,如通道 `rate=0.03` 返回 `3.0000` |
+| `actual_amount` | 实际到账金额,后端计算 |
+| `config_status/channel_status/runtime_channel_available/effective_status` | 配置状态/通道状态/线上通道可用/后台配置最终状态 |
+| `effective_group_ids/unavailable_group_ids` | 当前仍包含该通道/已失效的组合 ID |
+| `operator_id/operator_name` | 最近操作人 |
+| `created_at/updated_at` | 创建/修改时间 |
+
+## 新增或编辑
+
+`POST /admin/paymentConfig/direct/save`
+
+```json
+{
+  "id": 1,
+  "recharge_channel_id": 6,
+  "recharge_channel_group_ids": [1, 2],
+  "recharge_code": "2025698",
+  "name": "到账金额153.56",
+  "amount": 698,
+  "status": 1
+}
+```
+
+`id` 不传为新增;编辑时必传。`amount` 必须大于 0。所选通道必须是 `data_type=1` 的充值通道;每个组合的 `recharge_type` 必须包含该通道的 `type`。
+
+## 状态、删除、操作记录
+
+- `POST /admin/paymentConfig/direct/status`:`{"id":1,"status":0}`。
+- `POST /admin/paymentConfig/direct/delete`:`{"id":1}`,软删除。
+- `GET /admin/paymentConfig/logs?resource_type=payment_direct_recharge&resource_id=1&page=1&limit=20`
+
+操作记录可追加 `start_date`、`end_date`、`operator_name`。返回项的 `action` 为 `create/update/status/delete`,`changes.before/after` 为修改前后字段。

+ 73 - 0
docs/前端接口/支付配置/网关支付/接口.md

@@ -0,0 +1,73 @@
+# 支付配置 / 网关支付
+
+后台接口前缀:`/admin`。请求头:`Authorization: Bearer <admin_token>`。
+
+统一成功返回:`{"code":0,"msg":"OK","data":...}`;分页数据为 `data.total`、`data.data`。
+
+下拉选项:`GET /admin/paymentConfig/options`。`recharge_channels` 来自充值通道菜单;`channel_groups` 来自层级设置;兼容字段 `levels` 也是这些通道组合,不再是会员等级。接口还返回 `payment_companies`、`payment_methods`、`collection_providers`、`collection_methods`。
+
+## 列表
+
+`GET /admin/paymentConfig/gateways`
+
+| 参数 | 类型 | 必填 | 中文含义 |
+|---|---|---|---|
+| `kind` | string | 是 | `deposit` 存款网关;`withdraw` 出款网关 |
+| `payment_company` | string | 否 | 支付公司 |
+| `payment_method` | string | 否 | 支付方式 |
+| `recharge_channel_id` | int | 否 | 关联的充值/提现通道 ID |
+| `group_id` | int | 否 | 通道组合 ID |
+| `status` | int | 否 | `1` 启用,`0` 停用 |
+| `page/limit` | int | 否 | 页码/每页条数 |
+
+列表项字段:
+
+| 字段 | 中文含义 |
+|---|---|
+| `kind` | 存款或出款网关 |
+| `currency` | 币种,默认 `RMB` |
+| `payment_company` | 支付公司 |
+| `merchant_no` | 商户号 |
+| `payment_method` | 支付方式;出款网关可为空 |
+| `channel_code/channel_name` | 通道编号/通道名称 |
+| `recharge_channel` | 充值通道菜单中的权威配置 |
+| `fee_rate/min_amount/max_amount/sort` | 从关联通道实时读取,只读 |
+| `groups` | 关联组合及当前是否仍有效 |
+| `config_status/channel_status/runtime_channel_available/effective_status` | 支付商配置状态/通道状态/线上通道可用/后台配置最终状态 |
+| `has_withdrawal_secret` | 是否已保存出款密钥 |
+| `operator_id/operator_name` | 最近操作人 |
+
+`withdrawal_secret` 永远不会在列表和保存响应中回传。
+
+## 新增或编辑
+
+`POST /admin/paymentConfig/gateways/save`
+
+存款网关示例:
+
+```json
+{
+  "kind": "deposit",
+  "recharge_channel_id": 2,
+  "recharge_channel_group_ids": [1, 2],
+  "currency": "RMB",
+  "payment_company": "188pay",
+  "merchant_no": "f6f087b56a",
+  "payment_method": "UPI",
+  "channel_code": "90",
+  "channel_name": "支付1",
+  "status": 1
+}
+```
+
+存款网关必须选择 `data_type=1` 的充值通道;出款网关必须选择 `data_type=2` 的提现通道。所选组合必须包含通道 `type`。费率、限额、排序、通道状态请在“充值通道”菜单修改。
+
+出款网关使用 `kind=withdraw`,且必须存在 `withdrawal_secret`。编辑时传 `id`;不修改密钥时不要传该字段或传空字符串。`kind` 创建后不可在存款/出款之间修改。
+
+## 状态、删除、操作记录
+
+- `POST /admin/paymentConfig/gateways/status`:`{"id":1,"status":0}`。
+- `POST /admin/paymentConfig/gateways/delete`:`{"id":1}`。
+- `GET /admin/paymentConfig/logs?resource_type=payment_gateway&resource_id=1&page=1&limit=20`
+
+密钥在数据库中加密保存,操作记录仅显示 `******`。

+ 50 - 0
routes/admin.php

@@ -50,6 +50,9 @@ use App\Http\Controllers\admin\ManualAudit;
 use App\Http\Controllers\admin\ThirdPartyDeposit;
 use App\Http\Controllers\admin\ThirdGameOrder;
 use App\Http\Controllers\admin\Agent as AgentAdmin;
+use App\Http\Controllers\admin\PaymentConfiguration;
+use App\Http\Controllers\admin\AppConfiguration;
+use App\Http\Controllers\admin\ShareEarning;
 
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -229,6 +232,53 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/summary', [Funds::class, 'summary']);
         });
 
+        Route::prefix('/paymentConfig')->middleware('operations.admin')->group(function () {
+            Route::get('/options', [PaymentConfiguration::class, 'options']);
+            Route::get('/direct', [PaymentConfiguration::class, 'directList']);
+            Route::post('/direct/save', [PaymentConfiguration::class, 'directSave']);
+            Route::post('/direct/status', [PaymentConfiguration::class, 'directStatus']);
+            Route::post('/direct/delete', [PaymentConfiguration::class, 'directDelete']);
+
+            Route::get('/gateways', [PaymentConfiguration::class, 'gatewayList']);
+            Route::post('/gateways/save', [PaymentConfiguration::class, 'gatewaySave']);
+            Route::post('/gateways/status', [PaymentConfiguration::class, 'gatewayStatus']);
+            Route::post('/gateways/delete', [PaymentConfiguration::class, 'gatewayDelete']);
+
+            Route::get('/collections', [PaymentConfiguration::class, 'collectionList']);
+            Route::post('/collections/save', [PaymentConfiguration::class, 'collectionSave']);
+            Route::post('/collections/status', [PaymentConfiguration::class, 'collectionStatus']);
+            Route::post('/collections/delete', [PaymentConfiguration::class, 'collectionDelete']);
+            Route::get('/logs', [PaymentConfiguration::class, 'logs']);
+        });
+
+        Route::prefix('/appConfig')->middleware('operations.admin')->group(function () {
+            Route::get('/display', [AppConfiguration::class, 'display']);
+            Route::post('/display/save', [AppConfiguration::class, 'saveDisplay']);
+            Route::get('/packages', [AppConfiguration::class, 'packages']);
+            Route::post('/packages/save', [AppConfiguration::class, 'savePackage']);
+            Route::post('/packages/status', [AppConfiguration::class, 'packageStatus']);
+            Route::post('/packages/delete', [AppConfiguration::class, 'deletePackage']);
+            Route::get('/downloadStats', [AppConfiguration::class, 'downloadStats']);
+            Route::get('/iosReviews', [AppConfiguration::class, 'iosReviews']);
+            Route::post('/iosReviews/save', [AppConfiguration::class, 'saveIosReview']);
+            Route::post('/iosReviews/status', [AppConfiguration::class, 'iosReviewStatus']);
+            Route::post('/iosReviews/delete', [AppConfiguration::class, 'deleteIosReview']);
+            Route::get('/logs', [AppConfiguration::class, 'logs']);
+        });
+
+        Route::prefix('/shareEarning')->middleware('operations.admin')->group(function () {
+            Route::get('/members', [ShareEarning::class, 'memberList']);
+            Route::get('/members/promoters', [ShareEarning::class, 'promoterDetails']);
+            Route::get('/members/registrations', [ShareEarning::class, 'registrationDetails']);
+            Route::get('/summary', [ShareEarning::class, 'summary']);
+            Route::get('/settings', [ShareEarning::class, 'settings']);
+            Route::post('/settings/save', [ShareEarning::class, 'saveSettings']);
+            Route::get('/blacklists', [ShareEarning::class, 'blacklists']);
+            Route::post('/blacklists/save', [ShareEarning::class, 'saveBlacklist']);
+            Route::post('/blacklists/status', [ShareEarning::class, 'blacklistStatus']);
+            Route::post('/blacklists/delete', [ShareEarning::class, 'deleteBlacklist']);
+        });
+
         Route::prefix('/manualAudit')->group(function () {
             Route::get('/', [ManualAudit::class, 'index']);
             Route::get('/detail', [ManualAudit::class, 'detail']);